total-recall-brain 3.21.2 → 3.22.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-C7_Xpk27.css +1 -0
- package/frontend/dist/assets/{index-D0XeeXhG.js → index-DJjuZ3t3.js} +106 -106
- package/frontend/dist/index.html +2 -2
- package/frontend/src/App.spec.tsx +1 -1
- package/frontend/src/api/_base.ts +3 -3
- package/frontend/src/api/embeddings.ts +46 -0
- package/frontend/src/api/mesh.ts +47 -0
- package/frontend/src/api/network.spec.ts +9 -9
- package/frontend/src/api/network.ts +4 -4
- package/frontend/src/api/notifications.ts +5 -4
- package/frontend/src/api/webhooks.ts +1 -1
- package/frontend/src/components/BrainSelector.spec.tsx +4 -4
- package/frontend/src/components/BrainSelector.tsx +1 -1
- package/frontend/src/components/ContextualHelp.spec.tsx +14 -14
- package/frontend/src/components/ContextualHelp.tsx +3 -1
- package/frontend/src/components/DocumentTable.spec.tsx +1 -1
- package/frontend/src/components/EmbeddingProviderPanel.spec.tsx +127 -0
- package/frontend/src/components/EmbeddingProviderPanel.tsx +177 -0
- package/frontend/src/components/Graph3D.spec.tsx +4 -4
- package/frontend/src/components/Graph3D.tsx +3 -3
- package/frontend/src/components/MobilePairing.spec.tsx +1 -1
- package/frontend/src/components/MobilePairing.tsx +1 -1
- package/frontend/src/components/UsageChart.spec.tsx +4 -4
- package/frontend/src/components/UsageChart.tsx +1 -1
- package/frontend/src/components/VoiceInput.spec.tsx +6 -6
- package/frontend/src/components/VoiceInput.tsx +19 -4
- package/frontend/src/components/brand/BrandMark.spec.tsx +3 -4
- package/frontend/src/index.css +6 -0
- package/frontend/src/pages/AutomationsPage.spec.tsx +4 -4
- package/frontend/src/pages/ChatPage.spec.tsx +3 -3
- package/frontend/src/pages/CollabPage.spec.tsx +1 -2
- package/frontend/src/pages/DesignDocsPage.spec.tsx +1 -1
- package/frontend/src/pages/FilesPage.spec.tsx +2 -2
- package/frontend/src/pages/GraphPage.spec.tsx +1 -1
- package/frontend/src/pages/HealthPage.spec.tsx +2 -2
- package/frontend/src/pages/HelpPage.spec.tsx +5 -5
- package/frontend/src/pages/InboxPage.spec.tsx +4 -4
- package/frontend/src/pages/InstructionsPage.spec.tsx +5 -5
- package/frontend/src/pages/IntegrationsPage.spec.tsx +7 -7
- package/frontend/src/pages/MemoryPage.spec.tsx +2 -2
- package/frontend/src/pages/MeshPage.spec.tsx +111 -2
- package/frontend/src/pages/MeshPage.tsx +95 -11
- package/frontend/src/pages/NetworkPage.spec.tsx +7 -8
- package/frontend/src/pages/NetworkPage.tsx +12 -12
- package/frontend/src/pages/NotificationsPage.tsx +9 -9
- package/frontend/src/pages/OnboardingPage.spec.tsx +7 -7
- package/frontend/src/pages/OpenWikiPage.tsx +3 -3
- package/frontend/src/pages/RulesPage.tsx +3 -3
- package/frontend/src/pages/SecretsPage.spec.tsx +5 -5
- package/frontend/src/pages/SecretsPage.tsx +13 -14
- package/frontend/src/pages/SettingsPage.spec.tsx +6 -6
- package/frontend/src/pages/SettingsPage.tsx +2 -2
- package/frontend/src/pages/SkillsPage.spec.tsx +1 -2
- package/frontend/src/pages/SkillsPage.tsx +3 -3
- package/frontend/src/pages/TasksPage.spec.tsx +3 -3
- package/frontend/src/pages/UsagePage.spec.tsx +2 -2
- package/frontend/src/pages/UsagePage.tsx +1 -1
- package/frontend/src/pages/WebhooksPage.spec.tsx +1 -1
- package/frontend/src/pages/WebhooksPage.tsx +19 -19
- package/frontend/src/setupTests.ts +4 -4
- package/package.json +1 -1
- package/scaffold/.agent/skills/total-recall/SKILL.md +92 -0
- package/scaffold/.agent/skills/total-recall/modules/agents/agents.yml +15 -13
- package/scaffold/.agent/skills/total-recall/research-queue.jsonl +0 -1
- package/src/cli/secret.mjs +93 -12
- package/src/core/browser-session.mjs +185 -0
- package/src/core/daemon-loop.mjs +28 -1
- package/src/core/embeddings.mjs +52 -10
- package/src/core/headscale-client.mjs +227 -0
- package/src/core/mesh-enroll.mjs +439 -0
- package/src/core/ollama-embeddings.mjs +336 -0
- package/src/core/provider-catalog.mjs +175 -6
- package/src/core/provider-rotation-recipes.mjs +299 -0
- package/src/core/rotation-capability.mjs +189 -0
- package/src/core/secrets-rotate.mjs +192 -0
- package/src/server/rest.mjs +2 -0
- package/src/server/route-manifest.json +20 -0
- package/src/server/routes/embeddings.mjs +111 -0
- package/src/server/routes/headscale.mjs +21 -81
- package/src/server/routes/mesh.mjs +41 -0
- package/frontend/dist/assets/index-B4RASWbV.css +0 -1
|
@@ -2,7 +2,13 @@ import { render, screen, waitFor } from '@testing-library/react';
|
|
|
2
2
|
import userEvent from '@testing-library/user-event';
|
|
3
3
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
4
4
|
import { MeshPage } from './MeshPage';
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
fetchLeader,
|
|
7
|
+
fetchNodes as fetchMeshNodes,
|
|
8
|
+
refreshElection,
|
|
9
|
+
fetchEnrollmentStatus,
|
|
10
|
+
enrollThisNode,
|
|
11
|
+
} from '../api/mesh';
|
|
6
12
|
import { fetchHeadscaleNodes, deleteHeadscaleNode, fetchPreAuthKeys, createPreAuthKey, fetchHeadscaleUsers } from '../api/headscale';
|
|
7
13
|
|
|
8
14
|
vi.mock('../api/mesh', () => ({
|
|
@@ -47,6 +53,21 @@ vi.mock('../api/mesh', () => ({
|
|
|
47
53
|
}),
|
|
48
54
|
fetchElectionHistory: vi.fn().mockResolvedValue([]),
|
|
49
55
|
logElectionObservation: vi.fn().mockResolvedValue({ success: true, recorded: true }),
|
|
56
|
+
fetchEnrollmentStatus: vi.fn().mockResolvedValue({
|
|
57
|
+
state: 'enrolled',
|
|
58
|
+
enrolled: true,
|
|
59
|
+
backend_state: 'Running',
|
|
60
|
+
auth_url: null,
|
|
61
|
+
ips: ['100.64.0.1'],
|
|
62
|
+
hostname: 'node-a',
|
|
63
|
+
login_server: 'https://control.example.org',
|
|
64
|
+
can_auto_enroll: true,
|
|
65
|
+
auto_enroll_blocked_reason: null,
|
|
66
|
+
auto_enroll_enabled: true,
|
|
67
|
+
client_available: true,
|
|
68
|
+
checked_at: new Date().toISOString(),
|
|
69
|
+
}),
|
|
70
|
+
enrollThisNode: vi.fn(),
|
|
50
71
|
}));
|
|
51
72
|
|
|
52
73
|
vi.mock('../api/headscale', () => ({
|
|
@@ -135,7 +156,7 @@ describe('MeshPage', () => {
|
|
|
135
156
|
vi.mocked(fetchPreAuthKeys).mockResolvedValue([
|
|
136
157
|
{ id: 'key1', key: 'hspkey_xyz', user: 'default', reusable: true, expiration: '2099-12-31T23:59:59Z', createdAt: '2024-01-01', used: false }
|
|
137
158
|
]);
|
|
138
|
-
vi.mocked(createPreAuthKey).mockResolvedValue({} as
|
|
159
|
+
vi.mocked(createPreAuthKey).mockResolvedValue({} as never);
|
|
139
160
|
|
|
140
161
|
render(<MeshPage />);
|
|
141
162
|
|
|
@@ -174,4 +195,92 @@ describe('MeshPage', () => {
|
|
|
174
195
|
expect(screen.getByText('alice')).toBeInTheDocument();
|
|
175
196
|
});
|
|
176
197
|
});
|
|
198
|
+
|
|
199
|
+
describe('enrollment banner', () => {
|
|
200
|
+
const notEnrolled = {
|
|
201
|
+
state: 'needs_login' as const,
|
|
202
|
+
enrolled: false,
|
|
203
|
+
backend_state: 'NeedsLogin',
|
|
204
|
+
auth_url: 'https://control.example.org/register/abc123',
|
|
205
|
+
ips: [],
|
|
206
|
+
hostname: 'node-a',
|
|
207
|
+
login_server: 'https://control.example.org',
|
|
208
|
+
can_auto_enroll: true,
|
|
209
|
+
auto_enroll_blocked_reason: null,
|
|
210
|
+
auto_enroll_enabled: true,
|
|
211
|
+
client_available: true,
|
|
212
|
+
checked_at: new Date().toISOString(),
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
beforeEach(() => {
|
|
216
|
+
vi.mocked(fetchLeader).mockResolvedValue({ hostname: 'node-a.mesh', ip: '100.64.0.1' });
|
|
217
|
+
vi.mocked(fetchMeshNodes).mockResolvedValue([]);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('stays hidden when this node is already on the mesh', async () => {
|
|
221
|
+
vi.mocked(fetchEnrollmentStatus).mockResolvedValue({ ...notEnrolled, state: 'enrolled', enrolled: true });
|
|
222
|
+
render(<MeshPage />);
|
|
223
|
+
await waitFor(() => expect(screen.getByText('Mesh Operations Center')).toBeInTheDocument());
|
|
224
|
+
expect(screen.queryByTestId('enrollment-banner')).not.toBeInTheDocument();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('prompts to enroll and shows the pending registration link', async () => {
|
|
228
|
+
vi.mocked(fetchEnrollmentStatus).mockResolvedValue(notEnrolled);
|
|
229
|
+
render(<MeshPage />);
|
|
230
|
+
|
|
231
|
+
const banner = await screen.findByTestId('enrollment-banner');
|
|
232
|
+
expect(banner).toHaveTextContent('This node is not on the mesh');
|
|
233
|
+
expect(screen.getByRole('link', { name: /approve this node/i })).toHaveAttribute(
|
|
234
|
+
'href',
|
|
235
|
+
'https://control.example.org/register/abc123',
|
|
236
|
+
);
|
|
237
|
+
expect(screen.getByRole('button', { name: /enroll this node/i })).toBeInTheDocument();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('enrolls on click and re-syncs enrollment state from the server', async () => {
|
|
241
|
+
const enrolled = { ...notEnrolled, state: 'enrolled' as const, enrolled: true, ips: ['100.64.0.7'], auth_url: null };
|
|
242
|
+
// The page must trust the server, not its own optimistic state: the
|
|
243
|
+
// status call flips to `enrolled` only once the node is really up.
|
|
244
|
+
vi.mocked(fetchEnrollmentStatus).mockResolvedValueOnce(notEnrolled).mockResolvedValue(enrolled);
|
|
245
|
+
vi.mocked(enrollThisNode).mockResolvedValue({
|
|
246
|
+
ok: true,
|
|
247
|
+
changed: true,
|
|
248
|
+
state: 'enrolled',
|
|
249
|
+
method: 'preauth-key',
|
|
250
|
+
status: enrolled,
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
render(<MeshPage />);
|
|
254
|
+
await userEvent.click(await screen.findByRole('button', { name: /enroll this node/i }));
|
|
255
|
+
|
|
256
|
+
await waitFor(() => expect(enrollThisNode).toHaveBeenCalled());
|
|
257
|
+
await waitFor(() => expect(screen.queryByTestId('enrollment-banner')).not.toBeInTheDocument());
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('explains what to configure when no automatic path exists', async () => {
|
|
261
|
+
vi.mocked(fetchEnrollmentStatus).mockResolvedValue({
|
|
262
|
+
...notEnrolled,
|
|
263
|
+
can_auto_enroll: false,
|
|
264
|
+
auto_enroll_blocked_reason: 'no-headscale-api-key',
|
|
265
|
+
login_server: null,
|
|
266
|
+
});
|
|
267
|
+
render(<MeshPage />);
|
|
268
|
+
const banner = await screen.findByTestId('enrollment-banner');
|
|
269
|
+
expect(banner).toHaveTextContent(/No control server configured/i);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('hides the enroll button when no tailscale client is installed', async () => {
|
|
273
|
+
vi.mocked(fetchEnrollmentStatus).mockResolvedValue({
|
|
274
|
+
...notEnrolled,
|
|
275
|
+
state: 'client_unavailable',
|
|
276
|
+
client_available: false,
|
|
277
|
+
auth_url: null,
|
|
278
|
+
can_auto_enroll: false,
|
|
279
|
+
});
|
|
280
|
+
render(<MeshPage />);
|
|
281
|
+
const banner = await screen.findByTestId('enrollment-banner');
|
|
282
|
+
expect(banner).toHaveTextContent(/No Tailscale client detected/i);
|
|
283
|
+
expect(screen.queryByRole('button', { name: /enroll this node/i })).not.toBeInTheDocument();
|
|
284
|
+
});
|
|
285
|
+
});
|
|
177
286
|
});
|
|
@@ -10,11 +10,21 @@ import {
|
|
|
10
10
|
registerLanMeshNodes,
|
|
11
11
|
fetchElectionHistory,
|
|
12
12
|
logElectionObservation,
|
|
13
|
+
fetchEnrollmentStatus,
|
|
14
|
+
enrollThisNode,
|
|
13
15
|
} from '../api/mesh';
|
|
14
16
|
import { fetchHeadscaleNodes, fetchPreAuthKeys, fetchHeadscaleUsers, createPreAuthKey, deleteHeadscaleNode } from '../api/headscale';
|
|
15
|
-
import type {
|
|
17
|
+
import type {
|
|
18
|
+
MeshNode,
|
|
19
|
+
LeaderInfo,
|
|
20
|
+
LanHost,
|
|
21
|
+
MeshInterfaceSummary,
|
|
22
|
+
DeviceIoProfile,
|
|
23
|
+
EnrollmentStatus,
|
|
24
|
+
} from '../api/mesh';
|
|
16
25
|
import type { HeadscaleNode, PreAuthKey as HeadscalePreAuthKey, HeadscaleUser } from '../api/headscale';
|
|
17
26
|
import { MeshTopology } from '../components/MeshTopology';
|
|
27
|
+
import { EmbeddingProviderPanel } from '../components/EmbeddingProviderPanel';
|
|
18
28
|
import { LatencySparkline } from '../components/LatencySparkline';
|
|
19
29
|
import './MeshPage.css';
|
|
20
30
|
|
|
@@ -45,6 +55,9 @@ export function MeshPage() {
|
|
|
45
55
|
const [lanTrCount, setLanTrCount] = useState(0);
|
|
46
56
|
const [deviceIo, setDeviceIo] = useState<DeviceIoProfile | null>(null);
|
|
47
57
|
const [uiHints, setUiHints] = useState<string[]>([]);
|
|
58
|
+
const [enrollment, setEnrollment] = useState<EnrollmentStatus | null>(null);
|
|
59
|
+
const [enrollBusy, setEnrollBusy] = useState(false);
|
|
60
|
+
const [enrollMsg, setEnrollMsg] = useState<string | null>(null);
|
|
48
61
|
const [lanRegisterBusy, setLanRegisterBusy] = useState(false);
|
|
49
62
|
const [lanRegisterMsg, setLanRegisterMsg] = useState<string | null>(null);
|
|
50
63
|
const prevLeaderRef = useRef<string | null>(null);
|
|
@@ -145,6 +158,10 @@ export function MeshPage() {
|
|
|
145
158
|
setUiHints(ioRes.ui_hints || ioRes.io?.ui_hints || []);
|
|
146
159
|
})
|
|
147
160
|
.catch(() => { /* optional */ });
|
|
161
|
+
// Enrollment state drives the join banner; never fail the page on it.
|
|
162
|
+
fetchEnrollmentStatus()
|
|
163
|
+
.then(setEnrollment)
|
|
164
|
+
.catch(() => { /* optional */ });
|
|
148
165
|
pollMsRef.current = POLL_BASE_MS;
|
|
149
166
|
} else if (activeTab === 'headscale-nodes') {
|
|
150
167
|
const nodes = await fetchHeadscaleNodes();
|
|
@@ -156,8 +173,8 @@ export function MeshPage() {
|
|
|
156
173
|
const users = await fetchHeadscaleUsers();
|
|
157
174
|
setHsUsers(users);
|
|
158
175
|
}
|
|
159
|
-
} catch (err
|
|
160
|
-
setError(err.message
|
|
176
|
+
} catch (err) {
|
|
177
|
+
setError(err instanceof Error ? err.message : `Failed to load ${activeTab} data`);
|
|
161
178
|
// Back off polling on errors to reduce long-task pressure.
|
|
162
179
|
pollMsRef.current = Math.min(POLL_MAX_MS, pollMsRef.current * 1.5);
|
|
163
180
|
} finally {
|
|
@@ -188,8 +205,8 @@ export function MeshPage() {
|
|
|
188
205
|
const l = await refreshElection();
|
|
189
206
|
recordLeaderChange(l, 'manual refresh');
|
|
190
207
|
await loadData();
|
|
191
|
-
} catch (err
|
|
192
|
-
setError(err.message
|
|
208
|
+
} catch (err) {
|
|
209
|
+
setError(err instanceof Error ? err.message : 'Failed to refresh election state');
|
|
193
210
|
}
|
|
194
211
|
}
|
|
195
212
|
|
|
@@ -202,13 +219,36 @@ export function MeshPage() {
|
|
|
202
219
|
`Registered ${res.registration?.written_count ?? 0} of ${res.registration?.attempted ?? 0} TR-reachable LAN hosts as mesh_node entities.`,
|
|
203
220
|
);
|
|
204
221
|
await loadData();
|
|
205
|
-
} catch (err
|
|
206
|
-
setError(err.message
|
|
222
|
+
} catch (err) {
|
|
223
|
+
setError(err instanceof Error ? err.message : 'LAN register failed');
|
|
207
224
|
} finally {
|
|
208
225
|
setLanRegisterBusy(false);
|
|
209
226
|
}
|
|
210
227
|
}
|
|
211
228
|
|
|
229
|
+
async function handleEnroll() {
|
|
230
|
+
setEnrollBusy(true);
|
|
231
|
+
setEnrollMsg(null);
|
|
232
|
+
try {
|
|
233
|
+
const result = await enrollThisNode();
|
|
234
|
+
setEnrollment(result.status);
|
|
235
|
+
if (result.ok && result.changed) {
|
|
236
|
+
setEnrollMsg(`Enrolled on the mesh via ${result.method || 'control server'}.`);
|
|
237
|
+
} else if (result.ok) {
|
|
238
|
+
setEnrollMsg('This node is already enrolled.');
|
|
239
|
+
} else if (result.auth_url) {
|
|
240
|
+
setEnrollMsg('Approve the registration link below to finish joining.');
|
|
241
|
+
} else {
|
|
242
|
+
setEnrollMsg(result.hint || result.reason || 'Enrollment did not complete.');
|
|
243
|
+
}
|
|
244
|
+
await loadData();
|
|
245
|
+
} catch (err) {
|
|
246
|
+
setError(err instanceof Error ? err.message : 'Enrollment failed');
|
|
247
|
+
} finally {
|
|
248
|
+
setEnrollBusy(false);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
212
252
|
async function handleCreateKey(e: React.FormEvent) {
|
|
213
253
|
e.preventDefault();
|
|
214
254
|
try {
|
|
@@ -219,8 +259,8 @@ export function MeshPage() {
|
|
|
219
259
|
expiration: newKeyExpiration
|
|
220
260
|
});
|
|
221
261
|
await loadData();
|
|
222
|
-
} catch (err
|
|
223
|
-
setError(err.message
|
|
262
|
+
} catch (err) {
|
|
263
|
+
setError(err instanceof Error ? err.message : 'Failed to create pre-auth key');
|
|
224
264
|
}
|
|
225
265
|
}
|
|
226
266
|
|
|
@@ -229,8 +269,8 @@ export function MeshPage() {
|
|
|
229
269
|
try {
|
|
230
270
|
await deleteHeadscaleNode(id);
|
|
231
271
|
await loadData();
|
|
232
|
-
} catch (err
|
|
233
|
-
setError(err.message
|
|
272
|
+
} catch (err) {
|
|
273
|
+
setError(err instanceof Error ? err.message : 'Failed to delete node');
|
|
234
274
|
}
|
|
235
275
|
}
|
|
236
276
|
|
|
@@ -278,8 +318,52 @@ export function MeshPage() {
|
|
|
278
318
|
|
|
279
319
|
{error && <div className="alert alert-error" style={{ marginBottom: '24px' }}>{error}</div>}
|
|
280
320
|
|
|
321
|
+
{enrollment && !enrollment.enrolled && (
|
|
322
|
+
<div className="alert alert-warning mesh-enrollment-banner" style={{ marginBottom: '24px' }} data-testid="enrollment-banner">
|
|
323
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16, flexWrap: 'wrap' }}>
|
|
324
|
+
<div>
|
|
325
|
+
<strong>This node is not on the mesh</strong>
|
|
326
|
+
<div style={{ fontSize: 13, marginTop: 4 }}>
|
|
327
|
+
{enrollment.state === 'client_unavailable'
|
|
328
|
+
? 'No Tailscale client detected. Install Tailscale (or set TR_TAILSCALE_BIN) to join the mesh.'
|
|
329
|
+
: enrollment.can_auto_enroll
|
|
330
|
+
? `Ready to enroll automatically against ${enrollment.login_server}.`
|
|
331
|
+
: enrollment.login_server
|
|
332
|
+
? `Control server ${enrollment.login_server} — add a Headscale API key to enroll without approving in a browser.`
|
|
333
|
+
: 'No control server configured. Add a Headscale API key with its URL, or set TR_HEADSCALE_URL.'}
|
|
334
|
+
</div>
|
|
335
|
+
{enrollment.auth_url && (
|
|
336
|
+
<div style={{ fontSize: 13, marginTop: 8 }}>
|
|
337
|
+
Pending registration:{' '}
|
|
338
|
+
<a href={enrollment.auth_url} target="_blank" rel="noreferrer noopener">
|
|
339
|
+
approve this node
|
|
340
|
+
</a>
|
|
341
|
+
</div>
|
|
342
|
+
)}
|
|
343
|
+
{enrollMsg && (
|
|
344
|
+
<div style={{ fontSize: 13, marginTop: 8, color: 'var(--accent-hover)' }}>{enrollMsg}</div>
|
|
345
|
+
)}
|
|
346
|
+
</div>
|
|
347
|
+
{enrollment.client_available && (
|
|
348
|
+
<button
|
|
349
|
+
type="button"
|
|
350
|
+
className="btn btn-primary"
|
|
351
|
+
onClick={handleEnroll}
|
|
352
|
+
disabled={enrollBusy}
|
|
353
|
+
>
|
|
354
|
+
{enrollBusy ? 'Enrolling…' : 'Enroll this node'}
|
|
355
|
+
</button>
|
|
356
|
+
)}
|
|
357
|
+
</div>
|
|
358
|
+
</div>
|
|
359
|
+
)}
|
|
360
|
+
|
|
281
361
|
{activeTab === 'mesh' && (
|
|
282
362
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
|
363
|
+
{/* Which mesh node actually serves embeddings, and which model it uses. */}
|
|
364
|
+
<div className="card">
|
|
365
|
+
<EmbeddingProviderPanel />
|
|
366
|
+
</div>
|
|
283
367
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
|
|
284
368
|
<div className="card">
|
|
285
369
|
<h3 style={{ marginTop: 0, marginBottom: 12 }}>Topology</h3>
|
|
@@ -33,14 +33,14 @@ vi.mock('../api/network', () => {
|
|
|
33
33
|
describe('NetworkPage', () => {
|
|
34
34
|
beforeEach(() => {
|
|
35
35
|
vi.clearAllMocks();
|
|
36
|
-
(getNetworkStats
|
|
37
|
-
stats: { active: 2, queueLength: 0, errors: 0, timeouts: 0 }
|
|
38
|
-
});
|
|
39
|
-
(getNetworkPolicy
|
|
36
|
+
vi.mocked(getNetworkStats).mockResolvedValue({
|
|
37
|
+
stats: { active: 2, queueLength: 0, errors: 0, timeouts: 0 } as any
|
|
38
|
+
} as any);
|
|
39
|
+
vi.mocked(getNetworkPolicy).mockResolvedValue({
|
|
40
40
|
blocked_domains: ['evil.com'],
|
|
41
41
|
max_global_concurrency: 20
|
|
42
|
-
});
|
|
43
|
-
(getAuditLog
|
|
42
|
+
} as any);
|
|
43
|
+
vi.mocked(getAuditLog).mockResolvedValue({
|
|
44
44
|
audit: []
|
|
45
45
|
});
|
|
46
46
|
});
|
|
@@ -54,10 +54,9 @@ describe('NetworkPage', () => {
|
|
|
54
54
|
render(<NetworkPage />);
|
|
55
55
|
|
|
56
56
|
await waitFor(() => {
|
|
57
|
-
expect(screen.
|
|
57
|
+
expect(screen.getByText(/Active Connections/i)).toBeInTheDocument();
|
|
58
58
|
});
|
|
59
59
|
|
|
60
|
-
expect(screen.getByText(/Active Connections/i)).toBeInTheDocument();
|
|
61
60
|
expect(screen.getByText('2')).toBeInTheDocument();
|
|
62
61
|
expect(screen.getByText('evil.com')).toBeInTheDocument();
|
|
63
62
|
});
|
|
@@ -53,7 +53,7 @@ export default function NetworkPage() {
|
|
|
53
53
|
: undefined
|
|
54
54
|
),
|
|
55
55
|
]);
|
|
56
|
-
setStats(statsRes.stats || statsRes as
|
|
56
|
+
setStats(statsRes.stats || statsRes as unknown as NetworkStats);
|
|
57
57
|
if (policyRes) {
|
|
58
58
|
setPolicy(policyRes);
|
|
59
59
|
if (!limitsChanged) {
|
|
@@ -66,15 +66,15 @@ export default function NetworkPage() {
|
|
|
66
66
|
}
|
|
67
67
|
setAudit(auditRes.audit || []);
|
|
68
68
|
setError('');
|
|
69
|
-
} catch (err
|
|
70
|
-
setError(err.message
|
|
69
|
+
} catch (err) {
|
|
70
|
+
setError(err instanceof Error ? err.message : 'Failed to load network data');
|
|
71
71
|
} finally {
|
|
72
72
|
setLoading(false);
|
|
73
73
|
}
|
|
74
74
|
}, [filterDomain, filterStatus, limitsChanged]);
|
|
75
75
|
|
|
76
76
|
useEffect(() => {
|
|
77
|
-
loadData();
|
|
77
|
+
setTimeout(() => void loadData(), 0);
|
|
78
78
|
}, [loadData]);
|
|
79
79
|
|
|
80
80
|
useEffect(() => {
|
|
@@ -94,8 +94,8 @@ export default function NetworkPage() {
|
|
|
94
94
|
setSuccess(`Blocked ${domain}`);
|
|
95
95
|
setTimeout(() => setSuccess(''), 3000);
|
|
96
96
|
await loadData();
|
|
97
|
-
} catch (err
|
|
98
|
-
setError(err.message);
|
|
97
|
+
} catch (err) {
|
|
98
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
99
99
|
}
|
|
100
100
|
};
|
|
101
101
|
|
|
@@ -105,8 +105,8 @@ export default function NetworkPage() {
|
|
|
105
105
|
setSuccess(`Unblocked ${domain}`);
|
|
106
106
|
setTimeout(() => setSuccess(''), 3000);
|
|
107
107
|
await loadData();
|
|
108
|
-
} catch (err
|
|
109
|
-
setError(err.message);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
110
110
|
}
|
|
111
111
|
};
|
|
112
112
|
|
|
@@ -117,8 +117,8 @@ export default function NetworkPage() {
|
|
|
117
117
|
setSuccess('Limits updated');
|
|
118
118
|
setTimeout(() => setSuccess(''), 3000);
|
|
119
119
|
await loadData();
|
|
120
|
-
} catch (err
|
|
121
|
-
setError(err.message);
|
|
120
|
+
} catch (err) {
|
|
121
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
122
122
|
}
|
|
123
123
|
};
|
|
124
124
|
|
|
@@ -129,8 +129,8 @@ export default function NetworkPage() {
|
|
|
129
129
|
setSuccess(`Switched to ${!policy.whitelist_mode ? 'whitelist' : 'blocklist'} mode`);
|
|
130
130
|
setTimeout(() => setSuccess(''), 3000);
|
|
131
131
|
await loadData();
|
|
132
|
-
} catch (err
|
|
133
|
-
setError(err.message);
|
|
132
|
+
} catch (err) {
|
|
133
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
134
134
|
}
|
|
135
135
|
};
|
|
136
136
|
|
|
@@ -41,15 +41,15 @@ export function NotificationsPage() {
|
|
|
41
41
|
const [r, h] = await Promise.all([listNotificationRules(), getNotificationHistory()]);
|
|
42
42
|
setRules(r);
|
|
43
43
|
setHistory(h);
|
|
44
|
-
} catch (err
|
|
45
|
-
setError(err.message
|
|
44
|
+
} catch (err) {
|
|
45
|
+
setError(err instanceof Error ? err.message : 'Failed to load notifications data');
|
|
46
46
|
} finally {
|
|
47
47
|
setLoading(false);
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
useEffect(() => {
|
|
52
|
-
loadData();
|
|
52
|
+
setTimeout(() => { void loadData() }, 0);
|
|
53
53
|
const interval = setInterval(loadData, 15000);
|
|
54
54
|
return () => clearInterval(interval);
|
|
55
55
|
}, []);
|
|
@@ -70,8 +70,8 @@ export function NotificationsPage() {
|
|
|
70
70
|
setNewPriority('high');
|
|
71
71
|
setNewQuietHours(false);
|
|
72
72
|
await loadData();
|
|
73
|
-
} catch (err
|
|
74
|
-
setError(err.message
|
|
73
|
+
} catch (err) {
|
|
74
|
+
setError(err instanceof Error ? err.message : 'Failed to create notification rule');
|
|
75
75
|
}
|
|
76
76
|
}
|
|
77
77
|
|
|
@@ -80,8 +80,8 @@ export function NotificationsPage() {
|
|
|
80
80
|
try {
|
|
81
81
|
await deleteNotificationRule(id);
|
|
82
82
|
await loadData();
|
|
83
|
-
} catch (err
|
|
84
|
-
setError(err.message
|
|
83
|
+
} catch (err) {
|
|
84
|
+
setError(err instanceof Error ? err.message : 'Failed to delete notification rule');
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
87
|
|
|
@@ -90,8 +90,8 @@ export function NotificationsPage() {
|
|
|
90
90
|
await sendTestNotification();
|
|
91
91
|
setSuccess('Test notification sent');
|
|
92
92
|
setTimeout(() => setSuccess(null), 3000);
|
|
93
|
-
} catch (err
|
|
94
|
-
setError(err.message
|
|
93
|
+
} catch (err) {
|
|
94
|
+
setError(err instanceof Error ? err.message : 'Failed to send test notification');
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
97
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
|
|
2
2
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
3
3
|
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
|
4
4
|
import { BrowserRouter } from 'react-router-dom';
|
|
@@ -18,7 +18,7 @@ describe('OnboardingPage', () => {
|
|
|
18
18
|
};
|
|
19
19
|
|
|
20
20
|
it('renders welcome step first', async () => {
|
|
21
|
-
vi.mocked(api.listMemory).mockResolvedValue([] as
|
|
21
|
+
vi.mocked(api.listMemory).mockResolvedValue([] as never);
|
|
22
22
|
|
|
23
23
|
renderWithRouter(<OnboardingPage />);
|
|
24
24
|
|
|
@@ -28,8 +28,8 @@ describe('OnboardingPage', () => {
|
|
|
28
28
|
});
|
|
29
29
|
|
|
30
30
|
it('navigates to next step', async () => {
|
|
31
|
-
vi.mocked(api.listMemory).mockResolvedValue([] as
|
|
32
|
-
vi.mocked(api.scanEnvSecrets).mockResolvedValue({ candidates: [] } as
|
|
31
|
+
vi.mocked(api.listMemory).mockResolvedValue([] as never);
|
|
32
|
+
vi.mocked(api.scanEnvSecrets).mockResolvedValue({ candidates: [] } as never);
|
|
33
33
|
|
|
34
34
|
renderWithRouter(<OnboardingPage />);
|
|
35
35
|
|
|
@@ -42,10 +42,10 @@ describe('OnboardingPage', () => {
|
|
|
42
42
|
});
|
|
43
43
|
|
|
44
44
|
it('handles scan import mode', async () => {
|
|
45
|
-
vi.mocked(api.listMemory).mockResolvedValue([] as
|
|
45
|
+
vi.mocked(api.listMemory).mockResolvedValue([] as never);
|
|
46
46
|
vi.mocked(api.scanEnvSecrets).mockResolvedValue({
|
|
47
|
-
candidates: [{ key: 'TEST_KEY', provider: 'test', masked: 'sk-***', source_label: '.env' }]
|
|
48
|
-
});
|
|
47
|
+
candidates: [{ key: 'TEST_KEY', provider: 'test', masked: 'sk-***', source_label: '.env' } as any]
|
|
48
|
+
} as any);
|
|
49
49
|
|
|
50
50
|
renderWithRouter(<OnboardingPage />);
|
|
51
51
|
|
|
@@ -110,7 +110,7 @@ try {
|
|
|
110
110
|
// Re-fetch nodes after background ingest completes
|
|
111
111
|
void fetchNodes(activeBrainId || 'global');
|
|
112
112
|
});
|
|
113
|
-
} catch
|
|
113
|
+
} catch {
|
|
114
114
|
// ignore
|
|
115
115
|
}
|
|
116
116
|
|
|
@@ -118,8 +118,8 @@ try {
|
|
|
118
118
|
};
|
|
119
119
|
|
|
120
120
|
void initFetch();
|
|
121
|
-
void fetchHealthData();
|
|
122
|
-
const interval = setInterval(fetchHealthData, 15000);
|
|
121
|
+
setTimeout(() => { void fetchHealthData(); }, 0);
|
|
122
|
+
const interval = setInterval(() => { void fetchHealthData(); }, 15000);
|
|
123
123
|
return () => clearInterval(interval);
|
|
124
124
|
}, [activeBrainId]);
|
|
125
125
|
|
|
@@ -20,7 +20,7 @@ export default function RulesPage({ activeBrainId }: { activeBrainId: string })
|
|
|
20
20
|
fetchRules();
|
|
21
21
|
}, [activeBrainId]);
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
async function fetchRules() {
|
|
24
24
|
setLoading(true);
|
|
25
25
|
setError(null);
|
|
26
26
|
try {
|
|
@@ -51,7 +51,7 @@ export default function RulesPage({ activeBrainId }: { activeBrainId: string })
|
|
|
51
51
|
headers: { 'Content-Type': 'application/json' },
|
|
52
52
|
});
|
|
53
53
|
fetchRules();
|
|
54
|
-
} catch
|
|
54
|
+
} catch {
|
|
55
55
|
alert('Failed to archive rule');
|
|
56
56
|
}
|
|
57
57
|
};
|
|
@@ -64,7 +64,7 @@ export default function RulesPage({ activeBrainId }: { activeBrainId: string })
|
|
|
64
64
|
headers: { 'Content-Type': 'application/json' },
|
|
65
65
|
});
|
|
66
66
|
fetchRules();
|
|
67
|
-
} catch
|
|
67
|
+
} catch {
|
|
68
68
|
alert('Failed to restore rule');
|
|
69
69
|
}
|
|
70
70
|
};
|
|
@@ -12,15 +12,15 @@ describe('SecretsPage', () => {
|
|
|
12
12
|
});
|
|
13
13
|
|
|
14
14
|
it('renders without crashing', async () => {
|
|
15
|
-
vi.mocked(api.fetchConfigJson).mockResolvedValue({} as
|
|
16
|
-
vi.mocked(api.fetchSecretsCatalog).mockResolvedValue({ keys: [], by_provider: [], store: 'mock-store' } as
|
|
17
|
-
vi.mocked(api.listApiKeys).mockResolvedValue({ keys: [] } as
|
|
18
|
-
vi.mocked(api.fetchWebAuthnStatus).mockResolvedValue({} as
|
|
15
|
+
vi.mocked(api.fetchConfigJson).mockResolvedValue({} as never);
|
|
16
|
+
vi.mocked(api.fetchSecretsCatalog).mockResolvedValue({ keys: [], by_provider: [], store: 'mock-store' } as never);
|
|
17
|
+
vi.mocked(api.listApiKeys).mockResolvedValue({ keys: [] } as never);
|
|
18
|
+
vi.mocked(api.fetchWebAuthnStatus).mockResolvedValue({} as never);
|
|
19
19
|
vi.mocked(api.fetchGeminiModels).mockResolvedValue([]);
|
|
20
20
|
vi.mocked(api.fetchClaudeModels).mockResolvedValue([]);
|
|
21
21
|
vi.mocked(api.fetchOpenaiModels).mockResolvedValue([]);
|
|
22
22
|
vi.mocked(api.fetchOpenRouterModels).mockResolvedValue([]);
|
|
23
|
-
vi.mocked(api.getSyncStatus).mockResolvedValue({ nodes: [], localChecksum: 'abc' } as
|
|
23
|
+
vi.mocked(api.getSyncStatus).mockResolvedValue({ nodes: [], localChecksum: 'abc' } as never);
|
|
24
24
|
|
|
25
25
|
render(
|
|
26
26
|
<BrowserRouter>
|
|
@@ -231,8 +231,8 @@ export default function SecretsPage() {
|
|
|
231
231
|
const syncData = await getSyncStatus()
|
|
232
232
|
setSyncNodes(syncData.nodes || [])
|
|
233
233
|
setLocalChecksum(syncData.localChecksum)
|
|
234
|
-
} catch (err
|
|
235
|
-
setError(err.message
|
|
234
|
+
} catch (err) {
|
|
235
|
+
setError(err instanceof Error ? err.message : 'Failed to load sync status')
|
|
236
236
|
}
|
|
237
237
|
}, [])
|
|
238
238
|
|
|
@@ -249,10 +249,10 @@ export default function SecretsPage() {
|
|
|
249
249
|
setSyncLogs(prev => [{ time: new Date().toLocaleTimeString(), event: msg, status: 'success' }, ...prev])
|
|
250
250
|
|
|
251
251
|
await loadSyncData()
|
|
252
|
-
} catch (err
|
|
253
|
-
const msg = `Sync trigger failed: ${err.message
|
|
252
|
+
} catch (err) {
|
|
253
|
+
const msg = `Sync trigger failed: ${err instanceof Error ? err.message : String(err)}`
|
|
254
254
|
setSyncLogs(prev => [{ time: new Date().toLocaleTimeString(), event: msg, status: 'error' }, ...prev])
|
|
255
|
-
setError(err.message
|
|
255
|
+
setError(err instanceof Error ? err.message : 'Failed to trigger synchronization')
|
|
256
256
|
} finally {
|
|
257
257
|
setSyncing(false)
|
|
258
258
|
}
|
|
@@ -287,8 +287,8 @@ export default function SecretsPage() {
|
|
|
287
287
|
setNewSecretKeyName('')
|
|
288
288
|
setNewSecretVal('')
|
|
289
289
|
await loadCatalog()
|
|
290
|
-
} catch (err
|
|
291
|
-
setError(err.message
|
|
290
|
+
} catch (err) {
|
|
291
|
+
setError(err instanceof Error ? err.message : 'Failed to create secret')
|
|
292
292
|
} finally {
|
|
293
293
|
setSaving(false)
|
|
294
294
|
}
|
|
@@ -296,7 +296,7 @@ export default function SecretsPage() {
|
|
|
296
296
|
|
|
297
297
|
useEffect(() => {
|
|
298
298
|
if (tab === 'sync') {
|
|
299
|
-
void loadSyncData()
|
|
299
|
+
setTimeout(() => void loadSyncData(), 0)
|
|
300
300
|
}
|
|
301
301
|
}, [tab, loadSyncData])
|
|
302
302
|
|
|
@@ -360,12 +360,11 @@ export default function SecretsPage() {
|
|
|
360
360
|
}, [])
|
|
361
361
|
|
|
362
362
|
useEffect(() => {
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
void loadWebAuthn()
|
|
363
|
+
setTimeout(() => {
|
|
364
|
+
void loadCatalog()
|
|
365
|
+
void loadPats()
|
|
366
|
+
void loadWebAuthn()
|
|
367
|
+
}, 0)
|
|
369
368
|
}, [loadCatalog, loadPats, loadWebAuthn])
|
|
370
369
|
|
|
371
370
|
|