@cybermem/dashboard 0.9.12 → 0.13.4

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 (43) hide show
  1. package/Dockerfile +3 -3
  2. package/app/api/audit-logs/route.ts +12 -6
  3. package/app/api/health/route.ts +2 -1
  4. package/app/api/mcp-config/route.ts +128 -0
  5. package/app/api/metrics/route.ts +22 -70
  6. package/app/api/settings/route.ts +125 -30
  7. package/app/page.tsx +105 -127
  8. package/components/dashboard/{chart-card.tsx → charts/chart-card.tsx} +13 -19
  9. package/components/dashboard/{metrics-chart.tsx → charts/memory-chart.tsx} +1 -1
  10. package/components/dashboard/charts-section.tsx +3 -3
  11. package/components/dashboard/header.tsx +177 -176
  12. package/components/dashboard/{audit-log-table.tsx → logs/log-viewer.tsx} +12 -7
  13. package/components/dashboard/mcp/config-preview.tsx +246 -0
  14. package/components/dashboard/mcp/platform-selector.tsx +96 -0
  15. package/components/dashboard/mcp-config-modal.tsx +97 -503
  16. package/components/dashboard/{metric-card.tsx → metrics/stat-card.tsx} +4 -2
  17. package/components/dashboard/metrics-grid.tsx +10 -2
  18. package/components/dashboard/settings/access-token-section.tsx +131 -0
  19. package/components/dashboard/settings/data-management-section.tsx +122 -0
  20. package/components/dashboard/settings/system-info-section.tsx +98 -0
  21. package/components/dashboard/settings-modal.tsx +55 -299
  22. package/e2e/api.spec.ts +219 -0
  23. package/e2e/routing.spec.ts +39 -0
  24. package/e2e/ui.spec.ts +373 -0
  25. package/lib/data/dashboard-context.tsx +96 -29
  26. package/lib/data/types.ts +32 -38
  27. package/middleware.ts +31 -13
  28. package/package.json +6 -1
  29. package/playwright.config.ts +23 -58
  30. package/public/clients.json +5 -3
  31. package/release-reports/assets/local/1_dashboard.png +0 -0
  32. package/release-reports/assets/local/2_audit_logs.png +0 -0
  33. package/release-reports/assets/local/3_charts.png +0 -0
  34. package/release-reports/assets/local/4_mcp_modal.png +0 -0
  35. package/release-reports/assets/local/5_settings_modal.png +0 -0
  36. package/lib/data/demo-strategy.ts +0 -110
  37. package/lib/data/production-strategy.ts +0 -191
  38. package/lib/prometheus/client.ts +0 -58
  39. package/lib/prometheus/index.ts +0 -6
  40. package/lib/prometheus/metrics.ts +0 -234
  41. package/lib/prometheus/sparklines.ts +0 -71
  42. package/lib/prometheus/timeseries.ts +0 -305
  43. package/lib/prometheus/utils.ts +0 -176
@@ -2,33 +2,21 @@
2
2
 
3
3
  import { Button } from "@/components/ui/button";
4
4
  import { ConfirmationModal } from "@/components/ui/confirmation-modal";
5
- import { Input } from "@/components/ui/input";
6
- import { Label } from "@/components/ui/label";
7
- import { TintButton } from "@/components/ui/tint-button";
8
5
  import { useDashboard } from "@/lib/data/dashboard-context";
9
- import {
10
- Check,
11
- Copy,
12
- Database,
13
- Download,
14
- Eye,
15
- EyeOff,
16
- Loader2,
17
- RotateCcw,
18
- Server,
19
- Settings,
20
- Shield,
21
- Trash2,
22
- Upload,
23
- X,
24
- } from "lucide-react";
6
+ import { Settings, X } from "lucide-react";
25
7
  import { useEffect, useState } from "react";
26
8
  import { toast } from "sonner";
9
+ import AccessTokenSection from "./settings/access-token-section";
10
+ import DataManagementSection from "./settings/data-management-section";
11
+ import SystemInfoSection from "./settings/system-info-section";
27
12
 
28
13
  export default function SettingsModal({ onClose }: { onClose: () => void }) {
29
14
  const [apiKey, setApiKey] = useState("");
30
15
  const [endpoint, setEndpoint] = useState("");
31
16
  const [isManaged, setIsManaged] = useState(false);
17
+ const [instanceType, setInstanceType] = useState<"local" | "rpi" | "vps">(
18
+ "local",
19
+ );
32
20
  const [settings, setSettings] = useState<any>(null);
33
21
  const [isLoading, setIsLoading] = useState(true);
34
22
 
@@ -64,8 +52,9 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
64
52
  fetch("/api/settings")
65
53
  .then((res) => res.json())
66
54
  .then((data) => {
67
- // Enforce Local Mode if server says so
68
- setIsManaged(data.isManaged || false);
55
+ // Enforce Local Mode ONLY if it's truly local hardware AND accessed via localhost
56
+ setIsManaged(data.isManaged && data.instanceType === "local");
57
+ setInstanceType(data.instanceType || "local");
69
58
  setSettings(data);
70
59
 
71
60
  if (localKey && !data.isManaged) {
@@ -113,14 +102,6 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
113
102
  }
114
103
  };
115
104
 
116
- const [saved, setSaved] = useState(false);
117
-
118
- const handleSave = () => {
119
- // Settings saved
120
- setSaved(true);
121
- setTimeout(() => setSaved(false), 2000);
122
- };
123
-
124
105
  const handleBackup = async () => {
125
106
  try {
126
107
  setIsBackingUp(true);
@@ -219,291 +200,66 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
219
200
 
220
201
  return (
221
202
  <div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
222
- <div
223
- className="bg-[#0B1116]/80 backdrop-blur-xl border border-emerald-500/20 rounded-2xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto relative overflow-hidden"
224
- style={{
225
- backgroundImage: `radial-gradient(circle at 0% 0%, oklch(0.7 0 0 / 0.05) 0%, transparent 50%), radial-gradient(circle at 100% 0%, oklch(0.6 0 0 / 0.05) 0%, transparent 50%), radial-gradient(circle at 100% 100%, oklch(0.65 0 0 / 0.05) 0%, transparent 50%), radial-gradient(circle at 0% 100%, oklch(0.6 0 0 / 0.05) 0%, transparent 50%)`,
226
- }}
227
- >
228
- {/* Header */}
229
- <div className="flex items-center justify-between px-6 pt-6 pb-2">
230
- <div className="flex items-center gap-3">
231
- <div className="p-2 bg-white/5 rounded-lg border border-white/10 shadow-inner">
232
- <Settings className="w-5 h-5 text-white shadow-lg" />
203
+ <div className="bg-[#05100F] border-[0.5px] border-white/10 rounded-3xl shadow-[0_20px_50px_rgba(0,0,0,0.5)] max-w-2xl w-full max-h-[90vh] overflow-hidden relative flex flex-col">
204
+ <div className="relative rounded-t-lg bg-[#05100F] px-4 py-4 flex items-center justify-between border-b-0">
205
+ <div className="flex items-center gap-4">
206
+ {/* Title */}
207
+ <div className="text-sm text-white font-semibold flex items-center gap-2 pl-2">
208
+ <Settings className="w-4 h-4 opacity-70" />
209
+ <span>Settings</span>
233
210
  </div>
234
- <h2 className="text-xl font-semibold text-white">Settings</h2>
235
211
  </div>
212
+
236
213
  <Button
237
214
  variant="ghost"
238
215
  size="icon"
239
216
  onClick={onClose}
240
- className="text-neutral-400 hover:text-white rounded-full"
217
+ className="text-neutral-400 hover:text-white hover:bg-white/10 rounded-full w-8 h-8"
241
218
  >
242
- <X className="w-5 h-5" />
219
+ <X className="w-4 h-4" />
243
220
  </Button>
244
221
  </div>
245
222
 
246
223
  {/* Content */}
247
- <div className="p-6 space-y-6">
248
- {/* Access Token */}
249
- <section>
250
- <h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
251
- <Shield className="w-5 h-5" />
252
- Access Token
253
- </h3>
254
- <div className="bg-white/5 border border-white/10 rounded-lg p-5 space-y-4 shadow-[inset_0_0_20px_rgba(255,255,255,0.02)] backdrop-blur-sm">
255
- {/* Token Display with inline regenerate */}
256
- <div className="space-y-2">
257
- <Label htmlFor="access-token">Your Access Token</Label>
258
- <div className="flex gap-2">
259
- <div className="relative flex-1">
260
- <Input
261
- id="access-token"
262
- value={apiKey || "Token not generated yet"}
263
- readOnly
264
- className="bg-black/40 border-white/10 text-white font-mono text-sm pr-10"
265
- type={showApiKey ? "text" : "password"}
266
- />
267
- <button
268
- onClick={() => setShowApiKey(!showApiKey)}
269
- className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-white"
270
- >
271
- {showApiKey ? (
272
- <EyeOff className="w-4 h-4" />
273
- ) : (
274
- <Eye className="w-4 h-4" />
275
- )}
276
- </button>
277
- </div>
278
- <TintButton
279
- tint="neutral"
280
- variant="ghost"
281
- size="icon"
282
- onClick={() => copyToClipboard(apiKey, "accesstoken")}
283
- title="Copy token"
284
- >
285
- {copiedId === "accesstoken" ? (
286
- <Check className="h-4 w-4 text-emerald-400" />
287
- ) : (
288
- <Copy className="h-4 w-4" />
289
- )}
290
- </TintButton>
291
- <TintButton
292
- tint="yellow"
293
- variant="ghost"
294
- size="icon"
295
- onClick={() => setShowRegenConfirm(true)}
296
- title="Regenerate token"
297
- >
298
- <RotateCcw className="w-4 h-4" />
299
- </TintButton>
300
- </div>
301
- <p className="text-xs text-neutral-500">
302
- Use this token to connect MCP clients from other devices
303
- </p>
304
- </div>
305
-
306
- {/* Auth Status */}
307
- <div className="pt-4 border-t border-white/10">
308
- <div className="flex items-center gap-3 p-3 bg-black/20 rounded-lg border border-white/5">
309
- {isManaged ? (
310
- <>
311
- <div className="w-2 h-2 bg-emerald-400 rounded-full animate-pulse" />
312
- <div className="flex-1">
313
- <p className="text-sm text-emerald-300 font-medium">
314
- Local Mode Active
315
- </p>
316
- <p className="text-xs text-neutral-500">
317
- No token needed for local connections
318
- </p>
319
- </div>
320
- </>
321
- ) : (
322
- <>
323
- <div className="w-2 h-2 bg-yellow-400 rounded-full" />
324
- <div className="flex-1">
325
- <p className="text-sm text-yellow-300 font-medium">
326
- Remote Mode
327
- </p>
328
- <p className="text-xs text-neutral-500">
329
- Token required for MCP client connections
330
- </p>
331
- </div>
332
- </>
333
- )}
334
- </div>
335
- </div>
336
- </div>
337
- </section>
338
-
339
- {/* Data Management */}
340
- <section>
341
- <h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
342
- <Database className="w-5 h-5" />
343
- Data Management
344
- </h3>
345
- <div className="flex flex-col gap-3">
346
- <div className="flex items-center gap-3">
347
- <TintButton
348
- tint="neutral"
349
- variant="solid"
350
- className="flex-1 h-11"
351
- onClick={handleBackup}
352
- disabled={isBackingUp}
353
- >
354
- {isBackingUp ? (
355
- <Loader2 className="w-4 h-4 animate-spin" />
356
- ) : (
357
- <Download className="w-4 h-4" />
358
- )}
359
- Backup
360
- </TintButton>
361
-
362
- <div className="flex-1 relative">
363
- <input
364
- type="file"
365
- id="restore-file"
366
- className="hidden"
367
- accept=".tar.gz,.tgz"
368
- onChange={handleRestore}
369
- disabled={isRestoring}
370
- />
371
- <TintButton
372
- tint="neutral"
373
- variant="solid"
374
- className="w-full h-11"
375
- onClick={() =>
376
- document.getElementById("restore-file")?.click()
377
- }
378
- disabled={isRestoring}
379
- >
380
- {isRestoring ? (
381
- <Loader2 className="w-4 h-4 animate-spin" />
382
- ) : (
383
- <Upload className="w-4 h-4" />
384
- )}
385
- Restore
386
- </TintButton>
387
- </div>
388
-
389
- <TintButton
390
- tint="red"
391
- variant="solid"
392
- className="flex-1 h-11"
393
- onClick={() => setShowResetConfirm(true)}
394
- disabled={isResetting}
395
- >
396
- <Trash2 className="w-4 h-4" />
397
- Reset DB
398
- </TintButton>
399
- </div>
400
-
401
- {operationStatus && (
402
- <div
403
- className={`p-3 rounded-xl text-sm flex items-center gap-3 animate-in fade-in slide-in-from-top-1 ${
404
- operationStatus.type === "success"
405
- ? "bg-emerald-500/10 text-emerald-400 border border-emerald-500/20"
406
- : "bg-red-500/10 text-red-400 border border-red-500/20"
407
- }`}
408
- >
409
- {operationStatus.type === "success" ? (
410
- <Check className="w-4 h-4" />
411
- ) : (
412
- <X className="w-4 h-4" />
413
- )}
414
- <span className="flex-1">{operationStatus.message}</span>
415
- <button
416
- onClick={() => setOperationStatus(null)}
417
- className="opacity-50 hover:opacity-100 p-1"
418
- >
419
- <X className="w-4 h-4" />
420
- </button>
421
- </div>
422
- )}
423
- </div>
424
- </section>
425
-
426
- {/* System Info */}
427
- <section>
428
- <h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
429
- <Server className="w-5 h-5" />
430
- System
431
- </h3>
432
- <div className="bg-white/5 border border-white/10 rounded-xl p-6 shadow-[inset_0_0_30px_rgba(255,255,255,0.01)] backdrop-blur-md space-y-6">
433
- <div className="grid grid-cols-2 gap-12">
434
- <div className="space-y-4">
435
- <span className="text-[10px] uppercase text-neutral-500 font-bold tracking-[0.2em] block mb-2">
436
- Versions
437
- </span>
438
- <div className="space-y-3">
439
- <div className="flex justify-between items-center group/version">
440
- <span className="text-xs text-neutral-400 group-hover/version:text-neutral-300 transition-colors">
441
- Dashboard
442
- </span>
443
- <code className="text-[13px] font-mono text-neutral-200 bg-white/5 px-2 py-0.5 rounded border border-white/10 group-hover/version:border-emerald-500/30 group-hover/version:text-emerald-400 transition-all">
444
- {settings?.dashboardVersion || "v0.7.5"}
445
- </code>
446
- </div>
447
- <div className="flex justify-between items-center group/version">
448
- <span className="text-xs text-neutral-400 group-hover/version:text-neutral-300 transition-colors">
449
- MCP Server
450
- </span>
451
- <code className="text-[13px] font-mono text-neutral-200 bg-white/5 px-2 py-0.5 rounded border border-white/10 group-hover/version:border-emerald-500/30 group-hover/version:text-emerald-400 transition-all">
452
- {settings?.mcpVersion || "v0.7.5"}
453
- </code>
454
- </div>
455
- </div>
456
- </div>
457
- <div className="border-l border-white/5 pl-8">
458
- <span className="text-[10px] uppercase text-neutral-500 font-bold tracking-[0.2em] block mb-2">
459
- Environment
460
- </span>
461
- <div className="space-y-3">
462
- <div className="flex justify-between items-center">
463
- <span className="text-xs text-neutral-400">Status</span>
464
- <code className="text-[13px] font-mono text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded border border-emerald-500/20">
465
- Production
466
- </code>
467
- </div>
468
- <div className="flex justify-between items-center">
469
- <span className="text-xs text-neutral-400">Instance</span>
470
- <code className="text-[13px] font-mono text-neutral-200 bg-white/5 px-2 py-0.5 rounded border border-white/10">
471
- {settings?.isLocal
472
- ? "Local"
473
- : settings?.isManaged
474
- ? "RPi"
475
- : "VPS"}
476
- </code>
477
- </div>
478
- </div>
479
- </div>
480
- </div>
481
-
482
- <div className="pt-2 border-t border-white/5">
483
- <TintButton
484
- tint="sky"
485
- variant="solid"
486
- className="w-full h-10"
487
- onClick={handleRestart}
488
- disabled={isRestarting}
489
- >
490
- {isRestarting ? (
491
- <Loader2 className="w-4 h-4 animate-spin" />
492
- ) : (
493
- <RotateCcw className="w-4 h-4" />
494
- )}
495
- {isRestarting ? "Restarting..." : "Restart Service"}
496
- </TintButton>
497
- </div>
498
- </div>
499
- </section>
224
+ <div className="flex-1 overflow-y-auto p-8 space-y-8 bg-[#05100F]">
225
+ <AccessTokenSection
226
+ apiKey={apiKey}
227
+ showApiKey={showApiKey}
228
+ setShowApiKey={setShowApiKey}
229
+ copiedId={copiedId}
230
+ copyToClipboard={copyToClipboard}
231
+ setShowRegenConfirm={setShowRegenConfirm}
232
+ isManaged={isManaged}
233
+ instanceType={instanceType}
234
+ />
235
+
236
+ <DataManagementSection
237
+ handleBackup={handleBackup}
238
+ isBackingUp={isBackingUp}
239
+ handleRestore={handleRestore}
240
+ isRestoring={isRestoring}
241
+ setShowResetConfirm={setShowResetConfirm}
242
+ isResetting={isResetting}
243
+ operationStatus={operationStatus}
244
+ setOperationStatus={setOperationStatus}
245
+ />
246
+
247
+ <SystemInfoSection
248
+ settings={settings}
249
+ handleRestart={handleRestart}
250
+ isRestarting={isRestarting}
251
+ />
500
252
  </div>
501
253
 
502
254
  {/* Footer */}
503
- <div className="sticky bottom-0 bg-[#0B1116]/80 backdrop-blur-md border-t border-emerald-500/20 px-6 py-4 flex justify-end gap-3 z-10">
504
- <TintButton tint="neutral" variant="ghost" onClick={onClose}>
255
+ <div className="bg-[#05100F] border-t border-white/[0.03] px-8 py-5 flex justify-end gap-3 z-10">
256
+ <Button
257
+ variant="ghost"
258
+ onClick={onClose}
259
+ className="hover:bg-white/5 text-neutral-300 hover:text-white"
260
+ >
505
261
  Close
506
- </TintButton>
262
+ </Button>
507
263
  </div>
508
264
  </div>
509
265
 
@@ -0,0 +1,219 @@
1
+ import { expect, test } from "@playwright/test";
2
+ import { execSync } from "child_process";
3
+ import * as path from "path";
4
+
5
+ // This suite runs against the Next.js API Routes (Backend)
6
+ // IMPORTANT: This uses REAL database with clean state!
7
+ const DASHBOARD_URL = process.env.DASHBOARD_URL || "http://localhost:3000";
8
+ // Normalize MCP URL: strip /mcp suffix to get the base for legacy HTTP endpoints (/add, /query)
9
+ const RAW_MCP_URL = process.env.MCP_URL || "http://localhost:8626";
10
+ const MCP_API_URL = RAW_MCP_URL.replace(/\/mcp\/?$/, "");
11
+
12
+ // Tailscale environments require auth token
13
+ const isLocalhost =
14
+ DASHBOARD_URL.includes("localhost") || DASHBOARD_URL.includes("127.0.0.1");
15
+ const CYBERMEM_TOKEN = process.env.CYBERMEM_TOKEN || "";
16
+
17
+ // Helper to build headers with optional auth
18
+ function getHeaders(clientName: string): Record<string, string> {
19
+ const headers: Record<string, string> = { "X-Client-Name": clientName };
20
+ if (!isLocalhost && CYBERMEM_TOKEN) {
21
+ headers["X-API-Key"] = CYBERMEM_TOKEN;
22
+ }
23
+ return headers;
24
+ }
25
+
26
+ const SQLITE_PATH = path.join(
27
+ process.env.HOME || "",
28
+ ".cybermem",
29
+ "data",
30
+ "openmemory.sqlite",
31
+ );
32
+
33
+ // Helper to run CLI commands
34
+ function runCLI(cmd: string): { stdout: string; success: boolean } {
35
+ try {
36
+ const stdout = execSync(cmd, {
37
+ encoding: "utf-8",
38
+ timeout: 120000, // 2 min timeout
39
+ env: { ...process.env, FORCE_COLOR: "0" },
40
+ });
41
+ return { stdout, success: true };
42
+ } catch (error: any) {
43
+ return { stdout: error.stdout || error.message, success: false };
44
+ }
45
+ }
46
+
47
+ test.describe("Dashboard:E2E:API (Deep Verification)", () => {
48
+ const TEST_CLIENT = `e2e-api-journey-${Date.now()}`;
49
+
50
+ // Note: Reset is handled by global-setup.ts (runs ONCE before all tests)
51
+ test.beforeAll(async ({}, testInfo) => {
52
+ // Attach environment info
53
+ await testInfo.attach("🔧 Test Environment", {
54
+ body: `Dashboard URL: ${DASHBOARD_URL}\nMCP API URL: ${MCP_API_URL}\nSQLite Path: ${SQLITE_PATH}\nTest Client: ${TEST_CLIENT}\n\n✅ Clean state provided by global-setup.ts`,
55
+ contentType: "text/plain",
56
+ });
57
+ });
58
+
59
+ test("1. Health Check", async ({ request }, testInfo) => {
60
+ await test.step("📊 GET /api/health — Verify System Status", async () => {
61
+ console.log("📊 GET /api/health");
62
+
63
+ const response = await request.get(`${DASHBOARD_URL}/api/health`, {
64
+ headers: getHeaders("antigravity-client"),
65
+ });
66
+
67
+ const body = await response.json();
68
+ console.log(" Response:", JSON.stringify(body, null, 2));
69
+
70
+ expect(response.status()).toBe(200);
71
+ expect(body.overall).toBe("ok");
72
+ expect(body.services.length).toBeGreaterThan(0);
73
+
74
+ await testInfo.attach("📊 Health Check Result", {
75
+ body: `Status: ${response.status()}\nOverall: ${body.overall}\nServices: ${body.services.map((s: any) => `${s.name}: ${s.status}`).join(", ")}`,
76
+ contentType: "text/plain",
77
+ });
78
+ });
79
+ });
80
+
81
+ test("2. Full Journey: MCP Write → Metrics → Audit Logs", async ({
82
+ request,
83
+ }, testInfo) => {
84
+ const uniqueContent = `Journey Test ${Date.now()}`;
85
+
86
+ console.log("🔄 FULL JOURNEY TEST START");
87
+ console.log(` Test Client: ${TEST_CLIENT}`);
88
+ console.log(` Content: ${uniqueContent}`);
89
+
90
+ // Step 1: Trigger MCP Write
91
+ await test.step("📤 CRUD — POST /add — Create new memory", async () => {
92
+ console.log("📤 POST /add (MCP API)");
93
+ console.log(
94
+ ` Payload: { content: "${uniqueContent}", tags: ["journey"] }`,
95
+ );
96
+
97
+ const resp = await request.post(`${MCP_API_URL}/add`, {
98
+ data: { content: uniqueContent, tags: ["journey"] },
99
+ headers: getHeaders(TEST_CLIENT),
100
+ });
101
+
102
+ console.log(` Status: ${resp.status()}`);
103
+ const body = await resp.json();
104
+ console.log(` Response: ${JSON.stringify(body, null, 2)}`);
105
+
106
+ expect(resp.status()).toBe(200);
107
+
108
+ await testInfo.attach("📝 CRUD — CREATE", {
109
+ body: `Endpoint: POST ${MCP_API_URL}/add\n\nRequest:\n{\n "content": "${uniqueContent}",\n "tags": ["journey"]\n}\n\nResponse:\n${JSON.stringify(body, null, 2)}`,
110
+ contentType: "text/plain",
111
+ });
112
+ });
113
+
114
+ // Step 2: Verify Metrics
115
+ await test.step("📊 Discovery — Metrics Reflect Write Activity", async () => {
116
+ console.log("📊 GET /api/metrics");
117
+
118
+ const resp = await request.get(`${DASHBOARD_URL}/api/metrics`, {
119
+ headers: getHeaders("antigravity-client"),
120
+ });
121
+
122
+ const data = await resp.json();
123
+ console.log(` Last Writer: ${data.stats.lastWriter.name}`);
124
+ console.log(` Total Requests: ${data.stats.totalRequests}`);
125
+ console.log(
126
+ ` Creates Time Series Length: ${data.timeSeries.creates.length}`,
127
+ );
128
+
129
+ expect(data.stats.lastWriter.name).toContain(TEST_CLIENT);
130
+ expect(data.stats.totalRequests).toBeGreaterThan(0);
131
+
132
+ await testInfo.attach("📊 Metrics Snapshot", {
133
+ body: `Last Writer: ${data.stats.lastWriter.name}\nTotal Requests: ${data.stats.totalRequests}\nCreates Time Series: ${data.timeSeries.creates.length} entries`,
134
+ contentType: "text/plain",
135
+ });
136
+ });
137
+
138
+ // Step 3: Verify Audit Logs
139
+ await test.step("📋 Discovery — Audit Log Contains Journey Entry", async () => {
140
+ console.log("📋 GET /api/audit-logs");
141
+
142
+ const resp = await request.get(`${DASHBOARD_URL}/api/audit-logs`, {
143
+ headers: getHeaders("antigravity-client"),
144
+ });
145
+
146
+ const data = await resp.json();
147
+ const latestLog = data.logs.find((l: any) =>
148
+ l.client.includes(TEST_CLIENT),
149
+ );
150
+ console.log(` Found Log: ${latestLog ? "YES" : "NO"}`);
151
+ if (latestLog) {
152
+ console.log(` Log Details: ${JSON.stringify(latestLog, null, 2)}`);
153
+ }
154
+
155
+ expect(latestLog).toBeDefined();
156
+ expect(latestLog.operation).toBe("Write");
157
+
158
+ await testInfo.attach("📋 Audit Log Entry", {
159
+ body: `Found: ${latestLog ? "YES" : "NO"}\nClient: ${latestLog?.client}\nOperation: ${latestLog?.operation}\nStatus: ${latestLog?.status}`,
160
+ contentType: "text/plain",
161
+ });
162
+ });
163
+
164
+ console.log("✅ FULL JOURNEY TEST COMPLETE");
165
+
166
+ await testInfo.attach("✅ Journey Complete", {
167
+ body: `Test Client: ${TEST_CLIENT}\nContent: ${uniqueContent}\n\nVerified:\n✅ MCP Write (POST /add)\n✅ Metrics (GET /api/metrics) — Last Writer confirmed\n✅ Audit Logs (GET /api/audit-logs) — Entry found`,
168
+ contentType: "text/plain",
169
+ });
170
+ });
171
+
172
+ test("3. Config & Settings", async ({ request }, testInfo) => {
173
+ // MCP Config
174
+ await test.step("⚙️ Config — GET /api/mcp-config", async () => {
175
+ console.log("⚙️ GET /api/mcp-config?type=json");
176
+
177
+ const configResp = await request.get(
178
+ `${DASHBOARD_URL}/api/mcp-config?type=json`,
179
+ {
180
+ headers: getHeaders("antigravity-client"),
181
+ },
182
+ );
183
+
184
+ const config = await configResp.json();
185
+ console.log(` Config Type: ${config.configType}`);
186
+
187
+ expect(configResp.status()).toBe(200);
188
+ expect(config.configType).toBe("json");
189
+ expect(config.config.mcpServers).toBeDefined();
190
+
191
+ await testInfo.attach("⚙️ MCP Config", {
192
+ body: `Config Type: ${config.configType}\nmcpServers: ${Object.keys(config.config.mcpServers).join(", ")}`,
193
+ contentType: "text/plain",
194
+ });
195
+ });
196
+
197
+ // Settings
198
+ await test.step("⚙️ Settings — GET /api/settings", async () => {
199
+ console.log("⚙️ GET /api/settings");
200
+
201
+ const settingsResp = await request.get(`${DASHBOARD_URL}/api/settings`, {
202
+ headers: getHeaders("antigravity-client"),
203
+ });
204
+
205
+ const settings = await settingsResp.json();
206
+ console.log(` Instance Type: ${settings.instanceType}`);
207
+ console.log(` Endpoint: ${settings.endpoint}`);
208
+
209
+ expect(settingsResp.status()).toBe(200);
210
+ expect(settings).toHaveProperty("apiKey");
211
+ expect(settings).toHaveProperty("endpoint");
212
+
213
+ await testInfo.attach("⚙️ Settings", {
214
+ body: `Instance Type: ${settings.instanceType}\nEndpoint: ${settings.endpoint}\nAPI Key: ${settings.apiKey ? "***" + settings.apiKey.slice(-4) : "N/A"}`,
215
+ contentType: "text/plain",
216
+ });
217
+ });
218
+ });
219
+ });
@@ -0,0 +1,39 @@
1
+ import { expect, test } from "@playwright/test";
2
+
3
+ // This test is ONLY for localhost environments where dashboard serves at root.
4
+ // Tailscale environments use subpaths (/cybermem-staging, /cybermem) by design.
5
+ const dashboardUrl = process.env.DASHBOARD_URL || "";
6
+ const isLocalhost =
7
+ dashboardUrl.includes("localhost") || dashboardUrl.includes("127.0.0.1");
8
+
9
+ test.describe("Routing & URL Canonicalization", () => {
10
+ test.skip(!isLocalhost, "Skipped on Tailscale - uses subpath by design");
11
+
12
+ test("Root Check: Should stay at / and NOT redirect to legacy subpaths", async ({
13
+ request,
14
+ baseURL,
15
+ }) => {
16
+ // We expect the DASHBOARD_URL (baseURL) to be the root or specific entry point.
17
+ // This test ensures that requesting the root does NOT redirect to /cybermem or /cybermem-staging
18
+ // which was a legacy behavior we removed in v0.13.0.
19
+
20
+ // Note: baseURL provided by Playwright config or env var
21
+ console.log(`Checking routing for: ${baseURL}`);
22
+
23
+ const response = await request.get("/");
24
+
25
+ // 1. Should be 200 OK
26
+ expect(response.status()).toBe(200);
27
+
28
+ // 2. Should match the expected URL (no redirects)
29
+ // response.url() returns the final URL after redirects.
30
+ // We check that it doesn't contain legacy subpaths if we started at root
31
+ const finalUrl = response.url();
32
+ expect(finalUrl).not.toContain("/cybermem-staging");
33
+ expect(finalUrl).not.toContain("/cybermem/"); // Trailing slash check
34
+
35
+ // 3. Content Check to ensure it's not a generic 404/nginx page
36
+ const body = await response.text();
37
+ expect(body).toContain("<!DOCTYPE html>"); // Expecting Next.js app
38
+ });
39
+ });