@cybermem/dashboard 0.13.16 → 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.
package/app/api/backup/route.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
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 =
|
|
8
|
+
export const dynamic = "force-dynamic";
|
|
8
9
|
|
|
9
|
-
const DATA_DIR =
|
|
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()
|
|
18
|
-
|
|
19
|
-
|
|
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:
|
|
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(
|
|
35
|
-
stream.on(
|
|
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 {
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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 ||
|
|
62
|
-
{ status: 500 }
|
|
63
|
-
)
|
|
65
|
+
{ error: error.message || "Unknown error" },
|
|
66
|
+
{ status: 500 },
|
|
67
|
+
);
|
|
64
68
|
}
|
|
65
69
|
}
|
package/app/api/reset/route.ts
CHANGED
|
@@ -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 {
|
|
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 =
|
|
8
|
+
const DATA_DIR = resolveDataDir();
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* POST /api/reset
|
package/app/api/restore/route.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
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 =
|
|
8
|
+
export const dynamic = "force-dynamic";
|
|
8
9
|
|
|
9
|
-
const DATA_DIR =
|
|
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(
|
|
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(
|
|
31
|
+
if (!file.name.endsWith(".tar.gz") && !file.name.endsWith(".tgz")) {
|
|
31
32
|
return NextResponse.json(
|
|
32
|
-
{ error:
|
|
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(
|
|
48
|
-
try {
|
|
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:
|
|
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:
|
|
63
|
+
message:
|
|
64
|
+
"Database restored successfully. Restart openmemory container to apply.",
|
|
61
65
|
restartRequired: true,
|
|
62
|
-
restartCommand:
|
|
63
|
-
})
|
|
64
|
-
|
|
66
|
+
restartCommand: "docker restart cybermem-mcp",
|
|
67
|
+
});
|
|
65
68
|
} catch (restoreError: any) {
|
|
66
69
|
// Clean up temp file on error
|
|
67
|
-
try {
|
|
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 ||
|
|
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
|
}
|
package/e2e/reset-db.spec.ts
CHANGED
|
@@ -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
|
+
}
|