@cybermem/dashboard 0.9.12 → 0.13.3

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
package/Dockerfile CHANGED
@@ -15,10 +15,10 @@ RUN --mount=type=cache,target=/root/.pnpm-store pnpm install --no-frozen-lockfil
15
15
  # Copy source code
16
16
  COPY . .
17
17
 
18
- # Development stage - skips build, runs dev server
18
+ # Development stage - skips build, runs Turbopack dev server (instant startup)
19
19
  FROM base AS dev
20
20
  ENV NODE_ENV=development
21
- CMD ["pnpm", "dev"]
21
+ CMD ["pnpm", "dev", "--turbopack"]
22
22
 
23
23
  # Builder stage - runs build (slow)
24
24
  FROM base AS builder
@@ -28,7 +28,7 @@ RUN --mount=type=cache,target=/root/.pnpm-store \
28
28
 
29
29
  # Native stage for sqlite3 bindings and wrapper
30
30
  FROM node:20-alpine AS native-builder
31
- RUN apk add --no-cache python3 make g++
31
+ RUN apk update && apk add --no-cache python3 python3-dev make g++
32
32
  WORKDIR /native
33
33
  RUN npm init -y && npm install sqlite3@5.1.7 sqlite@5.1.1
34
34
 
@@ -71,25 +71,32 @@ export async function GET(request: Request) {
71
71
  const db = await open({
72
72
  filename: dbPath,
73
73
  driver: sqlite3.Database,
74
+ mode: sqlite3.OPEN_READONLY,
74
75
  });
75
76
 
76
- const rawLogs = await db.all(
77
+ await db.run("PRAGMA busy_timeout=5000");
78
+
79
+ const rawLogsResults = await db.all(
77
80
  "SELECT * FROM cybermem_access_log ORDER BY timestamp DESC LIMIT 100",
78
81
  );
82
+ const rawLogs = JSON.parse(JSON.stringify(rawLogsResults || []));
79
83
  await db.close();
80
84
 
81
85
  console.error(`[AUDIT-LOGS-API] Found ${rawLogs.length} logs in SQLite`);
82
86
 
83
- const logs = rawLogs.map((log: any) => {
87
+ const logs = (rawLogs || []).map((log: any) => {
84
88
  const statusCode = parseInt(log.status) || 0;
85
89
  let status = "Success";
86
90
  if (log.is_error === 1 || statusCode >= 400 || statusCode === 0)
87
91
  status = "Error";
88
92
  else if (statusCode >= 300) status = "Warning";
89
93
 
90
- // Capitalize operation
91
- const operation =
92
- log.operation.charAt(0).toUpperCase() + log.operation.slice(1);
94
+ let operation = log.operation.toLowerCase();
95
+ if (operation === "create") operation = "Write";
96
+ else if (operation === "read") operation = "Read";
97
+ else if (operation === "update") operation = "Update";
98
+ else if (operation === "delete") operation = "Delete";
99
+ else operation = operation.charAt(0).toUpperCase() + operation.slice(1);
93
100
 
94
101
  return {
95
102
  timestamp: log.timestamp,
@@ -105,7 +112,6 @@ export async function GET(request: Request) {
105
112
  return NextResponse.json({ logs });
106
113
  } catch (error) {
107
114
  console.error("[AUDIT-LOGS-API] Error fetching audit logs:", error);
108
- // Return empty list on error to avoid breaking UI with 500
109
115
  return NextResponse.json({ logs: [] });
110
116
  }
111
117
  }
@@ -32,7 +32,7 @@ async function checkService(
32
32
  signal: controller.signal,
33
33
  cache: "no-store",
34
34
  headers: {
35
- "X-Client-Name": "CyberMem-Dashboard",
35
+ "X-Client-Name": "antigravity-client",
36
36
  },
37
37
  });
38
38
  clearTimeout(timeoutId);
@@ -100,6 +100,7 @@ export async function GET(request: NextRequest) {
100
100
 
101
101
  // Check Core API if explicitly configured
102
102
  const apiEndpoint =
103
+ process.env.INTERNAL_MCP_URL ||
103
104
  process.env.CYBERMEM_URL ||
104
105
  process.env.OPENMEMORY_URL ||
105
106
  "http://localhost:8626";
@@ -0,0 +1,128 @@
1
+ import fs from "fs";
2
+ import { NextResponse } from "next/server";
3
+ import path from "path";
4
+
5
+ export const dynamic = "force-dynamic";
6
+
7
+ // Load clients config
8
+ let clientsConfig: any[] = [];
9
+ try {
10
+ const configPath = path.join(process.cwd(), "public", "clients.json");
11
+ clientsConfig = JSON.parse(fs.readFileSync(configPath, "utf-8"));
12
+ } catch (e) {
13
+ console.error("Failed to load clients.json:", e);
14
+ }
15
+
16
+ export async function GET(request: Request) {
17
+ try {
18
+ const { searchParams } = new URL(request.url);
19
+ const clientId = searchParams.get("client") || "claude";
20
+ const maskKey = searchParams.get("mask") === "true";
21
+
22
+ // Fetch settings to get env, endpoint, apiKey, isManaged
23
+ // We try to fetch from the origin first, but fallback to localhost:3000 if interior to Docker
24
+ const origin =
25
+ request.headers.get("origin") || request.headers.get("referer");
26
+ const internalUrl = `http://localhost:3000/api/settings`;
27
+ const externalUrl = `${origin}/api/settings`;
28
+
29
+ let settings: any = {};
30
+ try {
31
+ // Priority: Try fetching from the same process/port (Localhost:3000)
32
+ const settingsRes = await fetch(internalUrl, {
33
+ headers: request.headers,
34
+ signal: AbortSignal.timeout(2000),
35
+ });
36
+ if (settingsRes.ok) {
37
+ settings = await settingsRes.json();
38
+ } else if (origin) {
39
+ // Fallback to origin if internal fetch failed
40
+ const extRes = await fetch(externalUrl, { headers: request.headers });
41
+ if (extRes.ok) settings = await extRes.json();
42
+ }
43
+ } catch (e) {
44
+ console.warn(
45
+ "[MCP-CONFIG-API] Internal fetch failed, using fallback empty settings",
46
+ e,
47
+ );
48
+ }
49
+
50
+ const apiKey = settings.apiKey !== "not-set" ? settings.apiKey : "";
51
+ const baseUrl =
52
+ searchParams.get("baseUrl") ||
53
+ settings.endpoint ||
54
+ "http://localhost:8626/mcp";
55
+ const isManaged = settings.isManaged || false;
56
+ const env = settings.env || "prod";
57
+ const isStaging = env === "staging";
58
+
59
+ const displayKey = maskKey
60
+ ? "••••••••••••••••"
61
+ : apiKey || "sk-your-generated-token";
62
+ const actualKey = apiKey || "sk-your-generated-token";
63
+
64
+ const client = clientsConfig.find((c: any) => c.id === clientId);
65
+
66
+ // Generate config based on client type
67
+ let config: any;
68
+ let configType = client?.configType || "json";
69
+
70
+ if (configType === "toml") {
71
+ if (isManaged) {
72
+ config = `[mcpServers.cybermem]\ncommand = "npx"\nargs = ["@cybermem/mcp"]`;
73
+ } else {
74
+ const keyVal = maskKey ? displayKey : actualKey;
75
+ config = `[mcpServers.cybermem]\ncommand = "npx"\nargs = ["@cybermem/mcp", "--url", "${baseUrl}", "--token", "${keyVal}"]`;
76
+ }
77
+ } else if (configType === "command" || configType === "cmd") {
78
+ let cmd = isManaged ? client?.localCommand : client?.remoteCommand;
79
+ if (!cmd) {
80
+ cmd = client?.command?.replace("http://localhost:8080", baseUrl) || "";
81
+ }
82
+ cmd = cmd.replace("{{ENDPOINT}}", baseUrl);
83
+ cmd = cmd.replace("{{API_KEY}}", maskKey ? displayKey : actualKey);
84
+ cmd = cmd.replace("{{TOKEN}}", maskKey ? displayKey : actualKey);
85
+ config = cmd;
86
+ } else {
87
+ // JSON (default)
88
+ const args = isManaged
89
+ ? ["-y", "@cybermem/mcp"]
90
+ : [
91
+ "-y",
92
+ "@cybermem/mcp",
93
+ "--url",
94
+ baseUrl,
95
+ "--token",
96
+ maskKey ? displayKey : actualKey,
97
+ ];
98
+
99
+ if (isStaging && !isManaged) {
100
+ args.push("--staging");
101
+ }
102
+
103
+ config = {
104
+ mcpServers: {
105
+ cybermem: {
106
+ command: "npx",
107
+ args: args,
108
+ },
109
+ },
110
+ };
111
+ }
112
+
113
+ return NextResponse.json({
114
+ config,
115
+ configType,
116
+ apiKey: maskKey ? displayKey : actualKey,
117
+ baseUrl,
118
+ isManaged,
119
+ env,
120
+ });
121
+ } catch (error) {
122
+ console.error("[MCP-CONFIG-API] Error generating config:", error);
123
+ return NextResponse.json(
124
+ { error: "Failed to generate config" },
125
+ { status: 500 },
126
+ );
127
+ }
128
+ }
@@ -79,14 +79,19 @@ export async function GET(request: Request) {
79
79
 
80
80
  if (fs.existsSync(dbPath)) {
81
81
  console.error(`[STATS-API] Reading SQLite from ${dbPath}`);
82
+ let db: any = null;
82
83
  try {
83
84
  const sqlite3 = require("sqlite3").verbose();
84
85
  const { open } = require("sqlite");
85
- const db = await open({
86
+ db = await open({
86
87
  filename: dbPath,
87
88
  driver: sqlite3.Database,
89
+ mode: sqlite3.OPEN_READONLY,
88
90
  });
89
91
 
92
+ // Wait for locks if needed
93
+ await db.exec("PRAGMA busy_timeout=5000");
94
+
90
95
  // Basic Stats
91
96
  const memories = await db.get("SELECT COUNT(*) as count FROM memories");
92
97
  const totalReqs = await db.get(
@@ -115,23 +120,17 @@ export async function GET(request: Request) {
115
120
  100
116
121
  : 100;
117
122
 
118
- console.error(
119
- `[STATS-API] SQLite stats: ${stats.totalRequests} total requests, ${stats.memoryRecords} records`,
120
- );
121
-
122
123
  if (lastWrite) {
123
124
  stats.lastWriter = {
124
125
  name: normalizeClientName(lastWrite.client_name),
125
126
  timestamp: lastWrite.timestamp,
126
127
  };
127
- console.error(`[STATS-API] Last writer: ${stats.lastWriter.name}`);
128
128
  }
129
129
  if (lastRead) {
130
130
  stats.lastReader = {
131
131
  name: normalizeClientName(lastRead.client_name),
132
132
  timestamp: lastRead.timestamp,
133
133
  };
134
- console.error(`[STATS-API] Last reader: ${stats.lastReader.name}`);
135
134
  }
136
135
 
137
136
  // Top activity
@@ -154,46 +153,30 @@ export async function GET(request: Request) {
154
153
  };
155
154
 
156
155
  // --- TIME SERIES AGGREGATION (Robust Linear Sampling) ---
157
- let periodMs = 24 * 60 * 60 * 1000; // Default 24h
156
+ let periodMs = 24 * 60 * 60 * 1000;
158
157
  if (period === "1h") periodMs = 60 * 60 * 1000;
159
158
  else if (period === "7d") periodMs = 7 * 24 * 60 * 60 * 1000;
160
159
  else if (period === "30d") periodMs = 30 * 24 * 60 * 60 * 1000;
161
- else if (period === "90d") periodMs = 90 * 24 * 60 * 60 * 1000;
162
160
  else if (period === "24h") periodMs = 24 * 60 * 60 * 1000;
163
161
 
164
162
  const now = Date.now();
165
163
  const startTime = now - periodMs;
166
164
 
167
- console.error(
168
- `[STATS-API] Fetching cumulative chart data since ${new Date(startTime).toISOString()}`,
169
- );
170
-
171
- // Get all logs in period
172
- const allLogs = await db.all(
173
- `
174
- SELECT timestamp, operation, client_name
175
- FROM cybermem_access_log
176
- WHERE timestamp > ?
177
- ORDER BY timestamp ASC
178
- `,
165
+ // Strip any potential native proxy bits from sqlite3 results
166
+ const rawAllLogs = await db.all(
167
+ `SELECT timestamp, operation, client_name FROM cybermem_access_log WHERE timestamp > ? ORDER BY timestamp ASC`,
179
168
  [startTime],
180
169
  );
170
+ const allLogs = JSON.parse(JSON.stringify(rawAllLogs || []));
181
171
 
182
- // Get base counts (before startTime) to start the cumulative graph correctly
183
- const baseCounts = await db.all(
184
- `
185
- SELECT operation, client_name, COUNT(*) as count
186
- FROM cybermem_access_log
187
- WHERE timestamp <= ?
188
- GROUP BY 1, 2
189
- `,
172
+ const rawBaseCounts = await db.all(
173
+ `SELECT operation, client_name, COUNT(*) as count FROM cybermem_access_log WHERE timestamp <= ? GROUP BY 1, 2`,
190
174
  [startTime],
191
175
  );
176
+ const baseCounts = JSON.parse(JSON.stringify(rawBaseCounts || []));
192
177
 
193
178
  const buildBeautifulSeries = (targetOp: string) => {
194
179
  const clientTotals: Record<string, number> = {};
195
-
196
- // 1. Initialize with base counts
197
180
  baseCounts
198
181
  .filter((b: any) => b.operation === targetOp)
199
182
  .forEach((b: any) => {
@@ -202,16 +185,12 @@ export async function GET(request: Request) {
202
185
 
203
186
  const series: any[] = [];
204
187
  const opLogs = allLogs.filter((l: any) => l.operation === targetOp);
205
-
206
- // 2. Linear Sampling (60 points)
207
188
  const SAMPLES = 60;
208
189
  const interval = (now - startTime) / SAMPLES;
209
190
  let currentLogIdx = 0;
210
191
 
211
192
  for (let i = 0; i <= SAMPLES; i++) {
212
193
  const timePoint = startTime + i * interval;
213
-
214
- // Catch up logs that happened before this timePoint
215
194
  while (
216
195
  currentLogIdx < opLogs.length &&
217
196
  opLogs[currentLogIdx].timestamp <= timePoint
@@ -221,14 +200,11 @@ export async function GET(request: Request) {
221
200
  (clientTotals[log.client_name] || 0) + 1;
222
201
  currentLogIdx++;
223
202
  }
224
-
225
- // Record state at this exact linear time point
226
203
  series.push({
227
204
  time: Math.floor(timePoint / 1000),
228
205
  ...clientTotals,
229
206
  });
230
207
  }
231
-
232
208
  return series;
233
209
  };
234
210
 
@@ -237,44 +213,19 @@ export async function GET(request: Request) {
237
213
  timeseries.updates = buildBeautifulSeries("update");
238
214
  timeseries.deletes = buildBeautifulSeries("delete");
239
215
 
240
- console.error(`[STATS-API] SQLite Stats & Beautiful Charts processed.`);
241
-
242
- await db.close();
216
+ console.error(`[STATS-API] SQLite + Charts processed successfully.`);
243
217
  } catch (dbErr) {
244
- console.error(
245
- "[STATS-API] Direct SQLite metrics fetch failed, trying db-exporter fallback:",
246
- dbErr,
247
- );
248
- try {
249
- const exporterRes = await fetch(`${DB_EXPORTER_URL}/metrics`, {
250
- signal: controller.signal,
251
- });
252
- if (exporterRes.ok) {
253
- const text = await exporterRes.text();
254
- const getValue = (name: string) => {
255
- const match = text.match(new RegExp(`${name}\\s+([\\d.]+)`));
256
- return match ? parseFloat(match[1]) : 0;
257
- };
258
- stats.memoryRecords = getValue("openmemory_memories_total");
259
- stats.totalRequests = getValue(
260
- "openmemory_requests_aggregate_total",
261
- );
262
- stats.successRate = getValue("openmemory_success_rate_aggregate");
263
- console.error(
264
- `[STATS-API] Fallback stats from db-exporter: ${stats.totalRequests} total requests`,
265
- );
266
- }
267
- } catch (exporterErr) {
268
- console.error("[STATS-API] Fallback fetch failed:", exporterErr);
218
+ console.error("[STATS-API] Direct SQLite metrics fetch failed:", dbErr);
219
+ } finally {
220
+ if (db) {
221
+ await db.close();
269
222
  }
270
223
  }
271
- } else {
272
- console.error(`[STATS-API] SQLite DB NOT FOUND at ${dbPath}`);
273
224
  }
274
225
 
275
226
  clearTimeout(timeoutId);
276
227
 
277
- return NextResponse.json({
228
+ const responseData = {
278
229
  stats: {
279
230
  ...stats,
280
231
  topWriter: {
@@ -300,7 +251,8 @@ export async function GET(request: Request) {
300
251
  updates: normalizeTimeSeries(timeseries.updates || []),
301
252
  deletes: normalizeTimeSeries(timeseries.deletes || []),
302
253
  },
303
- });
254
+ };
255
+ return NextResponse.json(responseData);
304
256
  } catch (error) {
305
257
  console.error("Failed to fetch metrics:", error);
306
258
  return NextResponse.json(
@@ -1,54 +1,115 @@
1
1
  import { checkRateLimit, rateLimitResponse } from "@/lib/rate-limit";
2
2
  import fs from "fs";
3
3
  import { NextRequest, NextResponse } from "next/server";
4
+ import os from "os";
5
+ import path from "path";
4
6
 
5
7
  export const dynamic = "force-dynamic";
6
8
 
7
9
  const CONFIG_PATH = "/data/config.json";
8
10
 
9
- // Detect the correct MCP endpoint based on request host
11
+ // Detect the correct MCP endpoint based on request host and environment
10
12
  function getMcpEndpoint(request: NextRequest): {
11
13
  endpoint: string;
12
14
  isLocal: boolean;
13
15
  } {
14
- const host = request.headers.get("host") || "localhost:3000";
16
+ const host = request.headers.get("host") || "localhost:8626";
15
17
  const hostname = host.split(":")[0];
18
+ const port = host.split(":")[1] || "";
19
+ const env = process.env.CYBERMEM_ENV || "prod";
20
+ const isStaging = env === "staging";
21
+ const isTailscale =
22
+ process.env.CYBERMEM_TAILSCALE === "true" || host.includes(".ts.net");
23
+
24
+ // Priority 1: Explicit public override
25
+ if (process.env.CYBERMEM_PUBLIC_URL) {
26
+ const url = process.env.CYBERMEM_PUBLIC_URL;
27
+ return {
28
+ endpoint: url.endsWith("/mcp") ? url : `${url.replace(/\/$/, "")}/mcp`,
29
+ isLocal: false,
30
+ };
31
+ }
32
+
33
+ // Priority 2: Tailscale / Funnel (Port-Based)
34
+ if (isTailscale) {
35
+ const protocol = request.headers.get("x-forwarded-proto") || "https";
36
+ const displayPort = isStaging ? "8443" : "443";
37
+ // If port is already in the host, it will be used. Hostname is enough.
38
+ return {
39
+ endpoint: `${protocol}://${hostname}${port ? ":" + port : displayPort === "443" ? "" : ":" + displayPort}/mcp`,
40
+ isLocal: false,
41
+ };
42
+ }
16
43
 
17
- // Priority 1: Explicit env var
18
- if (process.env.CYBERMEM_URL) {
19
- return { endpoint: process.env.CYBERMEM_URL, isLocal: false };
44
+ // Priority 3: Localhost / LAN
45
+ if (
46
+ hostname === "localhost" ||
47
+ hostname === "127.0.0.1" ||
48
+ hostname.endsWith(".local")
49
+ ) {
50
+ // Port detection priority: Host header > X-Forwarded-Port > TRAEFIK_PORT env > env-based default
51
+ const forwardedPort = request.headers.get("x-forwarded-port") || "";
52
+ const traefikPort = process.env.TRAEFIK_PORT || "";
53
+ const effectivePort = port || forwardedPort || traefikPort;
54
+
55
+ const isStandardPort =
56
+ effectivePort === "8625" ||
57
+ effectivePort === "8626" ||
58
+ effectivePort === "8627";
59
+ // If accessed via non-standard port (k3d 8081), suggest placeholder
60
+ const isLoopback = hostname === "localhost" || hostname === "127.0.0.1";
61
+ const isRemote = !isStandardPort && isLoopback;
62
+
63
+ const displayHost = isRemote ? "YOUR_VPS_IP" : hostname;
64
+ // Use detected port if standard, otherwise fall back to env-based default
65
+ const displayPort = isStandardPort
66
+ ? effectivePort
67
+ : isStaging
68
+ ? "8625"
69
+ : "8626";
70
+
71
+ return {
72
+ endpoint: `http://${displayHost}:${displayPort}/mcp`,
73
+ isLocal: isLoopback && !isRemote,
74
+ };
20
75
  }
21
76
 
22
- // Priority 2: Tailscale domain (from env or detect)
23
- const tailscaleDomain = process.env.TAILSCALE_DOMAIN;
77
+ // Fallback
78
+ const protocol = request.headers.get("x-forwarded-proto") || "http";
79
+ const displayPort = port || (isStaging ? "8625" : "8626");
80
+ return {
81
+ endpoint: `${protocol}://${hostname}:${displayPort}/mcp`,
82
+ isLocal: false,
83
+ };
84
+ }
24
85
 
25
- // Priority 3: Based on request host
26
- if (hostname === "localhost" || hostname === "127.0.0.1") {
27
- // Local development
28
- return { endpoint: "http://localhost:8626/mcp", isLocal: true };
86
+ // Detect hardware type
87
+ function getInstanceType(): "local" | "rpi" | "vps" {
88
+ // Explicit override
89
+ if (process.env.CYBERMEM_INSTANCE) {
90
+ const val = process.env.CYBERMEM_INSTANCE.toLowerCase();
91
+ if (val === "rpi") return "rpi";
92
+ if (val === "vps") return "vps";
93
+ return "local";
29
94
  }
30
95
 
31
- if (hostname.endsWith(".local")) {
32
- // LAN access (raspberrypi.local)
33
- // If Tailscale domain is available, prefer it for remote config
34
- if (tailscaleDomain) {
35
- return {
36
- endpoint: `https://${tailscaleDomain}/cybermem/mcp`,
37
- isLocal: false,
38
- // Also provide LAN endpoint as fallback
39
- };
40
- }
41
- return { endpoint: `http://${hostname}:8626/mcp`, isLocal: true };
96
+ const hostname = os.hostname().toLowerCase();
97
+ const arch = process.arch;
98
+
99
+ // RPi detection: hostname has 'raspberry' OR it's arm/arm64 on linux
100
+ if (
101
+ hostname.includes("raspberry") ||
102
+ (process.platform === "linux" && (arch === "arm" || arch === "arm64"))
103
+ ) {
104
+ return "rpi";
42
105
  }
43
106
 
44
- if (hostname.includes(".ts.net")) {
45
- // Tailscale Funnel access
46
- return { endpoint: `https://${hostname}/cybermem/mcp`, isLocal: false };
107
+ // Cloud/VPS detection (heuristic)
108
+ if (process.env.VERCEL || process.env.KUBERNETES_SERVICE_HOST) {
109
+ return "vps";
47
110
  }
48
111
 
49
- // Fallback: use request host
50
- const protocol = request.headers.get("x-forwarded-proto") || "http";
51
- return { endpoint: `${protocol}://${hostname}:8626/mcp`, isLocal: false };
112
+ return "local";
52
113
  }
53
114
 
54
115
  export async function GET(request: NextRequest) {
@@ -77,12 +138,42 @@ export async function GET(request: NextRequest) {
77
138
  // ignore
78
139
  }
79
140
 
141
+ // Priority: Docker Secrets (Standard production way)
142
+ try {
143
+ const secretPath = "/run/secrets/om_api_key";
144
+ if (fs.existsSync(secretPath)) {
145
+ const secret = fs.readFileSync(secretPath, "utf-8").trim();
146
+ if (secret) apiKey = secret;
147
+ }
148
+ } catch (e) {
149
+ // ignore
150
+ }
151
+
80
152
  // Get dynamic endpoint based on request host
81
153
  const { endpoint, isLocal } = getMcpEndpoint(request);
82
154
 
155
+ // Detect instance type
156
+ const instanceType = getInstanceType();
157
+
83
158
  // isManaged = Local Mode (localhost auto-login)
84
159
  const isManaged = isLocal;
85
160
 
161
+ // Read version from package.json
162
+ let version = "v0.11.4"; // Default fallback
163
+ try {
164
+ const pkgPath = path.join(process.cwd(), "package.json");
165
+ if (fs.existsSync(pkgPath)) {
166
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
167
+ version = `v${pkg.version}`;
168
+ }
169
+ } catch (e) {
170
+ // ignore
171
+ }
172
+
173
+ // Environment detection SSoT: Trust environment variables set during stack start
174
+ const env = process.env.CYBERMEM_ENV || "prod";
175
+ const instance = process.env.CYBERMEM_INSTANCE || "local";
176
+
86
177
  return NextResponse.json(
87
178
  {
88
179
  token: apiKey,
@@ -90,8 +181,12 @@ export async function GET(request: NextRequest) {
90
181
  endpoint,
91
182
  isManaged,
92
183
  isLocal,
93
- dashboardVersion: "v0.8.0",
94
- mcpVersion: "v0.8.0",
184
+ instanceType,
185
+ env: env,
186
+ instance: instance,
187
+ tailscale: process.env.CYBERMEM_TAILSCALE === "true",
188
+ dashboardVersion: version,
189
+ mcpVersion: version,
95
190
  },
96
191
  {
97
192
  headers: {