total-recall-brain 3.22.0 → 3.23.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.mjs +2 -0
- package/frontend/src/api/headscale.ts +68 -1
- package/frontend/src/api/mesh.spec.ts +27 -0
- package/frontend/src/api/mesh.ts +85 -0
- package/frontend/src/components/EmbeddingProviderPanel.tsx +21 -3
- package/frontend/src/pages/MeshPage.spec.tsx +164 -0
- package/frontend/src/pages/MeshPage.tsx +294 -6
- package/frontend/src/pages/NetworkPage.spec.tsx +19 -4
- package/frontend/src/pages/OnboardingPage.spec.tsx +14 -2
- package/frontend/src/pages/SkillsPage.spec.tsx +1 -1
- package/package.json +2 -1
- package/scaffold/.agent/skills/total-recall/references/api-reference.md +1 -1
- package/scaffold/.agent/skills/total-recall/references/cli-reference.md +2 -1
- package/scaffold/.agent/skills/total-recall/skills-registry/index.yaml +6 -2574
- package/src/cli/mesh.mjs +416 -0
- package/src/cli/repo-expert-generate.mjs +5 -1
- package/src/cli/reset-password.mjs +75 -6
- package/src/cli/secret.mjs +250 -7
- package/src/core/browser-session.mjs +68 -13
- package/src/core/headscale-client.mjs +87 -0
- package/src/core/mesh-access.mjs +261 -0
- package/src/core/mesh-enroll.mjs +74 -32
- package/src/core/mesh.mjs +164 -12
- package/src/core/schema.mjs +36 -0
- package/src/core/secrets-keychain.mjs +83 -0
- package/src/core/secrets-rekey.mjs +0 -0
- package/src/core/secrets-store.mjs +26 -6
- package/src/core/tailscale-cli.mjs +69 -0
- package/src/server/index.mjs +23 -4
- package/src/server/pid-lock.mjs +64 -0
- package/src/server/route-inventory.mjs +62 -0
- package/src/server/route-manifest.json +20 -0
- package/src/server/routes/headscale.mjs +32 -0
- package/src/server/routes/mesh.mjs +118 -0
package/bin/total-recall.mjs
CHANGED
|
@@ -81,6 +81,7 @@ const COMMANDS = {
|
|
|
81
81
|
relay: 'relay.mjs',
|
|
82
82
|
uninstall: 'uninstall.mjs',
|
|
83
83
|
map: 'map.mjs',
|
|
84
|
+
mesh: 'mesh.mjs',
|
|
84
85
|
brain: 'brain.mjs',
|
|
85
86
|
config: 'config.mjs',
|
|
86
87
|
skill: 'skill.mjs',
|
|
@@ -109,6 +110,7 @@ function printHelp() {
|
|
|
109
110
|
daemon <cmd> Background worker (start|stop|status)
|
|
110
111
|
skill <cmd> Skills registry, deploy, multi-repo sync
|
|
111
112
|
secret <cmd> Secrets store + usage (not the vault)
|
|
113
|
+
mesh <cmd> Control-server mesh: nodes, ACL policy, Tailscale SSH
|
|
112
114
|
brain <cmd> Register / ensure any project brain
|
|
113
115
|
status / doctor Health and diagnostics
|
|
114
116
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { get, post, del } from './_base';
|
|
1
|
+
import { get, post, del, apiFetch } from './_base';
|
|
2
2
|
|
|
3
3
|
export interface HeadscaleNode {
|
|
4
4
|
id: string;
|
|
@@ -69,3 +69,70 @@ export async function fetchHeadscaleUsers(): Promise<HeadscaleUser[]> {
|
|
|
69
69
|
const data = await get<UsersResponse>('/api/headscale/user');
|
|
70
70
|
return data.users || [];
|
|
71
71
|
}
|
|
72
|
+
|
|
73
|
+
export interface HeadscalePolicy {
|
|
74
|
+
policy: string | null;
|
|
75
|
+
updatedAt: string | null;
|
|
76
|
+
configured: boolean;
|
|
77
|
+
/** True when the server is in database mode but no policy has been written yet. */
|
|
78
|
+
unset?: boolean;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Read the control server's ACL policy.
|
|
83
|
+
*
|
|
84
|
+
* A 409 means the server is in `policy.mode: file`, where the policy lives on
|
|
85
|
+
* the server's disk and the API cannot manage it. That is a fixable
|
|
86
|
+
* configuration state rather than a failure, so it is surfaced as a typed
|
|
87
|
+
* result the UI can explain instead of a thrown error.
|
|
88
|
+
*/
|
|
89
|
+
export async function fetchHeadscalePolicy(): Promise<HeadscalePolicy & { fileMode?: boolean }> {
|
|
90
|
+
try {
|
|
91
|
+
return await get<HeadscalePolicy>('/api/headscale/policy');
|
|
92
|
+
} catch (err) {
|
|
93
|
+
const status = (err as { status?: number })?.status;
|
|
94
|
+
if (status === 409) {
|
|
95
|
+
return { policy: null, updatedAt: null, configured: false, fileMode: true };
|
|
96
|
+
}
|
|
97
|
+
throw err;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function saveHeadscalePolicy(policy: string): Promise<HeadscalePolicy> {
|
|
102
|
+
const res = await apiFetch('/api/headscale/policy', {
|
|
103
|
+
method: 'PUT',
|
|
104
|
+
headers: { 'Content-Type': 'application/json' },
|
|
105
|
+
body: JSON.stringify({ policy }),
|
|
106
|
+
});
|
|
107
|
+
if (!res.ok) {
|
|
108
|
+
const detail = await res.json().catch(() => ({}));
|
|
109
|
+
const error = new Error((detail as { error?: string }).error || `Policy save failed (${res.status})`);
|
|
110
|
+
(error as { status?: number }).status = res.status;
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
return res.json();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The single-operator policy: every mesh member reaches every member, and may
|
|
118
|
+
* SSH between them. Mesh membership is the trust boundary, so anything admitted
|
|
119
|
+
* to the tailnet inherits this — narrow it before admitting a node you do not
|
|
120
|
+
* control.
|
|
121
|
+
*/
|
|
122
|
+
export function buildMeshSshPolicy(allowRoot = false): string {
|
|
123
|
+
return JSON.stringify(
|
|
124
|
+
{
|
|
125
|
+
acls: [{ action: 'accept', src: ['*'], dst: ['*:*'] }],
|
|
126
|
+
ssh: [
|
|
127
|
+
{
|
|
128
|
+
action: 'accept',
|
|
129
|
+
src: ['autogroup:member'],
|
|
130
|
+
dst: ['autogroup:self'],
|
|
131
|
+
users: allowRoot ? ['autogroup:nonroot', 'root'] : ['autogroup:nonroot'],
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
},
|
|
135
|
+
null,
|
|
136
|
+
2,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
@@ -5,6 +5,9 @@ import {
|
|
|
5
5
|
refreshElection,
|
|
6
6
|
fetchElectionHistory,
|
|
7
7
|
logElectionObservation,
|
|
8
|
+
fetchAccessProposals,
|
|
9
|
+
importAccessFromSshConfig,
|
|
10
|
+
setNodeAccess,
|
|
8
11
|
} from './mesh';
|
|
9
12
|
import * as base from './_base';
|
|
10
13
|
|
|
@@ -54,4 +57,28 @@ describe('mesh api', () => {
|
|
|
54
57
|
note: 'observed',
|
|
55
58
|
});
|
|
56
59
|
});
|
|
60
|
+
|
|
61
|
+
it('fetchAccessProposals calls GET /api/mesh/access/proposals', async () => {
|
|
62
|
+
vi.mocked(base.get).mockResolvedValue({ proposals: [], missing_access: [], checked_at: 't' });
|
|
63
|
+
await fetchAccessProposals();
|
|
64
|
+
expect(base.get).toHaveBeenCalledWith('/api/mesh/access/proposals');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// Proposing and applying are separate calls on purpose: a wrong login written
|
|
68
|
+
// to the vault is worse than none, because it then looks authored.
|
|
69
|
+
it('importAccessFromSshConfig calls POST /api/mesh/access/import', async () => {
|
|
70
|
+
vi.mocked(base.post).mockResolvedValue({ success: true, attempted: 0, saved: 0, failed: 0, results: [] });
|
|
71
|
+
await importAccessFromSshConfig();
|
|
72
|
+
expect(base.post).toHaveBeenCalledWith('/api/mesh/access/import', {});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('setNodeAccess posts the fields it was given, so an empty one can clear', async () => {
|
|
76
|
+
vi.mocked(base.post).mockResolvedValue({ success: true, path: 'p', access: {} });
|
|
77
|
+
await setNodeAccess({ node: 'node-b.mesh', ssh_user: 'operator', identity_file: '' });
|
|
78
|
+
expect(base.post).toHaveBeenCalledWith('/api/mesh/access', {
|
|
79
|
+
node: 'node-b.mesh',
|
|
80
|
+
ssh_user: 'operator',
|
|
81
|
+
identity_file: '',
|
|
82
|
+
});
|
|
83
|
+
});
|
|
57
84
|
});
|
package/frontend/src/api/mesh.ts
CHANGED
|
@@ -8,6 +8,40 @@ export interface MeshInterfaceSummary {
|
|
|
8
8
|
ipv6?: string[];
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
export type TailscaleVariant = 'daemon' | 'sandboxed' | 'missing' | 'unknown';
|
|
12
|
+
export type MeshSshCapability = 'available' | 'unsupported' | 'unknown';
|
|
13
|
+
export type AccessSource = 'probe' | 'ssh_config' | 'manual' | 'unknown';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* How to log in to a node — stored on the node entity, because the control
|
|
17
|
+
* server knows a machine exists but not which account you reach it as.
|
|
18
|
+
*/
|
|
19
|
+
export interface MeshNodeAccess {
|
|
20
|
+
ssh_user?: string | null;
|
|
21
|
+
ssh_port?: number | null;
|
|
22
|
+
ssh_host?: string | null;
|
|
23
|
+
identity_file?: string | null;
|
|
24
|
+
tailscale_variant?: TailscaleVariant;
|
|
25
|
+
mesh_ssh?: MeshSshCapability;
|
|
26
|
+
source?: AccessSource;
|
|
27
|
+
verified_at?: string | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Server-resolved connection details; address precedence is decided there. */
|
|
31
|
+
export interface ResolvedNodeAccess {
|
|
32
|
+
user: string | null;
|
|
33
|
+
host: string | null;
|
|
34
|
+
port: number;
|
|
35
|
+
identity_file: string | null;
|
|
36
|
+
mesh_ssh: MeshSshCapability;
|
|
37
|
+
tailscale_variant: TailscaleVariant;
|
|
38
|
+
source: AccessSource;
|
|
39
|
+
verified_at: string | null;
|
|
40
|
+
/** False when the login account is unknown — the node will refuse you. */
|
|
41
|
+
complete: boolean;
|
|
42
|
+
target: string | null;
|
|
43
|
+
}
|
|
44
|
+
|
|
11
45
|
export interface MeshNode {
|
|
12
46
|
hostname: string;
|
|
13
47
|
ip: string | null;
|
|
@@ -31,6 +65,9 @@ export interface MeshNode {
|
|
|
31
65
|
/** Device I/O profile for agent UI generation (screen/touch/mic/…). */
|
|
32
66
|
io?: DeviceIoProfile | null;
|
|
33
67
|
ui_hints?: string[];
|
|
68
|
+
/** Raw access record from the vault entity (absent until one is recorded). */
|
|
69
|
+
access?: MeshNodeAccess | null;
|
|
70
|
+
access_resolved?: ResolvedNodeAccess;
|
|
34
71
|
}
|
|
35
72
|
|
|
36
73
|
export interface DeviceIoProfile {
|
|
@@ -79,6 +116,54 @@ export async function fetchNodes(): Promise<MeshNode[]> {
|
|
|
79
116
|
return data.nodes;
|
|
80
117
|
}
|
|
81
118
|
|
|
119
|
+
export interface AccessProposal {
|
|
120
|
+
hostname: string;
|
|
121
|
+
ip: string | null;
|
|
122
|
+
/** The `Host` block the login was taken from, so the guess is auditable. */
|
|
123
|
+
matched_host: string;
|
|
124
|
+
access: MeshNodeAccess;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Logins this host's ssh config could supply for nodes that have none. */
|
|
128
|
+
export async function fetchAccessProposals(): Promise<{
|
|
129
|
+
proposals: AccessProposal[];
|
|
130
|
+
missing_access: string[];
|
|
131
|
+
checked_at: string;
|
|
132
|
+
}> {
|
|
133
|
+
return get('/api/mesh/access/proposals');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Apply every ssh-config proposal to its node entity. */
|
|
137
|
+
export async function importAccessFromSshConfig(): Promise<{
|
|
138
|
+
success: boolean;
|
|
139
|
+
attempted: number;
|
|
140
|
+
saved: number;
|
|
141
|
+
failed: number;
|
|
142
|
+
results: Array<{
|
|
143
|
+
hostname: string;
|
|
144
|
+
ssh_user: string | null;
|
|
145
|
+
matched_host: string;
|
|
146
|
+
written: boolean;
|
|
147
|
+
reason: string | null;
|
|
148
|
+
}>;
|
|
149
|
+
}> {
|
|
150
|
+
return post('/api/mesh/access/import', {});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Record how to reach a node. Omit a field to leave it alone; send it empty to
|
|
155
|
+
* clear it.
|
|
156
|
+
*/
|
|
157
|
+
export async function setNodeAccess(body: {
|
|
158
|
+
node: string;
|
|
159
|
+
ssh_user?: string;
|
|
160
|
+
ssh_port?: string | number;
|
|
161
|
+
ssh_host?: string;
|
|
162
|
+
identity_file?: string;
|
|
163
|
+
}): Promise<{ success: boolean; path: string; access: MeshNodeAccess }> {
|
|
164
|
+
return post('/api/mesh/access', body);
|
|
165
|
+
}
|
|
166
|
+
|
|
82
167
|
export async function refreshElection(): Promise<LeaderInfo> {
|
|
83
168
|
const data = await post<{ leader: LeaderInfo | null }>('/api/mesh/election/refresh', {});
|
|
84
169
|
if (!data.leader) throw new Error('No online mesh leader is available');
|
|
@@ -29,17 +29,35 @@ export function EmbeddingProviderPanel() {
|
|
|
29
29
|
const [error, setError] = useState<string | null>(null)
|
|
30
30
|
const [busy, setBusy] = useState(false)
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
/**
|
|
33
|
+
* `shouldApply` lets the mount effect drop a response that arrived after the
|
|
34
|
+
* panel went away, or after a newer request superseded it — a fetch started
|
|
35
|
+
* on mount has no other way to know it is no longer the current one.
|
|
36
|
+
*/
|
|
37
|
+
const load = useCallback(async (shouldApply: () => boolean = () => true) => {
|
|
33
38
|
try {
|
|
34
|
-
|
|
39
|
+
const next = await fetchEmbeddingProvider()
|
|
40
|
+
if (!shouldApply()) return
|
|
41
|
+
setStatus(next)
|
|
35
42
|
setError(null)
|
|
36
43
|
} catch (err) {
|
|
44
|
+
if (!shouldApply()) return
|
|
37
45
|
setError(err instanceof Error ? err.message : String(err))
|
|
38
46
|
}
|
|
39
47
|
}, [])
|
|
40
48
|
|
|
41
49
|
useEffect(() => {
|
|
42
|
-
|
|
50
|
+
let active = true
|
|
51
|
+
// Deferred a tick rather than called from the effect body: loading straight
|
|
52
|
+
// from an effect trips react-hooks/set-state-in-effect, and this is how the
|
|
53
|
+
// rest of the app loads on mount (see BrainSelector).
|
|
54
|
+
const timer = setTimeout(() => {
|
|
55
|
+
void load(() => active)
|
|
56
|
+
}, 0)
|
|
57
|
+
return () => {
|
|
58
|
+
active = false
|
|
59
|
+
clearTimeout(timer)
|
|
60
|
+
}
|
|
43
61
|
}, [load])
|
|
44
62
|
|
|
45
63
|
const onPin = useCallback(
|
|
@@ -8,6 +8,9 @@ import {
|
|
|
8
8
|
refreshElection,
|
|
9
9
|
fetchEnrollmentStatus,
|
|
10
10
|
enrollThisNode,
|
|
11
|
+
fetchAccessProposals,
|
|
12
|
+
importAccessFromSshConfig,
|
|
13
|
+
setNodeAccess,
|
|
11
14
|
} from '../api/mesh';
|
|
12
15
|
import { fetchHeadscaleNodes, deleteHeadscaleNode, fetchPreAuthKeys, createPreAuthKey, fetchHeadscaleUsers } from '../api/headscale';
|
|
13
16
|
|
|
@@ -68,6 +71,15 @@ vi.mock('../api/mesh', () => ({
|
|
|
68
71
|
checked_at: new Date().toISOString(),
|
|
69
72
|
}),
|
|
70
73
|
enrollThisNode: vi.fn(),
|
|
74
|
+
// Default to a host with no ssh config to import from, so the access banner
|
|
75
|
+
// stays out of the way of tests asserting on other things.
|
|
76
|
+
fetchAccessProposals: vi.fn().mockResolvedValue({
|
|
77
|
+
proposals: [],
|
|
78
|
+
missing_access: [],
|
|
79
|
+
checked_at: new Date().toISOString(),
|
|
80
|
+
}),
|
|
81
|
+
importAccessFromSshConfig: vi.fn(),
|
|
82
|
+
setNodeAccess: vi.fn(),
|
|
71
83
|
}));
|
|
72
84
|
|
|
73
85
|
vi.mock('../api/headscale', () => ({
|
|
@@ -76,6 +88,11 @@ vi.mock('../api/headscale', () => ({
|
|
|
76
88
|
fetchPreAuthKeys: vi.fn(),
|
|
77
89
|
createPreAuthKey: vi.fn(),
|
|
78
90
|
fetchHeadscaleUsers: vi.fn(),
|
|
91
|
+
// Mesh SSH policy: default to "already configured" so the banner stays out of
|
|
92
|
+
// the way of tests that are asserting on other things.
|
|
93
|
+
fetchHeadscalePolicy: vi.fn().mockResolvedValue({ policy: '{}', configured: true }),
|
|
94
|
+
saveHeadscalePolicy: vi.fn().mockResolvedValue({ policy: '{}', configured: true }),
|
|
95
|
+
buildMeshSshPolicy: vi.fn(() => '{}'),
|
|
79
96
|
}));
|
|
80
97
|
|
|
81
98
|
describe('MeshPage', () => {
|
|
@@ -283,4 +300,151 @@ describe('MeshPage', () => {
|
|
|
283
300
|
expect(screen.queryByRole('button', { name: /enroll this node/i })).not.toBeInTheDocument();
|
|
284
301
|
});
|
|
285
302
|
});
|
|
303
|
+
|
|
304
|
+
// A node reached with the wrong login refuses the connection in exactly the
|
|
305
|
+
// way an unreachable node does. The page has to say so, or the operator
|
|
306
|
+
// debugs the network instead of the one missing fact.
|
|
307
|
+
describe('node access', () => {
|
|
308
|
+
const reachable = {
|
|
309
|
+
hostname: 'node-a.mesh',
|
|
310
|
+
ip: '100.64.0.1',
|
|
311
|
+
online: true,
|
|
312
|
+
self: false,
|
|
313
|
+
os: 'linux',
|
|
314
|
+
access: { ssh_user: 'operator', source: 'ssh_config' as const },
|
|
315
|
+
access_resolved: {
|
|
316
|
+
user: 'operator',
|
|
317
|
+
host: '100.64.0.1',
|
|
318
|
+
port: 22,
|
|
319
|
+
identity_file: null,
|
|
320
|
+
mesh_ssh: 'unknown' as const,
|
|
321
|
+
tailscale_variant: 'unknown' as const,
|
|
322
|
+
source: 'ssh_config' as const,
|
|
323
|
+
verified_at: null,
|
|
324
|
+
complete: true,
|
|
325
|
+
target: 'operator@100.64.0.1',
|
|
326
|
+
},
|
|
327
|
+
};
|
|
328
|
+
const noLogin = {
|
|
329
|
+
hostname: 'node-b.mesh',
|
|
330
|
+
ip: '100.64.0.2',
|
|
331
|
+
online: true,
|
|
332
|
+
self: false,
|
|
333
|
+
os: 'linux',
|
|
334
|
+
access_resolved: { ...reachable.access_resolved, user: null, host: '100.64.0.2', source: 'unknown' as const, complete: false, target: null },
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
beforeEach(() => {
|
|
338
|
+
vi.mocked(fetchLeader).mockResolvedValue({ hostname: 'node-a.mesh', ip: '100.64.0.1' });
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('shows the resolved login target for a node that has one', async () => {
|
|
342
|
+
vi.mocked(fetchMeshNodes).mockResolvedValue([reachable]);
|
|
343
|
+
render(<MeshPage />);
|
|
344
|
+
await waitFor(() => expect(screen.getAllByText('operator@100.64.0.1').length).toBeGreaterThan(0));
|
|
345
|
+
expect(screen.queryByTestId('mesh-access-banner')).not.toBeInTheDocument();
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('warns about nodes with no recorded login account', async () => {
|
|
349
|
+
vi.mocked(fetchMeshNodes).mockResolvedValue([reachable, noLogin]);
|
|
350
|
+
render(<MeshPage />);
|
|
351
|
+
const banner = await screen.findByTestId('mesh-access-banner');
|
|
352
|
+
expect(banner).toHaveTextContent('1 node has no recorded login account');
|
|
353
|
+
expect(banner).toHaveTextContent('node-b.mesh');
|
|
354
|
+
// Nothing to import from: the button must not appear and imply otherwise.
|
|
355
|
+
expect(screen.queryByRole('button', { name: /import from ssh config/i })).not.toBeInTheDocument();
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('offers the ssh config as a source and imports it on click', async () => {
|
|
359
|
+
vi.mocked(fetchMeshNodes).mockResolvedValue([noLogin]);
|
|
360
|
+
vi.mocked(fetchAccessProposals).mockResolvedValue({
|
|
361
|
+
proposals: [
|
|
362
|
+
{ hostname: 'node-b.mesh', ip: '100.64.0.2', matched_host: 'node-b', access: { ssh_user: 'operator', source: 'ssh_config' } },
|
|
363
|
+
],
|
|
364
|
+
missing_access: ['node-b.mesh'],
|
|
365
|
+
checked_at: new Date().toISOString(),
|
|
366
|
+
});
|
|
367
|
+
vi.mocked(importAccessFromSshConfig).mockResolvedValue({
|
|
368
|
+
success: true, attempted: 1, saved: 1, failed: 0,
|
|
369
|
+
results: [{ hostname: 'node-b.mesh', ssh_user: 'operator', matched_host: 'node-b', written: true, reason: null }],
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
render(<MeshPage />);
|
|
373
|
+
// The proposal is shown before it is applied — a wrong login written to
|
|
374
|
+
// the vault is worse than none, because it looks authored.
|
|
375
|
+
const proposals = await screen.findByTestId('access-proposals');
|
|
376
|
+
expect(proposals).toHaveTextContent('from Host node-b');
|
|
377
|
+
|
|
378
|
+
await userEvent.click(screen.getByRole('button', { name: /import from ssh config/i }));
|
|
379
|
+
await waitFor(() => expect(importAccessFromSshConfig).toHaveBeenCalled());
|
|
380
|
+
expect(await screen.findByText(/Recorded a login account for 1 node/)).toBeInTheDocument();
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it('reports a partial import honestly rather than as a success', async () => {
|
|
384
|
+
vi.mocked(fetchMeshNodes).mockResolvedValue([noLogin]);
|
|
385
|
+
vi.mocked(fetchAccessProposals).mockResolvedValue({
|
|
386
|
+
proposals: [
|
|
387
|
+
{ hostname: 'node-b.mesh', ip: '100.64.0.2', matched_host: 'node-b', access: { ssh_user: 'operator', source: 'ssh_config' } },
|
|
388
|
+
],
|
|
389
|
+
missing_access: ['node-b.mesh'],
|
|
390
|
+
checked_at: new Date().toISOString(),
|
|
391
|
+
});
|
|
392
|
+
vi.mocked(importAccessFromSshConfig).mockResolvedValue({
|
|
393
|
+
success: false, attempted: 2, saved: 1, failed: 1,
|
|
394
|
+
results: [],
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
render(<MeshPage />);
|
|
398
|
+
await userEvent.click(await screen.findByRole('button', { name: /import from ssh config/i }));
|
|
399
|
+
expect(await screen.findByText(/Recorded 1 of 2; 1 could not be saved/)).toBeInTheDocument();
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it('records a login typed for the selected node', async () => {
|
|
403
|
+
vi.mocked(fetchMeshNodes).mockResolvedValue([noLogin]);
|
|
404
|
+
vi.mocked(setNodeAccess).mockResolvedValue({
|
|
405
|
+
success: true,
|
|
406
|
+
path: 'system/mesh-nodes/node-b.md',
|
|
407
|
+
access: { ssh_user: 'operator' },
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
render(<MeshPage />);
|
|
411
|
+
await userEvent.click(await screen.findByTestId('node-row-node-b.mesh'));
|
|
412
|
+
|
|
413
|
+
await userEvent.type(screen.getByLabelText(/login account/i), 'operator');
|
|
414
|
+
await userEvent.type(screen.getByLabelText(/^port$/i), '2222');
|
|
415
|
+
await userEvent.click(screen.getByRole('button', { name: /save access/i }));
|
|
416
|
+
|
|
417
|
+
await waitFor(() =>
|
|
418
|
+
expect(setNodeAccess).toHaveBeenCalledWith({
|
|
419
|
+
node: 'node-b.mesh',
|
|
420
|
+
ssh_user: 'operator',
|
|
421
|
+
ssh_port: '2222',
|
|
422
|
+
identity_file: '',
|
|
423
|
+
ssh_host: '',
|
|
424
|
+
}),
|
|
425
|
+
);
|
|
426
|
+
expect(await screen.findByTestId('access-save-msg')).toHaveTextContent('Saved to the node entity');
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
// Polling replaces the node list on every cycle; a captured node object
|
|
430
|
+
// would keep the pane on the state at the moment of the click — including
|
|
431
|
+
// straight after an edit, where it would show the value it just replaced.
|
|
432
|
+
it('keeps the detail pane on live data after a reload', async () => {
|
|
433
|
+
vi.mocked(refreshElection).mockResolvedValue({ hostname: 'node-a.mesh', ip: '100.64.0.1' });
|
|
434
|
+
vi.mocked(fetchMeshNodes)
|
|
435
|
+
.mockResolvedValueOnce([noLogin])
|
|
436
|
+
.mockResolvedValue([
|
|
437
|
+
{ ...noLogin, access: { ssh_user: 'operator' }, access_resolved: reachable.access_resolved },
|
|
438
|
+
]);
|
|
439
|
+
|
|
440
|
+
render(<MeshPage />);
|
|
441
|
+
await userEvent.click(await screen.findByTestId('node-row-node-b.mesh'));
|
|
442
|
+
expect(await screen.findByTestId('node-access-target')).toHaveTextContent('no login recorded');
|
|
443
|
+
|
|
444
|
+
await userEvent.click(screen.getByText('Refresh Election'));
|
|
445
|
+
await waitFor(() =>
|
|
446
|
+
expect(screen.getByTestId('node-access-target')).toHaveTextContent('operator@100.64.0.1'),
|
|
447
|
+
);
|
|
448
|
+
});
|
|
449
|
+
});
|
|
286
450
|
});
|