@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.
- package/Dockerfile +3 -3
- package/app/api/audit-logs/route.ts +12 -6
- package/app/api/health/route.ts +2 -1
- package/app/api/mcp-config/route.ts +128 -0
- package/app/api/metrics/route.ts +22 -70
- package/app/api/settings/route.ts +125 -30
- package/app/page.tsx +105 -127
- package/components/dashboard/{chart-card.tsx → charts/chart-card.tsx} +13 -19
- package/components/dashboard/{metrics-chart.tsx → charts/memory-chart.tsx} +1 -1
- package/components/dashboard/charts-section.tsx +3 -3
- package/components/dashboard/header.tsx +177 -176
- package/components/dashboard/{audit-log-table.tsx → logs/log-viewer.tsx} +12 -7
- package/components/dashboard/mcp/config-preview.tsx +246 -0
- package/components/dashboard/mcp/platform-selector.tsx +96 -0
- package/components/dashboard/mcp-config-modal.tsx +97 -503
- package/components/dashboard/{metric-card.tsx → metrics/stat-card.tsx} +4 -2
- package/components/dashboard/metrics-grid.tsx +10 -2
- package/components/dashboard/settings/access-token-section.tsx +131 -0
- package/components/dashboard/settings/data-management-section.tsx +122 -0
- package/components/dashboard/settings/system-info-section.tsx +98 -0
- package/components/dashboard/settings-modal.tsx +55 -299
- package/e2e/api.spec.ts +219 -0
- package/e2e/routing.spec.ts +39 -0
- package/e2e/ui.spec.ts +373 -0
- package/lib/data/dashboard-context.tsx +96 -29
- package/lib/data/types.ts +32 -38
- package/middleware.ts +31 -13
- package/package.json +6 -1
- package/playwright.config.ts +23 -58
- package/public/clients.json +5 -3
- package/release-reports/assets/local/1_dashboard.png +0 -0
- package/release-reports/assets/local/2_audit_logs.png +0 -0
- package/release-reports/assets/local/3_charts.png +0 -0
- package/release-reports/assets/local/4_mcp_modal.png +0 -0
- package/release-reports/assets/local/5_settings_modal.png +0 -0
- package/lib/data/demo-strategy.ts +0 -110
- package/lib/data/production-strategy.ts +0 -191
- package/lib/prometheus/client.ts +0 -58
- package/lib/prometheus/index.ts +0 -6
- package/lib/prometheus/metrics.ts +0 -234
- package/lib/prometheus/sparklines.ts +0 -71
- package/lib/prometheus/timeseries.ts +0 -305
- 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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
}
|
package/app/api/health/route.ts
CHANGED
|
@@ -32,7 +32,7 @@ async function checkService(
|
|
|
32
32
|
signal: controller.signal,
|
|
33
33
|
cache: "no-store",
|
|
34
34
|
headers: {
|
|
35
|
-
"X-Client-Name": "
|
|
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
|
+
}
|
package/app/api/metrics/route.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
|
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
|
-
|
|
168
|
-
|
|
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
|
-
|
|
183
|
-
|
|
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
|
|
241
|
-
|
|
242
|
-
await db.close();
|
|
216
|
+
console.error(`[STATS-API] SQLite + Charts processed successfully.`);
|
|
243
217
|
} catch (dbErr) {
|
|
244
|
-
console.error(
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
18
|
-
if (
|
|
19
|
-
|
|
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
|
-
//
|
|
23
|
-
const
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
return
|
|
107
|
+
// Cloud/VPS detection (heuristic)
|
|
108
|
+
if (process.env.VERCEL || process.env.KUBERNETES_SERVICE_HOST) {
|
|
109
|
+
return "vps";
|
|
47
110
|
}
|
|
48
111
|
|
|
49
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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: {
|