total-recall-brain 3.25.6 → 3.28.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 +31 -1
- package/docs/ARCHITECTURE.md +76 -9
- package/frontend/dist/assets/index-U7o-qU99.js +5742 -0
- package/frontend/dist/index.html +1 -1
- package/frontend/src/App.tsx +39 -1
- package/frontend/src/api/index.ts +1 -0
- package/frontend/src/api/mesh.ts +32 -0
- package/frontend/src/api/plugins.ts +167 -0
- package/frontend/src/components/MobilePairing.spec.tsx +89 -0
- package/frontend/src/components/MobilePairing.tsx +72 -1
- package/frontend/src/components/TailnetEnroll.spec.tsx +29 -1
- package/frontend/src/components/TailnetEnroll.tsx +85 -3
- package/frontend/src/pages/ChatPage.tsx +44 -17
- package/frontend/src/pages/IntegrationsPage.tsx +7 -0
- package/frontend/src/pages/PluginsPage.spec.tsx +17 -0
- package/frontend/src/pages/PluginsPage.tsx +1331 -0
- package/package.json +1 -1
- package/scaffold/.agent/plugins/.gitkeep +0 -0
- package/scaffold/.agent/plugins/code-quality/README.md +16 -0
- package/scaffold/.agent/plugins/code-quality/cli.mjs +15 -0
- package/scaffold/.agent/plugins/code-quality/plugin.json +34 -0
- package/scaffold/.agent/plugins/git-sentinel/README.md +16 -0
- package/scaffold/.agent/plugins/git-sentinel/cli.mjs +86 -0
- package/scaffold/.agent/plugins/git-sentinel/plugin.json +34 -0
- package/scaffold/.agent/plugins/meta-harness/README.md +16 -0
- package/scaffold/.agent/plugins/meta-harness/cli.mjs +34 -0
- package/scaffold/.agent/plugins/meta-harness/plugin.json +34 -0
- package/scaffold/.agent/plugins/system-monitor/README.md +16 -0
- package/scaffold/.agent/plugins/system-monitor/cli.mjs +79 -0
- package/scaffold/.agent/plugins/system-monitor/generator.mjs +20 -0
- package/scaffold/.agent/plugins/system-monitor/plugin.json +38 -0
- package/scaffold/.agent/skills/meta-harness/SKILL.md +113 -0
- package/scaffold/.agent/skills/total-recall/references/cli-reference.md +23 -0
- package/src/cli/agent.mjs +173 -0
- package/src/cli/connect.mjs +89 -4
- package/src/cli/deploy-ui.mjs +6 -0
- package/src/cli/harness.mjs +135 -0
- package/src/cli/ingest-openwiki.mjs +71 -2
- package/src/cli/init.mjs +1 -0
- package/src/cli/lint.mjs +47 -3
- package/src/cli/mesh.mjs +286 -0
- package/src/cli/plugin/create.mjs +186 -0
- package/src/cli/plugin/index.mjs +139 -0
- package/src/cli/plugin/install.mjs +141 -0
- package/src/cli/plugin/list.mjs +83 -0
- package/src/cli/plugin/remove.mjs +51 -0
- package/src/cli/plugin/search.mjs +37 -0
- package/src/cli/skill-projection.mjs +14 -0
- package/src/cli/wizard.html +9 -0
- package/src/core/agent-manager.mjs +290 -0
- package/src/core/bound-hosts.mjs +64 -0
- package/src/core/cache-prune.mjs +341 -0
- package/src/core/context-cache.mjs +130 -0
- package/src/core/daemon-loop.mjs +47 -1
- package/src/core/evolving-context.mjs +171 -0
- package/src/core/mesh-activity.mjs +140 -0
- package/src/core/mesh-late-bind.mjs +105 -0
- package/src/core/mesh.mjs +174 -1
- package/src/core/meta-harness.mjs +251 -0
- package/src/core/pairing.mjs +49 -10
- package/src/core/plugin-context.mjs +206 -0
- package/src/core/plugin-loader.mjs +210 -0
- package/src/core/registration-watch.mjs +193 -0
- package/src/core/runtime.mjs +65 -13
- package/src/core/secrets-store.mjs +8 -0
- package/src/core/surface.mjs +32 -10
- package/src/server/api.mjs +18 -2
- package/src/server/auth.mjs +11 -1
- package/src/server/index.mjs +43 -2
- package/src/server/rest.mjs +4 -0
- package/src/server/route-manifest.json +12 -0
- package/src/server/routes/auth.mjs +31 -0
- package/src/server/routes/integrations.mjs +5 -1
- package/src/server/routes/mesh-presence.mjs +143 -0
- package/src/server/routes/mesh.mjs +55 -0
- package/src/server/routes/models.mjs +42 -30
- package/src/server/routes/plugins.mjs +517 -0
- package/src/server/routes/secrets.mjs +1 -0
- package/src/server/routes/system.mjs +5 -0
- package/src/server/tools.mjs +266 -0
- package/frontend/dist/assets/index-DSxCyPAx.js +0 -5742
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-U7o-qU99.js"></script>
|
|
21
21
|
<link rel="stylesheet" crossorigin href="/assets/index-C7_Xpk27.css">
|
|
22
22
|
</head>
|
|
23
23
|
<body>
|
package/frontend/src/App.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useState, useEffect } from 'react'
|
|
2
2
|
import { BrowserRouter, Routes, Route, NavLink, Navigate, useLocation } from 'react-router-dom'
|
|
3
3
|
import './App.css'
|
|
4
|
-
import { getApiBase, setApiBase, checkSession, logout, registerUnauthedCallback, fetchHealth } from './api'
|
|
4
|
+
import { getApiBase, setApiBase, checkSession, logout, registerUnauthedCallback, fetchHealth, fetchPlugins, type PluginInfo } from './api'
|
|
5
5
|
import type { HealthData } from './types'
|
|
6
6
|
import LoginPage from './pages/LoginPage'
|
|
7
7
|
import ChatPage from './pages/ChatPage'
|
|
@@ -19,6 +19,7 @@ import RulesPage from './pages/RulesPage'
|
|
|
19
19
|
import HelpPage from './pages/HelpPage'
|
|
20
20
|
import GraphPage from './pages/GraphPage'
|
|
21
21
|
import OpenWikiPage from './pages/OpenWikiPage'
|
|
22
|
+
import PluginsPage from './pages/PluginsPage'
|
|
22
23
|
import OnboardingPage from './pages/OnboardingPage'
|
|
23
24
|
import { NotificationsPage } from './pages/NotificationsPage'
|
|
24
25
|
import { isOnboardingComplete } from './utils/onboarding'
|
|
@@ -86,6 +87,12 @@ interface SidebarProps {
|
|
|
86
87
|
const getNavLinkClass = ({ isActive }: { isActive: boolean }) => `nav-link ${isActive ? 'active' : ''}`;
|
|
87
88
|
|
|
88
89
|
function Sidebar({ onLogout, health, activeBrainId, onBrainChange }: SidebarProps) {
|
|
90
|
+
const [plugins, setPlugins] = useState<PluginInfo[]>([])
|
|
91
|
+
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
fetchPlugins().then(setPlugins).catch(() => {})
|
|
94
|
+
}, [])
|
|
95
|
+
|
|
89
96
|
return (
|
|
90
97
|
<aside className="sidebar">
|
|
91
98
|
<div className="sidebar-brand">
|
|
@@ -158,6 +165,36 @@ function Sidebar({ onLogout, health, activeBrainId, onBrainChange }: SidebarProp
|
|
|
158
165
|
</svg>
|
|
159
166
|
OpenWiki
|
|
160
167
|
</NavLink>
|
|
168
|
+
<NavLink to="/plugins" className={getNavLinkClass} id="nav-plugins">
|
|
169
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
170
|
+
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
|
|
171
|
+
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
|
|
172
|
+
<line x1="12" y1="22.08" x2="12" y2="12" />
|
|
173
|
+
</svg>
|
|
174
|
+
Plugins
|
|
175
|
+
</NavLink>
|
|
176
|
+
|
|
177
|
+
{plugins.length > 0 && (
|
|
178
|
+
<>
|
|
179
|
+
<div className="nav-section-label">Plugins</div>
|
|
180
|
+
{plugins.map((p) => (
|
|
181
|
+
<NavLink
|
|
182
|
+
key={p.id}
|
|
183
|
+
to={`/plugins?id=${encodeURIComponent(p.id)}`}
|
|
184
|
+
className={getNavLinkClass}
|
|
185
|
+
id={`nav-plugin-${p.id}`}
|
|
186
|
+
title={p.description}
|
|
187
|
+
>
|
|
188
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
189
|
+
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
|
|
190
|
+
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
|
|
191
|
+
<line x1="12" y1="22.08" x2="12" y2="12" />
|
|
192
|
+
</svg>
|
|
193
|
+
{p.name}
|
|
194
|
+
</NavLink>
|
|
195
|
+
))}
|
|
196
|
+
</>
|
|
197
|
+
)}
|
|
161
198
|
|
|
162
199
|
<div className="nav-section-label">Account</div>
|
|
163
200
|
<NavLink to="/network" className={getNavLinkClass} id="nav-network">
|
|
@@ -396,6 +433,7 @@ function MainContent({ activeBrainId, onBrainChange }: { activeBrainId: string;
|
|
|
396
433
|
<Route path="/tasks" element={<TasksPage />} />
|
|
397
434
|
<Route path="/skills" element={<SkillsPage activeBrainId={activeBrainId} />} />
|
|
398
435
|
<Route path="/openwiki" element={<OpenWikiPage activeBrainId={activeBrainId} />} />
|
|
436
|
+
<Route path="/plugins" element={<PluginsPage activeBrainId={activeBrainId} />} />
|
|
399
437
|
<Route path="/integrations" element={<IntegrationsPage activeBrainId={activeBrainId} />} />
|
|
400
438
|
<Route path="/keys" element={<Navigate to="/secrets" replace />} />
|
|
401
439
|
<Route path="/settings" element={<SettingsPage activeBrainId={activeBrainId} />} />
|
package/frontend/src/api/mesh.ts
CHANGED
|
@@ -303,6 +303,38 @@ export async function registerNode(authId: string, user?: string): Promise<Regis
|
|
|
303
303
|
return data as RegisteredNode;
|
|
304
304
|
}
|
|
305
305
|
|
|
306
|
+
export interface WatchStatus {
|
|
307
|
+
state: 'idle' | 'watching' | 'registered' | 'expired' | 'stopped' | 'unavailable' | 'error';
|
|
308
|
+
id?: string | null;
|
|
309
|
+
node?: { id: string | null; name: string | null; ip_addresses: string[] } | null;
|
|
310
|
+
error?: string | null;
|
|
311
|
+
remaining_ms?: number;
|
|
312
|
+
source?: string;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Arm the server to approve the next device that signs in. */
|
|
316
|
+
export async function startWatch(ttlMinutes?: number): Promise<WatchStatus> {
|
|
317
|
+
const res = await apiFetch(`${API_BASE}/api/mesh/watch`, {
|
|
318
|
+
method: 'POST',
|
|
319
|
+
headers: { 'Content-Type': 'application/json' },
|
|
320
|
+
body: JSON.stringify(ttlMinutes ? { ttlMinutes } : {}),
|
|
321
|
+
});
|
|
322
|
+
// 409 means watch mode is not available here — a real answer the UI acts on,
|
|
323
|
+
// not an error to throw away.
|
|
324
|
+
const data = await res.json().catch(() => ({}));
|
|
325
|
+
if (!res.ok && res.status !== 409) throw new Error(data.error || `watch failed: ${res.status}`);
|
|
326
|
+
return data as WatchStatus;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export async function getWatchStatus(): Promise<WatchStatus> {
|
|
330
|
+
return get('/api/mesh/watch');
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export async function stopWatch(): Promise<WatchStatus> {
|
|
334
|
+
const res = await apiFetch(`${API_BASE}/api/mesh/watch`, { method: 'DELETE' });
|
|
335
|
+
return (await res.json().catch(() => ({ state: 'idle' }))) as WatchStatus;
|
|
336
|
+
}
|
|
337
|
+
|
|
306
338
|
export async function fetchEnrollmentStatus(): Promise<EnrollmentStatus> {
|
|
307
339
|
return get('/api/mesh/enrollment');
|
|
308
340
|
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// ─── Plugins domain ───────────────────────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
import { apiFetch, API_BASE } from "./_base"
|
|
4
|
+
|
|
5
|
+
export interface PluginInfo {
|
|
6
|
+
id: string
|
|
7
|
+
name: string
|
|
8
|
+
version: string
|
|
9
|
+
description: string
|
|
10
|
+
valid: boolean
|
|
11
|
+
errors: string[]
|
|
12
|
+
dir: string
|
|
13
|
+
rating: number
|
|
14
|
+
reviewCount: number
|
|
15
|
+
installCount: string
|
|
16
|
+
userRating?: number | null
|
|
17
|
+
userReview?: string | null
|
|
18
|
+
verified?: boolean
|
|
19
|
+
categories: Array<{ name: string; description?: string }>
|
|
20
|
+
tasks: Array<{ intent: string; schedule: string }>
|
|
21
|
+
openwiki_hubs: Array<{ title: string; path: string }>
|
|
22
|
+
tools: Array<{ name: string; description?: string }>
|
|
23
|
+
cli?: { command: string; handler: string } | null
|
|
24
|
+
manifest?: Record<string, unknown>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface CatalogPlugin {
|
|
28
|
+
id: string
|
|
29
|
+
name: string
|
|
30
|
+
version: string
|
|
31
|
+
description: string
|
|
32
|
+
tags: string[]
|
|
33
|
+
author: string
|
|
34
|
+
sourceUrl: string
|
|
35
|
+
rating: number
|
|
36
|
+
reviewCount: number
|
|
37
|
+
installCount: string
|
|
38
|
+
userRating?: number | null
|
|
39
|
+
verified?: boolean
|
|
40
|
+
isInstalled: boolean
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface PluginsResponse {
|
|
44
|
+
success: boolean
|
|
45
|
+
count: number
|
|
46
|
+
plugins: PluginInfo[]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface CatalogResponse {
|
|
50
|
+
success: boolean
|
|
51
|
+
catalog: CatalogPlugin[]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function fetchPlugins(): Promise<PluginInfo[]> {
|
|
55
|
+
try {
|
|
56
|
+
const res = await apiFetch(`${API_BASE}/api/plugins`)
|
|
57
|
+
if (!res.ok) return []
|
|
58
|
+
const data: PluginsResponse = await res.json()
|
|
59
|
+
return Array.isArray(data.plugins) ? data.plugins : []
|
|
60
|
+
} catch {
|
|
61
|
+
return []
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function fetchPlugin(id: string): Promise<PluginInfo | null> {
|
|
66
|
+
try {
|
|
67
|
+
const res = await apiFetch(`${API_BASE}/api/plugins/${encodeURIComponent(id)}`)
|
|
68
|
+
if (!res.ok) return null
|
|
69
|
+
const data = await res.json()
|
|
70
|
+
return data.plugin || null
|
|
71
|
+
} catch {
|
|
72
|
+
return null
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function fetchPluginCatalog(): Promise<CatalogPlugin[]> {
|
|
77
|
+
try {
|
|
78
|
+
const res = await apiFetch(`${API_BASE}/api/plugins/catalog`)
|
|
79
|
+
if (!res.ok) return []
|
|
80
|
+
const data: CatalogResponse = await res.json()
|
|
81
|
+
return Array.isArray(data.catalog) ? data.catalog : []
|
|
82
|
+
} catch {
|
|
83
|
+
return []
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function installPlugin(options: { source: string; link?: boolean; global?: boolean }): Promise<{ success: boolean; message?: string; error?: string }> {
|
|
88
|
+
try {
|
|
89
|
+
const res = await apiFetch(`${API_BASE}/api/plugins/install`, {
|
|
90
|
+
method: "POST",
|
|
91
|
+
headers: { "Content-Type": "application/json" },
|
|
92
|
+
body: JSON.stringify(options)
|
|
93
|
+
})
|
|
94
|
+
const data = await res.json()
|
|
95
|
+
if (!res.ok) {
|
|
96
|
+
return { success: false, error: data.error || data.message || "Failed to install plugin" }
|
|
97
|
+
}
|
|
98
|
+
return { success: true, message: data.message }
|
|
99
|
+
} catch (err: any) {
|
|
100
|
+
return { success: false, error: err.message || "Network error" }
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function removePlugin(id: string, global = false): Promise<{ success: boolean; message?: string; error?: string }> {
|
|
105
|
+
try {
|
|
106
|
+
const res = await apiFetch(`${API_BASE}/api/plugins/${encodeURIComponent(id)}?global=${global ? "true" : "false"}`, {
|
|
107
|
+
method: "DELETE"
|
|
108
|
+
})
|
|
109
|
+
const data = await res.json()
|
|
110
|
+
if (!res.ok) {
|
|
111
|
+
return { success: false, error: data.error || data.message || "Failed to remove plugin" }
|
|
112
|
+
}
|
|
113
|
+
return { success: true, message: data.message }
|
|
114
|
+
} catch (err: any) {
|
|
115
|
+
return { success: false, error: err.message || "Network error" }
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function ratePlugin(id: string, rating: number, review?: string): Promise<{ success: boolean; message?: string; error?: string }> {
|
|
120
|
+
try {
|
|
121
|
+
const res = await apiFetch(`${API_BASE}/api/plugins/${encodeURIComponent(id)}/rate`, {
|
|
122
|
+
method: "POST",
|
|
123
|
+
headers: { "Content-Type": "application/json" },
|
|
124
|
+
body: JSON.stringify({ rating, review })
|
|
125
|
+
})
|
|
126
|
+
const data = await res.json()
|
|
127
|
+
if (!res.ok) {
|
|
128
|
+
return { success: false, error: data.error || data.message || "Failed to submit rating" }
|
|
129
|
+
}
|
|
130
|
+
return { success: true, message: data.message }
|
|
131
|
+
} catch (err: any) {
|
|
132
|
+
return { success: false, error: err.message || "Network error" }
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function runPluginCommand(
|
|
137
|
+
id: string,
|
|
138
|
+
subcommand = "",
|
|
139
|
+
args: string[] = []
|
|
140
|
+
): Promise<{ success: boolean; output?: string; error?: string }> {
|
|
141
|
+
try {
|
|
142
|
+
const res = await apiFetch(`${API_BASE}/api/plugins/${encodeURIComponent(id)}/run`, {
|
|
143
|
+
method: "POST",
|
|
144
|
+
headers: { "Content-Type": "application/json" },
|
|
145
|
+
body: JSON.stringify({ subcommand, args })
|
|
146
|
+
})
|
|
147
|
+
const data = await res.json()
|
|
148
|
+
if (!res.ok) {
|
|
149
|
+
return { success: false, error: data.error || data.message || "Failed to execute plugin command" }
|
|
150
|
+
}
|
|
151
|
+
return { success: true, output: data.output || "" }
|
|
152
|
+
} catch (err: any) {
|
|
153
|
+
return { success: false, error: err.message || "Network error" }
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function fetchPluginReadme(id: string): Promise<string> {
|
|
158
|
+
try {
|
|
159
|
+
const res = await apiFetch(`${API_BASE}/api/plugins/${encodeURIComponent(id)}/readme`)
|
|
160
|
+
if (!res.ok) return ""
|
|
161
|
+
const data = await res.json()
|
|
162
|
+
return data.readme || ""
|
|
163
|
+
} catch {
|
|
164
|
+
return ""
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
@@ -61,4 +61,93 @@ describe('MobilePairing', () => {
|
|
|
61
61
|
expect(screen.getByText(/Reset Keychain/i)).toBeTruthy();
|
|
62
62
|
});
|
|
63
63
|
|
|
64
|
+
const pairingResponse = (over = {}) => ({
|
|
65
|
+
ok: true,
|
|
66
|
+
json: async () => ({
|
|
67
|
+
port: 3000,
|
|
68
|
+
protocol: 'http',
|
|
69
|
+
preferred_url: 'http://100.64.0.2:3000',
|
|
70
|
+
listen_hosts: ['127.0.0.1'],
|
|
71
|
+
listen_hosts_source: 'actual',
|
|
72
|
+
reachable_from_other_devices: false,
|
|
73
|
+
warnings: ['This brain is listening on loopback only'],
|
|
74
|
+
endpoints: [
|
|
75
|
+
{
|
|
76
|
+
kind: 'mesh',
|
|
77
|
+
label: 'Tailscale / mesh',
|
|
78
|
+
ip: '100.64.0.2',
|
|
79
|
+
url: 'http://100.64.0.2:3000',
|
|
80
|
+
recommended: true,
|
|
81
|
+
reachable_hint: 'Tailscale',
|
|
82
|
+
listening: false,
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
...over,
|
|
86
|
+
}),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('says no device can reach the brain instead of drawing a QR that fails', async () => {
|
|
90
|
+
// The Mac Mini shipped this card for a week: brain bound loopback-only
|
|
91
|
+
// after losing the boot race to the mesh client, while the card happily
|
|
92
|
+
// recommended a mesh URL nothing had ever listened on.
|
|
93
|
+
vi.mocked(apiFetch).mockResolvedValue(pairingResponse() as never);
|
|
94
|
+
|
|
95
|
+
render(<MobilePairing />);
|
|
96
|
+
|
|
97
|
+
const alert = await screen.findByTestId('pairing-unreachable');
|
|
98
|
+
expect(alert.textContent).toMatch(/No other device can reach this brain/i);
|
|
99
|
+
// Naming the cause is what turns an error into something a user can act on.
|
|
100
|
+
expect(alert.textContent).toMatch(/still starting when the brain launched/i);
|
|
101
|
+
expect(alert.textContent).toMatch(/restart the brain/i);
|
|
102
|
+
// And the code itself must not look scannable.
|
|
103
|
+
expect(screen.getByTestId('pairing-qr-dead')).toBeTruthy();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('flags the specific address that is not listening', async () => {
|
|
107
|
+
vi.mocked(apiFetch).mockResolvedValue(pairingResponse() as never);
|
|
108
|
+
render(<MobilePairing />);
|
|
109
|
+
await screen.findByTestId('pairing-unreachable');
|
|
110
|
+
expect(screen.getByTestId('pairing-endpoint-select').textContent).toMatch(/not listening/i);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('stays quiet when the brain is genuinely reachable', async () => {
|
|
114
|
+
vi.mocked(apiFetch).mockResolvedValue(
|
|
115
|
+
pairingResponse({
|
|
116
|
+
preferred_url: 'http://100.64.0.6:3000',
|
|
117
|
+
listen_hosts: ['100.64.0.6', '127.0.0.1'],
|
|
118
|
+
reachable_from_other_devices: true,
|
|
119
|
+
warnings: [],
|
|
120
|
+
endpoints: [
|
|
121
|
+
{
|
|
122
|
+
kind: 'mesh',
|
|
123
|
+
label: 'Tailscale / mesh',
|
|
124
|
+
ip: '100.64.0.6',
|
|
125
|
+
url: 'http://100.64.0.6:3000',
|
|
126
|
+
recommended: true,
|
|
127
|
+
reachable_hint: 'Tailscale',
|
|
128
|
+
listening: true,
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
}) as never,
|
|
132
|
+
);
|
|
133
|
+
render(<MobilePairing />);
|
|
134
|
+
await waitFor(() => {
|
|
135
|
+
expect(screen.getByTestId('pairing-url').textContent).toContain('100.64.0.6');
|
|
136
|
+
});
|
|
137
|
+
expect(screen.queryByTestId('pairing-unreachable')).toBeNull();
|
|
138
|
+
expect(screen.queryByTestId('pairing-qr-dead')).toBeNull();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('does not cry wolf when the server could not observe its own sockets', async () => {
|
|
142
|
+
// `null` is unknown. Rendering unknown as a red banner would put a false
|
|
143
|
+
// alarm on every install whose page load beat the listen callback.
|
|
144
|
+
vi.mocked(apiFetch).mockResolvedValue(
|
|
145
|
+
pairingResponse({ reachable_from_other_devices: null, listen_hosts_source: 'derived' }) as never,
|
|
146
|
+
);
|
|
147
|
+
render(<MobilePairing />);
|
|
148
|
+
await waitFor(() => {
|
|
149
|
+
expect(screen.getByTestId('pairing-url')).toBeTruthy();
|
|
150
|
+
});
|
|
151
|
+
expect(screen.queryByTestId('pairing-unreachable')).toBeNull();
|
|
152
|
+
});
|
|
64
153
|
});
|
|
@@ -10,6 +10,8 @@ export interface PairingEndpoint {
|
|
|
10
10
|
interface?: string | null;
|
|
11
11
|
recommended: boolean;
|
|
12
12
|
reachable_hint: string;
|
|
13
|
+
/** null when the server could not observe its own sockets. */
|
|
14
|
+
listening?: boolean | null;
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
export interface PairingInfo {
|
|
@@ -19,6 +21,10 @@ export interface PairingInfo {
|
|
|
19
21
|
endpoints: PairingEndpoint[];
|
|
20
22
|
warnings: string[];
|
|
21
23
|
listen_hosts: string[];
|
|
24
|
+
/** 'actual' = observed sockets. 'derived' = a guess; do not render as fact. */
|
|
25
|
+
listen_hosts_source?: 'actual' | 'derived';
|
|
26
|
+
/** null means unknown — must not be shown as either working or broken. */
|
|
27
|
+
reachable_from_other_devices?: boolean | null;
|
|
22
28
|
}
|
|
23
29
|
|
|
24
30
|
export function MobilePairing() {
|
|
@@ -67,6 +73,8 @@ export function MobilePairing() {
|
|
|
67
73
|
],
|
|
68
74
|
warnings: ['Could not load live network endpoints from the brain.'],
|
|
69
75
|
listen_hosts: [],
|
|
76
|
+
listen_hosts_source: 'derived',
|
|
77
|
+
reachable_from_other_devices: null,
|
|
70
78
|
});
|
|
71
79
|
} finally {
|
|
72
80
|
setLoading(false);
|
|
@@ -78,6 +86,9 @@ export function MobilePairing() {
|
|
|
78
86
|
}, []);
|
|
79
87
|
|
|
80
88
|
const selected = info?.endpoints?.find((e) => e.url === selectedUrl) || null;
|
|
89
|
+
// Observed, not guessed. `undefined`/`null` means the server could not tell
|
|
90
|
+
// us — that is not the same as broken, so it must not raise this banner.
|
|
91
|
+
const unreachable = info?.reachable_from_other_devices === false;
|
|
81
92
|
const isLoopback =
|
|
82
93
|
selected?.kind === 'loopback' ||
|
|
83
94
|
selectedUrl.includes('127.0.0.1') ||
|
|
@@ -111,6 +122,39 @@ export function MobilePairing() {
|
|
|
111
122
|
Wi‑Fi, or <strong>Tailscale/mesh</strong> if the phone is on your tailnet.
|
|
112
123
|
</p>
|
|
113
124
|
|
|
125
|
+
{unreachable && (
|
|
126
|
+
<div
|
|
127
|
+
role="alert"
|
|
128
|
+
data-testid="pairing-unreachable"
|
|
129
|
+
style={{
|
|
130
|
+
marginBottom: 16,
|
|
131
|
+
padding: '12px 14px',
|
|
132
|
+
borderRadius: 8,
|
|
133
|
+
border: '1px solid #ef4444',
|
|
134
|
+
background: 'rgba(239, 68, 68, 0.08)',
|
|
135
|
+
color: 'var(--text-primary)',
|
|
136
|
+
fontSize: 13,
|
|
137
|
+
lineHeight: 1.55,
|
|
138
|
+
}}
|
|
139
|
+
>
|
|
140
|
+
<div style={{ fontWeight: 700, marginBottom: 4 }}>
|
|
141
|
+
No other device can reach this brain
|
|
142
|
+
</div>
|
|
143
|
+
<div style={{ color: 'var(--text-secondary)' }}>
|
|
144
|
+
It is listening on{' '}
|
|
145
|
+
<code>{info?.listen_hosts?.join(', ') || 'loopback'}</code> only, so every code on this
|
|
146
|
+
card is a dead address — a phone that scans one will time out no matter how it is
|
|
147
|
+
connected. This normally means the mesh client was still starting when the brain
|
|
148
|
+
launched, so the brain never got a mesh address to bind.
|
|
149
|
+
<div style={{ marginTop: 6 }}>
|
|
150
|
+
<strong>Fix:</strong> restart the brain now that the mesh is up. It re-checks for a
|
|
151
|
+
mesh address on its own for the first few minutes after launch, so a restart is
|
|
152
|
+
usually all this needs.
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
)}
|
|
157
|
+
|
|
114
158
|
{loading && (
|
|
115
159
|
<div data-testid="pairing-loading" style={{ color: 'var(--text-tertiary)', fontSize: 13 }}>
|
|
116
160
|
Detecting reachable addresses…
|
|
@@ -149,7 +193,33 @@ export function MobilePairing() {
|
|
|
149
193
|
data-testid="pairing-qr"
|
|
150
194
|
>
|
|
151
195
|
{selectedUrl ? (
|
|
152
|
-
<
|
|
196
|
+
<div style={{ position: 'relative' }}>
|
|
197
|
+
{/* Greyed rather than hidden: the address is still the right one
|
|
198
|
+
to reach for once the bind is fixed. */}
|
|
199
|
+
<div style={{ opacity: unreachable ? 0.15 : 1 }}>
|
|
200
|
+
<QRCodeSVG value={selectedUrl} size={150} level="M" includeMargin />
|
|
201
|
+
</div>
|
|
202
|
+
{unreachable && (
|
|
203
|
+
<div
|
|
204
|
+
data-testid="pairing-qr-dead"
|
|
205
|
+
style={{
|
|
206
|
+
position: 'absolute',
|
|
207
|
+
inset: 0,
|
|
208
|
+
display: 'flex',
|
|
209
|
+
alignItems: 'center',
|
|
210
|
+
justifyContent: 'center',
|
|
211
|
+
textAlign: 'center',
|
|
212
|
+
padding: 8,
|
|
213
|
+
color: '#b91c1c',
|
|
214
|
+
fontSize: 12,
|
|
215
|
+
fontWeight: 700,
|
|
216
|
+
lineHeight: 1.4,
|
|
217
|
+
}}
|
|
218
|
+
>
|
|
219
|
+
Nothing is listening on this address
|
|
220
|
+
</div>
|
|
221
|
+
)}
|
|
222
|
+
</div>
|
|
153
223
|
) : (
|
|
154
224
|
<div style={{ width: 150, height: 150 }} />
|
|
155
225
|
)}
|
|
@@ -173,6 +243,7 @@ export function MobilePairing() {
|
|
|
173
243
|
<option key={ep.url} value={ep.url}>
|
|
174
244
|
{ep.label}
|
|
175
245
|
{ep.recommended ? ' ★' : ''} — {ep.url}
|
|
246
|
+
{ep.listening === false ? ' (not listening)' : ''}
|
|
176
247
|
</option>
|
|
177
248
|
))}
|
|
178
249
|
</select>
|
|
@@ -6,8 +6,12 @@ vi.mock('../api/mesh', () => ({
|
|
|
6
6
|
fetchNodes: vi.fn(),
|
|
7
7
|
fetchEnrollmentStatus: vi.fn(),
|
|
8
8
|
mintPreAuthKey: vi.fn(),
|
|
9
|
+
registerNode: vi.fn(),
|
|
10
|
+
startWatch: vi.fn(),
|
|
11
|
+
getWatchStatus: vi.fn(),
|
|
12
|
+
stopWatch: vi.fn(),
|
|
9
13
|
}));
|
|
10
|
-
import { fetchNodes, fetchEnrollmentStatus, mintPreAuthKey } from '../api/mesh';
|
|
14
|
+
import { fetchNodes, fetchEnrollmentStatus, mintPreAuthKey, startWatch } from '../api/mesh';
|
|
11
15
|
|
|
12
16
|
const node = (over = {}) => ({
|
|
13
17
|
hostname: 'laptop', ip: '100.64.0.3', online: true, self: false, os: 'macOS', ...over,
|
|
@@ -150,4 +154,28 @@ describe('TailnetEnroll', () => {
|
|
|
150
154
|
expect(screen.getByText(/Crostini/i)).toBeTruthy();
|
|
151
155
|
});
|
|
152
156
|
|
|
157
|
+
it('leads with a watch button rather than a key to carry between devices', async () => {
|
|
158
|
+
// No Tailscale client can scan a QR to authenticate, and nobody types a
|
|
159
|
+
// 40-character secret from a laptop into a phone. The device generates the
|
|
160
|
+
// id and waits; the server approves it.
|
|
161
|
+
vi.mocked(startWatch).mockResolvedValue({ state: 'watching', remaining_ms: 300000 } as never);
|
|
162
|
+
render(<TailnetEnroll />);
|
|
163
|
+
const btn = await screen.findByTestId('watch-start');
|
|
164
|
+
fireEvent.click(btn);
|
|
165
|
+
await waitFor(() => expect(screen.getByTestId('watch-active')).toBeTruthy());
|
|
166
|
+
expect(startWatch).toHaveBeenCalled();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('says watch mode is unavailable instead of offering a button that cannot work', async () => {
|
|
170
|
+
vi.mocked(startWatch).mockResolvedValue({
|
|
171
|
+
state: 'unavailable',
|
|
172
|
+
error: 'no log source is configured.',
|
|
173
|
+
} as never);
|
|
174
|
+
render(<TailnetEnroll />);
|
|
175
|
+
fireEvent.click(await screen.findByTestId('watch-start'));
|
|
176
|
+
await waitFor(() => expect(screen.getByTestId('watch-unavailable')).toBeTruthy());
|
|
177
|
+
// and the manual path is still reachable
|
|
178
|
+
expect(screen.getByTestId('enroll-approve')).toBeTruthy();
|
|
179
|
+
});
|
|
180
|
+
|
|
153
181
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react';
|
|
2
2
|
import { QRCodeSVG } from 'qrcode.react';
|
|
3
|
-
import { fetchNodes, fetchEnrollmentStatus, mintPreAuthKey, registerNode } from '../api/mesh';
|
|
3
|
+
import { fetchNodes, fetchEnrollmentStatus, mintPreAuthKey, registerNode, startWatch, getWatchStatus, stopWatch } from '../api/mesh';
|
|
4
|
+
import type { WatchStatus } from '../api/mesh';
|
|
4
5
|
import type { MeshNode, PreAuthKey } from '../api/mesh';
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -28,6 +29,7 @@ export function TailnetEnroll() {
|
|
|
28
29
|
const [authId, setAuthId] = useState('');
|
|
29
30
|
const [approving, setApproving] = useState(false);
|
|
30
31
|
const [approved, setApproved] = useState<string | null>(null);
|
|
32
|
+
const [watch, setWatch] = useState<WatchStatus | null>(null);
|
|
31
33
|
|
|
32
34
|
useEffect(() => {
|
|
33
35
|
fetchNodes()
|
|
@@ -64,6 +66,34 @@ export function TailnetEnroll() {
|
|
|
64
66
|
}
|
|
65
67
|
};
|
|
66
68
|
|
|
69
|
+
// Poll while armed. The server holds the watch; this only mirrors it, so a
|
|
70
|
+
// page reload cannot orphan a running watch.
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (watch?.state !== 'watching') return undefined;
|
|
73
|
+
const id = setInterval(async () => {
|
|
74
|
+
try {
|
|
75
|
+
const st = await getWatchStatus();
|
|
76
|
+
setWatch(st);
|
|
77
|
+
if (st.state === 'registered') setNodes(await fetchNodes().catch(() => nodes));
|
|
78
|
+
} catch { /* transient */ }
|
|
79
|
+
}, 2000);
|
|
80
|
+
return () => clearInterval(id);
|
|
81
|
+
}, [watch?.state, nodes]);
|
|
82
|
+
|
|
83
|
+
const arm = async () => {
|
|
84
|
+
setError(null);
|
|
85
|
+
setApproved(null);
|
|
86
|
+
try {
|
|
87
|
+
setWatch(await startWatch(5));
|
|
88
|
+
} catch (e) {
|
|
89
|
+
setError(e instanceof Error ? e.message : 'could not start watching');
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const disarm = async () => {
|
|
94
|
+
try { setWatch(await stopWatch()); } catch { setWatch(null); }
|
|
95
|
+
};
|
|
96
|
+
|
|
67
97
|
const approve = async () => {
|
|
68
98
|
setApproving(true);
|
|
69
99
|
setError(null);
|
|
@@ -136,11 +166,63 @@ export function TailnetEnroll() {
|
|
|
136
166
|
existed, finishing that required SSH and a CLI -- on the machine you
|
|
137
167
|
are not holding.
|
|
138
168
|
*/}
|
|
169
|
+
{/*
|
|
170
|
+
The primary action. The device generates the id and waits; the server
|
|
171
|
+
approves it. Nothing is typed, copied or scanned — which matters
|
|
172
|
+
because no Tailscale client scans QR codes to authenticate, and moving
|
|
173
|
+
a 40-character key from a laptop to a phone by hand is not a flow
|
|
174
|
+
anyone finishes.
|
|
175
|
+
*/}
|
|
139
176
|
<div
|
|
177
|
+
data-testid="enroll-watch"
|
|
178
|
+
style={{ margin: '0 0 12px', padding: 12, borderRadius: 8, border: '1px solid var(--border)' }}
|
|
179
|
+
>
|
|
180
|
+
<strong style={{ fontSize: 12 }}>Add a device</strong>
|
|
181
|
+
<p style={{ fontSize: 12, opacity: 0.75, margin: '4px 0 8px' }}>
|
|
182
|
+
Press this, then sign in on the device. It joins on its own — nothing to copy.
|
|
183
|
+
</p>
|
|
184
|
+
|
|
185
|
+
{watch?.state === 'watching' ? (
|
|
186
|
+
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
|
187
|
+
<span data-testid="watch-active" style={{ fontSize: 12, color: '#10b981' }}>
|
|
188
|
+
● Watching — sign in on the device now
|
|
189
|
+
{typeof watch.remaining_ms === 'number'
|
|
190
|
+
? ` (${Math.ceil(watch.remaining_ms / 1000)}s left)`
|
|
191
|
+
: ''}
|
|
192
|
+
</span>
|
|
193
|
+
<button type="button" onClick={disarm} data-testid="watch-stop">Stop</button>
|
|
194
|
+
</div>
|
|
195
|
+
) : (
|
|
196
|
+
<button type="button" onClick={arm} data-testid="watch-start">
|
|
197
|
+
Watch for a device
|
|
198
|
+
</button>
|
|
199
|
+
)}
|
|
200
|
+
|
|
201
|
+
{watch?.state === 'registered' && (
|
|
202
|
+
<p data-testid="watch-registered" style={{ fontSize: 12, color: '#10b981', margin: '8px 0 0' }}>
|
|
203
|
+
{watch.node?.name || 'Device'} joined the tailnet
|
|
204
|
+
{watch.node?.ip_addresses?.[0] ? ` (${watch.node.ip_addresses[0]})` : ''}.
|
|
205
|
+
</p>
|
|
206
|
+
)}
|
|
207
|
+
{watch?.state === 'expired' && (
|
|
208
|
+
<p data-testid="watch-expired" style={{ fontSize: 12, color: '#f59e0b', margin: '8px 0 0' }}>
|
|
209
|
+
Nothing signed in before the watch ran out. Press it again when the device is ready.
|
|
210
|
+
</p>
|
|
211
|
+
)}
|
|
212
|
+
{watch?.state === 'unavailable' && (
|
|
213
|
+
<p data-testid="watch-unavailable" style={{ fontSize: 12, color: '#f59e0b', margin: '8px 0 0' }}>
|
|
214
|
+
{watch.error} Use the manual box below in the meantime.
|
|
215
|
+
</p>
|
|
216
|
+
)}
|
|
217
|
+
</div>
|
|
218
|
+
|
|
219
|
+
<details
|
|
140
220
|
data-testid="enroll-approve"
|
|
141
221
|
style={{ margin: '0 0 12px', padding: 12, borderRadius: 8, border: '1px solid var(--border)' }}
|
|
142
222
|
>
|
|
143
|
-
<
|
|
223
|
+
<summary style={{ cursor: 'pointer' }}>
|
|
224
|
+
<strong style={{ fontSize: 12 }}>Device waiting for approval? (manual)</strong>
|
|
225
|
+
</summary>
|
|
144
226
|
<p style={{ fontSize: 12, opacity: 0.75, margin: '4px 0 8px' }}>
|
|
145
227
|
If a device shows “run the command below in the headscale server”, paste that whole line
|
|
146
228
|
(or just the <code>hskey-authreq-…</code> part) here.
|
|
@@ -173,7 +255,7 @@ export function TailnetEnroll() {
|
|
|
173
255
|
{approved}
|
|
174
256
|
</p>
|
|
175
257
|
)}
|
|
176
|
-
</
|
|
258
|
+
</details>
|
|
177
259
|
|
|
178
260
|
<details open style={{ fontSize: 12 }}>
|
|
179
261
|
<summary style={{ cursor: 'pointer', fontWeight: 600 }}>On a phone (iOS)</summary>
|