total-recall-brain 3.24.2 → 3.25.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/docs/ARCHITECTURE.md +5 -5
- package/frontend/dist/assets/{index-BQL-opnK.js → index-wTl6WfEH.js} +134 -134
- package/frontend/dist/index.html +1 -1
- package/frontend/src/api/mesh.ts +24 -0
- package/frontend/src/api/system.ts +26 -0
- package/frontend/src/api/update.ts +16 -0
- package/frontend/src/components/TailnetEnroll.spec.tsx +73 -0
- package/frontend/src/components/TailnetEnroll.tsx +179 -0
- package/frontend/src/components/brand/BrandMark.tsx +1 -1
- package/frontend/src/index.css +1 -1
- package/frontend/src/pages/SettingsPage.spec.tsx +31 -0
- package/frontend/src/pages/SettingsPage.tsx +59 -2
- package/package.json +1 -1
- package/scaffold/.agent/skills/total-recall/SKILL.md +3 -2
- package/scaffold/.agent/skills/total-recall/references/api-reference.md +1 -1
- package/scaffold/.agent/skills/total-recall/references/cli-reference.md +1 -1
- package/scaffold/.agent/skills/total-recall/skills-registry/index.yaml +3 -2461
- package/src/cli/mesh.mjs +9 -0
- package/src/core/server-restart.mjs +130 -0
- package/src/server/route-manifest.json +8 -0
- package/src/server/routes/mesh.mjs +40 -0
- package/src/server/routes/system.mjs +38 -1
- package/src/server/routes/update.mjs +67 -8
package/frontend/dist/index.html
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
18
18
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
|
19
19
|
<title>Total Recall — Portable Memory</title>
|
|
20
|
-
<script type="module" crossorigin src="/assets/index-
|
|
20
|
+
<script type="module" crossorigin src="/assets/index-wTl6WfEH.js"></script>
|
|
21
21
|
<link rel="stylesheet" crossorigin href="/assets/index-C7_Xpk27.css">
|
|
22
22
|
</head>
|
|
23
23
|
<body>
|
package/frontend/src/api/mesh.ts
CHANGED
|
@@ -306,3 +306,27 @@ export async function registerLanMeshNodes(opts?: { probe?: boolean; limit?: num
|
|
|
306
306
|
limit: opts?.limit ?? 32,
|
|
307
307
|
});
|
|
308
308
|
}
|
|
309
|
+
|
|
310
|
+
/** A short-lived bearer credential that lets a NEW device join the tailnet. */
|
|
311
|
+
export interface PreAuthKey {
|
|
312
|
+
success: boolean;
|
|
313
|
+
key: string;
|
|
314
|
+
expiration: string;
|
|
315
|
+
reusable: boolean;
|
|
316
|
+
ephemeral: boolean;
|
|
317
|
+
ttl_minutes: number;
|
|
318
|
+
/** Control-server URL to enter on the device. Never carries the token. */
|
|
319
|
+
login_server: string | null;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export async function mintPreAuthKey(opts?: {
|
|
323
|
+
reusable?: boolean;
|
|
324
|
+
ephemeral?: boolean;
|
|
325
|
+
ttlMinutes?: number;
|
|
326
|
+
}): Promise<PreAuthKey> {
|
|
327
|
+
return post('/api/mesh/preauthkey', {
|
|
328
|
+
reusable: opts?.reusable === true,
|
|
329
|
+
ephemeral: opts?.ephemeral === true,
|
|
330
|
+
ttlMinutes: opts?.ttlMinutes,
|
|
331
|
+
});
|
|
332
|
+
}
|
|
@@ -77,6 +77,32 @@ export async function restartDaemon(): Promise<{ success: boolean; message: stri
|
|
|
77
77
|
return res.json()
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
export interface RestartResult {
|
|
81
|
+
success: boolean
|
|
82
|
+
scheduled: boolean
|
|
83
|
+
message: string
|
|
84
|
+
supervisor?: { supervised: boolean; kind: string | null; label: string | null; reason: string }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Restart the brain server itself. Answers 409 on a host where nothing would
|
|
89
|
+
* respawn the process — that is a real answer, not a failure, so the message is
|
|
90
|
+
* surfaced rather than thrown.
|
|
91
|
+
*/
|
|
92
|
+
export async function restartServer(): Promise<RestartResult> {
|
|
93
|
+
const res = await apiFetch(`${API_BASE}/api/server/restart`, { method: 'POST' })
|
|
94
|
+
const data = await res.json().catch(() => ({}))
|
|
95
|
+
if (!res.ok && res.status !== 409) {
|
|
96
|
+
throw new Error(data.message || data.error || `Restart server API error: ${res.status}`)
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
success: Boolean(data.success),
|
|
100
|
+
scheduled: Boolean(data.scheduled),
|
|
101
|
+
message: data.message || (data.scheduled ? 'Server restarting.' : 'Server did not restart.'),
|
|
102
|
+
supervisor: data.supervisor,
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
80
106
|
export async function fetchBrains(): Promise<Record<string, unknown>[]> {
|
|
81
107
|
const res = await apiFetch(`${API_BASE}/api/brains`)
|
|
82
108
|
const data = await res.json()
|
|
@@ -22,9 +22,18 @@ export async function checkUpdate(): Promise<UpdateCheckResult> {
|
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
export interface UpdateRestartInfo {
|
|
26
|
+
/** true when the update replaced the code this server is running */
|
|
27
|
+
required: boolean
|
|
28
|
+
/** true when a restart into that code has been scheduled */
|
|
29
|
+
scheduled: boolean
|
|
30
|
+
reason: string
|
|
31
|
+
}
|
|
32
|
+
|
|
25
33
|
export async function runUpdate(): Promise<{
|
|
26
34
|
success: boolean
|
|
27
35
|
message: string
|
|
36
|
+
restart?: UpdateRestartInfo
|
|
28
37
|
summary?: {
|
|
29
38
|
latest?: string
|
|
30
39
|
updated?: number
|
|
@@ -49,6 +58,13 @@ export async function runUpdate(): Promise<{
|
|
|
49
58
|
return {
|
|
50
59
|
success,
|
|
51
60
|
message: data.message || (success ? 'Package auto-update finished' : 'Package auto-update failed'),
|
|
61
|
+
restart: data.restart
|
|
62
|
+
? {
|
|
63
|
+
required: Boolean(data.restart.required),
|
|
64
|
+
scheduled: Boolean(data.restart.scheduled),
|
|
65
|
+
reason: String(data.restart.reason ?? ''),
|
|
66
|
+
}
|
|
67
|
+
: undefined,
|
|
52
68
|
summary: data.summary,
|
|
53
69
|
}
|
|
54
70
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
|
3
|
+
import { TailnetEnroll } from './TailnetEnroll';
|
|
4
|
+
|
|
5
|
+
vi.mock('../api/mesh', () => ({
|
|
6
|
+
fetchNodes: vi.fn(),
|
|
7
|
+
mintPreAuthKey: vi.fn(),
|
|
8
|
+
}));
|
|
9
|
+
import { fetchNodes, mintPreAuthKey } from '../api/mesh';
|
|
10
|
+
|
|
11
|
+
const node = (over = {}) => ({
|
|
12
|
+
hostname: 'laptop', ip: '100.64.0.3', online: true, self: false, os: 'macOS', ...over,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe('TailnetEnroll', () => {
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
vi.mocked(fetchNodes).mockResolvedValue([node(), node({ hostname: 'macmini', ip: '100.64.0.2', self: true })] as never);
|
|
18
|
+
vi.mocked(mintPreAuthKey).mockResolvedValue({
|
|
19
|
+
success: true,
|
|
20
|
+
key: 'hskey-auth-TESTKEY',
|
|
21
|
+
expiration: new Date(Date.now() + 60_000).toISOString(),
|
|
22
|
+
reusable: false,
|
|
23
|
+
ephemeral: true,
|
|
24
|
+
ttl_minutes: 15,
|
|
25
|
+
login_server: 'https://headscale.example.com',
|
|
26
|
+
} as never);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('lists mesh nodes', async () => {
|
|
30
|
+
render(<TailnetEnroll />);
|
|
31
|
+
await waitFor(() => expect(screen.getByTestId('enroll-node-list')).toHaveTextContent('laptop'));
|
|
32
|
+
expect(screen.getByTestId('enroll-node-list')).toHaveTextContent('macmini');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('mints a key and shows the control server plus a QR to scan', async () => {
|
|
36
|
+
render(<TailnetEnroll />);
|
|
37
|
+
fireEvent.click(screen.getByTestId('enroll-mint'));
|
|
38
|
+
await waitFor(() => expect(screen.getByTestId('enroll-login-server')).toHaveTextContent('headscale.example.com'));
|
|
39
|
+
expect(screen.getByTestId('enroll-qr').querySelector('svg')).toBeTruthy();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('passes the reusable and ephemeral choices through to the API', async () => {
|
|
43
|
+
render(<TailnetEnroll />);
|
|
44
|
+
fireEvent.click(screen.getByTestId('enroll-reusable'));
|
|
45
|
+
fireEvent.click(screen.getByTestId('enroll-mint'));
|
|
46
|
+
await waitFor(() => expect(mintPreAuthKey).toHaveBeenCalledWith({ reusable: true, ephemeral: false }));
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// A key is a bearer credential with a short TTL; a dead one must not sit on
|
|
50
|
+
// screen looking scannable.
|
|
51
|
+
it('marks the key expired once its TTL has elapsed', async () => {
|
|
52
|
+
vi.mocked(mintPreAuthKey).mockResolvedValue({
|
|
53
|
+
success: true, key: 'hskey-auth-OLD', expiration: new Date(Date.now() - 1000).toISOString(),
|
|
54
|
+
reusable: false, ephemeral: false, ttl_minutes: 15, login_server: 'https://headscale.example.com',
|
|
55
|
+
} as never);
|
|
56
|
+
render(<TailnetEnroll />);
|
|
57
|
+
fireEvent.click(screen.getByTestId('enroll-mint'));
|
|
58
|
+
await waitFor(() => expect(screen.getByTestId('enroll-ttl')).toHaveTextContent('expired'));
|
|
59
|
+
expect(screen.getByTestId('enroll-qr')).toHaveTextContent('Key expired');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('surfaces a mint failure instead of silently doing nothing', async () => {
|
|
63
|
+
vi.mocked(mintPreAuthKey).mockRejectedValue(new Error('headscale unreachable'));
|
|
64
|
+
render(<TailnetEnroll />);
|
|
65
|
+
fireEvent.click(screen.getByTestId('enroll-mint'));
|
|
66
|
+
await waitFor(() => expect(screen.getByTestId('tailnet-enroll-error')).toHaveTextContent('headscale unreachable'));
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('warns about nodes with no recorded login account', async () => {
|
|
70
|
+
render(<TailnetEnroll />);
|
|
71
|
+
await waitFor(() => expect(screen.getByTestId('enroll-access-warning')).toHaveTextContent('no recorded login account'));
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { QRCodeSVG } from 'qrcode.react';
|
|
3
|
+
import { fetchNodes, mintPreAuthKey } from '../api/mesh';
|
|
4
|
+
import type { MeshNode, PreAuthKey } from '../api/mesh';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Add a device (phone, tablet, another machine) to the self-hosted tailnet.
|
|
8
|
+
*
|
|
9
|
+
* Enrolling a phone had no UI at all: `/api/mesh/enroll` only ever enrolls the
|
|
10
|
+
* host the server runs on, so the only way to add a device was
|
|
11
|
+
* `total-recall mesh preauthkey` — a CLI, which is exactly what you do not have
|
|
12
|
+
* on the device you are trying to enroll.
|
|
13
|
+
*/
|
|
14
|
+
export function TailnetEnroll() {
|
|
15
|
+
const [nodes, setNodes] = useState<MeshNode[]>([]);
|
|
16
|
+
const [minted, setMinted] = useState<PreAuthKey | null>(null);
|
|
17
|
+
const [reusable, setReusable] = useState(false);
|
|
18
|
+
const [ephemeral, setEphemeral] = useState(false);
|
|
19
|
+
const [busy, setBusy] = useState(false);
|
|
20
|
+
const [error, setError] = useState<string | null>(null);
|
|
21
|
+
const [copied, setCopied] = useState(false);
|
|
22
|
+
const [secondsLeft, setSecondsLeft] = useState<number | null>(null);
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
fetchNodes()
|
|
26
|
+
.then(setNodes)
|
|
27
|
+
.catch((e) => setError(e?.message ?? 'could not load nodes'));
|
|
28
|
+
}, []);
|
|
29
|
+
|
|
30
|
+
// An enrollment key is a bearer credential with a short TTL. Show the time
|
|
31
|
+
// remaining rather than leaving a dead key on screen looking usable.
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (!minted) return undefined;
|
|
34
|
+
const tick = () => {
|
|
35
|
+
const ms = new Date(minted.expiration).getTime() - Date.now();
|
|
36
|
+
setSecondsLeft(Math.max(0, Math.floor(ms / 1000)));
|
|
37
|
+
};
|
|
38
|
+
tick();
|
|
39
|
+
const id = setInterval(tick, 1000);
|
|
40
|
+
return () => clearInterval(id);
|
|
41
|
+
}, [minted]);
|
|
42
|
+
|
|
43
|
+
const mint = async () => {
|
|
44
|
+
setBusy(true);
|
|
45
|
+
setError(null);
|
|
46
|
+
setCopied(false);
|
|
47
|
+
try {
|
|
48
|
+
setMinted(await mintPreAuthKey({ reusable, ephemeral }));
|
|
49
|
+
} catch (e) {
|
|
50
|
+
setError(e instanceof Error ? e.message : 'could not mint an enrollment key');
|
|
51
|
+
} finally {
|
|
52
|
+
setBusy(false);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const expired = secondsLeft !== null && secondsLeft <= 0;
|
|
57
|
+
const loginServer = minted?.login_server ?? null;
|
|
58
|
+
// `complete` is the resolver's own verdict: false when the login account is
|
|
59
|
+
// unknown and the node would refuse you. No resolved access at all counts as
|
|
60
|
+
// unknown too. (There is no `ssh_user` field — the account is `user`.)
|
|
61
|
+
const unknownAccess = nodes.filter((n) => !n.self && n.access_resolved?.complete !== true);
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<section data-testid="tailnet-enroll" style={{ marginTop: 24 }}>
|
|
65
|
+
<h3 style={{ marginBottom: 4 }}>Add a device to the tailnet</h3>
|
|
66
|
+
<p style={{ fontSize: 12, opacity: 0.75, marginTop: 0 }}>
|
|
67
|
+
Mints a short-lived enrollment key for a phone, tablet, or another machine.
|
|
68
|
+
</p>
|
|
69
|
+
|
|
70
|
+
{error && (
|
|
71
|
+
<div
|
|
72
|
+
role="alert"
|
|
73
|
+
data-testid="tailnet-enroll-error"
|
|
74
|
+
style={{ margin: '10px 0', padding: 10, borderRadius: 8, border: '1px solid #ef4444', color: '#ef4444', fontSize: 12 }}
|
|
75
|
+
>
|
|
76
|
+
{error}
|
|
77
|
+
</div>
|
|
78
|
+
)}
|
|
79
|
+
|
|
80
|
+
<div style={{ display: 'flex', gap: 16, alignItems: 'center', flexWrap: 'wrap', margin: '12px 0' }}>
|
|
81
|
+
<label style={{ fontSize: 12, display: 'flex', gap: 6, alignItems: 'center' }}>
|
|
82
|
+
<input type="checkbox" checked={reusable} onChange={(e) => setReusable(e.target.checked)} data-testid="enroll-reusable" />
|
|
83
|
+
Reusable (enroll more than one device)
|
|
84
|
+
</label>
|
|
85
|
+
<label style={{ fontSize: 12, display: 'flex', gap: 6, alignItems: 'center' }}>
|
|
86
|
+
<input type="checkbox" checked={ephemeral} onChange={(e) => setEphemeral(e.target.checked)} data-testid="enroll-ephemeral" />
|
|
87
|
+
Ephemeral (drops off the list when offline)
|
|
88
|
+
</label>
|
|
89
|
+
<button type="button" onClick={mint} disabled={busy} data-testid="enroll-mint" style={{ cursor: busy ? 'wait' : 'pointer' }}>
|
|
90
|
+
{busy ? 'Minting…' : minted ? 'Mint another key' : 'Get enrollment key'}
|
|
91
|
+
</button>
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
{minted && (
|
|
95
|
+
<div style={{ display: 'flex', gap: 20, flexWrap: 'wrap', alignItems: 'flex-start' }}>
|
|
96
|
+
<div style={{ background: '#fff', padding: 16, borderRadius: 8 }} data-testid="enroll-qr">
|
|
97
|
+
{expired ? (
|
|
98
|
+
<div style={{ width: 150, height: 150, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#ef4444', fontSize: 12, textAlign: 'center' }}>
|
|
99
|
+
Key expired — mint another
|
|
100
|
+
</div>
|
|
101
|
+
) : (
|
|
102
|
+
<QRCodeSVG value={minted.key} size={150} level="M" includeMargin />
|
|
103
|
+
)}
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
<div style={{ flex: 1, minWidth: 260 }}>
|
|
107
|
+
<div style={{ fontSize: 12, marginBottom: 6 }}>
|
|
108
|
+
<strong>Control server</strong>
|
|
109
|
+
<div><code data-testid="enroll-login-server">{loginServer ?? 'not configured'}</code></div>
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
<div style={{ fontSize: 12, marginBottom: 6 }}>
|
|
113
|
+
<strong>Enrollment key</strong>{' '}
|
|
114
|
+
<span data-testid="enroll-ttl" style={{ opacity: 0.7 }}>
|
|
115
|
+
{expired ? '(expired)' : `(expires in ${secondsLeft}s, ${minted.reusable ? 'reusable' : 'single-use'})`}
|
|
116
|
+
</span>
|
|
117
|
+
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginTop: 4 }}>
|
|
118
|
+
<code style={{ wordBreak: 'break-all', flex: 1, opacity: expired ? 0.4 : 1 }}>{minted.key}</code>
|
|
119
|
+
<button
|
|
120
|
+
type="button"
|
|
121
|
+
disabled={expired}
|
|
122
|
+
onClick={() => { navigator.clipboard?.writeText(minted.key); setCopied(true); }}
|
|
123
|
+
data-testid="enroll-copy"
|
|
124
|
+
>
|
|
125
|
+
{copied ? 'Copied' : 'Copy'}
|
|
126
|
+
</button>
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
<details style={{ fontSize: 12, marginTop: 10 }}>
|
|
131
|
+
<summary style={{ cursor: 'pointer' }}>On a phone (iOS / Android)</summary>
|
|
132
|
+
<ol style={{ paddingLeft: 18, lineHeight: 1.6 }}>
|
|
133
|
+
<li>Install Tailscale (iOS needs 1.38.1 or newer).</li>
|
|
134
|
+
<li>Account icon → <strong>Log in</strong> → options menu → <strong>Use custom coordination server</strong>.</li>
|
|
135
|
+
<li>Enter <code>{loginServer ?? 'your control server URL'}</code>.</li>
|
|
136
|
+
<li>Scan the QR or paste the key above when asked to authenticate.</li>
|
|
137
|
+
</ol>
|
|
138
|
+
<p style={{ opacity: 0.75 }}>
|
|
139
|
+
If that menu does not respond (reported on some 2026 iOS builds), set the same URL under
|
|
140
|
+
iOS Settings → Tailscale → <em>Alternate coordination server URL</em>.
|
|
141
|
+
</p>
|
|
142
|
+
</details>
|
|
143
|
+
|
|
144
|
+
<details style={{ fontSize: 12, marginTop: 6 }}>
|
|
145
|
+
<summary style={{ cursor: 'pointer' }}>On a computer</summary>
|
|
146
|
+
<pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
|
|
147
|
+
{`tailscale up --login-server ${loginServer ?? '<control-server>'} --authkey ${expired ? '<expired — mint another>' : minted.key}`}
|
|
148
|
+
</pre>
|
|
149
|
+
<p style={{ opacity: 0.75 }}>
|
|
150
|
+
Always pass <code>--login-server</code> alongside any <code>--reset</code>, or the client
|
|
151
|
+
silently repoints at the public Tailscale control plane.
|
|
152
|
+
</p>
|
|
153
|
+
</details>
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
)}
|
|
157
|
+
|
|
158
|
+
<div style={{ marginTop: 20 }}>
|
|
159
|
+
<h4 style={{ marginBottom: 6 }}>Nodes ({nodes.length})</h4>
|
|
160
|
+
<ul data-testid="enroll-node-list" style={{ listStyle: 'none', padding: 0, fontSize: 12, margin: 0 }}>
|
|
161
|
+
{nodes.map((n) => (
|
|
162
|
+
<li key={`${n.hostname}-${n.ip ?? 'noip'}`} style={{ padding: '4px 0', display: 'flex', gap: 8 }}>
|
|
163
|
+
<span title={n.online ? 'online' : 'offline'}>{n.online ? '🟢' : '⚪️'}</span>
|
|
164
|
+
<strong>{n.hostname}</strong>
|
|
165
|
+
<code style={{ opacity: 0.75 }}>{n.ip ?? '—'}</code>
|
|
166
|
+
{n.self && <span style={{ opacity: 0.6 }}>(this host)</span>}
|
|
167
|
+
</li>
|
|
168
|
+
))}
|
|
169
|
+
</ul>
|
|
170
|
+
{unknownAccess.length > 0 && (
|
|
171
|
+
<p data-testid="enroll-access-warning" style={{ fontSize: 12, color: '#f59e0b', marginBottom: 0 }}>
|
|
172
|
+
{unknownAccess.length} node(s) have no recorded login account — connecting to one fails as
|
|
173
|
+
though it were unreachable. Run <code>total-recall mesh access import</code>.
|
|
174
|
+
</p>
|
|
175
|
+
)}
|
|
176
|
+
</div>
|
|
177
|
+
</section>
|
|
178
|
+
);
|
|
179
|
+
}
|
package/frontend/src/index.css
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/* ═══════════════════════════════════════════════════════════════════════════════
|
|
2
2
|
Total Recall — Design System (premium · brand blue vault)
|
|
3
|
-
Brand mark:
|
|
3
|
+
Brand mark: the Total Recall cube lockup.
|
|
4
4
|
═══════════════════════════════════════════════════════════════════════════════ */
|
|
5
5
|
|
|
6
6
|
:root {
|
|
@@ -54,4 +54,35 @@ describe('SettingsPage', () => {
|
|
|
54
54
|
expect(await screen.findByTestId('settings-error')).toBeInTheDocument();
|
|
55
55
|
expect(screen.getByText(/Config JSON API error: 500/i)).toBeInTheDocument();
|
|
56
56
|
}, 10000);
|
|
57
|
+
|
|
58
|
+
it('restarts the server from the UI and reports the outcome', async () => {
|
|
59
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
60
|
+
vi.mocked(api.fetchConfigJson).mockResolvedValue({
|
|
61
|
+
security: { bind: {}, network: {}, rate_limits: {}, sandbox: {}, dashboard: {}, api: {} },
|
|
62
|
+
budget: { budget: {} },
|
|
63
|
+
brain: {},
|
|
64
|
+
secrets: {},
|
|
65
|
+
} as never);
|
|
66
|
+
vi.mocked(api.fetchHealth).mockResolvedValue({ status: 'healthy', version: '1.0.0' } as never);
|
|
67
|
+
vi.mocked(api.checkUpdate).mockResolvedValue({ updateAvailable: false } as never);
|
|
68
|
+
vi.mocked(api.restartServer).mockResolvedValue({
|
|
69
|
+
success: false,
|
|
70
|
+
scheduled: false,
|
|
71
|
+
message: 'refusing to exit: no supervisor would restart this process',
|
|
72
|
+
} as never);
|
|
73
|
+
|
|
74
|
+
await act(async () => {
|
|
75
|
+
render(<SettingsPage />);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const button = await screen.findByTestId('restart-server', {}, { timeout: 4000 });
|
|
79
|
+
await act(async () => {
|
|
80
|
+
button.click();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect(api.restartServer).toHaveBeenCalled();
|
|
84
|
+
// An unsupervised host is a real answer, surfaced verbatim — not swallowed
|
|
85
|
+
// and not presented as a successful restart.
|
|
86
|
+
expect(screen.getByText(/refusing to exit/i)).toBeInTheDocument();
|
|
87
|
+
}, 10000);
|
|
57
88
|
});
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
checkUpdate,
|
|
8
8
|
runUpdate,
|
|
9
9
|
restartDaemon,
|
|
10
|
+
restartServer,
|
|
10
11
|
triggerRecompile,
|
|
11
12
|
apiFetch,
|
|
12
13
|
getApiBase,
|
|
@@ -14,6 +15,7 @@ import {
|
|
|
14
15
|
import type { HealthData } from '../types';
|
|
15
16
|
import type { ConfigJson } from '../types';
|
|
16
17
|
import { MobilePairing } from '../components/MobilePairing';
|
|
18
|
+
import { TailnetEnroll } from '../components/TailnetEnroll';
|
|
17
19
|
|
|
18
20
|
function normalizeConfigJson(data: ConfigJson): ConfigJson {
|
|
19
21
|
const next = { ...data } as ConfigJson & Record<string, unknown>;
|
|
@@ -52,6 +54,40 @@ export default function SettingsPage({ activeBrainId }: { activeBrainId?: string
|
|
|
52
54
|
const [updating, setUpdating] = useState(false);
|
|
53
55
|
const [updateMessage, setUpdateMessage] = useState<string | null>(null);
|
|
54
56
|
const [restartingDaemon, setRestartingDaemon] = useState(false);
|
|
57
|
+
const [restartingServer, setRestartingServer] = useState(false);
|
|
58
|
+
|
|
59
|
+
const handleRestartServer = async () => {
|
|
60
|
+
setRestartingServer(true);
|
|
61
|
+
setUpdateMessage(null);
|
|
62
|
+
try {
|
|
63
|
+
const res = await restartServer();
|
|
64
|
+
setUpdateMessage(res.message);
|
|
65
|
+
if (res.scheduled) {
|
|
66
|
+
// The server is about to drop this connection on purpose. Poll health
|
|
67
|
+
// until it answers again instead of leaving the page looking hung.
|
|
68
|
+
setTimeout(function poll(attempt = 0) {
|
|
69
|
+
fetchHealth()
|
|
70
|
+
.then((h) => {
|
|
71
|
+
setHealth(h);
|
|
72
|
+
setUpdateMessage('Server restarted — reconnected.');
|
|
73
|
+
setRestartingServer(false);
|
|
74
|
+
})
|
|
75
|
+
.catch(() => {
|
|
76
|
+
if (attempt >= 20) {
|
|
77
|
+
setUpdateMessage('Server restarted but has not answered yet — reload in a moment.');
|
|
78
|
+
setRestartingServer(false);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
setTimeout(() => poll(attempt + 1), 1500);
|
|
82
|
+
});
|
|
83
|
+
}, 2000);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
} catch (err: unknown) {
|
|
87
|
+
setUpdateMessage('Restart error: ' + (err as Error).message);
|
|
88
|
+
}
|
|
89
|
+
setRestartingServer(false);
|
|
90
|
+
};
|
|
55
91
|
|
|
56
92
|
const handleRestartDaemon = async () => {
|
|
57
93
|
setRestartingDaemon(true);
|
|
@@ -141,10 +177,17 @@ export default function SettingsPage({ activeBrainId }: { activeBrainId?: string
|
|
|
141
177
|
s?.up_to_date != null ? `${s.up_to_date} current` : null,
|
|
142
178
|
s?.latest ? `latest ${s.latest}` : null,
|
|
143
179
|
].filter(Boolean);
|
|
180
|
+
// Say what actually happened to this server, rather than telling the
|
|
181
|
+
// user to go work out whether a restart is needed.
|
|
182
|
+
const restartNote = res.restart?.scheduled
|
|
183
|
+
? ' Server is restarting into the new code.'
|
|
184
|
+
: res.restart?.required
|
|
185
|
+
? ` Server still runs the old code — ${res.restart.reason}`
|
|
186
|
+
: '';
|
|
144
187
|
setUpdateMessage(
|
|
145
188
|
bits.length
|
|
146
|
-
? `Update complete — ${bits.join(' · ')}
|
|
147
|
-
: res.message || 'Update complete.',
|
|
189
|
+
? `Update complete — ${bits.join(' · ')}.${restartNote}`
|
|
190
|
+
: (res.message || 'Update complete.') + restartNote,
|
|
148
191
|
);
|
|
149
192
|
} else {
|
|
150
193
|
const failDetail = (res.summary?.results || [])
|
|
@@ -559,6 +602,10 @@ export default function SettingsPage({ activeBrainId }: { activeBrainId?: string
|
|
|
559
602
|
<MobilePairing />
|
|
560
603
|
</div>
|
|
561
604
|
|
|
605
|
+
<div className="settings-card glow-on-hover">
|
|
606
|
+
<TailnetEnroll />
|
|
607
|
+
</div>
|
|
608
|
+
|
|
562
609
|
{/* --- Global System Layer --- */}
|
|
563
610
|
{/* Network & Binding */}
|
|
564
611
|
<div className="settings-card">
|
|
@@ -1010,6 +1057,16 @@ export default function SettingsPage({ activeBrainId }: { activeBrainId?: string
|
|
|
1010
1057
|
>
|
|
1011
1058
|
{restartingDaemon ? 'Restarting…' : 'Restart Daemon'}
|
|
1012
1059
|
</button>
|
|
1060
|
+
<button
|
|
1061
|
+
className="btn-secondary"
|
|
1062
|
+
onClick={handleRestartServer}
|
|
1063
|
+
disabled={restartingServer}
|
|
1064
|
+
data-testid="restart-server"
|
|
1065
|
+
title="Restart the brain server so newly installed code is the code that runs"
|
|
1066
|
+
style={{ fontSize: 13, padding: '8px 14px', borderRadius: 8, cursor: 'pointer' }}
|
|
1067
|
+
>
|
|
1068
|
+
{restartingServer ? 'Restarting…' : 'Restart Server'}
|
|
1069
|
+
</button>
|
|
1013
1070
|
<button
|
|
1014
1071
|
className="btn-primary"
|
|
1015
1072
|
onClick={handleRunUpdate}
|
package/package.json
CHANGED
|
@@ -591,8 +591,9 @@ Current baseline: **288 spec files / 1493 tests, ~160 s** on the Mac Mini.
|
|
|
591
591
|
> and OOM kills. Sync and run remotely:
|
|
592
592
|
>
|
|
593
593
|
> ```bash
|
|
594
|
-
>
|
|
595
|
-
>
|
|
594
|
+
> REMOTE=build-host; REMOTE_DIR=code/total-recall # your own host and path
|
|
595
|
+
> rsync -a --delete --exclude=node_modules/ --exclude=.git/ ./ "$REMOTE:$REMOTE_DIR/"
|
|
596
|
+
> ssh "$REMOTE" "export PATH=/opt/homebrew/bin:\$PATH; cd $REMOTE_DIR && npx vitest run"
|
|
596
597
|
> ```
|
|
597
598
|
>
|
|
598
599
|
> A non-login `ssh` shell has no Homebrew on PATH — export it or use `bash -lc`,
|
|
@@ -23,7 +23,7 @@ Rebuild the derived indexes and compile memory surfaces.
|
|
|
23
23
|
### 1.3 `connect`
|
|
24
24
|
Configure an IDE or external client to bind to the Total Recall local server.
|
|
25
25
|
* **Syntax**: `npx total-recall connect <client> [options]`
|
|
26
|
-
* **Supported Clients**: `cursor`, `
|
|
26
|
+
* **Supported Clients**: `vscode`, `cursor`, `cline`, `codex`, `antigravity`, `gemini`, `aider`, `obsidian`, `pi`, `hermes`, `openclaw`, `generic`.
|
|
27
27
|
* **Options**:
|
|
28
28
|
* `--brain <url>`: Override default brain server URL.
|
|
29
29
|
* `--token <PAT>`: Inject bearer PAT credentials for authenticating requests.
|