total-recall-brain 3.41.1 → 3.41.2
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/package.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/auth/README.md +10 -0
- package/scaffold/.agent/skills/total-recall/plugins/auth/package-lock.json +1456 -12
- package/scaffold/.agent/skills/total-recall/plugins/auth/package.json +4 -4
- package/scaffold/.agent/skills/total-recall/plugins/auth/plugin.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/cli-secret.mjs +210 -27
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/provider-catalog.mjs +10 -1
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/rotation-capability.mjs +15 -0
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-env-export.mjs +78 -6
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-merge.mjs +131 -0
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-remote-deploy.mjs +111 -2
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-store.mjs +26 -5
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/package-lock.json +4 -4
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package-lock.json +4 -4
- package/scaffold/.agent/skills/total-recall/plugins/embeddings/package-lock.json +4 -4
- package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package-lock.json +4 -4
- package/scaffold/.agent/skills/total-recall/plugins/mesh/README.md +10 -0
- package/scaffold/.agent/skills/total-recall/plugins/mesh/{src/core/headscale-server.mjs → capability/control-server.mjs} +5 -2
- package/scaffold/.agent/skills/total-recall/plugins/mesh/capability/headscale-api.mjs +192 -0
- package/scaffold/.agent/skills/total-recall/plugins/mesh/capability/index.mjs +46 -0
- package/scaffold/.agent/skills/total-recall/plugins/mesh/cli.mjs +9 -1
- package/scaffold/.agent/skills/total-recall/plugins/mesh/package-lock.json +23 -22
- package/scaffold/.agent/skills/total-recall/plugins/mesh/package.json +6 -3
- package/scaffold/.agent/skills/total-recall/plugins/mesh/plugin.json +242 -8
- package/scaffold/.agent/skills/total-recall/plugins/mesh/src/cli/mesh.mjs +14 -9
- package/scaffold/.agent/skills/total-recall/plugins/mesh/src/cli/server.mjs +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/credentials.mjs +4 -2
- package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/headscale-client.mjs +34 -181
- package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/mesh-auth.mjs +2 -2
- package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/presence.mjs +5 -5
- package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/screen-view.mjs +78 -0
- package/scaffold/.agent/skills/total-recall/plugins/mesh/ui/DESIGN.md +211 -12
- package/scaffold/.agent/skills/total-recall/plugins/mesh/ui/card.js +323 -388
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/README.md +12 -1
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/cli.mjs +7 -4
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/package-lock.json +6 -6
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/package.json +3 -2
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/plugin.json +34 -1
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/bootstrap.mjs +99 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/templates/openwiki/README.md +10 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/templates/openwiki/architecture.md +5 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/templates/openwiki/ide.md +10 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/templates/openwiki/memory.md +11 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/templates/openwiki/secrets.md +7 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/templates/openwiki/skills.md +7 -0
- package/scaffold/.agent/skills/total-recall/plugins/security/package-lock.json +4 -4
- package/scaffold/.agent/skills/total-recall/references/auth.md +2 -0
|
@@ -1,434 +1,369 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
* Every figure comes from a returned `status`, `doctor` or `leader` payload. Nothing is seeded:
|
|
6
|
-
* a value the payload did not supply renders as an em dash with the reason beside it.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
const DASH = '\u2014';
|
|
10
|
-
|
|
11
|
-
/** A finite number only when the payload supplied one. */
|
|
12
|
-
function num(value) {
|
|
13
|
-
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
function str(value) {
|
|
17
|
-
return typeof value === 'string' && value ? value : undefined;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/** Parse a host.run result into an object, or null. Never guesses. */
|
|
1
|
+
/** The mesh operator's five views. All observations arrive through the scoped host. */
|
|
2
|
+
const DASH = '—';
|
|
3
|
+
const words = value => typeof value === 'string' && value ? value : DASH;
|
|
4
|
+
const list = value => Array.isArray(value) ? value : [];
|
|
21
5
|
export function asObject(result) {
|
|
22
|
-
if (result
|
|
23
|
-
if (typeof result === 'object')
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
if (inString) {
|
|
36
|
-
if (ch === '\\') i += 1;
|
|
37
|
-
else if (ch === '"') inString = false;
|
|
38
|
-
} else if (ch === '"') inString = true;
|
|
39
|
-
else if (ch === '[' || ch === '{') depth += 1;
|
|
40
|
-
else if (ch === ']' || ch === '}') {
|
|
41
|
-
depth -= 1;
|
|
42
|
-
if (depth === 0) {
|
|
43
|
-
try {
|
|
44
|
-
const parsed = JSON.parse(result.slice(start, i + 1));
|
|
45
|
-
return typeof parsed === 'object' ? parsed : null;
|
|
46
|
-
} catch { return null; }
|
|
47
|
-
}
|
|
6
|
+
if (result == null || result.ok === false) return null;
|
|
7
|
+
if (typeof result === 'object') return typeof result.output === 'string' ? asObject(result.output) : result;
|
|
8
|
+
if (typeof result !== 'string') return null;
|
|
9
|
+
for (let start = 0; start < result.length; start++) {
|
|
10
|
+
if (!'{['.includes(result[start])) continue;
|
|
11
|
+
let depth = 0, quoted = false;
|
|
12
|
+
for (let end = start; end < result.length; end++) {
|
|
13
|
+
const ch = result[end];
|
|
14
|
+
if (quoted) { if (ch === '\\') end++; else if (ch === '"') quoted = false; }
|
|
15
|
+
else if (ch === '"') quoted = true;
|
|
16
|
+
else if ('{['.includes(ch)) depth++;
|
|
17
|
+
else if ('}]'.includes(ch) && --depth === 0) {
|
|
18
|
+
try { return JSON.parse(result.slice(start, end + 1)); } catch { break; }
|
|
48
19
|
}
|
|
49
20
|
}
|
|
50
21
|
}
|
|
51
22
|
return null;
|
|
52
23
|
}
|
|
53
|
-
|
|
54
|
-
function rawText(result) {
|
|
55
|
-
if (result === null || result === undefined) return '';
|
|
56
|
-
if (typeof result === 'string') return result;
|
|
57
|
-
if (typeof result.output === 'string') return result.output;
|
|
58
|
-
try { return JSON.stringify(result, null, 2); } catch { return String(result); }
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Join the registry view (`status`) with the probe view (`doctor`) into one record per node.
|
|
63
|
-
* `reach` is 'reachable', 'unreachable' or 'unprobed': three honest states, never a default.
|
|
64
|
-
*/
|
|
65
24
|
export function joinNodes(status, doctor) {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const
|
|
70
|
-
let
|
|
71
|
-
if (
|
|
72
|
-
else if (
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
if (vaultOnly) { severity = 0.5; label = 'Recorded only'; }
|
|
79
|
-
else if (reach === 'unreachable' && probe && probe.sshConfigured) { severity = 4; label = 'Unreachable'; }
|
|
80
|
-
else if (!online) { severity = 3; label = 'Offline'; }
|
|
81
|
-
else if (!hasLogin) { severity = 2; label = 'No login recorded'; }
|
|
82
|
-
else if (reach === 'unprobed') { severity = 1; label = 'Not probed'; }
|
|
83
|
-
return {
|
|
84
|
-
hostname: str(node.hostname),
|
|
85
|
-
ip: str(node.ip),
|
|
86
|
-
os: str(node.os),
|
|
87
|
-
self: node.self === true,
|
|
88
|
-
vaultOnly,
|
|
89
|
-
online,
|
|
90
|
-
networkState: str(node.network_state),
|
|
91
|
-
accessState: str(node.access_state),
|
|
92
|
-
reach,
|
|
93
|
-
reason: probe ? str(probe.error) : undefined,
|
|
94
|
-
runtimes: probe && Array.isArray(probe.runtimes) ? probe.runtimes : undefined,
|
|
95
|
-
harnesses: probe && Array.isArray(probe.harnesses) ? probe.harnesses : undefined,
|
|
96
|
-
severity,
|
|
97
|
-
label,
|
|
98
|
-
};
|
|
25
|
+
return list(status?.nodes).map(node => {
|
|
26
|
+
const probe = list(doctor).find(p => node.ip ? p.ip === node.ip : !p.ip && node.hostname && p.hostname === node.hostname);
|
|
27
|
+
const loginApplicable = !node.self && !/^(ios|ipados|android)$/i.test(node.os || '') && !node.vault_only;
|
|
28
|
+
const reach = probe?.reachable === true ? 'reachable' : probe?.reachable === false && (probe.sshConfigured || node.self) ? 'unreachable' : 'unprobed';
|
|
29
|
+
let tone = 'neutral', severity = 1, label = 'Not probed';
|
|
30
|
+
if (node.vault_only) { label = 'Recorded only'; severity = -2; }
|
|
31
|
+
else if (node.online === false) { label = 'Offline'; severity = -1; }
|
|
32
|
+
else if (node.online === true && reach === 'unreachable') { label = 'Stops at Run'; tone = 'danger'; severity = 4; }
|
|
33
|
+
else if (loginApplicable && node.access_state !== 'configured') { label = 'Stops at Login'; tone = 'warn'; severity = 3; }
|
|
34
|
+
else if (!loginApplicable && !node.self) { label = 'Member · no remote shell'; severity = 0; }
|
|
35
|
+
else if (reach === 'reachable') { label = 'Execution verified'; tone = 'ok'; severity = 0; }
|
|
36
|
+
return { ...node, probe, reach, loginApplicable, vaultOnly: node.vault_only === true, accessState: node.access_state, tone, severity, label, runtimes: list(probe?.runtimes), harnesses: list(probe?.harnesses), reason: probe?.error };
|
|
99
37
|
});
|
|
100
38
|
}
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* The verdict, computed only from returned values, first match wins.
|
|
104
|
-
* Returns { tone, text } where tone is ok | warn | danger | neutral.
|
|
105
|
-
*/
|
|
106
39
|
export function meshVerdict(status, doctor, leader) {
|
|
107
|
-
if (!status
|
|
108
|
-
if (status.state !== 'connected') return { tone: 'danger', text: '
|
|
109
|
-
if (
|
|
110
|
-
const nodes = joinNodes(status, doctor).filter(
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
if (
|
|
117
|
-
|
|
118
|
-
return { tone: 'ok', text: 'Every node reachable.' };
|
|
40
|
+
if (!status) return { tone: 'neutral', text: 'Cannot verify the mesh.' };
|
|
41
|
+
if (status.state !== 'connected') return { tone: 'danger', text: 'This machine is disconnected.' };
|
|
42
|
+
if (status.control?.verified !== true) return { tone: 'danger', text: 'Control server unverified; observations are incomplete.' };
|
|
43
|
+
const nodes = joinNodes(status, doctor).filter(n => !n.vaultOnly && (n.loginApplicable || n.self) && n.online !== false);
|
|
44
|
+
if (!nodes.length) return { tone: 'neutral', text: 'No online execution targets observed.' };
|
|
45
|
+
const danger = nodes.filter(n => n.tone === 'danger').length;
|
|
46
|
+
if (danger) return { tone: 'danger', text: `${danger} online machine${danger === 1 ? '' : 's'} failed execution.` };
|
|
47
|
+
const missing = nodes.filter(n => n.label === 'Stops at Login').length;
|
|
48
|
+
if (missing) return { tone: 'warn', text: `${missing} machine${missing === 1 ? '' : 's'} need a recorded login.` };
|
|
49
|
+
if (nodes.some(n => n.reach !== 'reachable') || !Array.isArray(doctor) || !leader) return { tone: 'warn', text: 'Execution is partly verified.' };
|
|
50
|
+
return { tone: 'ok', text: 'Online execution targets answered.' };
|
|
119
51
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
52
|
+
export function capabilityState(node, column) {
|
|
53
|
+
const [kind, name] = column.split(':');
|
|
54
|
+
if (kind === 'io') {
|
|
55
|
+
const io = node.io;
|
|
56
|
+
const values = { screen: io?.display?.present, microphone: io?.audio?.input, speaker: io?.audio?.output, camera: io?.camera?.present, headless: io?.headless };
|
|
57
|
+
return typeof values[name] === 'boolean' ? values[name] ? 'Present · recorded' : 'Absent · recorded' : 'Unknown';
|
|
58
|
+
}
|
|
59
|
+
if (node.reach !== 'reachable') return 'Unknown';
|
|
60
|
+
const available = kind === 'runtime' ? node.runtimes : node.harnesses;
|
|
61
|
+
if (available.includes(name)) return 'Present';
|
|
62
|
+
const tested = kind === 'runtime' ? node.probe?.tested_runtimes : node.probe?.tested_harnesses;
|
|
63
|
+
return list(tested).includes(name) ? 'Absent' : 'Unknown';
|
|
123
64
|
}
|
|
65
|
+
function hostName(url) { try { return new URL(url).host; } catch { return DASH; } }
|
|
66
|
+
const sourceLabels = { grants: 'Secrets', plugins: 'Plugins', updates: 'Updates', tasks: 'Scheduled work', agents: 'Agents', alerts: 'Alerts', deployments: 'Deployments', policy: 'Policy', presence: 'Presence' };
|
|
124
67
|
|
|
125
68
|
export class MeshlinkControlCard extends HTMLElement {
|
|
126
69
|
constructor() {
|
|
127
|
-
super();
|
|
128
|
-
this.
|
|
129
|
-
this.
|
|
130
|
-
this.
|
|
131
|
-
this.
|
|
132
|
-
this.lastAttempt = null;
|
|
133
|
-
this.busy = false;
|
|
134
|
-
this.screen = 'fleet';
|
|
135
|
-
this.pings = {};
|
|
70
|
+
super(); this.status = null; this.doctor = null; this.leader = null;
|
|
71
|
+
this.errors = {}; this.views = {}; this.loading = {}; this.pings = {};
|
|
72
|
+
this.screen = 'machines'; this.selected = null; this.filters = new Set();
|
|
73
|
+
this.loginDrafts = {};
|
|
74
|
+
this.busy = false; this.confirmEnrollment = false;
|
|
136
75
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
{ id: 'fleet', label: 'Fleet' },
|
|
142
|
-
{ id: 'control', label: 'Control' },
|
|
143
|
-
{ id: 'capability', label: 'Capability' },
|
|
144
|
-
];
|
|
145
|
-
}
|
|
146
|
-
|
|
76
|
+
get screens() { return [
|
|
77
|
+
{ id: 'machines', label: 'Machines' }, { id: 'flows', label: 'Flows' },
|
|
78
|
+
{ id: 'placement', label: 'Placement' }, { id: 'boundary', label: 'Boundary' }, { id: 'join', label: 'Join' },
|
|
79
|
+
]; }
|
|
147
80
|
connectedCallback() {
|
|
148
81
|
if (!this.shadowRoot) this.attachShadow({ mode: 'open' });
|
|
149
82
|
this.render();
|
|
150
|
-
this.onScreen = (event) =>
|
|
151
|
-
const detail = event.detail || {};
|
|
152
|
-
if (detail.pluginId && detail.pluginId !== this.host?.pluginId) return;
|
|
153
|
-
if (detail.screen && this.screens.some((s) => s.id === detail.screen)) this.show(detail.screen);
|
|
154
|
-
};
|
|
83
|
+
this.onScreen = event => { if ((!event.detail?.pluginId || event.detail.pluginId === this.host?.pluginId) && this.screens.some(s => s.id === event.detail?.screen)) void this.show(event.detail.screen); };
|
|
155
84
|
window.addEventListener('plugin-show-screen', this.onScreen);
|
|
156
85
|
void this.read();
|
|
157
86
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
if (this.
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
if (!this.host || typeof this.host.run !== 'function') {
|
|
165
|
-
throw new Error('The host did not provide a command runner, so the mesh cannot be inspected.');
|
|
166
|
-
}
|
|
167
|
-
return this.host.run(subcommand, args);
|
|
87
|
+
disconnectedCallback() { window.removeEventListener('plugin-show-screen', this.onScreen); }
|
|
88
|
+
async call(operation, args = []) {
|
|
89
|
+
if (!this.host?.run) throw new Error('Mesh command runner unavailable. Open this view in the dashboard.');
|
|
90
|
+
const result = await this.host.run(operation, args);
|
|
91
|
+
if (result?.ok === false) throw new Error('Owner command failed. Inspect its CLI result.');
|
|
92
|
+
return result;
|
|
168
93
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
async readOne(key, subcommand, args) {
|
|
94
|
+
async readOne(key, op, args) {
|
|
95
|
+
this[key] = null; delete this.errors[key]; this.paint();
|
|
172
96
|
try {
|
|
173
|
-
const
|
|
174
|
-
if (!
|
|
175
|
-
this[key] =
|
|
176
|
-
|
|
177
|
-
} catch (err) {
|
|
178
|
-
this[key] = null;
|
|
179
|
-
this.errors[key] = err && err.message ? err.message : String(err);
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/** The three reads are independent: a slow or failed `doctor` must not blank the fleet. */
|
|
184
|
-
async read() {
|
|
185
|
-
if (this.busy) return;
|
|
186
|
-
this.busy = true;
|
|
187
|
-
this.lastAttempt = new Date();
|
|
188
|
-
this.paint();
|
|
189
|
-
await Promise.all([
|
|
190
|
-
this.readOne('status', 'status', ['--json']),
|
|
191
|
-
this.readOne('doctor', 'doctor', ['--json']),
|
|
192
|
-
this.readOne('leader', 'leader', ['--json']),
|
|
193
|
-
]);
|
|
194
|
-
this.busy = false;
|
|
97
|
+
const value = asObject(await this.call(op, args));
|
|
98
|
+
if (!value || key === 'doctor' && !Array.isArray(value) || key !== 'doctor' && Array.isArray(value)) throw new Error('Command returned an unreadable observation.');
|
|
99
|
+
this[key] = value;
|
|
100
|
+
} catch (error) { this.errors[key] = error.message; }
|
|
195
101
|
this.paint();
|
|
196
102
|
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
this.
|
|
200
|
-
this.
|
|
103
|
+
read() {
|
|
104
|
+
if (this.busy) return this.readPromise;
|
|
105
|
+
this.busy = true; this.lastAttempt = new Date().toISOString(); this.pings = {}; this.paint();
|
|
106
|
+
this.readPromise = Promise.all([
|
|
107
|
+
this.readOne('status', 'status', ['--json']), this.readOne('doctor', 'doctor', ['--json']),
|
|
108
|
+
this.readOne('leader', 'leader', ['--json']), this.readView(this.screen, true),
|
|
109
|
+
]).finally(() => { this.busy = false; this.paint(); });
|
|
110
|
+
return this.readPromise;
|
|
201
111
|
}
|
|
202
|
-
|
|
112
|
+
async readView(screen, force = false) {
|
|
113
|
+
if (this.loading[screen]) return this.loading[screen];
|
|
114
|
+
if (this.views[screen] && !force) return;
|
|
115
|
+
delete this.views[screen]; delete this.errors[`view-${screen}`];
|
|
116
|
+
this.loading[screen] = (async () => {
|
|
117
|
+
try {
|
|
118
|
+
const view = asObject(await this.call('view', [screen, '--json']));
|
|
119
|
+
if (!view?.sources || typeof view.sources !== 'object') throw new Error('Screen sources were not returned.');
|
|
120
|
+
this.views[screen] = view;
|
|
121
|
+
} catch (error) { this.errors[`view-${screen}`] = error.message; }
|
|
122
|
+
finally { delete this.loading[screen]; this.paint(); }
|
|
123
|
+
})();
|
|
124
|
+
this.paint(); return this.loading[screen];
|
|
125
|
+
}
|
|
126
|
+
async show(screen) {
|
|
127
|
+
if (!this.screens.some(s => s.id === screen)) return;
|
|
128
|
+
this.screen = screen; this.confirmEnrollment = false; this.paint();
|
|
129
|
+
this.host?.screenChanged?.(screen);
|
|
130
|
+
if (this.busy) await this.readPromise;
|
|
131
|
+
await this.readView(screen);
|
|
132
|
+
}
|
|
133
|
+
canRun(operation) { return Array.isArray(this.host?.operations) && this.host.operations.includes(operation); }
|
|
203
134
|
async ping(node) {
|
|
204
|
-
|
|
205
|
-
if (!target) return;
|
|
206
|
-
this.pings[target] = { state: 'running' };
|
|
207
|
-
this.paint();
|
|
135
|
+
this.pings[node.ip] = { state: 'checking' }; this.paint();
|
|
208
136
|
try {
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
137
|
+
const value = asObject(await this.call('ping', [node.ip, '--json']));
|
|
138
|
+
const rows = Array.isArray(value) ? value : value?.ip ? [value] : list(value?.results);
|
|
139
|
+
const observation = rows.find(row => row.ip === node.ip || row.node === node.ip || row.hostname === node.hostname);
|
|
140
|
+
if (!observation || typeof observation.reachable !== 'boolean') throw new Error('Ping returned no matching path observation.');
|
|
141
|
+
this.pings[node.ip] = { state: observation.reachable ? 'passed' : 'failed', detail: observation.latency_ms == null ? words(observation.error) : `${observation.latency_ms} ms` };
|
|
142
|
+
} catch (error) { this.pings[node.ip] = { state: 'failed', detail: error.message }; }
|
|
214
143
|
this.paint();
|
|
215
144
|
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
145
|
+
async action(operation, args, label) {
|
|
146
|
+
this.actionMessage = `${label}…`; this.paint();
|
|
147
|
+
try { await this.call(operation, args); this.actionMessage = `${label} completed.`; await this.read(); }
|
|
148
|
+
catch (error) { this.actionMessage = `${label} failed: ${error.message}`; }
|
|
149
|
+
this.paint();
|
|
150
|
+
}
|
|
151
|
+
el(tag, props = {}, ...children) {
|
|
152
|
+
const element = document.createElement(tag);
|
|
153
|
+
for (const [key, value] of Object.entries(props)) {
|
|
154
|
+
if (key === 'text') element.textContent = value;
|
|
155
|
+
else if (key === 'class') element.className = value;
|
|
156
|
+
else if (key === 'dataset') Object.assign(element.dataset, value);
|
|
157
|
+
else if (key.startsWith('on')) element.addEventListener(key.slice(2), value);
|
|
158
|
+
else element.setAttribute(key, value);
|
|
159
|
+
}
|
|
160
|
+
for (const child of children.flat(Infinity)) if (child) element.append(child);
|
|
161
|
+
return element;
|
|
162
|
+
}
|
|
163
|
+
text(value, cls = '') { return this.el('p', { text: value, class: cls }); }
|
|
164
|
+
button(label, fn, props = {}) { return this.el('button', { type: 'button', text: label, onclick: fn, ...props }); }
|
|
165
|
+
section(title, ...children) { return this.el('section', { class: 'block' }, this.el('h3', { text: title }), children); }
|
|
166
|
+
command(value) { return this.el('pre', { class: 'command', text: value }); }
|
|
167
|
+
fields(rows) { return this.el('dl', {}, rows.map(([key, value]) => [this.el('dt', { text: key }), this.el('dd', { text: value == null ? DASH : String(value) })])); }
|
|
219
168
|
render() {
|
|
220
|
-
|
|
221
|
-
if (!root) return;
|
|
222
|
-
root.innerHTML = `
|
|
169
|
+
this.shadowRoot.innerHTML = `
|
|
223
170
|
<style>
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
--brand: #818cf8;
|
|
228
|
-
--ok: #34d399; --warn: #fbbf24; --danger: #f87171; --neutral: #94a3b8;
|
|
229
|
-
display: block; box-sizing: border-box; background: var(--surface); color: var(--text);
|
|
230
|
-
font-family: 'Space Grotesk', system-ui, sans-serif; border: 1px solid var(--line);
|
|
231
|
-
border-radius: 8px; padding: 14px; container-type: inline-size;
|
|
232
|
-
}
|
|
233
|
-
* { box-sizing: border-box; }
|
|
234
|
-
header { display: flex; align-items: center; gap: 12px; margin-bottom: 10px; }
|
|
235
|
-
.mark { width: 30px; height: 30px; flex: none; }
|
|
236
|
-
.name { font-size: 13px; font-weight: 700; letter-spacing: .04em; color: var(--brand); }
|
|
237
|
-
.badge { font-size: 11px; color: var(--muted); }
|
|
238
|
-
.verdict { margin-left: auto; text-align: right; font-size: 15px; font-weight: 700; }
|
|
239
|
-
.verdict small { display: block; font-size: 11px; font-weight: 400; color: var(--muted); }
|
|
240
|
-
[data-tone="ok"] { color: var(--ok); } [data-tone="warn"] { color: var(--warn); }
|
|
241
|
-
[data-tone="danger"] { color: var(--danger); } [data-tone="neutral"] { color: var(--neutral); }
|
|
242
|
-
nav { display: flex; gap: 4px; margin-bottom: 10px; border-bottom: 1px solid var(--line); }
|
|
243
|
-
nav button { background: none; border: 0; color: var(--muted); font: inherit; font-size: 12px;
|
|
244
|
-
padding: 8px 12px; min-height: 44px; cursor: pointer; border-bottom: 2px solid transparent; }
|
|
245
|
-
nav button[aria-selected="true"] { color: var(--text); border-bottom-color: var(--brand); }
|
|
246
|
-
nav .spacer { flex: 1; }
|
|
247
|
-
.figures { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin-bottom: 12px; }
|
|
248
|
-
@container (max-width: 520px) { .figures { grid-template-columns: repeat(2, 1fr); } }
|
|
249
|
-
.figure { background: var(--surface-2); border-left: 3px solid var(--neutral); border-radius: 4px; padding: 8px 10px; }
|
|
250
|
-
.figure[data-tone="ok"] { border-left-color: var(--ok); } .figure[data-tone="warn"] { border-left-color: var(--warn); }
|
|
251
|
-
.figure[data-tone="danger"] { border-left-color: var(--danger); }
|
|
252
|
-
.figure .n { font-size: 24px; font-weight: 700; line-height: 1.1; }
|
|
253
|
-
.figure .l { font-size: 11px; color: var(--muted); }
|
|
254
|
-
.figure .why { font-size: 11px; color: var(--muted); }
|
|
255
|
-
ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
|
|
256
|
-
li.node { background: var(--surface-2); border-left: 3px solid var(--neutral); border-radius: 4px; padding: 8px 10px;
|
|
257
|
-
display: grid; grid-template-columns: 1fr auto; gap: 4px 10px; align-items: center; }
|
|
258
|
-
li.node[data-tone="danger"] { border-left-color: var(--danger); } li.node[data-tone="warn"] { border-left-color: var(--warn); }
|
|
259
|
-
li.node[data-tone="ok"] { border-left-color: var(--ok); }
|
|
260
|
-
.host { font-size: 13px; font-weight: 600; word-break: break-all; }
|
|
261
|
-
.sub { font-size: 11px; color: var(--muted); font-family: 'JetBrains Mono', ui-monospace, monospace; }
|
|
262
|
-
.states { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 6px 14px; font-size: 11px; color: var(--muted); }
|
|
263
|
-
.states b { color: var(--text); font-weight: 600; }
|
|
264
|
-
.reason { grid-column: 1 / -1; font-size: 11px; color: var(--warn); }
|
|
265
|
-
.ping { background: transparent; border: 1px solid var(--line); color: var(--text); font: inherit; font-size: 12px;
|
|
266
|
-
border-radius: 4px; padding: 0 12px; min-height: 44px; cursor: pointer; }
|
|
267
|
-
.ping:disabled { opacity: .5; cursor: default; }
|
|
268
|
-
.pingout { grid-column: 1 / -1; font-size: 11px; font-family: 'JetBrains Mono', ui-monospace, monospace; color: var(--muted); }
|
|
269
|
-
dl { display: grid; grid-template-columns: max-content 1fr; gap: 6px 14px; margin: 0; font-size: 12px; }
|
|
270
|
-
dt { color: var(--muted); } dd { margin: 0; word-break: break-all; }
|
|
271
|
-
.err { background: rgba(248,113,113,.1); border: 1px solid var(--danger); color: var(--danger);
|
|
272
|
-
border-radius: 4px; padding: 8px 10px; font-size: 12px; margin-bottom: 10px; }
|
|
273
|
-
.chips { display: flex; flex-wrap: wrap; gap: 4px; }
|
|
274
|
-
.chip { background: var(--surface); border: 1px solid var(--line); border-radius: 3px; padding: 1px 6px; font-size: 11px; }
|
|
275
|
-
.foot { margin-top: 10px; font-size: 11px; color: var(--muted); }
|
|
276
|
-
.section { margin: 12px 0 6px; font-size: 11px; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); }
|
|
171
|
+
:host { --face-bg:var(--bg-primary,#0b1020); --face-panel:var(--bg-secondary,#121933); --face-line:var(--border,#26305a); --face-text:var(--text-primary,#e8ecff); --face-muted:var(--text-secondary,#aab5d7); --face-brand:var(--accent,#818cf8); --face-ok:#34d399; --face-warn:#fbbf24; --face-danger:#f87171; display:block; color:var(--face-text); background:var(--face-bg); font:14px/1.5 system-ui,sans-serif; padding:16px; container-type:inline-size; border:1px solid var(--face-line); border-radius:8px; }
|
|
172
|
+
* { box-sizing:border-box; min-width:0; } h2,h3,p { margin:0 0 8px; } h2 { font-size:18px; } h3 { font-size:14px; } button,input { font:inherit; min-height:44px; color:inherit; background:var(--face-bg); border:1px solid var(--face-line); border-radius:5px; padding:8px 12px; } button { cursor:pointer; text-align:left; } button:hover { border-color:var(--face-brand); } button:disabled { opacity:.65; cursor:wait; } button:focus-visible,input:focus-visible,summary:focus-visible { outline:2px solid var(--face-brand); outline-offset:2px; } [data-tone=ok] { color:var(--face-ok); } [data-tone=warn] { color:var(--face-warn); } [data-tone=danger] { color:var(--face-danger); } .muted,dt,small { color:var(--face-muted); } .strip { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px; margin-bottom:14px; } .strip strong { display:block; overflow-wrap:anywhere; } .verdict { border-left:3px solid currentColor; padding:8px 12px; background:var(--face-panel); } nav { display:flex; flex-wrap:wrap; gap:6px; margin:14px 0; } nav button[aria-selected=true] { border-color:var(--face-brand); background:var(--face-panel); } .refresh { margin-left:auto; } .block { padding:14px; background:var(--face-panel); border:1px solid var(--face-line); border-radius:6px; margin-bottom:12px; overflow-wrap:anywhere; } .panes { display:grid; grid-template-columns:minmax(190px,.8fr) minmax(0,1.6fr); gap:12px; align-items:start; } .roster { display:grid; gap:6px; } .roster button { display:grid; gap:3px; width:100%; } .roster button[aria-pressed=true] { border-color:var(--face-brand); } .ladder { display:grid; gap:8px; } .rung { display:grid; grid-template-columns:70px 1fr; gap:8px; border:1px dashed var(--face-line); padding:10px; border-radius:4px; } .rung[data-state=passed] { border-style:solid; border-left:4px solid var(--face-ok); } .rung[data-state=failed] { border-style:double; border-left:4px solid var(--face-danger); } .rung[data-state=na] { border-style:dotted; color:var(--face-muted); } .actions,.filters { display:flex; flex-wrap:wrap; gap:8px; margin:10px 0; } .filters label { display:flex; align-items:center; gap:6px; min-height:44px; padding:0 8px; border:1px solid var(--face-line); border-radius:4px; } .filters input { min-height:20px; width:20px; } dl { display:grid; grid-template-columns:minmax(80px,.6fr) minmax(0,1.4fr); gap:5px 12px; margin:10px 0; } dd { margin:0; overflow-wrap:anywhere; } .command { white-space:pre-wrap; overflow-wrap:anywhere; font:12px/1.6 ui-monospace,monospace; background:var(--face-bg); padding:12px; border:1px solid var(--face-line); border-radius:4px; } .error { color:var(--face-danger); padding:10px; border:1px solid var(--face-danger); border-radius:4px; margin:8px 0; overflow-wrap:anywhere; } .flow { border-bottom:1px solid var(--face-line); padding:12px 0; } .flow:last-child { border:0; } .table-wrap { max-width:100%; overflow:auto; } table { border-collapse:collapse; width:100%; font-size:12px; } th,td { text-align:left; padding:9px; border:1px solid var(--face-line); vertical-align:top; } th { background:var(--face-bg); } summary { min-height:44px; cursor:pointer; padding:10px 0; } .recorded { margin-top:12px; } .login-form { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; } .login-form label { display:grid; gap:3px; } .login-form button { grid-column:1/-1; } .danger { border-color:var(--face-danger); } .foot { margin-top:12px; font-size:12px; color:var(--face-muted); }
|
|
173
|
+
@container(max-width:690px) { .panes,.strip { grid-template-columns:1fr; } .strip { gap:6px; } .strip strong { display:inline; margin-right:8px; } .login-form { grid-template-columns:1fr; } } @media(prefers-reduced-motion:reduce) { * { animation:none; transition:none; } }
|
|
277
174
|
</style>
|
|
278
|
-
<header>
|
|
279
|
-
|
|
280
|
-
<circle cx="12" cy="5" r="3"/><circle cx="5" cy="19" r="3"/><circle cx="19" cy="19" r="3"/>
|
|
281
|
-
<line x1="12" y1="8" x2="5" y2="16"/><line x1="12" y1="8" x2="19" y2="16"/><line x1="8" y1="19" x2="16" y2="19"/>
|
|
282
|
-
</svg>
|
|
283
|
-
<div><div class="name">HEADSCALE</div><div class="badge">Mesh reachability</div></div>
|
|
284
|
-
<div class="verdict" id="verdict" data-tone="neutral">Reading the mesh<small id="attempt"></small></div>
|
|
285
|
-
</header>
|
|
286
|
-
<nav id="tabs" role="tablist"></nav>
|
|
287
|
-
<div id="errors"></div>
|
|
288
|
-
<section id="body"></section>
|
|
289
|
-
<div class="foot" id="foot"></div>
|
|
290
|
-
`;
|
|
175
|
+
<header><h2><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><circle cx="12" cy="4" r="3"/><circle cx="4" cy="19" r="3"/><circle cx="20" cy="19" r="3"/><path d="M10 7 5 16m9-9 5 9M7 19h10"/></svg> HEADSCALE <small>· Your machines</small></h2><div id="strip" class="strip"></div><p id="verdict" class="verdict"></p></header>
|
|
176
|
+
<nav id="tabs" role="tablist" aria-label="Mesh views"></nav><div id="errors" role="status"></div><main id="body" role="tabpanel"></main><footer id="foot" class="foot"></footer>`;
|
|
291
177
|
this.paint();
|
|
292
178
|
}
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
179
|
+
paint() {
|
|
180
|
+
const root = this.shadowRoot; if (!root?.querySelector('#body')) return;
|
|
181
|
+
const focused = root.activeElement;
|
|
182
|
+
const focusKey = focused && ['id', 'aria-label', 'name'].map(attribute => [attribute, focused.getAttribute(attribute)]).find(([, value]) => value);
|
|
183
|
+
const verdict = meshVerdict(this.status, this.doctor, this.leader);
|
|
184
|
+
root.querySelector('#verdict').textContent = verdict.text; root.querySelector('#verdict').dataset.tone = verdict.tone;
|
|
185
|
+
const status = this.status;
|
|
186
|
+
root.querySelector('#strip').replaceChildren(...[
|
|
187
|
+
['Control', `${hostName(status?.control?.url)} · ${status?.control?.verified === true ? 'Verified' : 'Not verified'}`],
|
|
188
|
+
['Leader', `${words(this.leader?.leader?.hostname)} · ${words(this.leader?.leader?.strategy)}`],
|
|
189
|
+
['This machine', `${words(status?.enrollment?.hostname)} · ${words(status?.enrollment?.state)}`],
|
|
190
|
+
].map(([title, value]) => this.el('div', {}, this.el('strong', { text: title }), this.el('span', { text: value }))));
|
|
191
|
+
root.querySelector('#tabs').replaceChildren(...this.screens.map(screen => this.button(screen.label, () => void this.show(screen.id), { role: 'tab', id: `tab-${screen.id}`, 'aria-selected': String(this.screen === screen.id), 'aria-controls': 'body' })), this.button(this.busy ? 'Reading…' : 'Refresh', () => void this.read(), { class: 'refresh', ...(this.busy ? { disabled: '' } : {}) }));
|
|
192
|
+
root.querySelector('#errors').replaceChildren(...Object.entries(this.errors).map(([key, error]) => this.text(`${key}: ${error}`, 'error')), ...(status?.control?.error || status?.control?.reason ? [this.text(`Control: ${status.control.error || status.control.reason}`, 'error')] : []), ...(this.actionMessage ? [this.text(this.actionMessage)] : []));
|
|
193
|
+
const nodes = joinNodes(status, this.doctor);
|
|
194
|
+
const body = root.querySelector('#body'); body.setAttribute('aria-labelledby', `tab-${this.screen}`);
|
|
195
|
+
const firstRun = status?.configured === false && ['no-headscale-api-key', 'no-control-url'].includes(status.control?.reason);
|
|
196
|
+
body.replaceChildren(firstRun ? this.firstRun() : this.screen === 'machines' ? this.machines(nodes) : this.screen === 'flows' ? this.flows() : this.screen === 'placement' ? this.placement(nodes) : this.screen === 'boundary' ? this.boundary(nodes) : this.join());
|
|
197
|
+
root.querySelector('#foot').textContent = this.lastAttempt ? `Last refresh attempted ${this.lastAttempt}. Hardware and access records retain their own dates.` : 'No observations yet.';
|
|
198
|
+
if (focusKey) [...root.querySelectorAll('button,input,summary')].find(element => element.getAttribute(focusKey[0]) === focusKey[1])?.focus({ preventScroll: true });
|
|
199
|
+
}
|
|
200
|
+
presenceFor(node) {
|
|
201
|
+
const source = this.views.machines?.sources?.presence;
|
|
202
|
+
if (!source?.ok) return `Presence unavailable${source?.error ? `: ${source.error}` : ' · checking'}`;
|
|
203
|
+
const data = source.data;
|
|
204
|
+
const record = [data.local, ...list(data.peers)].find(item => item?.mesh_ip && item.mesh_ip === node.ip);
|
|
205
|
+
if (!record || record.state === 'unavailable') return `Presence unavailable${record?.error ? `: ${record.error}` : ''}`;
|
|
206
|
+
const age = Date.now() - Date.parse(record.timestamp);
|
|
207
|
+
if (!Number.isFinite(age) || age < -5000 || age > 60000) return 'Presence stale · refresh to check activity';
|
|
208
|
+
if (!Number.isFinite(record.idle_seconds) || record.idle_seconds < 0 || typeof record.user_active !== 'boolean') return 'Activity unknown · no idle measurement';
|
|
209
|
+
return `${record.user_active === true ? 'Active here' : record.user_active === false ? 'Idle' : 'Activity unknown'}${typeof record.idle_seconds === 'number' ? ` · ${record.idle_seconds}s idle` : ''}`;
|
|
210
|
+
}
|
|
211
|
+
machines(nodes) {
|
|
212
|
+
const live = nodes.filter(node => !node.vaultOnly).sort((a, b) => b.severity - a.severity || words(a.hostname).localeCompare(words(b.hostname)));
|
|
213
|
+
const selected = live.find(node => node.ip === this.selected) || live[0];
|
|
214
|
+
if (selected) this.selected = selected.ip;
|
|
215
|
+
const roster = this.el('div', { class: 'roster', 'aria-label': 'Machines' }, live.map(node => this.button('', () => { this.selected = node.ip; this.paint(); }, { 'aria-pressed': String(selected?.ip === node.ip), 'aria-label': `${words(node.hostname)}: ${node.label}` })));
|
|
216
|
+
[...roster.children].forEach((button, index) => button.append(this.el('strong', { text: words(live[index].hostname) }), this.el('span', { text: live[index].label, dataset: { tone: live[index].tone } }), this.el('small', { text: this.presenceFor(live[index]) })));
|
|
217
|
+
const recorded = nodes.filter(node => node.vaultOnly);
|
|
218
|
+
if (recorded.length) roster.append(this.el('details', { class: 'recorded' }, this.el('summary', { text: `${recorded.length} recorded-only record${recorded.length === 1 ? '' : 's'}` }), recorded.map(node => this.text(`${words(node.hostname)} · recorded ${words(node.last_heartbeat)}`, 'muted'))));
|
|
219
|
+
return this.el('div', { class: 'panes' }, this.section(`${this.status ? live.length : DASH} members`, roster), selected ? this.machineDetail(selected) : this.section('Machines', this.text(this.status ? 'No member machines returned.' : 'Machine inventory is unreadable.')));
|
|
220
|
+
}
|
|
221
|
+
machineDetail(node) {
|
|
222
|
+
const ping = this.pings[node.ip];
|
|
223
|
+
const applicable = node.loginApplicable || node.self;
|
|
224
|
+
const rungs = [
|
|
225
|
+
['Control', node.online === true ? 'passed' : node.online === false ? 'failed' : 'checking', node.online === true ? 'Online on the mesh' : node.online === false ? 'Offline · sleeping devices are not faults' : 'Not yet checked'],
|
|
226
|
+
['Path', node.self ? 'na' : ping?.state || 'checking', node.self ? 'Not applicable · this machine' : ping?.detail || 'Not yet checked · use Ping now'],
|
|
227
|
+
['Login', node.self || !applicable ? 'na' : node.access_state === 'configured' ? 'passed' : 'failed', node.self ? 'Not applicable · local execution' : !applicable ? 'Not applicable · no remote shell on this device' : node.access_state === 'configured' ? `Recorded ${words(node.access?.ssh_user)} · verified ${words(node.access?.verified_at)}` : 'No SSH login recorded'],
|
|
228
|
+
['Run', !applicable ? 'na' : node.reach === 'reachable' ? 'passed' : node.reach === 'unreachable' ? 'failed' : 'checking', !applicable ? 'Not applicable' : node.reach === 'reachable' ? 'Probe command executed' : node.reason || 'Not yet checked'],
|
|
229
|
+
];
|
|
230
|
+
const actions = this.el('div', { class: 'actions' }, !node.self && node.ip ? this.button('Ping now', () => void this.ping(node)) : null, applicable ? this.button('Verify execution', () => void this.readOne('doctor', 'doctor', ['--json'])) : null);
|
|
231
|
+
const detail = this.section(words(node.hostname), this.text(`${words(node.ip)} · ${words(node.os)}`, 'muted'), this.el('div', { class: 'ladder' }, rungs.map(([name, state, evidence]) => this.el('div', { class: 'rung', dataset: { state } }, this.el('strong', { text: name }), this.el('div', {}, this.el('b', { text: state === 'na' ? 'Not applicable' : state === 'checking' ? 'Not yet checked' : state === 'passed' ? 'Passed' : 'Failed' }), this.text(evidence, 'muted'))))), actions);
|
|
232
|
+
if (node.loginApplicable && node.access_state !== 'configured') {
|
|
233
|
+
if (this.canRun('access')) detail.append(this.button('Find a login', () => void this.action('access', ['discover', node.ip], 'Login discovery')), this.loginForm(node));
|
|
234
|
+
else detail.append(this.text('This card is read-only. Find or record a login through the CLI.'), this.command(`total-recall mesh access discover ${node.ip}\ntotal-recall mesh access ${node.ip} --user <login>`));
|
|
303
235
|
}
|
|
304
|
-
|
|
305
|
-
|
|
236
|
+
if (node.reach === 'reachable' && !node.self) detail.append(this.text('SSH handoff'), this.command(`total-recall mesh ssh ${node.ip}`));
|
|
237
|
+
detail.append(this.el('h3', { text: 'Recorded earlier' }), this.fields([
|
|
238
|
+
['Hardware measured', node.io?.measured_at], ['Login source', node.access?.source], ['Login verified', node.access?.verified_at], ['Heartbeat recorded', node.last_heartbeat], ['LAN address', node.lan_ip],
|
|
239
|
+
]), this.text('Only physical LAN and mesh addresses are shown; a recorded heartbeat is not a live probe.', 'muted'));
|
|
240
|
+
return detail;
|
|
306
241
|
}
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
const
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
const tabs = root.querySelector('#tabs');
|
|
322
|
-
tabs.replaceChildren(...this.screens.map((s) => this.el('button', {
|
|
323
|
-
role: 'tab', 'aria-selected': String(s.id === this.screen), text: s.label, onclick: () => this.show(s.id),
|
|
324
|
-
})), this.el('span', { class: 'spacer' }), this.el('button', {
|
|
325
|
-
text: this.busy ? 'Reading' : 'Refresh', onclick: () => void this.read(),
|
|
326
|
-
...(this.busy ? { disabled: '' } : {}),
|
|
327
|
-
}));
|
|
328
|
-
|
|
329
|
-
const errors = root.querySelector('#errors');
|
|
330
|
-
errors.replaceChildren(...Object.entries(this.errors).map(([key, message]) => this.el('div', {
|
|
331
|
-
class: 'err', text: `${key} could not be read: ${message}`,
|
|
332
|
-
})));
|
|
333
|
-
|
|
334
|
-
const body = root.querySelector('#body');
|
|
335
|
-
const nodes = joinNodes(this.status, this.doctor);
|
|
336
|
-
if (this.screen === 'control') body.replaceChildren(this.controlView());
|
|
337
|
-
else if (this.screen === 'capability') body.replaceChildren(this.capabilityView(nodes));
|
|
338
|
-
else body.replaceChildren(...this.fleetView(nodes));
|
|
339
|
-
|
|
340
|
-
const foot = root.querySelector('#foot');
|
|
341
|
-
foot.textContent = this.status && str(this.status.observed_at) ? `Registry observed ${this.status.observed_at}` : '';
|
|
242
|
+
loginForm(node) {
|
|
243
|
+
const form = this.el('form', { class: 'login-form' });
|
|
244
|
+
const controls = {};
|
|
245
|
+
for (const [key, label, value] of [['user', 'Login account', ''], ['host', 'SSH host', node.ip || ''], ['port', 'Port', '22'], ['identity', 'Key file path (optional)', '']]) {
|
|
246
|
+
const input = this.el('input', { name: key, value: this.loginDrafts[node.ip]?.[key] ?? value, oninput: event => { (this.loginDrafts[node.ip] ||= {})[key] = event.target.value; }, ...(key === 'user' ? { required: '' } : {}), ...(key === 'port' ? { type: 'number', min: '1', max: '65535' } : {}) });
|
|
247
|
+
controls[key] = input; form.append(this.el('label', { text: label }, input));
|
|
248
|
+
}
|
|
249
|
+
form.append(this.el('button', { type: 'submit', text: 'Record login' }));
|
|
250
|
+
form.addEventListener('submit', event => {
|
|
251
|
+
event.preventDefault(); const args = [node.ip, '--user', controls.user.value, '--host', controls.host.value, '--port', controls.port.value];
|
|
252
|
+
if (controls.identity.value) args.push('--identity', controls.identity.value);
|
|
253
|
+
void this.action('access', args, 'Record login');
|
|
254
|
+
});
|
|
255
|
+
return form;
|
|
342
256
|
}
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
return this.el('
|
|
346
|
-
this.el('div', { class: 'n', text: value === undefined ? DASH : String(value), dataset: { tone } }),
|
|
347
|
-
this.el('div', { class: 'l', text: label }),
|
|
348
|
-
value === undefined && why ? this.el('div', { class: 'why', text: why }) : null);
|
|
257
|
+
sourceBlock(name, source) {
|
|
258
|
+
if (!source) return this.el('div', { class: 'flow' }, this.el('h3', { text: sourceLabels[name] || name }), this.text('Observation not yet read.', 'muted'));
|
|
259
|
+
return this.el('details', { class: 'flow' }, this.el('summary', { text: `${sourceLabels[name] || name} · ${source.ok ? this.flowHeadline(name, source.data) : `Unavailable: ${words(source.error)}`}`, dataset: { tone: source.ok ? 'neutral' : 'danger' } }), source.ok ? this.describeSource(name, source.data) : this.text(`Unavailable: ${words(source.error)}`, 'error'), this.el('small', { text: `Attempted ${words(source.checked_at)}` }));
|
|
349
260
|
}
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
const
|
|
358
|
-
|
|
359
|
-
const grid = this.el('div', { class: 'figures' },
|
|
360
|
-
this.figure('Nodes', haveStatus ? live.length : undefined, 'neutral', why(haveStatus, 'status')),
|
|
361
|
-
this.figure('Online', online, online === undefined ? 'neutral' : (online === live.length ? 'ok' : 'warn'), why(haveStatus, 'status')),
|
|
362
|
-
this.figure('Reachable', reachable, reachable === undefined ? 'neutral' : (reachable === live.length ? 'ok' : 'danger'), why(haveDoctor, 'doctor')),
|
|
363
|
-
this.figure('No login recorded', noLogin, noLogin === undefined ? 'neutral' : (noLogin ? 'warn' : 'ok'), why(haveStatus, 'status')));
|
|
364
|
-
|
|
365
|
-
if (!haveStatus) return [grid];
|
|
366
|
-
const ordered = [...nodes].sort((a, b) => b.severity - a.severity || String(a.hostname).localeCompare(String(b.hostname)));
|
|
367
|
-
const list = this.el('ul', {}, ...ordered.map((n) => this.nodeRow(n)));
|
|
368
|
-
return [grid, this.el('div', { class: 'section', text: 'Nodes, most urgent first' }), list];
|
|
261
|
+
flowHeadline(name, data) {
|
|
262
|
+
if (name === 'grants') return Array.isArray(data.grants) ? `${data.grants.length} secret grants` : 'Grant inventory unreadable';
|
|
263
|
+
if (name === 'updates') return data.last ? `${data.last.failed ?? DASH} failures in the last recorded update` : 'No recorded update report';
|
|
264
|
+
if (name === 'deployments') return data.executionConfigured === false ? 'Not configured for this project' : 'Release state read';
|
|
265
|
+
if (name === 'plugins') return Array.isArray(data.peers) ? `${data.peers.filter(peer => peer.status === 'ok').length} peers answered · ${data.peers.filter(peer => peer.status === 'offline').length} offline · ${data.peers.filter(peer => !['ok', 'offline'].includes(peer.status)).length} unavailable` : 'Peer observations unreadable';
|
|
266
|
+
if (name === 'tasks') return Array.isArray(data.nodes) ? `Task declarations from ${data.nodes.filter(node => node.status === 'ok').length} nodes · ${data.nodes.filter(node => node.status !== 'ok').length} unavailable` : 'Task declarations read';
|
|
267
|
+
if (name === 'agents') return Array.isArray(data) ? `${data.length} process records` : 'Process inventory read';
|
|
268
|
+
if (name === 'alerts') { const count = Array.isArray(data) ? data.length : 1; return `${count} recorded delivery attempt${count === 1 ? '' : 's'}`; }
|
|
269
|
+
return 'Observation read';
|
|
369
270
|
}
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
this.
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
271
|
+
describeSource(name, data) {
|
|
272
|
+
if (name === 'grants') {
|
|
273
|
+
if (!Array.isArray(data.grants)) return this.text('Grant list was not returned.', 'error');
|
|
274
|
+
return this.el('div', {}, data.grants.length ? data.grants.map(grant => this.text(`${grant.node || grant.tag || grant.user || grant.selector?.node || grant.selector?.tag || grant.selector?.user || 'Unrecognised selector'} → ${list(grant.keys).join(', ') || 'No key names returned'}`)) : this.text('No machine may read a secret through the mesh.'));
|
|
275
|
+
}
|
|
276
|
+
if (name === 'updates') {
|
|
277
|
+
const report = data.last || data.report || data;
|
|
278
|
+
return this.fields([['Last result', report.ok === true || report.success === true ? 'Succeeded' : report.ok === false || report.success === false ? 'Failed' : words(report.status || report.state)], ['Recorded', report.finished_at || report.completed_at || report.timestamp || report.at], ['Failures', Array.isArray(report.failures) ? report.failures.length : report.failed ?? DASH]]);
|
|
279
|
+
}
|
|
280
|
+
if (name === 'deployments') return data.executionConfigured === false ? this.text('No deployment execution is configured for this project. Other projects are not inferred.') : this.fields([['Active slot', data.activeSlot || data.active_slot || data.slot], ['Release', data.activeVersion || data.version || data.release], ['Health', typeof data.healthy === 'boolean' ? String(data.healthy) : data.health?.status]]);
|
|
281
|
+
if (name === 'tasks' && Array.isArray(data.nodes)) return this.el('div', {}, data.nodes.map(node => this.el('div', {}, this.text(`${words(node.hostname)} · ${words(node.status)}${node.error ? ` · ${node.error}` : ''}`), list(node.automations).map(task => this.text(`${words(task.intent)} · ${words(task.placement)} · ${words(task.schedule)}`, 'muted')))));
|
|
282
|
+
if (name === 'plugins' && data.mesh?.available === false) return this.text('Peer sharing is unavailable because the mesh is unavailable.', 'error');
|
|
283
|
+
const rows = Array.isArray(data) ? data : name === 'alerts' && data.event_id ? [data] : data.peers || data.agents || data.tasks || data.automations || data.events || data.entries || data.hosts;
|
|
284
|
+
if (!Array.isArray(rows)) return this.text('Owner returned no recognised inventory. Inspect the owner CLI for details.', 'muted');
|
|
285
|
+
if (!rows.length) return this.text(name === 'agents' ? 'No agent processes returned.' : name === 'tasks' ? 'No scheduled tasks returned.' : name === 'plugins' ? 'No peer sharing observations returned.' : 'No entries returned.');
|
|
286
|
+
return this.el('div', {}, rows.slice(0, 30).map(row => this.fields([
|
|
287
|
+
[name === 'plugins' ? 'Peer' : 'Name', row.hostname || row.node || row.node_id || row.name || row.intent || row.id || row.type],
|
|
288
|
+
['State', row.status || row.state || row.result?.status],
|
|
289
|
+
...(!['plugins', 'alerts'].includes(name) ? [['Runs on', row.target_node || row.node_name || row.node || row.placement]] : []),
|
|
290
|
+
...(name === 'alerts' ? [['Channel', row.channel], ['Observed', row.at]] : []),
|
|
291
|
+
...(name === 'plugins' ? [['Shared plugins', row.status === 'ok' && Array.isArray(row.plugins) ? row.plugins.length : DASH]] : []),
|
|
292
|
+
])), rows.length > 30 ? this.text(`${rows.length - 30} more entries are available through the owner CLI.`, 'muted') : null);
|
|
392
293
|
}
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
if (!s) return this.el('div', { class: 'err', text: 'The status command returned nothing readable, so the control server cannot be shown.' });
|
|
397
|
-
const c = s.control || {};
|
|
398
|
-
const e = s.enrollment || {};
|
|
399
|
-
const l = this.leader && this.leader.leader ? this.leader.leader : null;
|
|
400
|
-
const row = (k, v, tone) => [this.el('dt', { text: k }), this.el('dd', { text: v === undefined ? DASH : String(v), dataset: tone ? { tone } : {} })];
|
|
401
|
-
const dl = this.el('dl', {},
|
|
402
|
-
...row('Control server', hostOf(c.url)),
|
|
403
|
-
...row('Verification', c.verified === true ? 'Verified' : (c.verified === false ? 'Not verified' : undefined), c.verified === true ? 'ok' : 'danger'),
|
|
404
|
-
...row('Control state', str(c.state)),
|
|
405
|
-
...row('This machine', str(e.hostname)),
|
|
406
|
-
...row('Enrollment', str(e.state), e.enrolled === true ? 'ok' : 'warn'),
|
|
407
|
-
...row('Backend', str(e.backend_state)),
|
|
408
|
-
...row('Addresses', Array.isArray(e.ips) ? e.ips.join(', ') : undefined),
|
|
409
|
-
...row('Enrollment checked', str(e.checked_at)),
|
|
410
|
-
...row('Leader', l ? l.hostname : undefined),
|
|
411
|
-
...row('Leader strategy', l ? l.strategy : undefined),
|
|
412
|
-
...row('This node leads', this.leader && typeof this.leader.is_current_node_leader === 'boolean' ? (this.leader.is_current_node_leader ? 'Yes' : 'No') : undefined));
|
|
413
|
-
return dl;
|
|
294
|
+
flows() {
|
|
295
|
+
const sources = this.views.flows?.sources || {};
|
|
296
|
+
return this.section('What moves across the mesh', this.text('Each row is an independent owner observation; a grant or configuration is not proof of a successful transfer.', 'muted'), ['grants', 'plugins', 'updates', 'tasks', 'agents', 'alerts', 'deployments'].map(name => this.sourceBlock(name, sources[name])), this.text('Transfer history, per-node service health and speed are not readable through the current CLI.', 'muted'));
|
|
414
297
|
}
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
298
|
+
placement(nodes) {
|
|
299
|
+
const live = nodes.filter(node => !node.vaultOnly);
|
|
300
|
+
const columns = [...new Set(live.flatMap(node => [...node.runtimes.map(name => `runtime:${name}`), ...node.harnesses.map(name => `agent:${name}`), ...list(node.probe?.tested_runtimes).map(name => `runtime:${name}`), ...list(node.probe?.tested_harnesses).map(name => `agent:${name}`)]))].sort();
|
|
301
|
+
columns.push(...['screen', 'microphone', 'speaker', 'camera', 'headless'].map(name => `io:${name}`));
|
|
302
|
+
const filters = this.el('div', { class: 'filters' }, columns.map(column => {
|
|
303
|
+
const input = this.el('input', { type: 'checkbox', 'aria-label': `Require ${column}`, ...(this.filters.has(column) ? { checked: '' } : {}), onchange: event => { event.target.checked ? this.filters.add(column) : this.filters.delete(column); this.paint(); } });
|
|
304
|
+
return this.el('label', {}, input, this.el('span', { text: column }));
|
|
305
|
+
}));
|
|
306
|
+
const matches = live.filter(node => [...this.filters].every(column => capabilityState(node, column).startsWith('Present')));
|
|
307
|
+
const table = this.el('table', {}, this.el('thead', {}, this.el('tr', {}, this.el('th', { text: 'Machine' }), columns.map(column => this.el('th', { text: column })))), this.el('tbody', {}, matches.map(node => this.el('tr', {}, this.el('th', { text: `${words(node.hostname)} · hardware ${words(node.io?.measured_at)}` }), columns.map(column => this.el('td', { text: capabilityState(node, column) }))))));
|
|
308
|
+
const sources = this.views.placement?.sources || {};
|
|
309
|
+
return this.section('Where work can run', this.text('Runtime and agent cells come from execution probes. Hardware cells are recorded facts with their own measurement date.', 'muted'), filters, this.el('div', { class: 'table-wrap', tabindex: '0', 'aria-label': 'Machine capability matrix' }, table), !matches.length ? this.text('No machine has all selected capabilities confirmed.') : null, this.integrationMatrix(nodes), this.el('h3', { text: 'Where work runs' }), this.sourceBlock('tasks', sources.tasks), this.sourceBlock('agents', sources.agents));
|
|
310
|
+
}
|
|
311
|
+
integrationMatrix(nodes) {
|
|
312
|
+
const declarations = this.views.placement?.declarations;
|
|
313
|
+
const rows = list(declarations?.integrations).map(integration => {
|
|
314
|
+
const [screen, sourceName] = integration.observation.split('.');
|
|
315
|
+
const source = sourceName ? this.views[screen]?.sources?.[sourceName] : null;
|
|
316
|
+
let state = 'Not checked', tone = 'neutral';
|
|
317
|
+
if (integration.id === 'ssh') {
|
|
318
|
+
const targets = nodes.filter(node => !node.vaultOnly && (node.loginApplicable || node.self));
|
|
319
|
+
state = Array.isArray(this.doctor) ? `${targets.filter(node => node.reach === 'reachable').length}/${targets.length} execution probes answered` : 'Execution not checked';
|
|
320
|
+
} else if (source?.ok === false) { state = `Failed: ${words(source.error)}`; tone = 'danger'; }
|
|
321
|
+
else if (source?.ok) {
|
|
322
|
+
const data = source.data;
|
|
323
|
+
if (integration.id === 'auth' || integration.id === 'headscale') { state = data.control?.verified === true ? 'Authentication verified' : 'Authentication unverified'; tone = data.control?.verified ? 'ok' : 'danger'; }
|
|
324
|
+
else if (integration.id === 'tailscale') { state = data.network_available === true ? 'Network observed' : 'Network unavailable'; tone = data.network_available ? 'ok' : 'warn'; }
|
|
325
|
+
else if (integration.id === 'updates') { state = data.last ? `${data.last.failed ?? DASH} failures in last recorded report` : 'No last report returned'; tone = data.last?.failed > 0 ? 'danger' : 'neutral'; }
|
|
326
|
+
else if (integration.id === 'deployments') state = data.executionConfigured === false ? 'Not configured for selected project' : 'Release state read';
|
|
327
|
+
else if (integration.id === 'presence') { const failures = list(data.peers).filter(peer => peer.state === 'unavailable').length; state = failures ? `${failures} peers unavailable` : 'Activity observations read'; tone = failures ? 'warn' : 'neutral'; }
|
|
328
|
+
else state = 'Owner observation read';
|
|
329
|
+
}
|
|
330
|
+
return this.el('tr', {}, this.el('th', { text: integration.id }), this.el('td', { text: integration.owner }), this.el('td', { text: integration.purpose }), this.el('td', { text: state, dataset: { tone } }), this.el('td', { text: integration.evidence }), this.el('td', { text: source?.checked_at || (integration.id === 'ssh' ? this.lastAttempt : DASH) }));
|
|
331
|
+
});
|
|
332
|
+
const table = this.el('table', {}, this.el('thead', {}, this.el('tr', {}, ['Integration', 'Owner', 'Declared purpose', 'Observed result', 'Evidence means', 'Attempted'].map(text => this.el('th', { text })))), this.el('tbody', {}, rows));
|
|
333
|
+
const primitives = list(declarations?.provides);
|
|
334
|
+
const primitiveTable = this.el('table', {}, this.el('thead', {}, this.el('tr', {}, ['Primitive', 'Operation', 'Effect', 'Observation contract'].map(text => this.el('th', { text })))), this.el('tbody', {}, primitives.map(capability => this.el('tr', {}, [capability.id, capability.operation, capability.effect || 'See command declaration', capability.observation || 'No read-back contract'].map(text => this.el('td', { text }))))));
|
|
335
|
+
return this.section('Plugin integration matrix', this.text('Support comes from this plugin’s manifest. Read results certify only the named observation, not every action in an integration.', 'muted'), this.button('Verify integrations', () => void Promise.all(['machines', 'flows', 'boundary', 'join'].map(screen => this.readView(screen, true))), { ...(this.busy || Object.keys(this.loading).length ? { disabled: '' } : {}) }), rows.length ? this.el('div', { class: 'table-wrap', tabindex: '0', 'aria-label': 'Plugin integration capability matrix' }, table) : this.text('Manifest integration declarations were not returned.', 'muted'), this.el('details', {}, this.el('summary', { text: `${primitives.length} declared mesh primitives` }), this.text('Effects come from the command declaration. This inventory grants no authority and does not certify unobserved actions.', 'muted'), this.el('div', { class: 'table-wrap', tabindex: '0', 'aria-label': 'Declared mesh primitives' }, primitiveTable)));
|
|
336
|
+
}
|
|
337
|
+
boundary(nodes) {
|
|
338
|
+
const sources = this.views.boundary?.sources || {};
|
|
339
|
+
const policy = sources.policy;
|
|
340
|
+
let parsed = policy?.data?.policy ?? policy?.data;
|
|
341
|
+
if (typeof parsed === 'string') { try { parsed = JSON.parse(parsed); } catch {} }
|
|
342
|
+
const policyView = !policy?.ok ? this.text(policy?.error || 'Policy not yet read.', 'muted') : this.el('div', {},
|
|
343
|
+
parsed && typeof parsed === 'object' ? list(parsed.acls).map(rule => this.text(`${words(rule.action)}: ${list(rule.src).join(', ')} → ${list(rule.dst).join(', ')}`)) : this.text('No structured policy returned; no permissions are inferred.'),
|
|
344
|
+
parsed && typeof parsed === 'object' ? list(parsed.ssh).map(rule => this.text(`SSH ${words(rule.action)}: ${list(rule.src).join(', ')} → ${list(rule.dst).join(', ')} as ${list(rule.users).join(', ')}${list(rule.users).includes('root') ? ' · root access is enabled' : ''}`)) : null,
|
|
345
|
+
this.text('Policy changes stay in the CLI; review a dry run before applying.'), this.command('total-recall mesh policy get\ntotal-recall mesh policy init-ssh --dry-run'));
|
|
346
|
+
const macs = new Map();
|
|
347
|
+
for (const node of nodes) for (const face of list(node.interfaces).filter(i => i.kind === 'ethernet' && i.mac)) { const names = macs.get(face.mac) || new Set(); names.add(node.hostname); macs.set(face.mac, names); }
|
|
348
|
+
const duplicates = [...macs.values()].filter(names => names.size > 1);
|
|
349
|
+
return this.el('div', { class: 'panes' }, this.section('Inside the boundary', this.text(`${this.status ? nodes.filter(node => !node.vaultOnly).length : DASH} mesh members. Membership defines who enters this boundary.`), policyView, this.sourceBlock('grants', sources.grants), duplicates.map(names => this.text(`Possible duplicate records: ${[...names].join(', ')}. Record retirement is not readable through the current CLI.`, 'muted')), this.text('Exit nodes and subnet routes are not readable through the current CLI.', 'muted')),
|
|
350
|
+
this.section('Outside the boundary', this.text('Public exposure and tunnel inventories are not readable through the current CLI. No absence of exposure is inferred.'), this.text('DNS, release health and firewall findings remain with their owning plugins; no live security audit is started by this view.', 'muted'), this.command('total-recall domains --help\ntotal-recall deploy status --json\ntotal-recall security status --json')));
|
|
351
|
+
}
|
|
352
|
+
firstRun() {
|
|
353
|
+
return this.section('Configure your own mesh', this.text('A control server credential or URL is missing. Configure your own server before admitting machines.'), this.command(this.status?.control?.url ? 'total-recall mesh token status\ntotal-recall mesh token --help' : 'total-recall mesh server init --host <your-host> --domain <your-domain>'), this.text('The server must belong to this project’s operator. No shared server address is supplied.', 'muted'));
|
|
354
|
+
}
|
|
355
|
+
join() {
|
|
356
|
+
const enrollment = this.status?.enrollment || {};
|
|
357
|
+
const target = words(enrollment.hostname);
|
|
358
|
+
const view = this.section('Admit a machine', this.text('Every admitted machine enters the mesh trust boundary. Review membership and policy before adding one.'), this.fields([
|
|
359
|
+
['This machine', target], ['Enrollment', enrollment.state], ['Backend', enrollment.backend_state], ['Addresses', list(enrollment.ips).join(', ') || DASH], ['Auto-enroll', typeof enrollment.auto_enroll_enabled === 'boolean' ? enrollment.auto_enroll_enabled ? 'Enabled' : 'Disabled' : DASH], ['Blocked reason', enrollment.auto_enroll_blocked_reason || DASH], ['Checked', enrollment.checked_at],
|
|
360
|
+
]), this.el('h3', { text: 'Enrollment key handoff' }), this.text('Mint the key in your shell, then pass it directly to the new machine. This view never requests or displays the enrollment key.'), this.command('total-recall mesh preauthkey\n# Optional: --reusable admits multiple machines; --ephemeral removes a disconnected node.\n# On the new machine: tailscale up --login-server <your-control-server> --auth-key <key-from-your-shell>'));
|
|
361
|
+
if (enrollment.hostname && this.canRun('enroll')) {
|
|
362
|
+
view.append(this.button(`Re-enroll ${target}`, () => { this.confirmEnrollment = true; this.paint(); }, { class: 'danger' }));
|
|
363
|
+
if (this.confirmEnrollment) view.append(this.text(`Re-enroll ${target}? This may interrupt its mesh connection.`, 'error'), this.button(`Confirm re-enrollment of ${target}`, () => { this.confirmEnrollment = false; void this.action('enroll', ['--force'], 'Re-enrollment'); }, { class: 'danger', 'data-action': 'confirm-enroll' }), this.button('Cancel', () => { this.confirmEnrollment = false; this.paint(); }));
|
|
419
364
|
}
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
: this.el('span', { class: 'sub', text: none }));
|
|
423
|
-
const probed = nodes.filter((n) => !n.vaultOnly);
|
|
424
|
-
return this.el('ul', {}, ...probed.map((n) => this.el('li', { class: 'node', dataset: { tone: n.reach === 'reachable' ? 'ok' : 'neutral' } },
|
|
425
|
-
this.el('div', { class: 'host', text: n.hostname || DASH }),
|
|
426
|
-
this.el('span', { class: 'sub', text: n.reach }),
|
|
427
|
-
this.el('div', { class: 'states' }, this.el('span', { text: 'Runtimes ' }), chips(n.runtimes, n.reach === 'unprobed' ? 'not probed' : 'none found')),
|
|
428
|
-
this.el('div', { class: 'states' }, this.el('span', { text: 'Agent harnesses ' }), chips(n.harnesses, n.reach === 'unprobed' ? 'not probed' : 'none found')))));
|
|
365
|
+
else if (enrollment.hostname) view.append(this.text(`This card is read-only. Re-enrolling ${target} can interrupt its connection; use the CLI after confirming that target.`), this.command('total-recall mesh enroll --force'));
|
|
366
|
+
return view;
|
|
429
367
|
}
|
|
430
368
|
}
|
|
431
|
-
|
|
432
|
-
if (!customElements.get('tr-mesh-card')) {
|
|
433
|
-
customElements.define('tr-mesh-card', MeshlinkControlCard);
|
|
434
|
-
}
|
|
369
|
+
if (!customElements.get('tr-mesh-card')) customElements.define('tr-mesh-card', MeshlinkControlCard);
|