total-recall-brain 3.16.0 → 3.17.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.
Files changed (51) hide show
  1. package/frontend/dist/sw.js +7 -1
  2. package/frontend/src/App.spec.tsx +1 -0
  3. package/frontend/src/App.tsx +34 -23
  4. package/frontend/src/api/_base.ts +3 -3
  5. package/frontend/src/api/chat.ts +1 -1
  6. package/frontend/src/api/headscale.ts +71 -0
  7. package/frontend/src/api/index.ts +1 -0
  8. package/frontend/src/api/keys.ts +1 -0
  9. package/frontend/src/api/network.spec.ts +6 -7
  10. package/frontend/src/api/network.ts +35 -42
  11. package/frontend/src/api/notifications.ts +41 -0
  12. package/frontend/src/api/secrets.spec.ts +62 -0
  13. package/frontend/src/api/secrets.ts +53 -0
  14. package/frontend/src/api/webhooks.ts +4 -0
  15. package/frontend/src/components/VoiceInput.tsx +13 -2
  16. package/frontend/src/index.css +150 -0
  17. package/frontend/src/pages/MeshPage.css +11 -0
  18. package/frontend/src/pages/MeshPage.spec.tsx +83 -3
  19. package/frontend/src/pages/MeshPage.tsx +312 -57
  20. package/frontend/src/pages/NetworkPage.css +263 -19
  21. package/frontend/src/pages/NetworkPage.spec.tsx +34 -17
  22. package/frontend/src/pages/NetworkPage.tsx +320 -124
  23. package/frontend/src/pages/NotificationsPage.css +219 -0
  24. package/frontend/src/pages/NotificationsPage.spec.tsx +62 -0
  25. package/frontend/src/pages/NotificationsPage.tsx +318 -0
  26. package/frontend/src/pages/SecretsPage.css +141 -0
  27. package/frontend/src/pages/{ApiKeysPage.spec.tsx → SecretsPage.spec.tsx} +4 -3
  28. package/frontend/src/pages/{ApiKeysPage.tsx → SecretsPage.tsx} +351 -19
  29. package/frontend/src/pages/TasksPage.spec.tsx +5 -1
  30. package/frontend/src/pages/WebhooksPage.css +138 -0
  31. package/frontend/src/pages/WebhooksPage.spec.tsx +93 -5
  32. package/frontend/src/pages/WebhooksPage.tsx +232 -45
  33. package/frontend/src/setupTests.ts +4 -0
  34. package/package.json +4 -1
  35. package/scaffold/.agent/skills/total-recall/modules/agents/agents.yml +1 -1
  36. package/src/core/project-brain.mjs +1 -0
  37. package/src/core/provider-catalog.mjs +13 -0
  38. package/src/core/runtime.mjs +1 -1
  39. package/src/core/secrets-store.mjs +2 -0
  40. package/src/server/index.mjs +1 -1
  41. package/src/server/rest.mjs +2 -0
  42. package/src/server/route-manifest.json +56 -0
  43. package/src/server/routes/headscale.mjs +121 -0
  44. package/src/server/routes/mesh.mjs +3 -3
  45. package/src/server/routes/secrets.mjs +156 -2
  46. package/src/server/routes/system.mjs +3 -0
  47. package/src/server/routes/webhooks.mjs +136 -13
  48. package/frontend/src/pages/ModelsPage.spec.tsx +0 -65
  49. package/frontend/src/pages/ModelsPage.tsx +0 -596
  50. package/frontend/src/pages/VaultPage.spec.tsx +0 -27
  51. package/frontend/src/pages/VaultPage.tsx +0 -223
@@ -50,7 +50,12 @@ export function VoiceInput({ onTranscript, isRecording = false }: VoiceInputProp
50
50
 
51
51
  if (!supported) {
52
52
  return (
53
- <button disabled className="p-2 bg-gray-200 text-gray-500 rounded-full" title="Voice input not supported">
53
+ <button
54
+ disabled
55
+ className="chat-send-btn"
56
+ style={{ background: 'var(--bg-tertiary)', border: '1px solid var(--border)', color: 'var(--text-tertiary)', opacity: 0.5, cursor: 'not-allowed' }}
57
+ title="Voice input not supported"
58
+ >
54
59
  <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" x2="12" y1="19" y2="22"/></svg>
55
60
  </button>
56
61
  );
@@ -59,7 +64,13 @@ export function VoiceInput({ onTranscript, isRecording = false }: VoiceInputProp
59
64
  return (
60
65
  <button
61
66
  onClick={toggleRecording}
62
- className={`p-2 rounded-full transition-colors ${recording ? 'bg-red-500 text-white animate-pulse' : 'bg-blue-500 text-white hover:bg-blue-600'}`}
67
+ className="chat-send-btn"
68
+ style={{
69
+ background: recording ? 'var(--error)' : 'var(--bg-tertiary)',
70
+ border: '1px solid var(--border)',
71
+ color: recording ? '#fff' : 'var(--text-secondary)',
72
+ transition: 'all var(--transition-fast)'
73
+ }}
63
74
  title={recording ? "Stop recording" : "Start voice input"}
64
75
  >
65
76
  <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" x2="12" y1="19" y2="22"/></svg>
@@ -355,6 +355,156 @@ code, pre { font-family: var(--font-mono); }
355
355
 
356
356
  .btn-sm { padding: 6px 11px; font-size: 12px; }
357
357
 
358
+ /* ─── Alerts ─────────────────────────────────────────────────────────────────── */
359
+
360
+ .alert {
361
+ padding: 12px 16px;
362
+ border-radius: var(--radius-sm);
363
+ font-size: 13px;
364
+ font-weight: 500;
365
+ border: 1px solid transparent;
366
+ }
367
+
368
+ .alert-error {
369
+ background: var(--error-muted);
370
+ border-color: rgba(248, 113, 113, 0.25);
371
+ color: var(--error);
372
+ }
373
+
374
+ .alert-success {
375
+ background: var(--success-muted);
376
+ border-color: rgba(52, 211, 153, 0.25);
377
+ color: var(--success);
378
+ }
379
+
380
+ /* ─── Form Controls ──────────────────────────────────────────────────────────── */
381
+
382
+ .input, .select {
383
+ background: var(--bg-secondary);
384
+ border: 1px solid var(--border);
385
+ border-radius: var(--radius-sm);
386
+ padding: 9px 12px;
387
+ color: var(--text-primary);
388
+ font-family: var(--font-sans);
389
+ font-size: 13px;
390
+ outline: none;
391
+ transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
392
+ }
393
+
394
+ .input:focus, .select:focus {
395
+ border-color: var(--accent);
396
+ box-shadow: 0 0 0 3px var(--accent-muted);
397
+ }
398
+
399
+ .input::placeholder {
400
+ color: var(--text-tertiary);
401
+ }
402
+
403
+ .select {
404
+ cursor: pointer;
405
+ appearance: none;
406
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
407
+ background-repeat: no-repeat;
408
+ background-position: right 10px center;
409
+ padding-right: 32px;
410
+ }
411
+
412
+ /* ─── Data Tables ────────────────────────────────────────────────────────────── */
413
+
414
+ .data-table {
415
+ width: 100%;
416
+ border-collapse: collapse;
417
+ }
418
+
419
+ .data-table th {
420
+ text-align: left;
421
+ padding: 12px 16px;
422
+ color: var(--text-secondary);
423
+ font-weight: 500;
424
+ font-size: 12px;
425
+ text-transform: uppercase;
426
+ letter-spacing: 0.04em;
427
+ border-bottom: 1px solid var(--border);
428
+ background: var(--bg-secondary);
429
+ }
430
+
431
+ .data-table td {
432
+ padding: 14px 16px;
433
+ border-bottom: 1px solid var(--border);
434
+ color: var(--text-primary);
435
+ font-size: 13px;
436
+ }
437
+
438
+ .data-table tbody tr {
439
+ transition: background var(--transition-fast);
440
+ }
441
+
442
+ .data-table tbody tr:hover {
443
+ background: var(--bg-hover);
444
+ }
445
+
446
+ .data-table tr.is-leader td {
447
+ background: var(--accent-soft);
448
+ }
449
+
450
+ /* ─── Page Layout ────────────────────────────────────────────────────────────── */
451
+
452
+ .page-container {
453
+ padding: 32px;
454
+ max-width: 1200px;
455
+ margin: 0 auto;
456
+ }
457
+
458
+ .page-header {
459
+ display: flex;
460
+ align-items: center;
461
+ justify-content: space-between;
462
+ margin-bottom: 32px;
463
+ }
464
+
465
+ .page-header h1 {
466
+ font-size: 24px;
467
+ font-weight: 600;
468
+ color: var(--text-primary);
469
+ }
470
+
471
+ /* ─── Badges ─────────────────────────────────────────────────────────────────── */
472
+
473
+ .badge {
474
+ display: inline-flex;
475
+ align-items: center;
476
+ padding: 3px 8px;
477
+ border-radius: 4px;
478
+ font-size: 11px;
479
+ font-weight: 600;
480
+ letter-spacing: 0.03em;
481
+ }
482
+
483
+ .badge-online, .badge-active {
484
+ background: var(--success-muted);
485
+ color: var(--success);
486
+ }
487
+
488
+ .badge-offline, .badge-inactive {
489
+ background: var(--error-muted);
490
+ color: var(--error);
491
+ }
492
+
493
+ .badge-leader {
494
+ background: rgba(59, 130, 246, 0.15);
495
+ color: var(--accent-hover);
496
+ }
497
+
498
+ .badge-follower {
499
+ background: var(--bg-hover);
500
+ color: var(--text-secondary);
501
+ }
502
+
503
+ .badge-warning {
504
+ background: var(--warning-muted);
505
+ color: var(--warning);
506
+ }
507
+
358
508
  /* Premium surfaces */
359
509
  .surface-premium {
360
510
  background: linear-gradient(165deg, rgba(23, 32, 51, 0.92), rgba(12, 18, 32, 0.96));
@@ -0,0 +1,11 @@
1
+ /* ─── MeshPage-specific styles ───────────────────────────────────────────────── */
2
+ /* Global classes (card, data-table, badge, page-container, page-header) live in index.css */
3
+
4
+ .mesh-page {
5
+ animation: mesh-fade-in 0.35s ease-out;
6
+ }
7
+
8
+ @keyframes mesh-fade-in {
9
+ from { opacity: 0; transform: translateY(8px); }
10
+ to { opacity: 1; transform: translateY(0); }
11
+ }
@@ -2,7 +2,8 @@ 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 { fetchLeader, fetchNodes, forceReElection } from '../api/mesh';
5
+ import { fetchLeader, fetchNodes as fetchMeshNodes, forceReElection } from '../api/mesh';
6
+ import { fetchHeadscaleNodes, deleteHeadscaleNode, fetchPreAuthKeys, createPreAuthKey, fetchHeadscaleUsers } from '../api/headscale';
6
7
 
7
8
  vi.mock('../api/mesh', () => ({
8
9
  fetchLeader: vi.fn(),
@@ -10,14 +11,23 @@ vi.mock('../api/mesh', () => ({
10
11
  forceReElection: vi.fn(),
11
12
  }));
12
13
 
14
+ vi.mock('../api/headscale', () => ({
15
+ fetchHeadscaleNodes: vi.fn(),
16
+ deleteHeadscaleNode: vi.fn(),
17
+ fetchPreAuthKeys: vi.fn(),
18
+ createPreAuthKey: vi.fn(),
19
+ fetchHeadscaleUsers: vi.fn(),
20
+ }));
21
+
13
22
  describe('MeshPage', () => {
14
23
  beforeEach(() => {
15
24
  vi.clearAllMocks();
25
+ window.confirm = vi.fn(() => true);
16
26
  });
17
27
 
18
28
  it('renders mesh nodes and leader', async () => {
19
29
  vi.mocked(fetchLeader).mockResolvedValue({ hostname: 'macmini.mesh', ip: '1.2.3.4' });
20
- vi.mocked(fetchNodes).mockResolvedValue([
30
+ vi.mocked(fetchMeshNodes).mockResolvedValue([
21
31
  { hostname: 'macmini.mesh', ip: '1.2.3.4', status: 'online', role: 'leader', lastHeartbeat: '2024-01-01T00:00:00Z' },
22
32
  { hostname: 'laptop.mesh', ip: '1.2.3.5', status: 'offline', role: 'follower', lastHeartbeat: '2024-01-01T00:00:00Z' },
23
33
  ]);
@@ -32,7 +42,7 @@ describe('MeshPage', () => {
32
42
 
33
43
  it('handles force re-election', async () => {
34
44
  vi.mocked(fetchLeader).mockResolvedValue({ hostname: 'macmini.mesh', ip: '1.2.3.4' });
35
- vi.mocked(fetchNodes).mockResolvedValue([]);
45
+ vi.mocked(fetchMeshNodes).mockResolvedValue([]);
36
46
  vi.mocked(forceReElection).mockResolvedValue(undefined);
37
47
 
38
48
  render(<MeshPage />);
@@ -44,4 +54,74 @@ describe('MeshPage', () => {
44
54
  await userEvent.click(screen.getByText('Force Re-Election'));
45
55
  expect(forceReElection).toHaveBeenCalled();
46
56
  });
57
+
58
+ it('renders Headscale Nodes tab and allows deletion', async () => {
59
+ vi.mocked(fetchLeader).mockResolvedValue({ hostname: 'macmini.mesh', ip: '1.2.3.4' });
60
+ vi.mocked(fetchMeshNodes).mockResolvedValue([]);
61
+ vi.mocked(fetchHeadscaleNodes).mockResolvedValue([
62
+ { id: '1', name: 'node-one', user: 'testuser', ipAddresses: ['100.64.0.1'], online: true, createdAt: '2024-01-01', lastSeen: '2024-01-01T00:00:00Z' }
63
+ ]);
64
+ vi.mocked(deleteHeadscaleNode).mockResolvedValue(undefined);
65
+
66
+ render(<MeshPage />);
67
+
68
+ const tabBtn = screen.getByText('Headscale Nodes');
69
+ await userEvent.click(tabBtn);
70
+
71
+ await waitFor(() => {
72
+ expect(screen.getByText('node-one')).toBeInTheDocument();
73
+ expect(screen.getByText('100.64.0.1')).toBeInTheDocument();
74
+ });
75
+
76
+ const deleteBtn = screen.getByText('Delete');
77
+ await userEvent.click(deleteBtn);
78
+ expect(window.confirm).toHaveBeenCalled();
79
+ expect(deleteHeadscaleNode).toHaveBeenCalledWith('1');
80
+ });
81
+
82
+ it('renders Pre-Auth Keys tab and supports key generation', async () => {
83
+ vi.mocked(fetchLeader).mockResolvedValue({ hostname: 'macmini.mesh', ip: '1.2.3.4' });
84
+ vi.mocked(fetchMeshNodes).mockResolvedValue([]);
85
+ vi.mocked(fetchPreAuthKeys).mockResolvedValue([
86
+ { id: 'key1', key: 'hspkey_xyz', user: 'default', reusable: true, expiration: '2099-12-31T23:59:59Z', createdAt: '2024-01-01', used: false }
87
+ ]);
88
+ vi.mocked(createPreAuthKey).mockResolvedValue({} as any);
89
+
90
+ render(<MeshPage />);
91
+
92
+ const tabBtn = screen.getByText('Pre-Auth Keys');
93
+ await userEvent.click(tabBtn);
94
+
95
+ await waitFor(() => {
96
+ expect(screen.getByText('hspkey_xyz')).toBeInTheDocument();
97
+ expect(screen.getByText('Generate Key')).toBeInTheDocument();
98
+ });
99
+
100
+ const generateBtn = screen.getByText('Generate Key');
101
+ await userEvent.click(generateBtn);
102
+
103
+ expect(createPreAuthKey).toHaveBeenCalledWith({
104
+ user: 'default',
105
+ reusable: false,
106
+ ephemeral: false,
107
+ expiration: '2099-12-31T23:59:59Z'
108
+ });
109
+ });
110
+
111
+ it('renders Users tab', async () => {
112
+ vi.mocked(fetchLeader).mockResolvedValue({ hostname: 'macmini.mesh', ip: '1.2.3.4' });
113
+ vi.mocked(fetchMeshNodes).mockResolvedValue([]);
114
+ vi.mocked(fetchHeadscaleUsers).mockResolvedValue([
115
+ { id: 'u1', name: 'alice', createdAt: '2024-01-01T00:00:00Z' }
116
+ ]);
117
+
118
+ render(<MeshPage />);
119
+
120
+ const tabBtn = screen.getByText('Users');
121
+ await userEvent.click(tabBtn);
122
+
123
+ await waitFor(() => {
124
+ expect(screen.getByText('alice')).toBeInTheDocument();
125
+ });
126
+ });
47
127
  });