total-recall-brain 3.18.2 → 3.19.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/frontend/dist/assets/{index-D5xRw5uq.js → index-B6AfuKv9.js} +126 -134
- package/frontend/dist/index.html +1 -1
- package/frontend/src/api/config.ts +16 -2
- package/frontend/src/api/keys.ts +111 -0
- package/frontend/src/api/notifications.ts +54 -6
- package/frontend/src/api/update.ts +41 -5
- package/frontend/src/components/BrainSelector.spec.tsx +71 -47
- package/frontend/src/components/BrainSelector.tsx +112 -35
- package/frontend/src/components/MobilePairing.spec.tsx +44 -14
- package/frontend/src/components/MobilePairing.tsx +229 -22
- package/frontend/src/pages/HealthPage.tsx +12 -16
- package/frontend/src/pages/NotificationsPage.tsx +6 -3
- package/frontend/src/pages/RulesPage.spec.tsx +46 -15
- package/frontend/src/pages/RulesPage.tsx +41 -9
- package/frontend/src/pages/SecretsPage.tsx +316 -3
- package/frontend/src/pages/SettingsPage.spec.tsx +30 -6
- package/frontend/src/pages/SettingsPage.tsx +228 -86
- package/package.json +1 -1
- package/src/cli/agent-dir.mjs +17 -2
- package/src/cli/chat.mjs +1 -1
- package/src/cli/daemon.mjs +2 -1
- package/src/cli/dream.mjs +14 -10
- package/src/cli/forget.mjs +14 -2
- package/src/cli/friction.mjs +1 -1
- package/src/cli/import-rules.mjs +8 -3
- package/src/cli/ingest.mjs +3 -2
- package/src/cli/lint.mjs +9 -6
- package/src/cli/rebuild.mjs +9 -4
- package/src/cli/remember.mjs +21 -15
- package/src/cli/secret.mjs +184 -4
- package/src/cli/share.mjs +5 -3
- package/src/cli/skill-projection.mjs +21 -2
- package/src/cli/snapshot.mjs +10 -8
- package/src/cli/update.mjs +2 -1
- package/src/core/clarity-rewriter.mjs +31 -12
- package/src/core/config.mjs +10 -4
- package/src/core/daemon-loop.mjs +33 -6
- package/src/core/embeddings.mjs +51 -3
- package/src/core/memory-title.mjs +70 -0
- package/src/core/notifications.mjs +204 -34
- package/src/core/package-auto-update.mjs +99 -3
- package/src/core/pairing.mjs +168 -0
- package/src/core/provider-account-sync.mjs +1145 -0
- package/src/core/provider-catalog.mjs +11 -4
- package/src/core/research.mjs +98 -24
- package/src/core/runtime.mjs +11 -1
- package/src/core/scheduler.mjs +31 -9
- package/src/core/schema.mjs +23 -0
- package/src/core/secret-integration-research.mjs +325 -0
- package/src/core/secrets-store.mjs +284 -14
- package/src/core/secrets-sync.mjs +1 -1
- package/src/core/skills-registry.mjs +12 -0
- package/src/core/source-adapters.mjs +247 -22
- package/src/core/surface.mjs +4 -4
- package/src/core/task-executors.mjs +161 -14
- package/src/core/throttled-fetch.mjs +5 -0
- package/src/core/vault.mjs +7 -1
- package/src/core/webhook-handlers.mjs +123 -14
- package/src/server/auth.mjs +6 -0
- package/src/server/index.mjs +56 -0
- package/src/server/rest.mjs +2 -0
- package/src/server/route-manifest.json +40 -0
- package/src/server/routes/_shared.mjs +33 -1
- package/src/server/routes/config.mjs +64 -23
- package/src/server/routes/context.mjs +14 -9
- package/src/server/routes/dashboard.mjs +39 -18
- package/src/server/routes/docs.mjs +27 -13
- package/src/server/routes/dream.mjs +10 -11
- package/src/server/routes/export.mjs +43 -12
- package/src/server/routes/field.mjs +11 -9
- package/src/server/routes/graph.mjs +31 -13
- package/src/server/routes/import.mjs +35 -16
- package/src/server/routes/instructions.mjs +41 -5
- package/src/server/routes/memory.mjs +61 -21
- package/src/server/routes/mesh.mjs +2 -1
- package/src/server/routes/notifications.mjs +103 -0
- package/src/server/routes/rules.mjs +75 -20
- package/src/server/routes/secrets.mjs +110 -4
- package/src/server/routes/share.mjs +2 -1
- package/src/server/routes/ssss.mjs +11 -3
- package/src/server/routes/system.mjs +20 -1
- package/src/server/routes/update.mjs +33 -7
- package/src/server/routes/vault.mjs +53 -28
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { useState, useEffect, useRef } from 'react'
|
|
2
|
-
import { getApiBase } from '../api'
|
|
2
|
+
import { apiFetch, getApiBase } from '../api'
|
|
3
3
|
|
|
4
4
|
interface BrainInfo {
|
|
5
5
|
id: string
|
|
6
6
|
name: string
|
|
7
|
-
layer: 'global' | 'project'
|
|
7
|
+
layer: 'global' | 'project' | 'tenant' | string
|
|
8
8
|
path: string
|
|
9
9
|
project_root?: string
|
|
10
10
|
exists: boolean
|
|
@@ -21,24 +21,46 @@ interface BrainSelectorProps {
|
|
|
21
21
|
function layerDot(layer: string, multi = false): string {
|
|
22
22
|
if (multi) return 'linear-gradient(135deg, #60a5fa, #34d399)'
|
|
23
23
|
if (layer === 'global') return 'linear-gradient(135deg, #60a5fa, #2563eb)'
|
|
24
|
+
if (layer === 'tenant') return 'linear-gradient(135deg, #c084fc, #7c3aed)'
|
|
24
25
|
return 'linear-gradient(135deg, #34d399, #059669)'
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
function layerBadge(layer: string): string {
|
|
29
|
+
if (layer === 'global') return 'G'
|
|
30
|
+
if (layer === 'tenant') return 'T'
|
|
31
|
+
return 'P'
|
|
32
|
+
}
|
|
33
|
+
|
|
27
34
|
export default function BrainSelector({ activeBrainId, onBrainChange }: BrainSelectorProps) {
|
|
28
35
|
const [brains, setBrains] = useState<BrainInfo[]>([])
|
|
29
36
|
const [expanded, setExpanded] = useState(false)
|
|
30
37
|
const [loading, setLoading] = useState(true)
|
|
38
|
+
const [error, setError] = useState<string | null>(null)
|
|
31
39
|
const selectorRef = useRef<HTMLDivElement>(null)
|
|
32
40
|
|
|
41
|
+
const loadBrains = async () => {
|
|
42
|
+
setLoading(true)
|
|
43
|
+
setError(null)
|
|
44
|
+
try {
|
|
45
|
+
const res = await apiFetch(`${getApiBase()}/api/brains`)
|
|
46
|
+
if (!res.ok) {
|
|
47
|
+
const body = await res.json().catch(() => ({}))
|
|
48
|
+
throw new Error(body.error || `Brains API ${res.status}`)
|
|
49
|
+
}
|
|
50
|
+
const data = await res.json()
|
|
51
|
+
const list: BrainInfo[] = Array.isArray(data.brains) ? data.brains : []
|
|
52
|
+
setBrains(list)
|
|
53
|
+
} catch (err) {
|
|
54
|
+
console.error(err)
|
|
55
|
+
setError(err instanceof Error ? err.message : 'Failed to load brains')
|
|
56
|
+
setBrains([])
|
|
57
|
+
} finally {
|
|
58
|
+
setLoading(false)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
33
62
|
useEffect(() => {
|
|
34
|
-
|
|
35
|
-
fetch(`${base}/api/brains`, { credentials: 'include' })
|
|
36
|
-
.then((r) => r.json())
|
|
37
|
-
.then((data) => {
|
|
38
|
-
setBrains(data.brains || [])
|
|
39
|
-
setLoading(false)
|
|
40
|
-
})
|
|
41
|
-
.catch(() => setLoading(false))
|
|
63
|
+
void loadBrains()
|
|
42
64
|
}, [])
|
|
43
65
|
|
|
44
66
|
useEffect(() => {
|
|
@@ -51,42 +73,91 @@ export default function BrainSelector({ activeBrainId, onBrainChange }: BrainSel
|
|
|
51
73
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
|
52
74
|
}, [])
|
|
53
75
|
|
|
76
|
+
// Normalize empty / missing selection to global when list is ready
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (loading || brains.length === 0) return
|
|
79
|
+
const selected = activeBrainId.split(',').filter(Boolean)
|
|
80
|
+
const known = new Set(brains.map((b) => b.id))
|
|
81
|
+
const valid = selected.filter((id) => known.has(id))
|
|
82
|
+
if (valid.length === 0) {
|
|
83
|
+
const fallback = brains.find((b) => b.id === 'global')?.id || brains[0]?.id
|
|
84
|
+
if (fallback && fallback !== activeBrainId) onBrainChange(fallback)
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
// Drop unknown ids (stale project:foo)
|
|
88
|
+
if (valid.join(',') !== selected.join(',')) {
|
|
89
|
+
onBrainChange(valid.join(','))
|
|
90
|
+
}
|
|
91
|
+
}, [loading, brains, activeBrainId, onBrainChange])
|
|
92
|
+
|
|
54
93
|
const selectedIds = activeBrainId.split(',').filter(Boolean)
|
|
55
94
|
const selectedBrains = brains.filter((b) => selectedIds.includes(b.id))
|
|
56
|
-
const primary = selectedBrains[0] || brains[0]
|
|
95
|
+
const primary = selectedBrains[0] || brains.find((b) => b.id === 'global') || brains[0]
|
|
57
96
|
const isMulti = selectedIds.length > 1
|
|
58
|
-
const totalNodes =
|
|
97
|
+
const totalNodes =
|
|
98
|
+
selectedBrains.reduce((sum, b) => sum + (b.node_count || 0), 0) || primary?.node_count || 0
|
|
59
99
|
|
|
60
|
-
const label = isMulti
|
|
61
|
-
|
|
62
|
-
|
|
100
|
+
const label = isMulti ? `${selectedIds.length} brains` : primary?.name || 'Brain'
|
|
101
|
+
|
|
102
|
+
const setSelection = (ids: string[]) => {
|
|
103
|
+
const next = ids.filter(Boolean)
|
|
104
|
+
if (next.length === 0) {
|
|
105
|
+
const fallback = brains.find((b) => b.id === 'global')?.id || brains[0]?.id
|
|
106
|
+
if (fallback) onBrainChange(fallback)
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
onBrainChange(next.join(','))
|
|
110
|
+
}
|
|
63
111
|
|
|
64
112
|
const handleToggle = (id: string, e: React.SyntheticEvent) => {
|
|
65
113
|
e.stopPropagation()
|
|
66
114
|
if (selectedIds.includes(id)) {
|
|
67
|
-
|
|
115
|
+
setSelection(selectedIds.filter((x) => x !== id))
|
|
68
116
|
} else {
|
|
69
|
-
|
|
117
|
+
setSelection([...selectedIds, id])
|
|
70
118
|
}
|
|
71
119
|
}
|
|
72
120
|
|
|
121
|
+
const handleSelectOnly = (id: string) => {
|
|
122
|
+
setSelection([id])
|
|
123
|
+
setExpanded(false)
|
|
124
|
+
}
|
|
125
|
+
|
|
73
126
|
if (loading) {
|
|
74
127
|
return (
|
|
75
|
-
<div className="brain-selector-trigger" style={{ opacity: 0.6 }}>
|
|
128
|
+
<div className="brain-selector-trigger" data-testid="brain-selector-loading" style={{ opacity: 0.6 }}>
|
|
76
129
|
<span style={{ fontSize: 11, color: 'var(--text-tertiary)' }}>Brains…</span>
|
|
77
130
|
</div>
|
|
78
131
|
)
|
|
79
132
|
}
|
|
80
133
|
|
|
81
|
-
if (
|
|
134
|
+
if (error) {
|
|
135
|
+
return (
|
|
136
|
+
<div data-testid="brain-selector-error" style={{ fontSize: 10, color: '#ef4444', padding: '4px 6px' }}>
|
|
137
|
+
Brains failed{' '}
|
|
138
|
+
<button type="button" onClick={() => void loadBrains()} style={{ cursor: 'pointer', marginLeft: 4 }}>
|
|
139
|
+
Retry
|
|
140
|
+
</button>
|
|
141
|
+
</div>
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (brains.length === 0) {
|
|
146
|
+
return (
|
|
147
|
+
<div data-testid="brain-selector-empty" style={{ fontSize: 10, color: 'var(--text-tertiary)', padding: '4px 6px' }}>
|
|
148
|
+
No brains registered
|
|
149
|
+
</div>
|
|
150
|
+
)
|
|
151
|
+
}
|
|
82
152
|
|
|
83
153
|
return (
|
|
84
|
-
<div ref={selectorRef} className="brain-selector" style={{ position: 'relative' }}>
|
|
154
|
+
<div ref={selectorRef} className="brain-selector" data-testid="brain-selector" style={{ position: 'relative' }}>
|
|
85
155
|
<button
|
|
86
156
|
type="button"
|
|
87
157
|
className="brain-selector-trigger"
|
|
158
|
+
data-testid="brain-selector-trigger"
|
|
88
159
|
onClick={() => setExpanded((v) => !v)}
|
|
89
|
-
title="Select brain
|
|
160
|
+
title="Select active brain (sidebar only)"
|
|
90
161
|
style={{
|
|
91
162
|
width: '100%',
|
|
92
163
|
display: 'flex',
|
|
@@ -128,9 +199,7 @@ export default function BrainSelector({ activeBrainId, onBrainChange }: BrainSel
|
|
|
128
199
|
>
|
|
129
200
|
{label}
|
|
130
201
|
</span>
|
|
131
|
-
<span style={{ fontSize: 10, color: 'var(--text-tertiary)', flexShrink: 0 }}>
|
|
132
|
-
{totalNodes}
|
|
133
|
-
</span>
|
|
202
|
+
<span style={{ fontSize: 10, color: 'var(--text-tertiary)', flexShrink: 0 }}>{totalNodes}</span>
|
|
134
203
|
<svg
|
|
135
204
|
width="10"
|
|
136
205
|
height="10"
|
|
@@ -152,6 +221,7 @@ export default function BrainSelector({ activeBrainId, onBrainChange }: BrainSel
|
|
|
152
221
|
{expanded && (
|
|
153
222
|
<div
|
|
154
223
|
className="brain-selector-menu"
|
|
224
|
+
data-testid="brain-selector-menu"
|
|
155
225
|
style={{
|
|
156
226
|
position: 'absolute',
|
|
157
227
|
bottom: '100%',
|
|
@@ -179,7 +249,7 @@ export default function BrainSelector({ activeBrainId, onBrainChange }: BrainSel
|
|
|
179
249
|
borderBottom: '1px solid var(--border)',
|
|
180
250
|
}}
|
|
181
251
|
>
|
|
182
|
-
|
|
252
|
+
Active brains · checkbox multi · click row = only
|
|
183
253
|
</div>
|
|
184
254
|
{brains.map((brain) => {
|
|
185
255
|
const isSelected = selectedIds.includes(brain.id)
|
|
@@ -188,16 +258,13 @@ export default function BrainSelector({ activeBrainId, onBrainChange }: BrainSel
|
|
|
188
258
|
key={brain.id}
|
|
189
259
|
role="button"
|
|
190
260
|
tabIndex={0}
|
|
261
|
+
data-testid={`brain-option-${brain.id}`}
|
|
191
262
|
onClick={() => {
|
|
192
263
|
if (!brain.exists) return
|
|
193
|
-
|
|
194
|
-
setExpanded(false)
|
|
264
|
+
handleSelectOnly(brain.id)
|
|
195
265
|
}}
|
|
196
266
|
onKeyDown={(e) => {
|
|
197
|
-
if (e.key === 'Enter' && brain.exists)
|
|
198
|
-
onBrainChange(brain.id)
|
|
199
|
-
setExpanded(false)
|
|
200
|
-
}
|
|
267
|
+
if (e.key === 'Enter' && brain.exists) handleSelectOnly(brain.id)
|
|
201
268
|
}}
|
|
202
269
|
style={{
|
|
203
270
|
display: 'flex',
|
|
@@ -214,7 +281,9 @@ export default function BrainSelector({ activeBrainId, onBrainChange }: BrainSel
|
|
|
214
281
|
if (brain.exists) e.currentTarget.style.background = 'rgba(148, 163, 184, 0.08)'
|
|
215
282
|
}}
|
|
216
283
|
onMouseLeave={(e) => {
|
|
217
|
-
e.currentTarget.style.background = isSelected
|
|
284
|
+
e.currentTarget.style.background = isSelected
|
|
285
|
+
? 'rgba(59, 130, 246, 0.12)'
|
|
286
|
+
: 'transparent'
|
|
218
287
|
}}
|
|
219
288
|
>
|
|
220
289
|
<input
|
|
@@ -223,6 +292,7 @@ export default function BrainSelector({ activeBrainId, onBrainChange }: BrainSel
|
|
|
223
292
|
disabled={!brain.exists}
|
|
224
293
|
onClick={(e) => e.stopPropagation()}
|
|
225
294
|
onChange={(e) => handleToggle(brain.id, e)}
|
|
295
|
+
aria-label={`Include ${brain.name}`}
|
|
226
296
|
style={{
|
|
227
297
|
width: 13,
|
|
228
298
|
height: 13,
|
|
@@ -274,11 +344,18 @@ export default function BrainSelector({ activeBrainId, onBrainChange }: BrainSel
|
|
|
274
344
|
background:
|
|
275
345
|
brain.layer === 'global'
|
|
276
346
|
? 'rgba(59, 130, 246, 0.15)'
|
|
277
|
-
:
|
|
278
|
-
|
|
347
|
+
: brain.layer === 'tenant'
|
|
348
|
+
? 'rgba(168, 85, 247, 0.15)'
|
|
349
|
+
: 'rgba(16, 185, 129, 0.12)',
|
|
350
|
+
color:
|
|
351
|
+
brain.layer === 'global'
|
|
352
|
+
? '#93c5fd'
|
|
353
|
+
: brain.layer === 'tenant'
|
|
354
|
+
? '#d8b4fe'
|
|
355
|
+
: '#6ee7b7',
|
|
279
356
|
}}
|
|
280
357
|
>
|
|
281
|
-
{brain.layer
|
|
358
|
+
{layerBadge(brain.layer)}
|
|
282
359
|
</span>
|
|
283
360
|
</div>
|
|
284
361
|
)
|
|
@@ -1,24 +1,54 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach
|
|
2
|
-
import { render, screen } from '@testing-library/react';
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { render, screen, waitFor } from '@testing-library/react';
|
|
3
3
|
import { MobilePairing } from './MobilePairing';
|
|
4
4
|
|
|
5
|
+
vi.mock('../api', () => ({
|
|
6
|
+
apiFetch: vi.fn(),
|
|
7
|
+
getApiBase: vi.fn(() => ''),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
import { apiFetch } from '../api';
|
|
11
|
+
|
|
5
12
|
describe('MobilePairing', () => {
|
|
6
13
|
beforeEach(() => {
|
|
7
|
-
|
|
8
|
-
vi.stubGlobal('location', {
|
|
9
|
-
protocol: 'http:',
|
|
10
|
-
host: '192.168.1.100:3000'
|
|
11
|
-
});
|
|
14
|
+
vi.clearAllMocks();
|
|
12
15
|
});
|
|
13
16
|
|
|
14
|
-
|
|
15
|
-
vi.
|
|
16
|
-
|
|
17
|
+
it('renders QR with preferred LAN/mesh URL from API', async () => {
|
|
18
|
+
vi.mocked(apiFetch).mockResolvedValue({
|
|
19
|
+
ok: true,
|
|
20
|
+
json: async () => ({
|
|
21
|
+
port: 3000,
|
|
22
|
+
protocol: 'http',
|
|
23
|
+
preferred_url: 'http://10.0.0.3:3000',
|
|
24
|
+
listen_hosts: ['127.0.0.1', '100.64.0.3'],
|
|
25
|
+
warnings: ['Brain is not listening on LAN'],
|
|
26
|
+
endpoints: [
|
|
27
|
+
{
|
|
28
|
+
kind: 'lan',
|
|
29
|
+
label: 'Local Wi‑Fi / LAN',
|
|
30
|
+
ip: '10.0.0.3',
|
|
31
|
+
url: 'http://10.0.0.3:3000',
|
|
32
|
+
recommended: false,
|
|
33
|
+
reachable_hint: 'Same Wi‑Fi',
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
kind: 'mesh',
|
|
37
|
+
label: 'Tailscale / mesh',
|
|
38
|
+
ip: '100.64.0.3',
|
|
39
|
+
url: 'http://100.64.0.3:3000',
|
|
40
|
+
recommended: true,
|
|
41
|
+
reachable_hint: 'Tailscale',
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
}),
|
|
45
|
+
} as any);
|
|
17
46
|
|
|
18
|
-
it('renders instructions and QR code', () => {
|
|
19
47
|
render(<MobilePairing />);
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
48
|
+
await waitFor(() => {
|
|
49
|
+
expect(screen.getByTestId('pairing-url').textContent).toContain('10.0.0.3:3000');
|
|
50
|
+
});
|
|
51
|
+
expect(screen.getByTestId('pairing-qr')).toBeTruthy();
|
|
52
|
+
expect(screen.getByTestId('pairing-warnings')).toBeTruthy();
|
|
23
53
|
});
|
|
24
54
|
});
|
|
@@ -1,36 +1,243 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
2
|
import { QRCodeSVG } from 'qrcode.react';
|
|
3
|
+
import { apiFetch, getApiBase } from '../api';
|
|
4
|
+
|
|
5
|
+
export interface PairingEndpoint {
|
|
6
|
+
kind: string;
|
|
7
|
+
label: string;
|
|
8
|
+
ip: string;
|
|
9
|
+
url: string;
|
|
10
|
+
interface?: string | null;
|
|
11
|
+
recommended: boolean;
|
|
12
|
+
reachable_hint: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface PairingInfo {
|
|
16
|
+
port: number;
|
|
17
|
+
protocol: string;
|
|
18
|
+
preferred_url: string | null;
|
|
19
|
+
endpoints: PairingEndpoint[];
|
|
20
|
+
warnings: string[];
|
|
21
|
+
listen_hosts: string[];
|
|
22
|
+
}
|
|
3
23
|
|
|
4
24
|
export function MobilePairing() {
|
|
5
|
-
const [
|
|
25
|
+
const [info, setInfo] = useState<PairingInfo | null>(null);
|
|
26
|
+
const [selectedUrl, setSelectedUrl] = useState('');
|
|
27
|
+
const [error, setError] = useState<string | null>(null);
|
|
28
|
+
const [loading, setLoading] = useState(true);
|
|
29
|
+
const [copied, setCopied] = useState(false);
|
|
30
|
+
|
|
31
|
+
const load = async () => {
|
|
32
|
+
setLoading(true);
|
|
33
|
+
setError(null);
|
|
34
|
+
try {
|
|
35
|
+
const res = await apiFetch(`${getApiBase()}/api/pairing`);
|
|
36
|
+
if (!res.ok) {
|
|
37
|
+
const body = await res.json().catch(() => ({}));
|
|
38
|
+
throw new Error(body.error || `Pairing API error: ${res.status}`);
|
|
39
|
+
}
|
|
40
|
+
const data = (await res.json()) as PairingInfo;
|
|
41
|
+
setInfo(data);
|
|
42
|
+
const preferred =
|
|
43
|
+
data.preferred_url ||
|
|
44
|
+
data.endpoints?.find((e) => e.recommended)?.url ||
|
|
45
|
+
data.endpoints?.[0]?.url ||
|
|
46
|
+
'';
|
|
47
|
+
setSelectedUrl(preferred);
|
|
48
|
+
} catch (err) {
|
|
49
|
+
console.error(err);
|
|
50
|
+
setError(err instanceof Error ? err.message : 'Failed to load pairing endpoints');
|
|
51
|
+
// Last-resort fallback — still better than nothing, but labeled
|
|
52
|
+
const fallback = `${window.location.protocol}//${window.location.host}`;
|
|
53
|
+
setSelectedUrl(fallback);
|
|
54
|
+
setInfo({
|
|
55
|
+
port: Number(window.location.port || 3000),
|
|
56
|
+
protocol: window.location.protocol.replace(':', '') || 'http',
|
|
57
|
+
preferred_url: fallback,
|
|
58
|
+
endpoints: [
|
|
59
|
+
{
|
|
60
|
+
kind: 'browser',
|
|
61
|
+
label: 'This browser URL (may not work on phone)',
|
|
62
|
+
ip: window.location.hostname,
|
|
63
|
+
url: fallback,
|
|
64
|
+
recommended: true,
|
|
65
|
+
reachable_hint: 'Fallback when pairing API is unavailable.',
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
warnings: ['Could not load live network endpoints from the brain.'],
|
|
69
|
+
listen_hosts: [],
|
|
70
|
+
});
|
|
71
|
+
} finally {
|
|
72
|
+
setLoading(false);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
6
75
|
|
|
7
76
|
useEffect(() => {
|
|
8
|
-
|
|
9
|
-
const protocol = window.location.protocol;
|
|
10
|
-
const host = window.location.host;
|
|
11
|
-
|
|
12
|
-
// In a real implementation we might fetch the actual LAN IP from the backend
|
|
13
|
-
// Since the backend knows its bind address.
|
|
14
|
-
setLocalIpUrl(`${protocol}//${host}`);
|
|
77
|
+
load();
|
|
15
78
|
}, []);
|
|
16
79
|
|
|
80
|
+
const selected = info?.endpoints?.find((e) => e.url === selectedUrl) || null;
|
|
81
|
+
const isLoopback =
|
|
82
|
+
selected?.kind === 'loopback' ||
|
|
83
|
+
selectedUrl.includes('127.0.0.1') ||
|
|
84
|
+
selectedUrl.includes('localhost');
|
|
85
|
+
|
|
86
|
+
const copyUrl = async () => {
|
|
87
|
+
if (!selectedUrl) return;
|
|
88
|
+
try {
|
|
89
|
+
await navigator.clipboard.writeText(selectedUrl);
|
|
90
|
+
setCopied(true);
|
|
91
|
+
setTimeout(() => setCopied(false), 2000);
|
|
92
|
+
} catch {
|
|
93
|
+
setError('Clipboard copy failed — select the URL text manually.');
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
17
97
|
return (
|
|
18
|
-
<div
|
|
19
|
-
|
|
98
|
+
<div
|
|
99
|
+
className="mobile-pairing"
|
|
100
|
+
data-testid="mobile-pairing"
|
|
101
|
+
style={{
|
|
102
|
+
padding: '20px',
|
|
103
|
+
background: 'var(--bg-secondary)',
|
|
104
|
+
borderRadius: '12px',
|
|
105
|
+
border: '1px solid var(--border)',
|
|
106
|
+
}}
|
|
107
|
+
>
|
|
108
|
+
<h3 style={{ margin: '0 0 8px' }}>Mobile Device Pairing</h3>
|
|
20
109
|
<p style={{ color: 'var(--text-tertiary)', fontSize: '13px', marginBottom: '16px' }}>
|
|
21
|
-
Scan
|
|
110
|
+
Scan with your phone to open this Total Recall dashboard. Prefer <strong>LAN</strong> on the same
|
|
111
|
+
Wi‑Fi, or <strong>Tailscale/mesh</strong> if the phone is on your tailnet.
|
|
22
112
|
</p>
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
113
|
+
|
|
114
|
+
{loading && (
|
|
115
|
+
<div data-testid="pairing-loading" style={{ color: 'var(--text-tertiary)', fontSize: 13 }}>
|
|
116
|
+
Detecting reachable addresses…
|
|
117
|
+
</div>
|
|
118
|
+
)}
|
|
119
|
+
|
|
120
|
+
{error && (
|
|
121
|
+
<div
|
|
122
|
+
role="alert"
|
|
123
|
+
data-testid="pairing-error"
|
|
124
|
+
style={{
|
|
125
|
+
marginBottom: 12,
|
|
126
|
+
padding: 10,
|
|
127
|
+
borderRadius: 8,
|
|
128
|
+
border: '1px solid #ef4444',
|
|
129
|
+
color: '#ef4444',
|
|
130
|
+
fontSize: 12,
|
|
131
|
+
}}
|
|
132
|
+
>
|
|
133
|
+
{error}{' '}
|
|
134
|
+
<button type="button" onClick={() => load()} style={{ marginLeft: 8, cursor: 'pointer' }}>
|
|
135
|
+
Retry
|
|
136
|
+
</button>
|
|
137
|
+
</div>
|
|
138
|
+
)}
|
|
139
|
+
|
|
140
|
+
{!loading && (
|
|
141
|
+
<div style={{ display: 'flex', gap: 20, flexWrap: 'wrap', alignItems: 'flex-start' }}>
|
|
142
|
+
<div
|
|
143
|
+
style={{
|
|
144
|
+
background: '#fff',
|
|
145
|
+
padding: '16px',
|
|
146
|
+
borderRadius: '8px',
|
|
147
|
+
display: 'inline-block',
|
|
148
|
+
}}
|
|
149
|
+
data-testid="pairing-qr"
|
|
150
|
+
>
|
|
151
|
+
{selectedUrl ? (
|
|
152
|
+
<QRCodeSVG value={selectedUrl} size={150} level="M" includeMargin />
|
|
153
|
+
) : (
|
|
154
|
+
<div style={{ width: 150, height: 150 }} />
|
|
155
|
+
)}
|
|
29
156
|
</div>
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
157
|
+
|
|
158
|
+
<div style={{ flex: 1, minWidth: 200 }}>
|
|
159
|
+
{info && info.endpoints.length > 0 && (
|
|
160
|
+
<div className="field-col" style={{ marginBottom: 12 }}>
|
|
161
|
+
<label className="field-label" htmlFor="pairing-endpoint">
|
|
162
|
+
Pairing address
|
|
163
|
+
</label>
|
|
164
|
+
<select
|
|
165
|
+
id="pairing-endpoint"
|
|
166
|
+
className="settings-select"
|
|
167
|
+
data-testid="pairing-endpoint-select"
|
|
168
|
+
value={selectedUrl}
|
|
169
|
+
onChange={(e) => setSelectedUrl(e.target.value)}
|
|
170
|
+
style={{ width: '100%', marginTop: 4 }}
|
|
171
|
+
>
|
|
172
|
+
{info.endpoints.map((ep) => (
|
|
173
|
+
<option key={ep.url} value={ep.url}>
|
|
174
|
+
{ep.label}
|
|
175
|
+
{ep.recommended ? ' ★' : ''} — {ep.url}
|
|
176
|
+
</option>
|
|
177
|
+
))}
|
|
178
|
+
</select>
|
|
179
|
+
</div>
|
|
180
|
+
)}
|
|
181
|
+
|
|
182
|
+
<p
|
|
183
|
+
data-testid="pairing-url"
|
|
184
|
+
style={{
|
|
185
|
+
margin: '0 0 8px',
|
|
186
|
+
fontSize: 12,
|
|
187
|
+
fontFamily: 'var(--font-mono)',
|
|
188
|
+
wordBreak: 'break-all',
|
|
189
|
+
color: 'var(--text-primary)',
|
|
190
|
+
}}
|
|
191
|
+
>
|
|
192
|
+
{selectedUrl || '—'}
|
|
193
|
+
</p>
|
|
194
|
+
|
|
195
|
+
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 12 }}>
|
|
196
|
+
<button type="button" className="btn-secondary" onClick={copyUrl} data-testid="pairing-copy">
|
|
197
|
+
{copied ? 'Copied' : 'Copy URL'}
|
|
198
|
+
</button>
|
|
199
|
+
<button type="button" className="btn-ghost btn-sm" onClick={() => load()}>
|
|
200
|
+
Refresh
|
|
201
|
+
</button>
|
|
202
|
+
</div>
|
|
203
|
+
|
|
204
|
+
{selected && (
|
|
205
|
+
<p style={{ fontSize: 12, color: 'var(--text-secondary)', margin: '0 0 8px' }}>
|
|
206
|
+
{selected.reachable_hint}
|
|
207
|
+
</p>
|
|
208
|
+
)}
|
|
209
|
+
|
|
210
|
+
{isLoopback && (
|
|
211
|
+
<p
|
|
212
|
+
data-testid="pairing-loopback-warning"
|
|
213
|
+
style={{ fontSize: 12, color: '#f59e0b', margin: 0 }}
|
|
214
|
+
>
|
|
215
|
+
This address only works on this computer. Choose LAN or Tailscale for your phone.
|
|
216
|
+
</p>
|
|
217
|
+
)}
|
|
218
|
+
</div>
|
|
219
|
+
</div>
|
|
220
|
+
)}
|
|
221
|
+
|
|
222
|
+
{info?.warnings && info.warnings.length > 0 && (
|
|
223
|
+
<ul
|
|
224
|
+
data-testid="pairing-warnings"
|
|
225
|
+
style={{
|
|
226
|
+
margin: '16px 0 0',
|
|
227
|
+
paddingLeft: 18,
|
|
228
|
+
fontSize: 12,
|
|
229
|
+
color: '#f59e0b',
|
|
230
|
+
}}
|
|
231
|
+
>
|
|
232
|
+
{info.warnings.map((w) => (
|
|
233
|
+
<li key={w}>{w}</li>
|
|
234
|
+
))}
|
|
235
|
+
</ul>
|
|
236
|
+
)}
|
|
237
|
+
|
|
238
|
+
<p style={{ marginTop: 16, fontSize: 11, color: 'var(--text-tertiary)' }}>
|
|
239
|
+
After scanning, log in on the phone (same password/PAT as this dashboard). Listening:{' '}
|
|
240
|
+
{(info?.listen_hosts || []).join(', ') || 'unknown'}
|
|
34
241
|
</p>
|
|
35
242
|
</div>
|
|
36
243
|
);
|
|
@@ -85,23 +85,19 @@ export default function HealthPage() {
|
|
|
85
85
|
setUpdateError('')
|
|
86
86
|
|
|
87
87
|
try {
|
|
88
|
-
await runUpdate()
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const res = await fetchHealth()
|
|
95
|
-
if (serverDown && res.status === 'healthy') {
|
|
96
|
-
clearInterval(pollInterval)
|
|
97
|
-
window.location.reload()
|
|
98
|
-
}
|
|
99
|
-
} catch {
|
|
100
|
-
// Connection failed = server is offline / restarting
|
|
101
|
-
serverDown = true
|
|
102
|
-
}
|
|
103
|
-
}, 2000)
|
|
88
|
+
const result = await runUpdate()
|
|
89
|
+
if (!result.success) {
|
|
90
|
+
setUpdateError(result.message || 'Package auto-update reported failures')
|
|
91
|
+
setIsUpdating(false)
|
|
92
|
+
return
|
|
93
|
+
}
|
|
104
94
|
|
|
95
|
+
// Multi-repo npm update does not reboot this host — show success, optional reload
|
|
96
|
+
setIsUpdating(false)
|
|
97
|
+
setCheckMessage(
|
|
98
|
+
result.message ||
|
|
99
|
+
'Registered projects updated. Restart local daemon/server if you need new code in this process.',
|
|
100
|
+
)
|
|
105
101
|
} catch (e) {
|
|
106
102
|
setUpdateError((e as Error).message)
|
|
107
103
|
setIsUpdating(false)
|
|
@@ -101,17 +101,20 @@ export function NotificationsPage() {
|
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
return (
|
|
104
|
-
<div className="page-container notifications-page">
|
|
104
|
+
<div className="page-container notifications-page" data-testid="notifications-page">
|
|
105
105
|
<div className="page-header">
|
|
106
106
|
<h1>Notifications</h1>
|
|
107
|
-
<button onClick={() => setShowModal(true)} className="btn btn-primary">
|
|
107
|
+
<button onClick={() => setShowModal(true)} className="btn btn-primary" data-testid="add-rule-btn">
|
|
108
108
|
Add Rule
|
|
109
109
|
</button>
|
|
110
110
|
</div>
|
|
111
111
|
|
|
112
112
|
{error && (
|
|
113
|
-
<div className="alert alert-error" style={{ marginBottom: '24px' }}>
|
|
113
|
+
<div className="alert alert-error" role="alert" data-testid="notifications-error" style={{ marginBottom: '24px' }}>
|
|
114
114
|
{error}
|
|
115
|
+
<button type="button" className="btn btn-ghost btn-sm" style={{ marginLeft: 12 }} onClick={() => loadData()}>
|
|
116
|
+
Retry
|
|
117
|
+
</button>
|
|
115
118
|
</div>
|
|
116
119
|
)}
|
|
117
120
|
{success && (
|