@cybermem/dashboard 0.5.14 → 0.8.5

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,125 +1,311 @@
1
- import fs from 'fs'
2
- import { NextResponse } from 'next/server'
3
- import path from 'path'
1
+ import fs from "fs";
2
+ import { NextResponse } from "next/server";
3
+ import path from "path";
4
4
 
5
- export const dynamic = 'force-dynamic'
6
- export const revalidate = 0
5
+ export const dynamic = "force-dynamic";
6
+ export const revalidate = 0;
7
7
 
8
8
  // Use env var for db-exporter URL (Docker internal vs local dev)
9
- const DB_EXPORTER_URL = process.env.DB_EXPORTER_URL || 'http://localhost:8000'
9
+ const DB_EXPORTER_URL = process.env.DB_EXPORTER_URL || "http://localhost:8000";
10
+ const PROMETHEUS_URL = process.env.PROMETHEUS_URL || "http://localhost:9092";
10
11
 
11
12
  // Load clients config for name normalization
12
- let clientsConfig: any[] = []
13
+ let clientsConfig: any[] = [];
13
14
  try {
14
- const configPath = path.join(process.cwd(), 'public', 'clients.json')
15
- clientsConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
15
+ const configPath = path.join(process.cwd(), "public", "clients.json");
16
+ clientsConfig = JSON.parse(fs.readFileSync(configPath, "utf-8"));
16
17
  } catch (e) {
17
- console.error('Failed to load clients.json:', e)
18
+ console.error("Failed to load clients.json:", e);
18
19
  }
19
20
 
20
21
  // Normalize raw client name to friendly name
21
22
  function normalizeClientName(rawName: string): string {
22
- if (!rawName || rawName === 'N/A') return rawName
23
- const nameLower = rawName.toLowerCase()
23
+ if (!rawName || rawName === "N/A") return rawName;
24
+ const nameLower = rawName.toLowerCase();
24
25
  const client = clientsConfig.find((c: any) => {
25
26
  try {
26
- return new RegExp(c.match, 'i').test(nameLower)
27
+ return new RegExp(c.match, "i").test(nameLower);
27
28
  } catch {
28
- return nameLower.includes(c.match)
29
+ return nameLower.includes(c.match);
29
30
  }
30
- })
31
- return client?.name || rawName
31
+ });
32
+ return client?.name || rawName;
32
33
  }
33
34
 
34
35
  // Normalize client names in time series data
35
36
  function normalizeTimeSeries(data: any[]): any[] {
36
- return data.map(point => {
37
- const normalized: any = { time: point.time }
37
+ return data.map((point) => {
38
+ const normalized: any = { time: point.time };
38
39
  for (const [key, value] of Object.entries(point)) {
39
- if (key !== 'time') {
40
- normalized[normalizeClientName(key)] = value
40
+ if (key !== "time") {
41
+ const normalizedKey = normalizeClientName(key);
42
+ normalized[normalizedKey] = value;
41
43
  }
42
44
  }
43
- return normalized
44
- })
45
+ return normalized;
46
+ });
45
47
  }
46
48
 
47
49
  export async function GET(request: Request) {
48
50
  try {
49
- const { searchParams } = new URL(request.url)
50
- const period = searchParams.get('period') || '24h'
51
-
52
- const controller = new AbortController()
53
- const timeoutId = setTimeout(() => controller.abort(), 5000)
54
-
55
- // Fetch stats and timeseries from db-exporter (SQLite)
56
- const [statsRes, timeseriesRes] = await Promise.all([
57
- fetch(`${DB_EXPORTER_URL}/api/stats`, {
58
- signal: controller.signal,
59
- cache: 'no-store'
60
- }),
61
- fetch(`${DB_EXPORTER_URL}/api/timeseries?period=${period}`, {
62
- signal: controller.signal,
63
- cache: 'no-store'
64
- })
65
- ])
66
-
67
- clearTimeout(timeoutId)
68
-
69
- if (!statsRes.ok) {
70
- throw new Error(`Failed to fetch stats: ${statsRes.statusText}`)
71
- }
51
+ const { searchParams } = new URL(request.url);
52
+ const period = searchParams.get("period") || "24h";
72
53
 
73
- const stats = await statsRes.json()
74
- const timeseries = timeseriesRes.ok ? await timeseriesRes.json() : { creates: [], reads: [], updates: [], deletes: [] }
75
-
76
- // Normalize client names
77
- const normalizedStats = {
78
- memoryRecords: stats.memoryRecords || 0,
79
- totalClients: stats.totalClients || 0,
80
- successRate: stats.successRate || 100,
81
- totalRequests: stats.totalRequests || 0,
82
- topWriter: {
83
- name: normalizeClientName(stats.topWriter?.name || 'N/A'),
84
- count: stats.topWriter?.count || 0
85
- },
86
- topReader: {
87
- name: normalizeClientName(stats.topReader?.name || 'N/A'),
88
- count: stats.topReader?.count || 0
89
- },
90
- lastWriter: {
91
- name: normalizeClientName(stats.lastWriter?.name || 'N/A'),
92
- timestamp: stats.lastWriter?.timestamp || 0
93
- },
94
- lastReader: {
95
- name: normalizeClientName(stats.lastReader?.name || 'N/A'),
96
- timestamp: stats.lastReader?.timestamp || 0
54
+ const controller = new AbortController();
55
+ const timeoutId = setTimeout(() => controller.abort(), 5000);
56
+
57
+ // --- PRIMARY DATA SOURCE: DIRECT SQLITE ---
58
+ let stats = {
59
+ memoryRecords: 0,
60
+ totalClients: 0,
61
+ successRate: 100,
62
+ totalRequests: 0,
63
+ topWriter: { name: "N/A", count: 0 },
64
+ topReader: { name: "N/A", count: 0 },
65
+ lastWriter: { name: "N/A", timestamp: 0 },
66
+ lastReader: { name: "N/A", timestamp: 0 },
67
+ };
68
+ let timeseries: Record<string, any[]> = {
69
+ creates: [],
70
+ reads: [],
71
+ updates: [],
72
+ deletes: [],
73
+ };
74
+
75
+ const homedir = process.env.HOME || process.env.USERPROFILE || "";
76
+ const dbPath =
77
+ process.env.OM_DB_PATH ||
78
+ path.resolve(homedir, ".cybermem/data/openmemory.sqlite");
79
+
80
+ if (fs.existsSync(dbPath)) {
81
+ console.error(`[STATS-API] Reading SQLite from ${dbPath}`);
82
+ try {
83
+ const sqlite3 = require("sqlite3").verbose();
84
+ const { open } = require("sqlite");
85
+ const db = await open({
86
+ filename: dbPath,
87
+ driver: sqlite3.Database,
88
+ });
89
+
90
+ // Basic Stats
91
+ const memories = await db.get("SELECT COUNT(*) as count FROM memories");
92
+ const totalReqs = await db.get(
93
+ "SELECT COUNT(*) as count FROM cybermem_access_log",
94
+ );
95
+ const errReqs = await db.get(
96
+ "SELECT COUNT(*) as count FROM cybermem_access_log WHERE is_error = 1",
97
+ );
98
+ const lastWrite = await db.get(
99
+ "SELECT client_name, timestamp FROM cybermem_access_log WHERE operation = 'create' ORDER BY timestamp DESC LIMIT 1",
100
+ );
101
+ const lastRead = await db.get(
102
+ "SELECT client_name, timestamp FROM cybermem_access_log WHERE operation = 'read' ORDER BY timestamp DESC LIMIT 1",
103
+ );
104
+ const uniqueClients = await db.get(
105
+ "SELECT COUNT(DISTINCT client_name) as count FROM cybermem_access_log",
106
+ );
107
+
108
+ stats.memoryRecords = memories?.count || 0;
109
+ stats.totalRequests = totalReqs?.count || 0;
110
+ stats.totalClients = uniqueClients?.count || 0;
111
+ stats.successRate =
112
+ stats.totalRequests > 0
113
+ ? ((stats.totalRequests - (errReqs?.count || 0)) /
114
+ stats.totalRequests) *
115
+ 100
116
+ : 100;
117
+
118
+ console.error(
119
+ `[STATS-API] SQLite stats: ${stats.totalRequests} total requests, ${stats.memoryRecords} records`,
120
+ );
121
+
122
+ if (lastWrite) {
123
+ stats.lastWriter = {
124
+ name: normalizeClientName(lastWrite.client_name),
125
+ timestamp: lastWrite.timestamp,
126
+ };
127
+ console.error(`[STATS-API] Last writer: ${stats.lastWriter.name}`);
128
+ }
129
+ if (lastRead) {
130
+ stats.lastReader = {
131
+ name: normalizeClientName(lastRead.client_name),
132
+ timestamp: lastRead.timestamp,
133
+ };
134
+ console.error(`[STATS-API] Last reader: ${stats.lastReader.name}`);
135
+ }
136
+
137
+ // Top activity
138
+ const topWriter = await db.get(
139
+ "SELECT client_name, COUNT(*) as count FROM cybermem_access_log WHERE operation = 'create' GROUP BY client_name ORDER BY count DESC LIMIT 1",
140
+ );
141
+ const topReader = await db.get(
142
+ "SELECT client_name, COUNT(*) as count FROM cybermem_access_log WHERE operation = 'read' GROUP BY client_name ORDER BY count DESC LIMIT 1",
143
+ );
144
+
145
+ if (topWriter)
146
+ stats.topWriter = {
147
+ name: normalizeClientName(topWriter.client_name),
148
+ count: topWriter.count,
149
+ };
150
+ if (topReader)
151
+ stats.topReader = {
152
+ name: normalizeClientName(topReader.client_name),
153
+ count: topReader.count,
154
+ };
155
+
156
+ // --- TIME SERIES AGGREGATION (Robust Linear Sampling) ---
157
+ let periodMs = 24 * 60 * 60 * 1000; // Default 24h
158
+ if (period === "1h") periodMs = 60 * 60 * 1000;
159
+ else if (period === "7d") periodMs = 7 * 24 * 60 * 60 * 1000;
160
+ else if (period === "30d") periodMs = 30 * 24 * 60 * 60 * 1000;
161
+ else if (period === "90d") periodMs = 90 * 24 * 60 * 60 * 1000;
162
+ else if (period === "24h") periodMs = 24 * 60 * 60 * 1000;
163
+
164
+ const now = Date.now();
165
+ const startTime = now - periodMs;
166
+
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
+ `,
179
+ [startTime],
180
+ );
181
+
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
+ `,
190
+ [startTime],
191
+ );
192
+
193
+ const buildBeautifulSeries = (targetOp: string) => {
194
+ const clientTotals: Record<string, number> = {};
195
+
196
+ // 1. Initialize with base counts
197
+ baseCounts
198
+ .filter((b: any) => b.operation === targetOp)
199
+ .forEach((b: any) => {
200
+ clientTotals[b.client_name] = b.count;
201
+ });
202
+
203
+ const series: any[] = [];
204
+ const opLogs = allLogs.filter((l: any) => l.operation === targetOp);
205
+
206
+ // 2. Linear Sampling (60 points)
207
+ const SAMPLES = 60;
208
+ const interval = (now - startTime) / SAMPLES;
209
+ let currentLogIdx = 0;
210
+
211
+ for (let i = 0; i <= SAMPLES; i++) {
212
+ const timePoint = startTime + i * interval;
213
+
214
+ // Catch up logs that happened before this timePoint
215
+ while (
216
+ currentLogIdx < opLogs.length &&
217
+ opLogs[currentLogIdx].timestamp <= timePoint
218
+ ) {
219
+ const log = opLogs[currentLogIdx];
220
+ clientTotals[log.client_name] =
221
+ (clientTotals[log.client_name] || 0) + 1;
222
+ currentLogIdx++;
223
+ }
224
+
225
+ // Record state at this exact linear time point
226
+ series.push({
227
+ time: Math.floor(timePoint / 1000),
228
+ ...clientTotals,
229
+ });
230
+ }
231
+
232
+ return series;
233
+ };
234
+
235
+ timeseries.creates = buildBeautifulSeries("create");
236
+ timeseries.reads = buildBeautifulSeries("read");
237
+ timeseries.updates = buildBeautifulSeries("update");
238
+ timeseries.deletes = buildBeautifulSeries("delete");
239
+
240
+ console.error(`[STATS-API] SQLite Stats & Beautiful Charts processed.`);
241
+
242
+ await db.close();
243
+ } 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);
269
+ }
97
270
  }
271
+ } else {
272
+ console.error(`[STATS-API] SQLite DB NOT FOUND at ${dbPath}`);
98
273
  }
99
274
 
275
+ clearTimeout(timeoutId);
276
+
100
277
  return NextResponse.json({
101
- stats: normalizedStats,
278
+ stats: {
279
+ ...stats,
280
+ topWriter: {
281
+ name: normalizeClientName(stats.topWriter?.name || "N/A"),
282
+ count: stats.topWriter?.count || 0,
283
+ },
284
+ topReader: {
285
+ name: normalizeClientName(stats.topReader?.name || "N/A"),
286
+ count: stats.topReader?.count || 0,
287
+ },
288
+ lastWriter: {
289
+ name: normalizeClientName(stats.lastWriter?.name || "N/A"),
290
+ timestamp: stats.lastWriter?.timestamp || 0,
291
+ },
292
+ lastReader: {
293
+ name: normalizeClientName(stats.lastReader?.name || "N/A"),
294
+ timestamp: stats.lastReader?.timestamp || 0,
295
+ },
296
+ },
102
297
  timeSeries: {
103
298
  creates: normalizeTimeSeries(timeseries.creates || []),
104
299
  reads: normalizeTimeSeries(timeseries.reads || []),
105
300
  updates: normalizeTimeSeries(timeseries.updates || []),
106
- deletes: normalizeTimeSeries(timeseries.deletes || [])
301
+ deletes: normalizeTimeSeries(timeseries.deletes || []),
107
302
  },
108
- // Legacy fields for backward compatibility
109
- sparklines: {
110
- memoryRecords: [],
111
- totalRequests: [],
112
- totalClients: [],
113
- successRate: []
114
- },
115
- clientStats: {
116
- reads: {},
117
- writes: {},
118
- successRate: {}
119
- }
120
- })
303
+ });
121
304
  } catch (error) {
122
- console.error('Failed to fetch metrics:', error)
123
- return NextResponse.json({ error: 'Failed to fetch metrics' }, { status: 500 })
305
+ console.error("Failed to fetch metrics:", error);
306
+ return NextResponse.json(
307
+ { error: "Failed to fetch metrics" },
308
+ { status: 500 },
309
+ );
124
310
  }
125
311
  }
@@ -50,7 +50,7 @@ export async function POST(request: NextRequest) {
50
50
  message: `Deleted ${deletedCount} database files. Restart openmemory container to reinitialize.`,
51
51
  deletedCount,
52
52
  restartRequired: true,
53
- restartCommand: 'docker restart cybermem-openmemory'
53
+ restartCommand: 'docker restart cybermem-mcp'
54
54
  })
55
55
 
56
56
  } catch (fsError: any) {
@@ -59,7 +59,7 @@ export async function POST(request: NextRequest) {
59
59
  success: true,
60
60
  message: 'Database restored successfully. Restart openmemory container to apply.',
61
61
  restartRequired: true,
62
- restartCommand: 'docker restart cybermem-openmemory'
62
+ restartCommand: 'docker restart cybermem-mcp'
63
63
  })
64
64
 
65
65
  } catch (restoreError: any) {
@@ -1,10 +1,10 @@
1
- import { checkRateLimit, rateLimitResponse } from '@/lib/rate-limit'
2
- import fs from 'fs'
3
- import { NextRequest, NextResponse } from 'next/server'
1
+ import { checkRateLimit, rateLimitResponse } from "@/lib/rate-limit";
2
+ import fs from "fs";
3
+ import { NextRequest, NextResponse } from "next/server";
4
4
 
5
- export const dynamic = 'force-dynamic'
5
+ export const dynamic = "force-dynamic";
6
6
 
7
- const CONFIG_PATH = '/data/config.json'
7
+ const CONFIG_PATH = "/data/config.json";
8
8
 
9
9
  export async function GET(request: NextRequest) {
10
10
  // Rate limiting check
@@ -13,39 +13,50 @@ export async function GET(request: NextRequest) {
13
13
  return rateLimitResponse(rateLimit.resetIn);
14
14
  }
15
15
 
16
- let apiKey = process.env.OM_API_KEY || 'not-set'
16
+ let apiKey = process.env.OM_API_KEY || "not-set";
17
17
 
18
18
  // Try to read from HttpOnly cookie first
19
- const cookieKey = request.cookies.get('cybermem_api_key')?.value
19
+ const cookieKey = request.cookies.get("cybermem_api_key")?.value;
20
20
  if (cookieKey) {
21
- apiKey = cookieKey
21
+ apiKey = cookieKey;
22
22
  }
23
23
 
24
24
  // Fallback to config file
25
25
  try {
26
- if (fs.existsSync(CONFIG_PATH)) {
27
- const raw = fs.readFileSync(CONFIG_PATH, 'utf-8')
28
- const conf = JSON.parse(raw)
29
- if (conf.api_key && apiKey === 'not-set') apiKey = conf.api_key
30
- }
26
+ if (fs.existsSync(CONFIG_PATH)) {
27
+ const raw = fs.readFileSync(CONFIG_PATH, "utf-8");
28
+ const conf = JSON.parse(raw);
29
+ if (conf.api_key && apiKey === "not-set") apiKey = conf.api_key;
30
+ }
31
31
  } catch (e) {
32
- // ignore
32
+ // ignore
33
33
  }
34
34
 
35
35
  // Endpoint resolution:
36
36
  // 1. Env var CYBERMEM_URL
37
37
  // 2. Default to localhost:8088/memory (Managed Mode)
38
38
  const rawEndpoint = process.env.CYBERMEM_URL;
39
- const endpoint = rawEndpoint || 'http://localhost:8088/memory';
40
- const isManaged = !rawEndpoint;
41
-
42
- return NextResponse.json({
43
- apiKey: apiKey,
44
- endpoint,
45
- isManaged
46
- }, {
47
- headers: {
48
- 'X-RateLimit-Remaining': String(rateLimit.remaining)
49
- }
50
- })
39
+ const endpoint = rawEndpoint || "http://localhost:8626/memory";
40
+ // isManaged = Local Mode (No Auth). Only if NO URL and NO API KEY.
41
+ // If API Key is present (RPi), we are in "Secure/Legacy" mode, not Local.
42
+ // In local development, rawEndpoint might be unset, but we still want to not be "managed" if we want to test auth flows.
43
+ const isManaged =
44
+ !rawEndpoint &&
45
+ (!process.env.OM_API_KEY || process.env.OM_API_KEY === "dev-secret-key");
46
+
47
+ return NextResponse.json(
48
+ {
49
+ token: apiKey,
50
+ apiKey: apiKey,
51
+ endpoint,
52
+ isManaged,
53
+ dashboardVersion: "v0.7.5",
54
+ mcpVersion: "v0.7.5",
55
+ },
56
+ {
57
+ headers: {
58
+ "X-RateLimit-Remaining": String(rateLimit.remaining),
59
+ },
60
+ },
61
+ );
51
62
  }
@@ -6,7 +6,7 @@ export const dynamic = 'force-dynamic'
6
6
  export async function POST(req: Request) {
7
7
  try {
8
8
  const docker = new Docker({ socketPath: '/var/run/docker.sock' });
9
- const container = docker.getContainer('cybermem-openmemory');
9
+ const container = docker.getContainer('cybermem-mcp');
10
10
 
11
11
  await container.restart();
12
12
 
package/app/layout.tsx CHANGED
@@ -1,37 +1,45 @@
1
- import { DashboardProvider } from "@/lib/data/dashboard-context"
2
- import { Analytics } from "@vercel/analytics/next"
3
- import type { Metadata } from "next"
4
- import { Exo_2, Geist, Geist_Mono } from "next/font/google"
5
- import type React from "react"
6
- import "./globals.css"
1
+ import { DashboardProvider } from "@/lib/data/dashboard-context";
2
+ import { Analytics } from "@vercel/analytics/next";
3
+ import type { Metadata } from "next";
4
+ import { Exo_2, Geist, Geist_Mono } from "next/font/google";
5
+ import type React from "react";
6
+ import "./globals.css";
7
7
 
8
- const _geist = Geist({ subsets: ["latin"] })
9
- const _geistMono = Geist_Mono({ subsets: ["latin"] })
10
- const exo2 = Exo_2({ subsets: ["latin"], variable: "--font-exo2" })
8
+ const _geist = Geist({ subsets: ["latin"] });
9
+ const _geistMono = Geist_Mono({ subsets: ["latin"] });
10
+ const exo2 = Exo_2({ subsets: ["latin"], variable: "--font-exo2" });
11
11
 
12
12
  export const metadata: Metadata = {
13
13
  title: "CyberMem",
14
14
  description: "Real-time memory operations dashboard",
15
15
  icons: {
16
- icon: '/favicon-dark.svg',
17
- shortcut: '/favicon-dark.svg',
18
- apple: '/favicon-dark.svg',
16
+ icon: "/favicon-dark.svg",
17
+ shortcut: "/favicon-dark.svg",
18
+ apple: "/favicon-dark.svg",
19
19
  },
20
- }
20
+ };
21
+
22
+ import { headers } from "next/headers";
21
23
 
22
- export default function RootLayout({
24
+ // ... imports ...
25
+
26
+ export default async function RootLayout({
23
27
  children,
24
28
  }: Readonly<{
25
- children: React.ReactNode
29
+ children: React.ReactNode;
26
30
  }>) {
31
+ const headersList = await headers();
32
+ const userId = headersList.get("x-user-id");
33
+ const initialAuth = !!userId;
34
+
27
35
  return (
28
36
  <html lang="en" suppressHydrationWarning className="relative">
29
37
  <body className={`font-sans antialiased relative z-10 ${exo2.variable}`}>
30
- <DashboardProvider>
38
+ <DashboardProvider initialAuth={initialAuth}>
31
39
  {children}
32
40
  </DashboardProvider>
33
41
  <Analytics />
34
42
  </body>
35
43
  </html>
36
- )
44
+ );
37
45
  }