total-recall-brain 3.40.7 → 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.
- package/bin/total-recall-memory.mjs +0 -0
- package/bin/total-recall.mjs +9 -40
- package/docs/reference/cli-reference.md +65 -1
- package/frontend/dist/assets/{index-AGUes-VG.css → index-BG9NkVUy.css} +1 -1
- package/frontend/dist/assets/{index-Bt5iVPZj.js → index-BtuiZqwP.js} +20 -7
- package/frontend/dist/index.html +2 -2
- package/frontend/src/components/plugins/PluginPanel.tsx +31 -5
- package/frontend/src/components/plugins/card/CardEngine.tsx +15 -23
- package/frontend/src/components/plugins/card/CardScenes.tsx +52 -23
- package/frontend/src/components/plugins/card/card.css +2 -5
- package/frontend/src/components/plugins/card/contract.ts +13 -19
- package/frontend/src/components/plugins/card/format.ts +8 -1
- package/frontend/src/components/plugins/card/widgets.tsx +4 -35
- package/frontend/src/components/plugins/plugin-frame.ts +30 -3
- package/frontend/src/pages/PluginsPage.tsx +3 -2
- package/package.json +1 -1
- package/scaffold/.agent/skills/plugin/SKILL.md +110 -0
- package/scaffold/.agent/skills/plugin/evals/README.md +53 -0
- package/scaffold/.agent/skills/plugin/evals/cases/01-fabricated-seed.mjs +42 -0
- package/scaffold/.agent/skills/plugin/evals/cases/02-reassuring-default.mjs +39 -0
- package/scaffold/.agent/skills/plugin/evals/cases/03-declared-template.mjs +32 -0
- package/scaffold/.agent/skills/plugin/evals/cases/04-card-collects-credential.mjs +46 -0
- package/scaffold/.agent/skills/plugin/evals/cases/05-host-leak-in-scaffold.mjs +55 -0
- package/scaffold/.agent/skills/plugin/evals/cases/06-correct-plugin.mjs +92 -0
- package/scaffold/.agent/skills/plugin/evals/run.mjs +118 -0
- package/scaffold/.agent/skills/plugin/prompts/01-function.md +29 -0
- package/scaffold/.agent/skills/plugin/prompts/02-surfaces.md +43 -0
- package/scaffold/.agent/skills/plugin/prompts/03-scaffold.md +31 -0
- package/scaffold/.agent/skills/plugin/prompts/04-cli.md +49 -0
- package/scaffold/.agent/skills/plugin/prompts/05-credentials.md +41 -0
- package/scaffold/.agent/skills/plugin/prompts/06-card.md +46 -0
- package/scaffold/.agent/skills/plugin/prompts/07-scaffold.md +52 -0
- package/scaffold/.agent/skills/plugin/prompts/08-verify.md +43 -0
- package/scaffold/.agent/skills/plugin/references/anti-patterns.md +123 -0
- package/scaffold/.agent/skills/plugin/references/card.md +90 -0
- package/scaffold/.agent/skills/plugin/references/scaffold.md +120 -0
- package/scaffold/.agent/skills/plugin/references/surfaces.md +206 -0
- package/scaffold/.agent/skills/plugin/scripts/record.mjs +360 -0
- package/scaffold/.agent/skills/plugin/subagents/card-designer.md +60 -0
- package/scaffold/.agent/skills/plugin/subagents/plugin-reviewer.md +55 -0
- package/scaffold/.agent/skills/plugin/subagents/scaffold-extractor.md +71 -0
- package/scaffold/.agent/skills/total-recall/plugins/auth/package-lock.json +71 -1441
- package/scaffold/.agent/skills/total-recall/plugins/auth/package.json +7 -5
- package/scaffold/.agent/skills/total-recall/plugins/auth/plugin.json +3 -44
- package/scaffold/.agent/skills/total-recall/plugins/auth/scripts/agent-login-pty.py +185 -0
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/cli-secret.mjs +20 -1
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/host-secrets-store.mjs +15 -0
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-env-export.mjs +6 -4
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-remote-deploy.mjs +1 -0
- package/scaffold/.agent/skills/total-recall/plugins/cli-agents/plugin.json +0 -24
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/package-lock.json +4 -4
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/plugin.json +0 -21
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package-lock.json +4 -4
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/plugin.json +0 -32
- package/scaffold/.agent/skills/total-recall/plugins/embeddings/package-lock.json +4 -4
- package/scaffold/.agent/skills/total-recall/plugins/embeddings/plugin.json +11 -0
- package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/README.md +5 -2
- package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package-lock.json +4 -4
- package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/plugin.json +1 -28
- package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/ui/DESIGN.md +95 -13
- package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/ui/card.js +553 -340
- package/scaffold/.agent/skills/total-recall/plugins/mesh/plugin.json +0 -36
- package/scaffold/.agent/skills/total-recall/plugins/mesh/ui/card.js +383 -400
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/package-lock.json +4 -4
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/package.json +0 -1
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/plugin.json +0 -28
- package/scaffold/.agent/skills/total-recall/plugins/security/package-lock.json +4 -4
- package/scaffold/.agent/skills/total-recall/plugins/security/plugin.json +0 -32
- package/scaffold/.agent/skills/total-recall/references/auth.md +3 -1
- package/scaffold/.agent/skills/total-recall/references/memory.md +4 -0
- package/scaffold/.agent/skills/total-recall/references/openwiki.md +2 -0
- package/scaffold/.agent/skills/total-recall/references/operations.md +12 -0
- package/src/cli/brain.mjs +4 -2
- package/src/cli/context-stats.mjs +104 -0
- package/src/cli/context.mjs +8 -0
- package/src/cli/daemon.mjs +10 -1
- package/src/cli/export.mjs +3 -2
- package/src/cli/ingest-okf.mjs +12 -15
- package/src/cli/init.mjs +0 -85
- package/src/cli/lint.mjs +3 -2
- package/src/cli/plugin/copies.mjs +68 -0
- package/src/cli/plugin/create.mjs +22 -24
- package/src/cli/plugin/index.mjs +49 -3
- package/src/cli/plugin/search.mjs +19 -2
- package/src/cli/rebuild.mjs +2 -0
- package/src/cli/remember.mjs +23 -0
- package/src/cli/status.mjs +10 -3
- package/src/cli/update.mjs +3 -2
- package/src/core/action-predictor.mjs +161 -0
- package/src/core/app-deploy/runtime-cli/node.mjs.tmpl +20 -3
- package/src/core/app-deploy/source.mjs +16 -9
- package/src/core/automatic-brain-updates.mjs +6 -3
- package/src/core/brain-registry.mjs +19 -2
- package/src/core/brain-state.mjs +23 -0
- package/src/core/cli-commands.mjs +1 -0
- package/src/core/cli-maintenance.mjs +1 -1
- package/src/core/cli-semantic-index.mjs +1 -1
- package/src/core/command-surface.mjs +3 -3
- package/src/core/composed-command-resolution.mjs +21 -0
- package/src/core/config.mjs +84 -0
- package/src/core/confined-memory.mjs +93 -0
- package/src/core/context-telemetry.mjs +214 -0
- package/src/core/daemon-control.mjs +14 -43
- package/src/core/fast-recall.mjs +1 -1
- package/src/core/memory-brain.mjs +2 -0
- package/src/core/plugin-activation.mjs +38 -6
- package/src/core/plugin-bundle.mjs +54 -0
- package/src/core/plugin-catalog.mjs +1 -1
- package/src/core/plugin-context.mjs +1 -1
- package/src/core/plugin-contracts/card.mjs +69 -37
- package/src/core/plugin-loader.mjs +5 -6
- package/src/core/plugin-runner.mjs +13 -6
- package/src/core/plugin-runtime.mjs +5 -4
- package/src/core/plugin-setup.mjs +5 -3
- package/src/core/plugin-state-store.mjs +12 -1
- package/src/core/plugin-state.mjs +46 -5
- package/src/core/plugin-store.mjs +78 -3
- package/src/core/plugin-tasks.mjs +3 -1
- package/src/core/plugin-updates.mjs +117 -0
- package/src/core/plugin-vendor.mjs +10 -4
- package/src/core/project-brain.mjs +14 -41
- package/src/core/project-id.mjs +4 -1
- package/src/core/required-plugins.mjs +151 -13
- package/src/core/skills-registry.mjs +27 -3
- package/src/core/ssss-kernel-bridge.mjs +3 -1
- package/src/core/startup-health.mjs +66 -16
- package/src/core/surface.mjs +51 -44
- package/src/core/testing/plugin-fixture.mjs +69 -0
- package/src/core/vault-files.mjs +13 -0
- package/src/core/vault.mjs +4 -30
- package/src/server/index.mjs +13 -1
- package/src/server/invoke-decision-child.mjs +49 -0
- package/src/server/invoke-execution.mjs +87 -0
- package/src/server/invoke-policy.mjs +64 -0
- package/src/server/invoke-read-child.mjs +26 -0
- package/src/server/invoke-targets.mjs +49 -0
- package/src/server/invoke-write-child.mjs +19 -0
- package/src/server/routes/invoke.mjs +314 -0
- package/src/server/routes/plugins.mjs +1 -1
- package/scaffold/.agent/skills/plugins/SKILL.md +0 -57
- package/scaffold/.agent/skills/plugins/references/card-design.md +0 -55
- package/scaffold/.agent/skills/plugins/references/distribution.md +0 -23
- package/scaffold/.agent/skills/plugins/references/manifest-contract.md +0 -90
- package/scaffold/.agent/skills/plugins/references/optimized/index.md +0 -9
- package/scaffold/.agent/skills/plugins/references/optimized/manifest.json +0 -35
- package/scaffold/.agent/skills/plugins/references/optimized/original.md +0 -58
- package/scaffold/.agent/skills/plugins/references/optimized/section-01.md +0 -5
- package/scaffold/.agent/skills/plugins/references/optimized/section-02.md +0 -25
- package/scaffold/.agent/skills/plugins/references/optimized/section-03.md +0 -12
- package/scaffold/.agent/skills/plugins/references/optimized/section-04.md +0 -7
- package/scaffold/.agent/skills/plugins/references/optimized/section-05.md +0 -5
- package/scaffold/.agent/skills/total-recall/openwiki/README.md +0 -10
- package/scaffold/.agent/skills/total-recall/openwiki/architecture.md +0 -5
- package/scaffold/.agent/skills/total-recall/openwiki/ide.md +0 -10
- package/scaffold/.agent/skills/total-recall/openwiki/memory.md +0 -11
- package/scaffold/.agent/skills/total-recall/openwiki/secrets.md +0 -7
- package/scaffold/.agent/skills/total-recall/openwiki/skills.md +0 -7
- package/scaffold/openwiki/README.md +0 -10
- package/scaffold/openwiki/architecture.md +0 -5
- package/scaffold/openwiki/ide.md +0 -10
- package/scaffold/openwiki/memory.md +0 -11
- package/scaffold/openwiki/secrets.md +0 -7
- package/scaffold/openwiki/skills.md +0 -7
- package/src/core/okf-adapter.mjs +0 -559
- package/templates/openwiki/README.md +0 -10
- package/templates/openwiki/architecture.md +0 -5
- package/templates/openwiki/ide.md +0 -10
- package/templates/openwiki/memory.md +0 -11
- package/templates/openwiki/secrets.md +0 -7
- package/templates/openwiki/skills.md +0 -7
|
@@ -1,448 +1,431 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Headscale
|
|
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
|
-
*
|
|
7
|
-
*
|
|
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.
|
|
14
|
-
this.
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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.
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
this.
|
|
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
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
border-
|
|
125
|
-
border: 1px solid
|
|
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
|
-
|
|
134
|
-
.
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
.
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
display:
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
.
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
.
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
<
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
-
|
|
424
|
-
|
|
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
|
-
|
|
428
|
-
|
|
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
|
-
|
|
432
|
-
|
|
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
|
-
|
|
436
|
-
|
|
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
|
-
|
|
440
|
-
|
|
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
|
-
|
|
444
|
-
|
|
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
|
|