@cybermem/dashboard 0.13.15 → 0.13.17

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.
@@ -1,12 +1,13 @@
1
- import { execSync } from 'child_process'
2
- import { createReadStream, rmSync, statSync } from 'fs'
3
- import { NextResponse } from 'next/server'
4
- import { tmpdir } from 'os'
5
- import { join } from 'path'
1
+ import { resolveDataDir } from "@/lib/resolve-data-dir";
2
+ import { execSync } from "child_process";
3
+ import { createReadStream, rmSync, statSync } from "fs";
4
+ import { NextResponse } from "next/server";
5
+ import { tmpdir } from "os";
6
+ import { join } from "path";
6
7
 
7
- export const dynamic = 'force-dynamic'
8
+ export const dynamic = "force-dynamic";
8
9
 
9
- const DATA_DIR = process.env.DATA_DIR || '/data'
10
+ const DATA_DIR = resolveDataDir();
10
11
 
11
12
  /**
12
13
  * GET /api/backup
@@ -14,52 +15,55 @@ const DATA_DIR = process.env.DATA_DIR || '/data'
14
15
  */
15
16
  export async function GET() {
16
17
  try {
17
- const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
18
- const backupName = `cybermem-backup-${timestamp}.tar.gz`
19
- const tmpPath = join(tmpdir(), backupName)
18
+ const timestamp = new Date()
19
+ .toISOString()
20
+ .replace(/[:.]/g, "-")
21
+ .slice(0, 19);
22
+ const backupName = `cybermem-backup-${timestamp}.tar.gz`;
23
+ const tmpPath = join(tmpdir(), backupName);
20
24
 
21
25
  try {
22
26
  // Create backup via tar command (available in Node alpine image)
23
- execSync(`tar -czf "${tmpPath}" -C "${DATA_DIR}" .`, { stdio: 'pipe' })
27
+ execSync(`tar -czf "${tmpPath}" -C "${DATA_DIR}" .`, { stdio: "pipe" });
24
28
 
25
29
  // Get file stats
26
- const stats = statSync(tmpPath)
30
+ const stats = statSync(tmpPath);
27
31
 
28
32
  // Stream the file
29
- const stream = createReadStream(tmpPath)
33
+ const stream = createReadStream(tmpPath);
30
34
 
31
35
  // Convert Node stream to Web ReadableStream
32
36
  const webStream = new ReadableStream({
33
37
  start(controller) {
34
- stream.on('data', (chunk) => controller.enqueue(chunk))
35
- stream.on('end', () => {
36
- controller.close()
38
+ stream.on("data", (chunk) => controller.enqueue(chunk));
39
+ stream.on("end", () => {
40
+ controller.close();
37
41
  // Clean up temp file after streaming
38
- try { rmSync(tmpPath) } catch {}
39
- })
40
- stream.on('error', (err) => controller.error(err))
41
- }
42
- })
42
+ try {
43
+ rmSync(tmpPath);
44
+ } catch {}
45
+ });
46
+ stream.on("error", (err) => controller.error(err));
47
+ },
48
+ });
43
49
 
44
50
  return new NextResponse(webStream, {
45
51
  headers: {
46
- 'Content-Type': 'application/gzip',
47
- 'Content-Disposition': `attachment; filename="${backupName}"`,
48
- 'Content-Length': String(stats.size)
49
- }
50
- })
51
-
52
+ "Content-Type": "application/gzip",
53
+ "Content-Disposition": `attachment; filename="${backupName}"`,
54
+ "Content-Length": String(stats.size),
55
+ },
56
+ });
52
57
  } catch (tarError: any) {
53
58
  return NextResponse.json(
54
59
  { error: `Backup failed: ${tarError.message}` },
55
- { status: 500 }
56
- )
60
+ { status: 500 },
61
+ );
57
62
  }
58
-
59
63
  } catch (error: any) {
60
64
  return NextResponse.json(
61
- { error: error.message || 'Unknown error' },
62
- { status: 500 }
63
- )
65
+ { error: error.message || "Unknown error" },
66
+ { status: 500 },
67
+ );
64
68
  }
65
69
  }
@@ -1,11 +1,11 @@
1
+ import { resolveDataDir } from "@/lib/resolve-data-dir";
1
2
  import { readdirSync, statSync, unlinkSync } from "fs";
2
3
  import { NextRequest, NextResponse } from "next/server";
3
- import { homedir } from "os";
4
- import { join, resolve } from "path";
4
+ import { join } from "path";
5
5
 
6
6
  export const dynamic = "force-dynamic";
7
7
 
8
- const DATA_DIR = process.env.DATA_DIR || resolve(homedir(), ".cybermem/data");
8
+ const DATA_DIR = resolveDataDir();
9
9
 
10
10
  /**
11
11
  * POST /api/reset
@@ -1,12 +1,13 @@
1
- import { execSync } from 'child_process'
2
- import { readdirSync, rmSync, unlinkSync, writeFileSync } from 'fs'
3
- import { NextRequest, NextResponse } from 'next/server'
4
- import { tmpdir } from 'os'
5
- import { join } from 'path'
1
+ import { resolveDataDir } from "@/lib/resolve-data-dir";
2
+ import { execSync } from "child_process";
3
+ import { readdirSync, rmSync, unlinkSync, writeFileSync } from "fs";
4
+ import { NextRequest, NextResponse } from "next/server";
5
+ import { tmpdir } from "os";
6
+ import { join } from "path";
6
7
 
7
- export const dynamic = 'force-dynamic'
8
+ export const dynamic = "force-dynamic";
8
9
 
9
- const DATA_DIR = process.env.DATA_DIR || '/data'
10
+ const DATA_DIR = resolveDataDir();
10
11
 
11
12
  /**
12
13
  * POST /api/restore
@@ -16,66 +17,69 @@ const DATA_DIR = process.env.DATA_DIR || '/data'
16
17
  */
17
18
  export async function POST(request: NextRequest) {
18
19
  try {
19
- const formData = await request.formData()
20
- const file = formData.get('backup') as File | null
20
+ const formData = await request.formData();
21
+ const file = formData.get("backup") as File | null;
21
22
 
22
23
  if (!file) {
23
24
  return NextResponse.json(
24
25
  { error: 'No backup file provided. Upload as "backup" field.' },
25
- { status: 400 }
26
- )
26
+ { status: 400 },
27
+ );
27
28
  }
28
29
 
29
30
  // Validate file type
30
- if (!file.name.endsWith('.tar.gz') && !file.name.endsWith('.tgz')) {
31
+ if (!file.name.endsWith(".tar.gz") && !file.name.endsWith(".tgz")) {
31
32
  return NextResponse.json(
32
- { error: 'Invalid file type. Must be .tar.gz or .tgz' },
33
- { status: 400 }
34
- )
33
+ { error: "Invalid file type. Must be .tar.gz or .tgz" },
34
+ { status: 400 },
35
+ );
35
36
  }
36
37
 
37
- const tmpPath = join(tmpdir(), `restore-${Date.now()}.tar.gz`)
38
+ const tmpPath = join(tmpdir(), `restore-${Date.now()}.tar.gz`);
38
39
 
39
40
  try {
40
41
  // Write uploaded file to temp location
41
- const buffer = Buffer.from(await file.arrayBuffer())
42
- writeFileSync(tmpPath, buffer)
42
+ const buffer = Buffer.from(await file.arrayBuffer());
43
+ writeFileSync(tmpPath, buffer);
43
44
 
44
45
  // Remove existing database files
45
- const existingFiles = readdirSync(DATA_DIR)
46
+ const existingFiles = readdirSync(DATA_DIR);
46
47
  for (const f of existingFiles) {
47
- if (f.startsWith('openmemory.sqlite')) {
48
- try { rmSync(join(DATA_DIR, f)) } catch {}
48
+ if (f.startsWith("openmemory.sqlite")) {
49
+ try {
50
+ rmSync(join(DATA_DIR, f));
51
+ } catch {}
49
52
  }
50
53
  }
51
54
 
52
55
  // Extract backup to data directory
53
- execSync(`tar -xzf "${tmpPath}" -C "${DATA_DIR}"`, { stdio: 'pipe' })
56
+ execSync(`tar -xzf "${tmpPath}" -C "${DATA_DIR}"`, { stdio: "pipe" });
54
57
 
55
58
  // Clean up temp file
56
- unlinkSync(tmpPath)
59
+ unlinkSync(tmpPath);
57
60
 
58
61
  return NextResponse.json({
59
62
  success: true,
60
- message: 'Database restored successfully. Restart openmemory container to apply.',
63
+ message:
64
+ "Database restored successfully. Restart openmemory container to apply.",
61
65
  restartRequired: true,
62
- restartCommand: 'docker restart cybermem-mcp'
63
- })
64
-
66
+ restartCommand: "docker restart cybermem-mcp",
67
+ });
65
68
  } catch (restoreError: any) {
66
69
  // Clean up temp file on error
67
- try { unlinkSync(tmpPath) } catch {}
70
+ try {
71
+ unlinkSync(tmpPath);
72
+ } catch {}
68
73
 
69
74
  return NextResponse.json(
70
75
  { error: `Restore failed: ${restoreError.message}` },
71
- { status: 500 }
72
- )
76
+ { status: 500 },
77
+ );
73
78
  }
74
-
75
79
  } catch (error: any) {
76
80
  return NextResponse.json(
77
- { error: error.message || 'Unknown error' },
78
- { status: 500 }
79
- )
81
+ { error: error.message || "Unknown error" },
82
+ { status: 500 },
83
+ );
80
84
  }
81
85
  }
@@ -191,7 +191,11 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
191
191
  description: data.message || "Database wiped successfully.",
192
192
  });
193
193
  } catch (err: any) {
194
+ setShowResetConfirm(false);
194
195
  setOperationStatus({ type: "error", message: err.message });
196
+ toast.error("Reset Failed", {
197
+ description: err.message,
198
+ });
195
199
  } finally {
196
200
  setIsResetting(false);
197
201
  }
@@ -276,4 +276,73 @@ test.describe("Reset DB - Settings Modal", () => {
276
276
 
277
277
  console.log("✅ Cancel closes confirmation, Settings modal persists");
278
278
  });
279
+
280
+ test("6. Reset API never returns ENOENT (DATA_DIR resolved)", async ({
281
+ request,
282
+ }) => {
283
+ // Call the reset API directly and verify we never get ENOENT
284
+ const res = await request.post("/api/reset", {
285
+ data: { confirm: "RESET" },
286
+ headers: { "Content-Type": "application/json" },
287
+ });
288
+
289
+ const body = await res.json();
290
+
291
+ // Must NOT contain ENOENT — this was the RPi bug
292
+ if (body.error) {
293
+ expect(body.error).not.toContain("ENOENT");
294
+ expect(body.error).not.toContain("no such file or directory");
295
+ }
296
+
297
+ // Should succeed (200) — file deletion works
298
+ expect(res.status()).toBe(200);
299
+ expect(body.success).toBe(true);
300
+ console.log(
301
+ `✅ Reset API succeeded without ENOENT (deleted ${body.deletedCount} files)`,
302
+ );
303
+ });
304
+
305
+ test("7. On API error, confirmation modal closes and error toast shown", async ({
306
+ page,
307
+ }) => {
308
+ await openSettings(page);
309
+
310
+ const resetBtn = page.getByRole("button", { name: /Reset DB/i });
311
+ await resetBtn.click();
312
+
313
+ await expect(
314
+ page.getByRole("heading", { name: "Reset Database" }),
315
+ ).toBeVisible();
316
+
317
+ // Intercept the reset API to simulate a server error
318
+ await page.route("**/api/reset", async (route) => {
319
+ await route.fulfill({
320
+ status: 500,
321
+ contentType: "application/json",
322
+ body: JSON.stringify({ error: "Simulated server error for E2E test" }),
323
+ });
324
+ });
325
+
326
+ const confirmBtn = page.getByRole("button", { name: /Reset Database/i });
327
+ const input = page.getByPlaceholder(/Type "RESET" to confirm/i);
328
+
329
+ await input.click();
330
+ await input.fill("RESET");
331
+ await expect(confirmBtn).toBeEnabled();
332
+ await confirmBtn.click();
333
+
334
+ // Confirmation modal MUST close even on error
335
+ await expect(page.getByPlaceholder(/Type "RESET" to confirm/i)).toBeHidden({
336
+ timeout: 10000,
337
+ });
338
+
339
+ // Error toast must appear
340
+ await expect(
341
+ page.locator('[data-sonner-toaster] [data-type="error"]'),
342
+ ).toBeVisible({ timeout: 5000 });
343
+
344
+ console.log(
345
+ "✅ On API error: modal closed, error toast shown (not hidden behind modal)",
346
+ );
347
+ });
279
348
  });
@@ -0,0 +1,17 @@
1
+ import { existsSync } from "fs";
2
+ import { homedir } from "os";
3
+ import { resolve } from "path";
4
+
5
+ /**
6
+ * Resolves the CyberMem data directory depending on the runtime environment:
7
+ *
8
+ * 1. Explicit `DATA_DIR` env var (highest priority)
9
+ * 2. Docker volume mount at `/data` (container runtime)
10
+ * 3. `~/.cybermem/data` (local dev / Next.js standalone)
11
+ */
12
+ export function resolveDataDir(): string {
13
+ const envDataDir = process.env.DATA_DIR?.trim();
14
+ if (envDataDir) return envDataDir;
15
+ if (existsSync("/data")) return "/data";
16
+ return resolve(homedir(), ".cybermem/data");
17
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cybermem/dashboard",
3
- "version": "0.13.15",
3
+ "version": "0.13.17",
4
4
  "description": "CyberMem Monitoring Dashboard",
5
5
  "homepage": "https://cybermem.dev",
6
6
  "repository": {