@cybermem/dashboard 0.5.16 → 0.8.7
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/Dockerfile +2 -1
- package/app/api/audit-logs/route.ts +79 -56
- package/app/api/auth/token/route.ts +59 -0
- package/app/api/environment/route.ts +70 -0
- package/app/api/health/route.ts +49 -22
- package/app/api/metrics/route.ts +272 -86
- package/app/api/reset/route.ts +1 -1
- package/app/api/restore/route.ts +1 -1
- package/app/api/settings/route.ts +81 -30
- package/app/api/system/restart/route.ts +1 -1
- package/app/layout.tsx +27 -17
- package/app/page.tsx +116 -126
- package/components/dashboard/audit-log-table.tsx +3 -3
- package/components/dashboard/login-modal.tsx +108 -36
- package/components/dashboard/mcp-config-modal.tsx +359 -251
- package/components/dashboard/metric-card.tsx +4 -7
- package/components/dashboard/settings-modal.tsx +183 -254
- package/components/ui/confirmation-modal.tsx +116 -0
- package/components/ui/tint-button.tsx +91 -0
- package/e2e/crud-happy-path.spec.ts +243 -173
- package/lib/auth.ts +50 -0
- package/lib/data/dashboard-context.tsx +117 -70
- package/lib/data/production-strategy.ts +124 -85
- package/middleware.ts +53 -31
- package/next.config.mjs +12 -20
- package/package.json +4 -1
- package/playwright.config.ts +50 -15
- package/public/clients.json +7 -23
- package/components/dashboard/password-alert-modal.tsx +0 -72
package/Dockerfile
CHANGED
|
@@ -3,7 +3,7 @@ FROM node:20-alpine AS base
|
|
|
3
3
|
WORKDIR /app
|
|
4
4
|
|
|
5
5
|
# Use corepack to get the pnpm version from the lockfile and speed up installs via cache
|
|
6
|
-
RUN corepack enable
|
|
6
|
+
RUN corepack enable && apk add --no-cache python3 make g++
|
|
7
7
|
|
|
8
8
|
# Copy package files
|
|
9
9
|
COPY package*.json ./
|
|
@@ -28,6 +28,7 @@ RUN --mount=type=cache,target=/root/.pnpm-store \
|
|
|
28
28
|
|
|
29
29
|
# Production stage
|
|
30
30
|
FROM node:20-alpine AS production
|
|
31
|
+
RUN apk add --no-cache libc6-compat
|
|
31
32
|
WORKDIR /app
|
|
32
33
|
|
|
33
34
|
ENV NODE_ENV=production
|
|
@@ -1,88 +1,111 @@
|
|
|
1
|
-
import fs from
|
|
2
|
-
import { NextResponse } from
|
|
3
|
-
import path from
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import { NextResponse } from "next/server";
|
|
3
|
+
import path from "path";
|
|
4
4
|
|
|
5
|
-
export const dynamic =
|
|
5
|
+
export const dynamic = "force-dynamic";
|
|
6
6
|
|
|
7
7
|
// Use env var for db-exporter URL (Docker internal vs local dev)
|
|
8
|
-
const DB_EXPORTER_URL = process.env.DB_EXPORTER_URL ||
|
|
8
|
+
const DB_EXPORTER_URL = process.env.DB_EXPORTER_URL || "http://localhost:8000";
|
|
9
9
|
|
|
10
10
|
// Load clients config for name normalization
|
|
11
|
-
let clientsConfig: any[] = []
|
|
11
|
+
let clientsConfig: any[] = [];
|
|
12
12
|
try {
|
|
13
|
-
const configPath = path.join(process.cwd(),
|
|
14
|
-
clientsConfig = JSON.parse(fs.readFileSync(configPath,
|
|
13
|
+
const configPath = path.join(process.cwd(), "public", "clients.json");
|
|
14
|
+
clientsConfig = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
|
15
15
|
} catch (e) {
|
|
16
|
-
console.error(
|
|
16
|
+
console.error("Failed to load clients.json:", e);
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
// Normalize raw client name (e.g. "
|
|
19
|
+
// Normalize raw client name (e.g. "claude-ai") to friendly name (e.g. "Claude Desktop")
|
|
20
20
|
function normalizeClientName(rawName: string): string {
|
|
21
|
-
if (!rawName) return
|
|
22
|
-
const nameLower = rawName.toLowerCase()
|
|
21
|
+
if (!rawName) return "Unknown";
|
|
22
|
+
const nameLower = rawName.toLowerCase();
|
|
23
23
|
const client = clientsConfig.find((c: any) => {
|
|
24
24
|
try {
|
|
25
|
-
return new RegExp(c.match,
|
|
25
|
+
return new RegExp(c.match, "i").test(nameLower);
|
|
26
26
|
} catch {
|
|
27
|
-
return nameLower.includes(c.match)
|
|
27
|
+
return nameLower.includes(c.match);
|
|
28
28
|
}
|
|
29
|
-
})
|
|
30
|
-
return client?.name || rawName
|
|
29
|
+
});
|
|
30
|
+
return client?.name || rawName;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
const CLIENTS = ["Claude Code", "v0", "Cursor", "GitHub Copilot", "Windsurf"]
|
|
34
|
-
const OPERATIONS = ["Read", "Write", "Update", "Delete", "Create"]
|
|
35
|
-
const STATUSES = ["Success", "Success", "Success", "Warning", "Error"]
|
|
33
|
+
const CLIENTS = ["Claude Code", "v0", "Cursor", "GitHub Copilot", "Windsurf"];
|
|
34
|
+
const OPERATIONS = ["Read", "Write", "Update", "Delete", "Create"];
|
|
35
|
+
const STATUSES = ["Success", "Success", "Success", "Warning", "Error"];
|
|
36
36
|
const DESCRIPTIONS = {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
37
|
+
Success: [
|
|
38
|
+
"Operation completed successfully",
|
|
39
|
+
"Resource accessed",
|
|
40
|
+
"Data synchronized",
|
|
41
|
+
],
|
|
42
|
+
Warning: [
|
|
43
|
+
"High latency detected",
|
|
44
|
+
"Rate limit approaching",
|
|
45
|
+
"Deprecation warning",
|
|
46
|
+
],
|
|
47
|
+
Error: [
|
|
48
|
+
"Unauthorized access",
|
|
49
|
+
"Internal server error",
|
|
50
|
+
"Timeout exceeded",
|
|
51
|
+
"Validation failed",
|
|
52
|
+
],
|
|
53
|
+
};
|
|
41
54
|
|
|
42
55
|
export async function GET(request: Request) {
|
|
43
56
|
try {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const res = await fetch(`${DB_EXPORTER_URL}/api/logs?limit=100`, {
|
|
50
|
-
signal: controller.signal,
|
|
51
|
-
cache: 'no-store'
|
|
52
|
-
})
|
|
53
|
-
clearTimeout(timeoutId)
|
|
57
|
+
const homedir =
|
|
58
|
+
process.env.HOME || process.env.USER || "/Users/mikhailkogan";
|
|
59
|
+
const dbPath =
|
|
60
|
+
process.env.OM_DB_PATH ||
|
|
61
|
+
path.resolve(homedir, ".cybermem/data/openmemory.sqlite");
|
|
54
62
|
|
|
55
|
-
if (!
|
|
56
|
-
|
|
63
|
+
if (!fs.existsSync(dbPath)) {
|
|
64
|
+
console.error(`[AUDIT-LOGS-API] SQLite DB NOT FOUND at ${dbPath}`);
|
|
65
|
+
return NextResponse.json({ logs: [] });
|
|
57
66
|
}
|
|
58
67
|
|
|
59
|
-
|
|
60
|
-
const
|
|
68
|
+
console.error(`[AUDIT-LOGS-API] Reading logs from ${dbPath}`);
|
|
69
|
+
const sqlite3 = require("sqlite3").verbose();
|
|
70
|
+
const { open } = require("sqlite");
|
|
71
|
+
const db = await open({
|
|
72
|
+
filename: dbPath,
|
|
73
|
+
driver: sqlite3.Database,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const rawLogs = await db.all(
|
|
77
|
+
"SELECT * FROM cybermem_access_log ORDER BY timestamp DESC LIMIT 100",
|
|
78
|
+
);
|
|
79
|
+
await db.close();
|
|
80
|
+
|
|
81
|
+
console.error(`[AUDIT-LOGS-API] Found ${rawLogs.length} logs in SQLite`);
|
|
61
82
|
|
|
62
83
|
const logs = rawLogs.map((log: any) => {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
84
|
+
const statusCode = parseInt(log.status) || 0;
|
|
85
|
+
let status = "Success";
|
|
86
|
+
if (log.is_error === 1 || statusCode >= 400 || statusCode === 0)
|
|
87
|
+
status = "Error";
|
|
88
|
+
else if (statusCode >= 300) status = "Warning";
|
|
67
89
|
|
|
68
|
-
|
|
69
|
-
|
|
90
|
+
// Capitalize operation
|
|
91
|
+
const operation =
|
|
92
|
+
log.operation.charAt(0).toUpperCase() + log.operation.slice(1);
|
|
70
93
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
})
|
|
94
|
+
return {
|
|
95
|
+
timestamp: log.timestamp,
|
|
96
|
+
client: normalizeClientName(log.client_name),
|
|
97
|
+
operation: operation,
|
|
98
|
+
status: status,
|
|
99
|
+
method: log.method,
|
|
100
|
+
description: log.endpoint,
|
|
101
|
+
rawStatus: log.status,
|
|
102
|
+
};
|
|
103
|
+
});
|
|
81
104
|
|
|
82
|
-
return NextResponse.json({ logs })
|
|
105
|
+
return NextResponse.json({ logs });
|
|
83
106
|
} catch (error) {
|
|
84
|
-
console.error("Error fetching audit logs:", error)
|
|
107
|
+
console.error("[AUDIT-LOGS-API] Error fetching audit logs:", error);
|
|
85
108
|
// Return empty list on error to avoid breaking UI with 500
|
|
86
|
-
return NextResponse.json({ logs: [] })
|
|
109
|
+
return NextResponse.json({ logs: [] });
|
|
87
110
|
}
|
|
88
111
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token Authentication Endpoint
|
|
3
|
+
*
|
|
4
|
+
* Validates JWT token and sets cookie for browser sessions.
|
|
5
|
+
* Called by: cybermem-cli dashboard (opens browser with ?token=xxx)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { verifyToken } from "@/lib/auth";
|
|
9
|
+
import { NextRequest } from "next/server";
|
|
10
|
+
|
|
11
|
+
export async function GET(req: NextRequest) {
|
|
12
|
+
const token = req.nextUrl.searchParams.get("token");
|
|
13
|
+
const error = req.nextUrl.searchParams.get("error");
|
|
14
|
+
|
|
15
|
+
// Check for error redirect
|
|
16
|
+
if (error) {
|
|
17
|
+
return new Response(
|
|
18
|
+
`<!DOCTYPE html>
|
|
19
|
+
<html>
|
|
20
|
+
<head><title>Auth Error</title></head>
|
|
21
|
+
<body style="background:#0a0a0a;color:#fff;font-family:system-ui;display:flex;justify-content:center;align-items:center;height:100vh;margin:0">
|
|
22
|
+
<div style="text-align:center">
|
|
23
|
+
<h1 style="color:#ef4444">Authentication Required</h1>
|
|
24
|
+
<p style="color:#888">Run: <code style="background:#222;padding:4px 8px;border-radius:4px">npx @cybermem/cli dashboard</code></p>
|
|
25
|
+
</div>
|
|
26
|
+
</body>
|
|
27
|
+
</html>`,
|
|
28
|
+
{ status: 401, headers: { "Content-Type": "text/html" } },
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!token) {
|
|
33
|
+
return new Response(JSON.stringify({ error: "No token provided" }), {
|
|
34
|
+
status: 400,
|
|
35
|
+
headers: { "Content-Type": "application/json" },
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Validate JWT
|
|
40
|
+
const payload = await verifyToken(token);
|
|
41
|
+
|
|
42
|
+
if (!payload) {
|
|
43
|
+
return new Response(JSON.stringify({ error: "Invalid token" }), {
|
|
44
|
+
status: 401,
|
|
45
|
+
headers: { "Content-Type": "application/json" },
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Set cookie and redirect to dashboard home
|
|
50
|
+
const response = new Response(null, {
|
|
51
|
+
status: 302,
|
|
52
|
+
headers: {
|
|
53
|
+
Location: "/",
|
|
54
|
+
"Set-Cookie": `cybermem_token=${token}; HttpOnly; Path=/; Max-Age=${30 * 24 * 60 * 60}; SameSite=Lax`,
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
return response;
|
|
59
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Environment detection API for dynamic MCP URL configuration
|
|
5
|
+
*
|
|
6
|
+
* Priority:
|
|
7
|
+
* 1. Tailscale hostname (if TAILSCALE_HOSTNAME env var is set)
|
|
8
|
+
* 2. LAN .local domain (if raspberrypi.local or similar)
|
|
9
|
+
* 3. VPS public URL (if CYBERMEM_PUBLIC_URL env var is set)
|
|
10
|
+
* 4. localhost:8626 (fallback)
|
|
11
|
+
*/
|
|
12
|
+
export async function GET(request: Request) {
|
|
13
|
+
const MCP_PORT = process.env.MCP_PORT || "8626";
|
|
14
|
+
|
|
15
|
+
// Check for Tailscale hostname first (highest priority for remote access)
|
|
16
|
+
const tailscaleHostname = process.env.TAILSCALE_HOSTNAME;
|
|
17
|
+
if (tailscaleHostname) {
|
|
18
|
+
return NextResponse.json({
|
|
19
|
+
url: `https://${tailscaleHostname}`,
|
|
20
|
+
type: "tailscale",
|
|
21
|
+
editable: false,
|
|
22
|
+
hint: "Using Tailscale Funnel for secure remote access",
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Check for VPS public URL
|
|
27
|
+
const publicUrl = process.env.CYBERMEM_PUBLIC_URL;
|
|
28
|
+
if (publicUrl) {
|
|
29
|
+
return NextResponse.json({
|
|
30
|
+
url: publicUrl,
|
|
31
|
+
type: "vps",
|
|
32
|
+
editable: true,
|
|
33
|
+
hint: "Configure CYBERMEM_PUBLIC_URL to change this URL",
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Detect from request headers (for LAN access)
|
|
38
|
+
const host = request.headers.get("host") || "";
|
|
39
|
+
|
|
40
|
+
// Check if accessing via .local domain (Raspberry Pi LAN)
|
|
41
|
+
if (host.includes(".local")) {
|
|
42
|
+
const hostname = host.split(":")[0];
|
|
43
|
+
return NextResponse.json({
|
|
44
|
+
url: `http://${hostname}:${MCP_PORT}`,
|
|
45
|
+
type: "lan",
|
|
46
|
+
editable: false,
|
|
47
|
+
hint: "Detected LAN access via mDNS (.local)",
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Check if accessing via IP address (likely LAN or VPS)
|
|
52
|
+
const ipMatch = host.match(/^(\d+\.\d+\.\d+\.\d+)/);
|
|
53
|
+
if (ipMatch) {
|
|
54
|
+
const protocol = request.headers.get("x-forwarded-proto") || "http";
|
|
55
|
+
return NextResponse.json({
|
|
56
|
+
url: `${protocol}://${ipMatch[1]}:${MCP_PORT}`,
|
|
57
|
+
type: "ip",
|
|
58
|
+
editable: true,
|
|
59
|
+
hint: "Detected IP-based access",
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Default to localhost
|
|
64
|
+
return NextResponse.json({
|
|
65
|
+
url: `http://localhost:${MCP_PORT}`,
|
|
66
|
+
type: "local",
|
|
67
|
+
editable: false,
|
|
68
|
+
hint: "Running locally",
|
|
69
|
+
});
|
|
70
|
+
}
|
package/app/api/health/route.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { checkRateLimit, rateLimitResponse } from "@/lib/rate-limit";
|
|
2
|
+
import fs from "fs";
|
|
2
3
|
import { NextRequest, NextResponse } from "next/server";
|
|
4
|
+
import path from "path";
|
|
3
5
|
|
|
4
6
|
export const dynamic = "force-dynamic";
|
|
5
7
|
|
|
@@ -38,7 +40,21 @@ async function checkService(
|
|
|
38
40
|
const latencyMs = Date.now() - start;
|
|
39
41
|
|
|
40
42
|
if (res.ok) {
|
|
41
|
-
|
|
43
|
+
try {
|
|
44
|
+
const data = await res.json();
|
|
45
|
+
if (data.ok === false || data.status === "error") {
|
|
46
|
+
return {
|
|
47
|
+
name,
|
|
48
|
+
status: "error",
|
|
49
|
+
message: data.error || data.message || "Service reported error",
|
|
50
|
+
latencyMs,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
return { name, status: "ok", latencyMs };
|
|
54
|
+
} catch {
|
|
55
|
+
// If not JSON but OK, assume OK (legacy services)
|
|
56
|
+
return { name, status: "ok", latencyMs };
|
|
57
|
+
}
|
|
42
58
|
}
|
|
43
59
|
return {
|
|
44
60
|
name,
|
|
@@ -65,29 +81,40 @@ export async function GET(request: NextRequest) {
|
|
|
65
81
|
return rateLimitResponse(rateLimit.resetIn);
|
|
66
82
|
}
|
|
67
83
|
|
|
68
|
-
//
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
84
|
+
// Check SQLite Database File
|
|
85
|
+
const homedir = process.env.HOME || process.env.USERPROFILE || "";
|
|
86
|
+
const dbPath =
|
|
87
|
+
process.env.OM_DB_PATH ||
|
|
88
|
+
path.resolve(homedir, ".cybermem/data/openmemory.sqlite");
|
|
89
|
+
|
|
90
|
+
const dbExists = fs.existsSync(dbPath);
|
|
91
|
+
console.error(
|
|
92
|
+
`[HEALTH-API] SQLite Check: ${dbExists ? "OK" : "MISSING"} (${dbPath})`,
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const dbStatus: ServiceStatus = dbExists
|
|
96
|
+
? { name: "Database", status: "ok" }
|
|
97
|
+
: { name: "Database", status: "error", message: "SQLite file not found" };
|
|
98
|
+
|
|
99
|
+
const checks: ServiceStatus[] = [dbStatus];
|
|
100
|
+
|
|
101
|
+
// Check Core API if explicitly configured
|
|
102
|
+
const apiEndpoint =
|
|
103
|
+
process.env.CYBERMEM_URL ||
|
|
104
|
+
process.env.OPENMEMORY_URL ||
|
|
105
|
+
"http://localhost:8626";
|
|
106
|
+
|
|
107
|
+
if (apiEndpoint) {
|
|
108
|
+
console.error(`[HEALTH-API] Checking API at ${apiEndpoint}/health`);
|
|
109
|
+
const apiStatus = await checkService(
|
|
110
|
+
"CyberMem (MCP) API",
|
|
111
|
+
`${apiEndpoint}/health`,
|
|
112
|
+
);
|
|
113
|
+
console.error(`[HEALTH-API] API Status: ${apiStatus.status}`);
|
|
114
|
+
checks.push(apiStatus);
|
|
83
115
|
}
|
|
84
116
|
|
|
85
|
-
|
|
86
|
-
if (vectorUrl) {
|
|
87
|
-
checks.push(checkService("Vector", `${vectorUrl}/health`));
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const services = await Promise.all(checks);
|
|
117
|
+
const services = checks;
|
|
91
118
|
|
|
92
119
|
// Determine overall status
|
|
93
120
|
const hasError = services.some((s) => s.status === "error");
|