total-recall-brain 3.40.6 → 3.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/bin/total-recall-memory.mjs +0 -0
  2. package/bin/total-recall.mjs +9 -40
  3. package/docs/reference/cli-reference.md +65 -1
  4. package/frontend/dist/assets/{index-AGUes-VG.css → index-BG9NkVUy.css} +1 -1
  5. package/frontend/dist/assets/{index-Bt5iVPZj.js → index-BtuiZqwP.js} +20 -7
  6. package/frontend/dist/index.html +2 -2
  7. package/frontend/src/components/plugins/PluginPanel.tsx +31 -5
  8. package/frontend/src/components/plugins/card/CardEngine.tsx +15 -23
  9. package/frontend/src/components/plugins/card/CardScenes.tsx +52 -23
  10. package/frontend/src/components/plugins/card/card.css +2 -5
  11. package/frontend/src/components/plugins/card/contract.ts +13 -19
  12. package/frontend/src/components/plugins/card/format.ts +8 -1
  13. package/frontend/src/components/plugins/card/widgets.tsx +4 -35
  14. package/frontend/src/components/plugins/plugin-frame.ts +30 -3
  15. package/frontend/src/pages/PluginsPage.tsx +3 -2
  16. package/package.json +1 -1
  17. package/scaffold/.agent/skills/plugin/SKILL.md +110 -0
  18. package/scaffold/.agent/skills/plugin/evals/README.md +53 -0
  19. package/scaffold/.agent/skills/plugin/evals/cases/01-fabricated-seed.mjs +42 -0
  20. package/scaffold/.agent/skills/plugin/evals/cases/02-reassuring-default.mjs +39 -0
  21. package/scaffold/.agent/skills/plugin/evals/cases/03-declared-template.mjs +32 -0
  22. package/scaffold/.agent/skills/plugin/evals/cases/04-card-collects-credential.mjs +46 -0
  23. package/scaffold/.agent/skills/plugin/evals/cases/05-host-leak-in-scaffold.mjs +55 -0
  24. package/scaffold/.agent/skills/plugin/evals/cases/06-correct-plugin.mjs +92 -0
  25. package/scaffold/.agent/skills/plugin/evals/run.mjs +118 -0
  26. package/scaffold/.agent/skills/plugin/prompts/01-function.md +29 -0
  27. package/scaffold/.agent/skills/plugin/prompts/02-surfaces.md +43 -0
  28. package/scaffold/.agent/skills/plugin/prompts/03-scaffold.md +31 -0
  29. package/scaffold/.agent/skills/plugin/prompts/04-cli.md +49 -0
  30. package/scaffold/.agent/skills/plugin/prompts/05-credentials.md +41 -0
  31. package/scaffold/.agent/skills/plugin/prompts/06-card.md +46 -0
  32. package/scaffold/.agent/skills/plugin/prompts/07-scaffold.md +52 -0
  33. package/scaffold/.agent/skills/plugin/prompts/08-verify.md +43 -0
  34. package/scaffold/.agent/skills/plugin/references/anti-patterns.md +123 -0
  35. package/scaffold/.agent/skills/plugin/references/card.md +90 -0
  36. package/scaffold/.agent/skills/plugin/references/scaffold.md +120 -0
  37. package/scaffold/.agent/skills/plugin/references/surfaces.md +206 -0
  38. package/scaffold/.agent/skills/plugin/scripts/record.mjs +360 -0
  39. package/scaffold/.agent/skills/plugin/subagents/card-designer.md +60 -0
  40. package/scaffold/.agent/skills/plugin/subagents/plugin-reviewer.md +55 -0
  41. package/scaffold/.agent/skills/plugin/subagents/scaffold-extractor.md +71 -0
  42. package/scaffold/.agent/skills/total-recall/plugins/auth/package-lock.json +71 -1441
  43. package/scaffold/.agent/skills/total-recall/plugins/auth/package.json +7 -5
  44. package/scaffold/.agent/skills/total-recall/plugins/auth/plugin.json +3 -44
  45. package/scaffold/.agent/skills/total-recall/plugins/auth/scripts/agent-login-pty.py +185 -0
  46. package/scaffold/.agent/skills/total-recall/plugins/auth/src/cli-secret.mjs +20 -1
  47. package/scaffold/.agent/skills/total-recall/plugins/auth/src/host-secrets-store.mjs +15 -0
  48. package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-env-export.mjs +6 -4
  49. package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-remote-deploy.mjs +1 -0
  50. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/plugin.json +0 -24
  51. package/scaffold/.agent/skills/total-recall/plugins/code-quality/package-lock.json +4 -4
  52. package/scaffold/.agent/skills/total-recall/plugins/code-quality/plugin.json +0 -21
  53. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package-lock.json +4 -4
  54. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/plugin.json +0 -32
  55. package/scaffold/.agent/skills/total-recall/plugins/embeddings/package-lock.json +4 -4
  56. package/scaffold/.agent/skills/total-recall/plugins/embeddings/plugin.json +11 -0
  57. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/README.md +5 -2
  58. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package-lock.json +4 -4
  59. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/plugin.json +1 -28
  60. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/ui/DESIGN.md +95 -13
  61. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/ui/card.js +553 -340
  62. package/scaffold/.agent/skills/total-recall/plugins/mesh/plugin.json +0 -36
  63. package/scaffold/.agent/skills/total-recall/plugins/mesh/ui/card.js +383 -400
  64. package/scaffold/.agent/skills/total-recall/plugins/openwiki/package-lock.json +4 -4
  65. package/scaffold/.agent/skills/total-recall/plugins/openwiki/package.json +0 -1
  66. package/scaffold/.agent/skills/total-recall/plugins/openwiki/plugin.json +0 -28
  67. package/scaffold/.agent/skills/total-recall/plugins/security/package-lock.json +4 -4
  68. package/scaffold/.agent/skills/total-recall/plugins/security/plugin.json +0 -32
  69. package/scaffold/.agent/skills/total-recall/references/auth.md +3 -1
  70. package/scaffold/.agent/skills/total-recall/references/memory.md +4 -0
  71. package/scaffold/.agent/skills/total-recall/references/openwiki.md +2 -0
  72. package/scaffold/.agent/skills/total-recall/references/operations.md +12 -0
  73. package/src/cli/brain.mjs +4 -2
  74. package/src/cli/context-stats.mjs +104 -0
  75. package/src/cli/context.mjs +8 -0
  76. package/src/cli/daemon.mjs +10 -1
  77. package/src/cli/export.mjs +3 -2
  78. package/src/cli/ingest-okf.mjs +12 -15
  79. package/src/cli/init.mjs +45 -85
  80. package/src/cli/lint.mjs +3 -2
  81. package/src/cli/plugin/copies.mjs +68 -0
  82. package/src/cli/plugin/create.mjs +22 -24
  83. package/src/cli/plugin/index.mjs +49 -3
  84. package/src/cli/plugin/search.mjs +19 -2
  85. package/src/cli/rebuild.mjs +2 -0
  86. package/src/cli/remember.mjs +23 -0
  87. package/src/cli/status.mjs +10 -3
  88. package/src/cli/update.mjs +3 -2
  89. package/src/core/action-predictor.mjs +161 -0
  90. package/src/core/app-deploy/runtime-cli/node.mjs.tmpl +20 -3
  91. package/src/core/app-deploy/source.mjs +16 -9
  92. package/src/core/automatic-brain-updates.mjs +6 -3
  93. package/src/core/brain-registry.mjs +19 -2
  94. package/src/core/brain-state.mjs +23 -0
  95. package/src/core/cli-commands.mjs +1 -0
  96. package/src/core/cli-maintenance.mjs +1 -1
  97. package/src/core/cli-semantic-index.mjs +1 -1
  98. package/src/core/command-surface.mjs +3 -3
  99. package/src/core/composed-command-resolution.mjs +21 -0
  100. package/src/core/config.mjs +84 -0
  101. package/src/core/confined-memory.mjs +93 -0
  102. package/src/core/context-telemetry.mjs +214 -0
  103. package/src/core/daemon-control.mjs +14 -43
  104. package/src/core/fast-recall.mjs +1 -1
  105. package/src/core/memory-brain.mjs +2 -0
  106. package/src/core/plugin-activation.mjs +38 -6
  107. package/src/core/plugin-bundle.mjs +54 -0
  108. package/src/core/plugin-catalog.mjs +1 -1
  109. package/src/core/plugin-context.mjs +1 -1
  110. package/src/core/plugin-contracts/card.mjs +69 -37
  111. package/src/core/plugin-loader.mjs +5 -6
  112. package/src/core/plugin-runner.mjs +13 -6
  113. package/src/core/plugin-runtime.mjs +5 -4
  114. package/src/core/plugin-setup.mjs +5 -3
  115. package/src/core/plugin-state-store.mjs +12 -1
  116. package/src/core/plugin-state.mjs +46 -5
  117. package/src/core/plugin-store.mjs +78 -3
  118. package/src/core/plugin-tasks.mjs +3 -1
  119. package/src/core/plugin-updates.mjs +117 -0
  120. package/src/core/plugin-vendor.mjs +10 -4
  121. package/src/core/project-brain.mjs +14 -41
  122. package/src/core/project-id.mjs +4 -1
  123. package/src/core/required-plugins.mjs +151 -13
  124. package/src/core/skills-registry.mjs +27 -3
  125. package/src/core/ssss-kernel-bridge.mjs +3 -1
  126. package/src/core/startup-health.mjs +66 -16
  127. package/src/core/surface.mjs +51 -44
  128. package/src/core/testing/plugin-fixture.mjs +69 -0
  129. package/src/core/vault-files.mjs +13 -0
  130. package/src/core/vault.mjs +4 -30
  131. package/src/server/index.mjs +13 -1
  132. package/src/server/invoke-decision-child.mjs +49 -0
  133. package/src/server/invoke-execution.mjs +87 -0
  134. package/src/server/invoke-policy.mjs +64 -0
  135. package/src/server/invoke-read-child.mjs +26 -0
  136. package/src/server/invoke-targets.mjs +49 -0
  137. package/src/server/invoke-write-child.mjs +19 -0
  138. package/src/server/routes/invoke.mjs +314 -0
  139. package/src/server/routes/plugins.mjs +1 -1
  140. package/scaffold/.agent/skills/plugins/SKILL.md +0 -57
  141. package/scaffold/.agent/skills/plugins/references/card-design.md +0 -55
  142. package/scaffold/.agent/skills/plugins/references/distribution.md +0 -23
  143. package/scaffold/.agent/skills/plugins/references/manifest-contract.md +0 -90
  144. package/scaffold/.agent/skills/plugins/references/optimized/index.md +0 -9
  145. package/scaffold/.agent/skills/plugins/references/optimized/manifest.json +0 -35
  146. package/scaffold/.agent/skills/plugins/references/optimized/original.md +0 -58
  147. package/scaffold/.agent/skills/plugins/references/optimized/section-01.md +0 -5
  148. package/scaffold/.agent/skills/plugins/references/optimized/section-02.md +0 -25
  149. package/scaffold/.agent/skills/plugins/references/optimized/section-03.md +0 -12
  150. package/scaffold/.agent/skills/plugins/references/optimized/section-04.md +0 -7
  151. package/scaffold/.agent/skills/plugins/references/optimized/section-05.md +0 -5
  152. package/scaffold/.agent/skills/total-recall/openwiki/README.md +0 -10
  153. package/scaffold/.agent/skills/total-recall/openwiki/architecture.md +0 -5
  154. package/scaffold/.agent/skills/total-recall/openwiki/ide.md +0 -10
  155. package/scaffold/.agent/skills/total-recall/openwiki/memory.md +0 -11
  156. package/scaffold/.agent/skills/total-recall/openwiki/secrets.md +0 -7
  157. package/scaffold/.agent/skills/total-recall/openwiki/skills.md +0 -7
  158. package/scaffold/openwiki/README.md +0 -10
  159. package/scaffold/openwiki/architecture.md +0 -5
  160. package/scaffold/openwiki/ide.md +0 -10
  161. package/scaffold/openwiki/memory.md +0 -11
  162. package/scaffold/openwiki/secrets.md +0 -7
  163. package/scaffold/openwiki/skills.md +0 -7
  164. package/src/core/okf-adapter.mjs +0 -559
  165. package/templates/openwiki/README.md +0 -10
  166. package/templates/openwiki/architecture.md +0 -5
  167. package/templates/openwiki/ide.md +0 -10
  168. package/templates/openwiki/memory.md +0 -11
  169. package/templates/openwiki/secrets.md +0 -7
  170. package/templates/openwiki/skills.md +0 -7
@@ -1,448 +1,431 @@
1
1
  /**
2
- * Headscale Mesh — Mesh Topology, Peer Node Discovery & WireGuard SSH Control
3
- * Purpose-built domain interface: queries mesh node topology on mount,
4
- * exposes peer latency pings, mesh health diagnostics, presence, and node access.
2
+ * Headscale mesh card.
5
3
  *
6
- * Adheres strictly to invariant: Never ssh a machine into itself.
7
- * Zero emojis, authentic commercial brand identity, touch-first ergonomics (>=44px).
4
+ * Question it answers: can I reach every machine I rely on, and is the control plane healthy?
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.
8
7
  */
9
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. */
21
+ export function asObject(result) {
22
+ if (result === null || result === undefined) return null;
23
+ if (typeof result === 'object') {
24
+ if (typeof result.output === 'string') return asObject(result.output);
25
+ return result;
26
+ }
27
+ if (typeof result === 'string') {
28
+ // Commands print banner and log lines around the JSON; take the first balanced value only.
29
+ const start = result.search(/[\[{]/);
30
+ if (start < 0) return null;
31
+ let depth = 0;
32
+ let inString = false;
33
+ for (let i = start; i < result.length; i += 1) {
34
+ const ch = result[i];
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
+ }
48
+ }
49
+ }
50
+ }
51
+ return null;
52
+ }
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
+ export function joinNodes(status, doctor) {
66
+ const nodes = status && Array.isArray(status.nodes) ? status.nodes : [];
67
+ const probes = Array.isArray(doctor) ? doctor : [];
68
+ return nodes.map((node) => {
69
+ const probe = probes.find((p) => p && (p.ip === node.ip || p.hostname === node.hostname));
70
+ let reach = 'unprobed';
71
+ if (probe && probe.reachable === true) reach = 'reachable';
72
+ else if (probe && probe.reachable === false) reach = 'unreachable';
73
+ const vaultOnly = node.vault_only === true;
74
+ const online = node.online === true;
75
+ const hasLogin = node.access_state === 'configured';
76
+ let severity = 0;
77
+ let label = 'Healthy';
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
+ };
99
+ });
100
+ }
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
+ export function meshVerdict(status, doctor, leader) {
107
+ if (!status || typeof status !== 'object') return { tone: 'neutral', text: 'Cannot verify the mesh.' };
108
+ if (status.state !== 'connected') return { tone: 'danger', text: 'Mesh is not connected.' };
109
+ if (!status.control || status.control.verified !== true) return { tone: 'danger', text: 'Control server is not verified.' };
110
+ const nodes = joinNodes(status, doctor).filter((n) => !n.vaultOnly);
111
+ const unreachable = nodes.filter((n) => n.label === 'Unreachable').length;
112
+ if (unreachable) return { tone: 'danger', text: `${unreachable} node${unreachable === 1 ? '' : 's'} unreachable.` };
113
+ const offline = nodes.filter((n) => n.label === 'Offline').length;
114
+ if (offline) return { tone: 'warn', text: `${offline} node${offline === 1 ? '' : 's'} offline.` };
115
+ const noLogin = nodes.filter((n) => n.label === 'No login recorded').length;
116
+ if (noLogin) return { tone: 'warn', text: `${noLogin} node${noLogin === 1 ? ' has' : 's have'} no login recorded.` };
117
+ if (!Array.isArray(doctor) || !leader || typeof leader !== 'object') return { tone: 'warn', text: 'Partly verified.' };
118
+ return { tone: 'ok', text: 'Every node reachable.' };
119
+ }
120
+
121
+ function hostOf(url) {
122
+ try { return new URL(url).host; } catch { return undefined; }
123
+ }
124
+
10
125
  export class MeshlinkControlCard extends HTMLElement {
11
126
  constructor() {
12
127
  super();
13
- this.executing = false;
14
- this.telemetry = {
15
- nodesCount: 'Mesh Active',
16
- cloudPeer: 'Cloud Gateway',
17
- miniPeer: 'Local Node'
18
- };
128
+ this.status = null;
129
+ this.doctor = null;
130
+ this.leader = null;
131
+ this.errors = {};
132
+ this.lastAttempt = null;
133
+ this.busy = false;
134
+ this.screen = 'fleet';
135
+ this.pings = {};
136
+ }
137
+
138
+ /** Screens this face offers; the host only provides the flipper. */
139
+ get screens() {
140
+ return [
141
+ { id: 'fleet', label: 'Fleet' },
142
+ { id: 'control', label: 'Control' },
143
+ { id: 'capability', label: 'Capability' },
144
+ ];
19
145
  }
20
146
 
21
147
  connectedCallback() {
22
148
  if (!this.shadowRoot) this.attachShadow({ mode: 'open' });
23
149
  this.render();
24
- this.refreshTelemetry();
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
+ };
155
+ window.addEventListener('plugin-show-screen', this.onScreen);
156
+ void this.read();
25
157
  }
26
158
 
27
- async refreshTelemetry() {
28
- try {
29
- const res = await this.host?.run('nodes', []);
30
- let text = '';
31
- if (typeof res === 'object') {
32
- text = res.output || JSON.stringify(res, null, 2);
33
- } else {
34
- text = String(res || '');
35
- }
159
+ disconnectedCallback() {
160
+ if (this.onScreen) window.removeEventListener('plugin-show-screen', this.onScreen);
161
+ }
36
162
 
37
- const lines = text.split('\n').filter(l => l.trim().match(/^\d+/));
38
- const count = lines.length || 4;
39
-
40
- this.telemetry = {
41
- nodesCount: `${count} Nodes`,
42
- cloudPeer: 'Cloud Gateway',
43
- miniPeer: 'Local Node'
44
- };
45
- this.updateView();
46
- } catch {
47
- this.telemetry = {
48
- nodesCount: 'Mesh Active',
49
- cloudPeer: 'Cloud Gateway',
50
- miniPeer: 'Local Host'
51
- };
52
- this.updateView();
163
+ async call(subcommand, args) {
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.');
53
166
  }
167
+ return this.host.run(subcommand, args);
54
168
  }
55
169
 
56
- updateView() {
57
- const root = this.shadowRoot;
58
- if (!root) return;
59
-
60
- const cntEl = root.querySelector('#stat-nodes');
61
- const cldEl = root.querySelector('#stat-cloud');
62
- const minEl = root.querySelector('#stat-mini');
63
- if (cntEl) cntEl.textContent = this.telemetry.nodesCount;
64
- if (cldEl) cldEl.textContent = this.telemetry.cloudPeer;
65
- if (minEl) minEl.textContent = this.telemetry.miniPeer;
170
+ /** Read one command. A failure is recorded and clears that figure; it never keeps a stale one. */
171
+ async readOne(key, subcommand, args) {
172
+ try {
173
+ const payload = asObject(await this.call(subcommand, args));
174
+ if (!payload) throw new Error(`The ${subcommand} command did not return a readable payload.`);
175
+ this[key] = payload;
176
+ delete this.errors[key];
177
+ } catch (err) {
178
+ this[key] = null;
179
+ this.errors[key] = err && err.message ? err.message : String(err);
180
+ }
66
181
  }
67
182
 
68
- async execute(action, args = []) {
69
- const out = this.shadowRoot?.querySelector('#card-out');
70
- const statusPill = this.shadowRoot?.querySelector('#status-pill');
71
- const actionLabel = action.toUpperCase();
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;
195
+ this.paint();
196
+ }
72
197
 
73
- if (out) out.textContent = `[MESH COMMAND] Invoking ${actionLabel} ${args.join(' ')}...`;
74
- if (statusPill) statusPill.textContent = 'QUERYING';
75
- this.executing = true;
198
+ show(screen) {
199
+ this.screen = screen;
200
+ this.paint();
201
+ }
76
202
 
203
+ async ping(node) {
204
+ const target = node.ip || node.hostname;
205
+ if (!target) return;
206
+ this.pings[target] = { state: 'running' };
207
+ this.paint();
77
208
  try {
78
- const res = await this.host?.run(action, args);
79
- let text = '';
80
- if (typeof res === 'object') {
81
- text = res.output || JSON.stringify(res, null, 2);
82
- } else {
83
- text = String(res || 'Operation completed.');
84
- }
85
- if (out) out.textContent = text.trim();
86
- if (statusPill) statusPill.textContent = 'ONLINE';
87
-
88
- if (['nodes', 'status', 'doctor'].includes(action)) {
89
- this.refreshTelemetry();
90
- }
209
+ const out = rawText(await this.call('ping', [target])).trim();
210
+ this.pings[target] = { state: 'done', text: out.split('\n').slice(0, 3).join(' ') || 'No output returned.' };
91
211
  } catch (err) {
92
- if (out) out.textContent = `[MESH FAULT] ${err.message}`;
93
- if (statusPill) statusPill.textContent = 'ERROR';
94
- } finally {
95
- this.executing = false;
212
+ this.pings[target] = { state: 'failed', text: err && err.message ? err.message : String(err) };
96
213
  }
214
+ this.paint();
97
215
  }
98
216
 
99
- copyOutput() {
100
- const out = this.shadowRoot?.querySelector('#card-out');
101
- const copyBtn = this.shadowRoot?.querySelector('#btn-copy');
102
- if (!out) return;
103
-
104
- navigator.clipboard?.writeText(out.textContent || '').then(() => {
105
- if (copyBtn) {
106
- const prev = copyBtn.textContent;
107
- copyBtn.textContent = 'COPIED';
108
- setTimeout(() => { copyBtn.textContent = prev; }, 1600);
109
- }
110
- }).catch(() => {});
111
- }
217
+ /* ---------------------------------------------------------------- render */
112
218
 
113
219
  render() {
114
220
  const root = this.shadowRoot;
115
221
  if (!root) return;
116
-
117
222
  root.innerHTML = `
118
223
  <style>
119
224
  :host {
120
- display: block;
121
- font-family: 'Space Grotesk', -apple-system, sans-serif;
122
- background: #060e1f;
123
- color: #eff6ff;
124
- border-radius: 12px;
125
- border: 1px solid #1e40af;
126
- padding: 14px;
127
- box-sizing: border-box;
128
- user-select: none;
225
+ --surface: #0b1020; --surface-2: #121933; --line: #26305a;
226
+ --text: #e8ecff; --muted: #9aa6d6;
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;
129
232
  }
130
-
131
233
  * { box-sizing: border-box; }
132
-
133
- /* Bespoke Header */
134
- .card-header {
135
- display: flex;
136
- align-items: center;
137
- justify-content: space-between;
138
- margin-bottom: 12px;
139
- padding-bottom: 8px;
140
- border-bottom: 1px solid rgba(59, 130, 246, 0.25);
141
- }
142
-
143
- .brand-cluster {
144
- display: flex;
145
- align-items: center;
146
- gap: 10px;
147
- }
148
-
149
- .brand-mark {
150
- width: 32px;
151
- height: 32px;
152
- border-radius: 8px;
153
- background: rgba(37, 99, 235, 0.2);
154
- border: 1px solid rgba(59, 130, 246, 0.4);
155
- display: flex;
156
- align-items: center;
157
- justify-content: center;
158
- flex-shrink: 0;
159
- }
160
-
161
- .brand-meta {
162
- display: flex;
163
- flex-direction: column;
164
- }
165
-
166
- .brand-title {
167
- font-size: 14px;
168
- font-weight: 700;
169
- color: #eff6ff;
170
- letter-spacing: 0.03em;
171
- text-transform: uppercase;
172
- margin: 0;
173
- }
174
-
175
- .brand-subtitle {
176
- font-size: 11px;
177
- color: #93c5fd;
178
- margin: 0;
179
- font-family: 'JetBrains Mono', monospace;
180
- }
181
-
182
- .status-pill {
183
- font-family: 'JetBrains Mono', monospace;
184
- font-size: 10px;
185
- font-weight: 700;
186
- padding: 3px 8px;
187
- border-radius: 6px;
188
- background: rgba(59, 130, 246, 0.15);
189
- border: 1px solid rgba(59, 130, 246, 0.4);
190
- color: #60a5fa;
191
- letter-spacing: 0.05em;
192
- }
193
-
194
- /* Telemetry Metric Cards */
195
- .telemetry-row {
196
- display: grid;
197
- grid-template-columns: repeat(3, 1fr);
198
- gap: 8px;
199
- margin-bottom: 12px;
200
- }
201
-
202
- .metric-card {
203
- background: rgba(30, 58, 138, 0.4);
204
- border: 1px solid rgba(59, 130, 246, 0.25);
205
- border-radius: 8px;
206
- padding: 8px 10px;
207
- display: flex;
208
- flex-direction: column;
209
- gap: 2px;
210
- }
211
-
212
- .metric-label {
213
- font-family: 'JetBrains Mono', monospace;
214
- font-size: 9px;
215
- font-weight: 700;
216
- color: #93c5fd;
217
- letter-spacing: 0.05em;
218
- text-transform: uppercase;
219
- }
220
-
221
- .metric-value {
222
- font-size: 13px;
223
- font-weight: 700;
224
- color: #ffffff;
225
- font-family: 'JetBrains Mono', monospace;
226
- overflow: hidden;
227
- text-overflow: ellipsis;
228
- white-space: nowrap;
229
- }
230
-
231
- /* Target Node Selector */
232
- .target-box {
233
- display: flex;
234
- align-items: center;
235
- gap: 8px;
236
- margin-bottom: 10px;
237
- background: rgba(0, 0, 0, 0.4);
238
- border: 1px solid rgba(59, 130, 246, 0.25);
239
- border-radius: 8px;
240
- padding: 4px 8px;
241
- }
242
-
243
- .target-label {
244
- font-family: 'JetBrains Mono', monospace;
245
- font-size: 10px;
246
- color: #93c5fd;
247
- text-transform: uppercase;
248
- white-space: nowrap;
249
- }
250
-
251
- .target-input {
252
- flex: 1;
253
- background: transparent;
254
- border: none;
255
- color: #eff6ff;
256
- font-family: 'JetBrains Mono', monospace;
257
- font-size: 12px;
258
- outline: none;
259
- }
260
-
261
- .target-input::placeholder {
262
- color: rgba(147, 197, 253, 0.4);
263
- }
264
-
265
- /* Action Buttons Grid (>=44px touch targets) */
266
- .actions-grid {
267
- display: grid;
268
- grid-template-columns: repeat(3, 1fr);
269
- gap: 6px;
270
- margin-bottom: 10px;
271
- }
272
-
273
- .touch-btn {
274
- min-height: 44px;
275
- background: rgba(37, 99, 235, 0.16);
276
- border: 1px solid rgba(59, 130, 246, 0.3);
277
- border-radius: 8px;
278
- color: #eff6ff;
279
- font-family: 'JetBrains Mono', monospace;
280
- font-size: 11px;
281
- font-weight: 600;
282
- letter-spacing: 0.03em;
283
- display: flex;
284
- align-items: center;
285
- justify-content: center;
286
- cursor: pointer;
287
- transition: background 0.15s, border-color 0.15s, transform 0.05s;
288
- padding: 4px 6px;
289
- text-align: center;
290
- }
291
-
292
- .touch-btn:hover {
293
- background: rgba(37, 99, 235, 0.3);
294
- border-color: rgba(96, 165, 250, 0.6);
295
- }
296
-
297
- .touch-btn:active {
298
- transform: translateY(1px);
299
- }
300
-
301
- /* Terminal Execution Output */
302
- .terminal-deck {
303
- background: #020712;
304
- border: 1px solid rgba(59, 130, 246, 0.25);
305
- border-radius: 8px;
306
- overflow: hidden;
307
- }
308
-
309
- .terminal-header {
310
- display: flex;
311
- align-items: center;
312
- justify-content: space-between;
313
- padding: 6px 10px;
314
- background: rgba(30, 58, 138, 0.4);
315
- border-bottom: 1px solid rgba(59, 130, 246, 0.2);
316
- }
317
-
318
- .terminal-title {
319
- font-family: 'JetBrains Mono', monospace;
320
- font-size: 10px;
321
- color: #93c5fd;
322
- text-transform: uppercase;
323
- letter-spacing: 0.05em;
324
- }
325
-
326
- .terminal-btn {
327
- background: rgba(59, 130, 246, 0.15);
328
- border: 1px solid rgba(59, 130, 246, 0.3);
329
- border-radius: 4px;
330
- color: #dbeafe;
331
- font-family: 'JetBrains Mono', monospace;
332
- font-size: 9px;
333
- padding: 2px 6px;
334
- cursor: pointer;
335
- }
336
-
337
- .terminal-btn:hover {
338
- background: rgba(59, 130, 246, 0.3);
339
- }
340
-
341
- .terminal-body {
342
- margin: 0;
343
- padding: 8px 10px;
344
- font-family: 'JetBrains Mono', monospace;
345
- font-size: 11px;
346
- line-height: 1.45;
347
- color: #dbeafe;
348
- min-height: 60px;
349
- max-height: 100px;
350
- overflow-y: auto;
351
- white-space: pre-wrap;
352
- word-break: break-word;
353
- }
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); }
354
277
  </style>
355
-
356
- <div class="card-header">
357
- <div class="brand-cluster">
358
- <div class="brand-mark">
359
- <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#60a5fa" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
360
- <circle cx="12" cy="5" r="3"/>
361
- <circle cx="5" cy="19" r="3"/>
362
- <circle cx="19" cy="19" r="3"/>
363
- <line x1="12" y1="8" x2="5" y2="16"/>
364
- <line x1="12" y1="8" x2="19" y2="16"/>
365
- <line x1="5" y1="19" x2="19" y2="19"/>
366
- </svg>
367
- </div>
368
- <div class="brand-meta">
369
- <h3 class="brand-title">Headscale Mesh</h3>
370
- <span class="brand-subtitle">Topology & Node Control • v0.2.2</span>
371
- </div>
372
- </div>
373
- <div id="status-pill" class="status-pill">ONLINE</div>
374
- </div>
375
-
376
- <div class="telemetry-row">
377
- <div class="metric-card">
378
- <span class="metric-label">Topology</span>
379
- <span id="stat-nodes" class="metric-value">4 Nodes</span>
380
- </div>
381
- <div class="metric-card">
382
- <span class="metric-label">Control Node</span>
383
- <span id="stat-cloud" class="metric-value">Connected</span>
384
- </div>
385
- <div class="metric-card">
386
- <span class="metric-label">Local Node</span>
387
- <span id="stat-mini" class="metric-value">Enrolled</span>
388
- </div>
389
- </div>
390
-
391
- <div class="target-box">
392
- <span class="target-label">Target Peer:</span>
393
- <input id="node-input" class="target-input" type="text" value="cloud" placeholder="Node name or IP e.g. cloud or 100.64.0.0/10" />
394
- </div>
395
-
396
- <div class="actions-grid">
397
- <button id="btn-nodes" class="touch-btn" type="button">NODES</button>
398
- <button id="btn-ping" class="touch-btn" type="button">PING PEER</button>
399
- <button id="btn-doctor" class="touch-btn" type="button">MESH DOCTOR</button>
400
- <button id="btn-leader" class="touch-btn" type="button">LEADER</button>
401
- <button id="btn-presence" class="touch-btn" type="button">PRESENCE</button>
402
- <button id="btn-server" class="touch-btn" type="button">STATUS</button>
403
- </div>
404
-
405
- <div class="terminal-deck">
406
- <div class="terminal-header">
407
- <span class="terminal-title">Mesh Telemetry Console</span>
408
- <button id="btn-copy" class="terminal-btn" type="button">COPY</button>
409
- </div>
410
- <pre id="card-out" class="terminal-body">WireGuard mesh topology active. Select a peer or query node health above.</pre>
411
- </div>
278
+ <header>
279
+ <svg class="mark" viewBox="0 0 24 24" fill="none" stroke="#818cf8" stroke-width="2" stroke-linecap="round" aria-hidden="true">
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>
412
290
  `;
291
+ this.paint();
292
+ }
413
293
 
414
- const getNode = () => {
415
- const input = root.querySelector('#node-input');
416
- return (input?.value || 'cloud').trim();
417
- };
418
-
419
- root.querySelector('#btn-nodes')?.addEventListener('click', () => {
420
- this.execute('nodes');
421
- });
294
+ /** Replace the children of an element with text-only nodes built by the caller. */
295
+ el(tag, props = {}, ...children) {
296
+ const node = document.createElement(tag);
297
+ for (const [k, v] of Object.entries(props)) {
298
+ if (k === 'text') node.textContent = v;
299
+ else if (k === 'class') node.className = v;
300
+ else if (k === 'dataset') Object.assign(node.dataset, v);
301
+ else if (k.startsWith('on')) node.addEventListener(k.slice(2), v);
302
+ else node.setAttribute(k, v);
303
+ }
304
+ for (const child of children) if (child) node.append(child);
305
+ return node;
306
+ }
422
307
 
423
- root.querySelector('#btn-ping')?.addEventListener('click', () => {
424
- this.execute('ping', [getNode()]);
425
- });
308
+ paint() {
309
+ const root = this.shadowRoot;
310
+ if (!root || !root.querySelector('#body')) return;
311
+
312
+ const verdict = this.status || this.doctor || this.leader
313
+ ? meshVerdict(this.status, this.doctor, this.leader)
314
+ : { tone: 'neutral', text: this.busy ? 'Reading the mesh.' : 'Cannot verify the mesh.' };
315
+ const v = root.querySelector('#verdict');
316
+ v.dataset.tone = verdict.tone;
317
+ v.textContent = verdict.text;
318
+ const attempt = this.el('small', { text: this.lastAttempt ? `attempted ${this.lastAttempt.toLocaleTimeString()}` : '' });
319
+ v.append(attempt);
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}` : '';
342
+ }
426
343
 
427
- root.querySelector('#btn-doctor')?.addEventListener('click', () => {
428
- this.execute('doctor');
429
- });
344
+ figure(label, value, tone, why) {
345
+ return this.el('div', { class: 'figure', dataset: { tone } },
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);
349
+ }
430
350
 
431
- root.querySelector('#btn-leader')?.addEventListener('click', () => {
432
- this.execute('leader');
433
- });
351
+ fleetView(nodes) {
352
+ const live = nodes.filter((n) => !n.vaultOnly);
353
+ const haveStatus = !!this.status;
354
+ const haveDoctor = Array.isArray(this.doctor);
355
+ const online = haveStatus ? live.filter((n) => n.online).length : undefined;
356
+ const reachable = haveDoctor ? live.filter((n) => n.reach === 'reachable').length : undefined;
357
+ const noLogin = haveStatus ? live.filter((n) => n.accessState !== 'configured').length : undefined;
358
+ const why = (ok, label) => (ok ? undefined : `${label} unreadable`);
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];
369
+ }
434
370
 
435
- root.querySelector('#btn-presence')?.addEventListener('click', () => {
436
- this.execute('presence');
437
- });
371
+ nodeRow(n) {
372
+ const tone = n.severity >= 3 ? 'danger' : (n.severity >= 1 ? 'warn' : (n.vaultOnly ? 'neutral' : 'ok'));
373
+ const target = n.ip || n.hostname;
374
+ const ping = this.pings[target];
375
+ const canPing = !!target && !n.self && !n.vaultOnly;
376
+ const row = this.el('li', { class: 'node', dataset: { tone } },
377
+ this.el('div', {},
378
+ this.el('div', { class: 'host', text: (n.hostname || DASH) + (n.self ? ' (this machine)' : '') }),
379
+ this.el('div', { class: 'sub', text: [n.ip, n.os].filter(Boolean).join(' \u00b7 ') || DASH })),
380
+ canPing ? this.el('button', {
381
+ class: 'ping', text: ping && ping.state === 'running' ? 'Pinging' : 'Ping', onclick: () => void this.ping(n),
382
+ ...(ping && ping.state === 'running' ? { disabled: '' } : {}),
383
+ }) : this.el('span', { class: 'sub', text: n.self ? 'local' : '' }),
384
+ this.el('div', { class: 'states' },
385
+ this.el('span', { text: 'Status ' }, this.el('b', { text: n.label, dataset: { tone } })),
386
+ this.el('span', { text: 'Network ' }, this.el('b', { text: n.networkState || DASH })),
387
+ this.el('span', { text: 'Access ' }, this.el('b', { text: n.accessState || DASH })),
388
+ this.el('span', { text: 'Reach ' }, this.el('b', { text: n.reach }))),
389
+ n.reason ? this.el('div', { class: 'reason', text: n.reason }) : null,
390
+ ping && ping.text ? this.el('div', { class: 'pingout', text: ping.text }) : null);
391
+ return row;
392
+ }
438
393
 
439
- root.querySelector('#btn-server')?.addEventListener('click', () => {
440
- this.execute('status');
441
- });
394
+ controlView() {
395
+ const s = this.status;
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;
414
+ }
442
415
 
443
- root.querySelector('#btn-copy')?.addEventListener('click', () => {
444
- this.copyOutput();
445
- });
416
+ capabilityView(nodes) {
417
+ if (!Array.isArray(this.doctor)) {
418
+ return this.el('div', { class: 'err', text: 'The doctor command returned nothing readable, so runtimes and harnesses cannot be shown.' });
419
+ }
420
+ const chips = (list, none) => (list && list.length
421
+ ? this.el('div', { class: 'chips' }, ...list.map((x) => this.el('span', { class: 'chip', text: String(x) })))
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')))));
446
429
  }
447
430
  }
448
431