@cybermem/dashboard 0.5.3 → 0.5.8

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,10 +1,35 @@
1
+ import fs from 'fs'
1
2
  import { NextResponse } from 'next/server'
3
+ import path from 'path'
2
4
 
3
5
  export const dynamic = 'force-dynamic'
4
6
 
5
7
  // Use env var for db-exporter URL (Docker internal vs local dev)
6
8
  const DB_EXPORTER_URL = process.env.DB_EXPORTER_URL || 'http://localhost:8000'
7
9
 
10
+ // Load clients config for name normalization
11
+ let clientsConfig: any[] = []
12
+ try {
13
+ const configPath = path.join(process.cwd(), 'public', 'clients.json')
14
+ clientsConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
15
+ } catch (e) {
16
+ console.error('Failed to load clients.json:', e)
17
+ }
18
+
19
+ // Normalize raw client name (e.g. "antigravity-client") to friendly name (e.g. "Antigravity")
20
+ function normalizeClientName(rawName: string): string {
21
+ if (!rawName) return 'Unknown'
22
+ const nameLower = rawName.toLowerCase()
23
+ const client = clientsConfig.find((c: any) => {
24
+ try {
25
+ return new RegExp(c.match, 'i').test(nameLower)
26
+ } catch {
27
+ return nameLower.includes(c.match)
28
+ }
29
+ })
30
+ return client?.name || rawName
31
+ }
32
+
8
33
  const CLIENTS = ["Claude Code", "v0", "Cursor", "GitHub Copilot", "Windsurf"]
9
34
  const OPERATIONS = ["Read", "Write", "Update", "Delete", "Create"]
10
35
  const STATUSES = ["Success", "Success", "Success", "Warning", "Error"]
@@ -45,7 +70,7 @@ export async function GET(request: Request) {
45
70
 
46
71
  return {
47
72
  timestamp: log.timestamp,
48
- client: log.client_name || "Unknown",
73
+ client: normalizeClientName(log.client_name),
49
74
  operation: operation,
50
75
  status: status,
51
76
  method: log.method,
@@ -0,0 +1,67 @@
1
+ import { exec } from 'child_process'
2
+ import { createReadStream, statSync } from 'fs'
3
+ import { NextResponse } from 'next/server'
4
+ import { tmpdir } from 'os'
5
+ import { join } from 'path'
6
+ import { promisify } from 'util'
7
+
8
+ export const dynamic = 'force-dynamic'
9
+
10
+ const execAsync = promisify(exec)
11
+
12
+ /**
13
+ * GET /api/backup
14
+ * Creates and downloads a backup of the OpenMemory database
15
+ */
16
+ export async function GET() {
17
+ try {
18
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
19
+ const backupName = `cybermem-backup-${timestamp}.tar.gz`
20
+ const tmpPath = join(tmpdir(), backupName)
21
+
22
+ // Create backup via docker
23
+ const containerName = process.env.OPENMEMORY_CONTAINER || 'cybermem-openmemory'
24
+
25
+ try {
26
+ // Copy data from container to temp location
27
+ await execAsync(
28
+ `docker cp ${containerName}:/data - | gzip > "${tmpPath}"`
29
+ )
30
+
31
+ // Get file stats
32
+ const stats = statSync(tmpPath)
33
+
34
+ // Stream the file
35
+ const stream = createReadStream(tmpPath)
36
+
37
+ // Convert Node stream to Web ReadableStream
38
+ const webStream = new ReadableStream({
39
+ start(controller) {
40
+ stream.on('data', (chunk) => controller.enqueue(chunk))
41
+ stream.on('end', () => controller.close())
42
+ stream.on('error', (err) => controller.error(err))
43
+ }
44
+ })
45
+
46
+ return new NextResponse(webStream, {
47
+ headers: {
48
+ 'Content-Type': 'application/gzip',
49
+ 'Content-Disposition': `attachment; filename="${backupName}"`,
50
+ 'Content-Length': String(stats.size)
51
+ }
52
+ })
53
+
54
+ } catch (dockerError: any) {
55
+ return NextResponse.json(
56
+ { error: `Backup failed: ${dockerError.message}` },
57
+ { status: 500 }
58
+ )
59
+ }
60
+
61
+ } catch (error: any) {
62
+ return NextResponse.json(
63
+ { error: error.message || 'Unknown error' },
64
+ { status: 500 }
65
+ )
66
+ }
67
+ }
@@ -1,137 +1,121 @@
1
- import {
2
- getClientCount,
3
- getCreatesByClient,
4
- getDeletesByClient,
5
- getErrorsByClient,
6
- getLastReader,
7
- getLastWriter,
8
- getMemoryRecordsCount,
9
- getMemoryRecordsSparkline,
10
- getReadsByClient,
11
- getRequestsByClient,
12
- getRequestsByMethod,
13
- getRequestsTimeSeries,
14
- getRequestsTimeSeriesByMethod,
15
- getResponseTimeTimeSeries,
16
- getSuccessRate,
17
- getSuccessRateByClient,
18
- getSuccessRateSparkline,
19
- getSuccessRateTimeSeries,
20
- getSuccessRateTimeSeriesByClient,
21
- getTopReader,
22
- getTopWriter,
23
- getTotalClientsSparkline,
24
- getTotalRequests,
25
- getTotalRequestsSparkline,
26
- getUpdatesByClient
27
- } from '@/lib/prometheus'
1
+ import fs from 'fs'
28
2
  import { NextResponse } from 'next/server'
3
+ import path from 'path'
29
4
 
30
5
  export const dynamic = 'force-dynamic'
31
6
  export const revalidate = 0
32
7
 
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'
10
+
11
+ // Load clients config for name normalization
12
+ let clientsConfig: any[] = []
13
+ try {
14
+ const configPath = path.join(process.cwd(), 'public', 'clients.json')
15
+ clientsConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
16
+ } catch (e) {
17
+ console.error('Failed to load clients.json:', e)
18
+ }
19
+
20
+ // Normalize raw client name to friendly name
21
+ function normalizeClientName(rawName: string): string {
22
+ if (!rawName || rawName === 'N/A') return rawName
23
+ const nameLower = rawName.toLowerCase()
24
+ const client = clientsConfig.find((c: any) => {
25
+ try {
26
+ return new RegExp(c.match, 'i').test(nameLower)
27
+ } catch {
28
+ return nameLower.includes(c.match)
29
+ }
30
+ })
31
+ return client?.name || rawName
32
+ }
33
+
34
+ // Normalize client names in time series data
35
+ function normalizeTimeSeries(data: any[]): any[] {
36
+ return data.map(point => {
37
+ const normalized: any = { time: point.time }
38
+ for (const [key, value] of Object.entries(point)) {
39
+ if (key !== 'time') {
40
+ normalized[normalizeClientName(key)] = value
41
+ }
42
+ }
43
+ return normalized
44
+ })
45
+ }
46
+
33
47
  export async function GET(request: Request) {
34
48
  try {
35
49
  const { searchParams } = new URL(request.url)
36
- const period = searchParams.get('period') || '15m'
50
+ const period = searchParams.get('period') || '24h'
51
+
52
+ const controller = new AbortController()
53
+ const timeoutId = setTimeout(() => controller.abort(), 5000)
37
54
 
38
- const [
39
- totalRequests,
40
- requestsByClient,
41
- requestsByMethod,
42
- successRate,
43
- requestsTimeSeries,
44
- responseTimeTimeSeries,
45
- successRateTimeSeries,
46
- writesTimeSeries,
47
- memoryRecords,
48
- clientCount,
49
- memoryRecordsSparkline,
50
- totalRequestsSparkline,
51
- totalClientsSparkline,
52
- successRateSparkline,
53
- successRateByClient,
54
- successRateTimeSeriesByClient,
55
- topWriter,
56
- topReader,
57
- lastWriter,
58
- lastReader,
59
- createsTimeSeries,
60
- readsTimeSeries,
61
- updatesTimeSeries,
62
- deletesTimeSeries,
63
- errorsTimeSeries
64
- ] = await Promise.all([
65
- getTotalRequests(period),
66
- getRequestsByClient(period),
67
- getRequestsByMethod(period),
68
- getSuccessRate(),
69
- getRequestsTimeSeries(period),
70
- getResponseTimeTimeSeries(period),
71
- getSuccessRateTimeSeries(period),
72
- getRequestsTimeSeriesByMethod('POST', period),
73
- getMemoryRecordsCount(),
74
- getClientCount(),
75
- getMemoryRecordsSparkline(period),
76
- getTotalRequestsSparkline(period),
77
- getTotalClientsSparkline(period),
78
- getSuccessRateSparkline(period),
79
- getSuccessRateByClient(),
80
- getSuccessRateTimeSeriesByClient(period),
81
- getTopWriter(),
82
- getTopReader(),
83
- getLastWriter(),
84
- getLastReader(),
85
- getCreatesByClient(period),
86
- getReadsByClient(period),
87
- getUpdatesByClient(period),
88
- getDeletesByClient(period),
89
- getErrorsByClient(period)
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
+ })
90
65
  ])
91
66
 
92
- // Sort clients by total requests
93
- const clientStats = Object.entries(requestsByClient)
94
- .map(([client, total]) => ({
95
- client,
96
- total,
97
- reads: requestsByMethod.reads[client] || 0,
98
- writes: requestsByMethod.writes[client] || 0
99
- }))
100
- .sort((a, b) => b.total - a.total)
67
+ clearTimeout(timeoutId)
101
68
 
102
- return NextResponse.json({
103
- stats: {
104
- memoryRecords,
105
- totalClients: clientCount,
106
- successRate,
107
- totalRequests,
108
- topWriter,
109
- topReader,
110
- lastWriter,
111
- lastReader
69
+ if (!statsRes.ok) {
70
+ throw new Error(`Failed to fetch stats: ${statsRes.statusText}`)
71
+ }
72
+
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
112
85
  },
113
- timeSeries: {
114
- requests: requestsTimeSeries,
115
- responseTime: responseTimeTimeSeries,
116
- successRate: successRateTimeSeries,
117
- successRateByClient: successRateTimeSeriesByClient,
118
- writes: writesTimeSeries,
119
- creates: createsTimeSeries,
120
- reads: readsTimeSeries,
121
- updates: updatesTimeSeries,
122
- deletes: deletesTimeSeries,
123
- errors: errorsTimeSeries
86
+ topReader: {
87
+ name: normalizeClientName(stats.topReader?.name || 'N/A'),
88
+ count: stats.topReader?.count || 0
124
89
  },
125
- clientStats: {
126
- reads: requestsByMethod.reads,
127
- writes: requestsByMethod.writes,
128
- successRate: successRateByClient
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
97
+ }
98
+ }
99
+
100
+ return NextResponse.json({
101
+ stats: normalizedStats,
102
+ timeSeries: {
103
+ creates: normalizeTimeSeries(timeseries.creates || []),
104
+ reads: normalizeTimeSeries(timeseries.reads || []),
105
+ updates: normalizeTimeSeries(timeseries.updates || []),
106
+ deletes: normalizeTimeSeries(timeseries.deletes || [])
129
107
  },
108
+ // Legacy fields for backward compatibility
130
109
  sparklines: {
131
- memoryRecords: memoryRecordsSparkline,
132
- totalRequests: totalRequestsSparkline,
133
- totalClients: totalClientsSparkline,
134
- successRate: successRateSparkline
110
+ memoryRecords: [],
111
+ totalRequests: [],
112
+ totalClients: [],
113
+ successRate: []
114
+ },
115
+ clientStats: {
116
+ reads: {},
117
+ writes: {},
118
+ successRate: {}
135
119
  }
136
120
  })
137
121
  } catch (error) {
@@ -0,0 +1,83 @@
1
+ import { exec } from 'child_process'
2
+ import { NextRequest, NextResponse } from 'next/server'
3
+ import { promisify } from 'util'
4
+
5
+ export const dynamic = 'force-dynamic'
6
+
7
+ const execAsync = promisify(exec)
8
+
9
+ /**
10
+ * POST /api/reset
11
+ * Wipes the OpenMemory database
12
+ *
13
+ * Requires confirmation token in body: { confirm: "RESET" }
14
+ */
15
+ export async function POST(request: NextRequest) {
16
+ try {
17
+ const body = await request.json()
18
+
19
+ // Require explicit confirmation
20
+ if (body.confirm !== 'RESET') {
21
+ return NextResponse.json(
22
+ { error: 'Confirmation required. Send { confirm: "RESET" }' },
23
+ { status: 400 }
24
+ )
25
+ }
26
+
27
+ // Wipe database via docker exec
28
+ const containerName = process.env.OPENMEMORY_CONTAINER || 'cybermem-openmemory'
29
+
30
+ try {
31
+ // Remove SQLite files
32
+ await execAsync(
33
+ `docker exec ${containerName} sh -c 'rm -f /data/openmemory.sqlite*'`
34
+ )
35
+
36
+ // Restart container to reinitialize
37
+ await execAsync(`docker restart ${containerName}`)
38
+
39
+ // Wait for health
40
+ let healthy = false
41
+ for (let i = 0; i < 30; i++) {
42
+ await new Promise(r => setTimeout(r, 2000))
43
+ try {
44
+ const healthUrl = process.env.CYBERMEM_URL || 'http://localhost:8626'
45
+ const res = await fetch(`${healthUrl}/health`, { cache: 'no-store' })
46
+ if (res.ok) {
47
+ healthy = true
48
+ break
49
+ }
50
+ } catch {
51
+ // Still starting up
52
+ }
53
+ }
54
+
55
+ if (!healthy) {
56
+ return NextResponse.json(
57
+ { error: 'Database reset but container failed to become healthy' },
58
+ { status: 500 }
59
+ )
60
+ }
61
+
62
+ // Restart log-exporter and db-exporter
63
+ await execAsync('docker restart cybermem-log-exporter cybermem-db-exporter').catch(() => {})
64
+
65
+ return NextResponse.json({
66
+ success: true,
67
+ message: 'Database reset successfully'
68
+ })
69
+
70
+ } catch (dockerError: any) {
71
+ return NextResponse.json(
72
+ { error: `Docker command failed: ${dockerError.message}` },
73
+ { status: 500 }
74
+ )
75
+ }
76
+
77
+ } catch (error: any) {
78
+ return NextResponse.json(
79
+ { error: error.message || 'Unknown error' },
80
+ { status: 500 }
81
+ )
82
+ }
83
+ }
@@ -0,0 +1,101 @@
1
+ import { exec } from 'child_process'
2
+ import { unlinkSync, writeFileSync } from 'fs'
3
+ import { NextRequest, NextResponse } from 'next/server'
4
+ import { tmpdir } from 'os'
5
+ import { join } from 'path'
6
+ import { promisify } from 'util'
7
+
8
+ export const dynamic = 'force-dynamic'
9
+
10
+ const execAsync = promisify(exec)
11
+
12
+ /**
13
+ * POST /api/restore
14
+ * Restores the OpenMemory database from uploaded backup
15
+ *
16
+ * Accepts multipart/form-data with 'backup' file field
17
+ */
18
+ export async function POST(request: NextRequest) {
19
+ try {
20
+ const formData = await request.formData()
21
+ const file = formData.get('backup') as File | null
22
+
23
+ if (!file) {
24
+ return NextResponse.json(
25
+ { error: 'No backup file provided. Upload as "backup" field.' },
26
+ { status: 400 }
27
+ )
28
+ }
29
+
30
+ // Validate file type
31
+ if (!file.name.endsWith('.tar.gz') && !file.name.endsWith('.tgz')) {
32
+ return NextResponse.json(
33
+ { error: 'Invalid file type. Must be .tar.gz or .tgz' },
34
+ { status: 400 }
35
+ )
36
+ }
37
+
38
+ const containerName = process.env.OPENMEMORY_CONTAINER || 'cybermem-openmemory'
39
+ const tmpPath = join(tmpdir(), `restore-${Date.now()}.tar.gz`)
40
+
41
+ try {
42
+ // Write uploaded file to temp location
43
+ const buffer = Buffer.from(await file.arrayBuffer())
44
+ writeFileSync(tmpPath, buffer)
45
+
46
+ // Stop container
47
+ await execAsync(`docker stop ${containerName}`)
48
+
49
+ // Extract backup to container volume
50
+ await execAsync(
51
+ `gunzip -c "${tmpPath}" | docker cp - ${containerName}:/`
52
+ )
53
+
54
+ // Clean up temp file
55
+ unlinkSync(tmpPath)
56
+
57
+ // Start container
58
+ await execAsync(`docker start ${containerName}`)
59
+
60
+ // Wait for health
61
+ let healthy = false
62
+ for (let i = 0; i < 30; i++) {
63
+ await new Promise(r => setTimeout(r, 2000))
64
+ try {
65
+ const healthUrl = process.env.CYBERMEM_URL || 'http://localhost:8626'
66
+ const res = await fetch(`${healthUrl}/health`, { cache: 'no-store' })
67
+ if (res.ok) {
68
+ healthy = true
69
+ break
70
+ }
71
+ } catch {
72
+ // Still starting up
73
+ }
74
+ }
75
+
76
+ // Restart exporters
77
+ await execAsync('docker restart cybermem-log-exporter cybermem-db-exporter').catch(() => {})
78
+
79
+ return NextResponse.json({
80
+ success: true,
81
+ message: 'Database restored successfully',
82
+ healthy
83
+ })
84
+
85
+ } catch (dockerError: any) {
86
+ // Clean up temp file on error
87
+ try { unlinkSync(tmpPath) } catch {}
88
+
89
+ return NextResponse.json(
90
+ { error: `Restore failed: ${dockerError.message}` },
91
+ { status: 500 }
92
+ )
93
+ }
94
+
95
+ } catch (error: any) {
96
+ return NextResponse.json(
97
+ { error: error.message || 'Unknown error' },
98
+ { status: 500 }
99
+ )
100
+ }
101
+ }
@@ -47,7 +47,7 @@ export default function AuditLogTable({
47
47
  const getClientConfig = (rawName: string) => {
48
48
  if (!rawName) return undefined
49
49
  const nameLower = rawName.toLowerCase()
50
- return clientConfigs.find((c: any) => nameLower.includes(c.match))
50
+ return clientConfigs.find((c: any) => new RegExp(c.match, 'i').test(nameLower))
51
51
  }
52
52
 
53
53
  const getClientDisplayName = (rawName: string) => {
@@ -83,29 +83,28 @@ export default function MCPConfigModal({ onClose }: { onClose: () => void }) {
83
83
  }
84
84
 
85
85
  const getMcpConfig = (clientId: string) => {
86
- const isAntigravity = clientId === 'antigravity'
87
- const urlKey = isAntigravity ? 'serverUrl' : 'url'
88
-
89
86
  // Local mode: use stdio (command-based) - no server needed, runs via npx
90
87
  if (isManaged) {
91
88
  return {
92
89
  mcpServers: {
93
90
  cybermem: {
94
91
  command: "npx",
95
- args: ["@cybermem/mcp-core"]
92
+ args: ["@cybermem/mcp"]
96
93
  }
97
94
  }
98
95
  }
99
96
  }
100
97
 
101
- // Remote mode: use SSE URL with API key header
98
+ // Remote mode: use stdio with --url and --api-key args (universal for all clients)
102
99
  return {
103
100
  mcpServers: {
104
101
  cybermem: {
105
- [urlKey]: `${baseUrl}/mcp`,
106
- "headers": {
107
- "x-api-key": apiKey || "sk-your-generated-key"
108
- }
102
+ command: "npx",
103
+ args: [
104
+ "-y", "@cybermem/mcp",
105
+ "--url", baseUrl,
106
+ "--api-key", apiKey || "sk-your-generated-key"
107
+ ]
109
108
  }
110
109
  }
111
110
  }
@@ -119,9 +118,9 @@ export default function MCPConfigModal({ onClose }: { onClose: () => void }) {
119
118
  // Handle TOML config (Codex)
120
119
  if (config?.configType === 'toml') {
121
120
  if (isManaged) {
122
- return `# CyberMem Configuration (Local Mode)\n[mcp]\ncommand = "npx"\nargs = ["@cybermem/mcp-core"]`;
121
+ return `# CyberMem Configuration (Local Mode)\n[mcp]\ncommand = "npx"\nargs = ["@cybermem/mcp"]`;
123
122
  }
124
- return `# CyberMem Configuration\n[mcp]\nserver_url = "${baseUrl}/mcp"\napi_key = "${maskKey ? displayKey : actualKey}"`;
123
+ return `# CyberMem Configuration (Remote Mode)\n[mcp]\ncommand = "npx"\nargs = ["@cybermem/mcp", "--url", "${baseUrl}", "--api-key", "${maskKey ? displayKey : actualKey}"]`;
125
124
  }
126
125
 
127
126
  // Handle command-based configs (Claude Code, Gemini CLI, etc.)
@@ -134,16 +133,9 @@ export default function MCPConfigModal({ onClose }: { onClose: () => void }) {
134
133
  cmd = config?.command?.replace("http://localhost:8080", baseUrl) || '';
135
134
  }
136
135
 
137
- // Substitute {{ENDPOINT}} placeholder with actual endpoint
138
- cmd = cmd.replace('{{ENDPOINT}}', `${baseUrl}/mcp`);
139
-
140
- // Remote mode - inject API key for SSE transport commands
141
- if (!isManaged && cmd.includes('--transport sse')) {
142
- const headerPart = `--header "x-api-key: ${maskKey ? displayKey : actualKey}"`;
143
- if (!cmd.includes('x-api-key')) {
144
- cmd = cmd.replace('mcp add', `mcp add ${headerPart}`);
145
- }
146
- }
136
+ // Substitute placeholders with actual values
137
+ cmd = cmd.replace('{{ENDPOINT}}', baseUrl);
138
+ cmd = cmd.replace('{{API_KEY}}', maskKey ? displayKey : actualKey);
147
139
 
148
140
  return cmd;
149
141
  }
@@ -151,7 +143,12 @@ export default function MCPConfigModal({ onClose }: { onClose: () => void }) {
151
143
  // Default to JSON config
152
144
  const jsonConfig = getMcpConfig(selectedClient);
153
145
  if (!isManaged && maskKey) {
154
- (jsonConfig.mcpServers.cybermem as any).headers["x-api-key"] = displayKey;
146
+ // Mask the API key in args array
147
+ const args = (jsonConfig.mcpServers.cybermem as any).args;
148
+ const apiKeyIdx = args.indexOf('--api-key');
149
+ if (apiKeyIdx !== -1 && args[apiKeyIdx + 1]) {
150
+ args[apiKeyIdx + 1] = displayKey;
151
+ }
155
152
  }
156
153
  return JSON.stringify(jsonConfig, null, 2);
157
154
  }
@@ -86,9 +86,9 @@ export default function MetricsChart({
86
86
  )}
87
87
  {isMultiSeries ? (
88
88
  clientNames.map((client, i) => {
89
- // Find matching config
89
+ // Find matching config using regex
90
90
  const keyLower = client.toLowerCase()
91
- const config = clientConfigs.find((c: any) => keyLower.includes(c.match))
91
+ const config = clientConfigs.find((c: any) => new RegExp(c.match, 'i').test(keyLower))
92
92
 
93
93
  // Use config if found, otherwise fallback
94
94
  const name = config?.name || client
@@ -1,7 +1,6 @@
1
1
  "use client"
2
2
 
3
3
  import { Card, CardContent } from "@/components/ui/card"
4
- import { useDashboard } from "@/lib/data/dashboard-context"
5
4
  import MetricCard from "./metric-card"
6
5
 
7
6
  // Types
@@ -32,16 +31,7 @@ interface MetricsGridProps {
32
31
 
33
32
 
34
33
  export default function MetricsGrid({ stats, trends }: MetricsGridProps) {
35
- const { clientConfigs } = useDashboard()
36
-
37
- const getClientDisplayName = (rawName: string) => {
38
- if (!rawName || rawName === "N/A" || rawName === "unknown") return rawName
39
-
40
- const nameLower = rawName.toLowerCase()
41
- // Find matching client config (e.g. "antigravity" matches "antigravity-client")
42
- const config = clientConfigs.find((c: any) => nameLower.includes(c.match))
43
- return config ? config.name : rawName
44
- }
34
+ // Note: Client names are now normalized by backend API, no frontend transformation needed
45
35
 
46
36
  const formatTimestamp = (timestamp: number) => {
47
37
  if (timestamp <= 0) return "No activity"
@@ -99,7 +89,7 @@ export default function MetricsGrid({ stats, trends }: MetricsGridProps) {
99
89
  <Card className="bg-white/5 border-white/10 backdrop-blur-md text-white shadow-lg overflow-hidden">
100
90
  <CardContent className="pt-6 pb-6 relative">
101
91
  <div className="text-sm font-medium text-slate-400 mb-2">Top Writer</div>
102
- <div className="text-4xl font-bold text-white mb-1 truncate">{getClientDisplayName(stats.topWriter.name)}</div>
92
+ <div className="text-4xl font-bold text-white mb-1 truncate">{stats.topWriter.name}</div>
103
93
  <div className="text-xl text-white/80 whitespace-nowrap">
104
94
  {stats.topWriter.count > 0 ? `${stats.topWriter.count.toLocaleString()} writes` : ""}
105
95
  </div>
@@ -111,7 +101,7 @@ export default function MetricsGrid({ stats, trends }: MetricsGridProps) {
111
101
  <CardContent className="pt-6 pb-6 relative">
112
102
  <div className="text-sm font-medium text-slate-400 mb-2">Top Reader</div>
113
103
  <div className="text-4xl font-bold text-white mb-1 truncate">
114
- {stats.topReader.count > 0 ? getClientDisplayName(stats.topReader.name) : "N/A"}
104
+ {stats.topReader.count > 0 ? stats.topReader.name : "N/A"}
115
105
  </div>
116
106
  <div className="text-xl text-white/80 whitespace-nowrap">
117
107
  {stats.topReader.count > 0 ? `${stats.topReader.count.toLocaleString()} reads` : ""}
@@ -124,7 +114,7 @@ export default function MetricsGrid({ stats, trends }: MetricsGridProps) {
124
114
  <CardContent className="pt-6 pb-6 relative">
125
115
  <div className="text-sm font-medium text-slate-400 mb-2">Last Writer</div>
126
116
  <div className="text-4xl font-bold text-white mb-1 truncate">
127
- {stats.lastWriter.name !== "N/A" ? getClientDisplayName(stats.lastWriter.name) : "N/A"}
117
+ {stats.lastWriter.name !== "N/A" ? stats.lastWriter.name : "N/A"}
128
118
  </div>
129
119
  <div className="text-xl text-white/80 whitespace-nowrap">
130
120
  {stats.lastWriter.timestamp > 0 ? formatTimestamp(stats.lastWriter.timestamp) : "No activity"}
@@ -137,7 +127,7 @@ export default function MetricsGrid({ stats, trends }: MetricsGridProps) {
137
127
  <CardContent className="pt-6 pb-6 relative">
138
128
  <div className="text-sm font-medium text-slate-400 mb-2">Last Reader</div>
139
129
  <div className="text-4xl font-bold text-white mb-1 truncate">
140
- {stats.lastReader.name !== "N/A" ? getClientDisplayName(stats.lastReader.name) : "N/A"}
130
+ {stats.lastReader.name !== "N/A" ? stats.lastReader.name : "N/A"}
141
131
  </div>
142
132
  <div className="text-xl text-white/80 whitespace-nowrap">
143
133
  {stats.lastReader.timestamp > 0 ? formatTimestamp(stats.lastReader.timestamp) : "No activity"}
@@ -72,10 +72,10 @@ test.describe('Dashboard Configuration UI', () => {
72
72
  // Master API Key should NOT be visible in local mode
73
73
  await expect(page.getByText('Master API Key')).not.toBeVisible();
74
74
 
75
- // Code block should show stdio command with npx @cybermem/mcp-core
75
+ // Code block should show stdio command with npx @cybermem/mcp
76
76
  await page.getByRole('button', { name: 'Gemini CLI' }).click();
77
77
  const codeBlock = page.locator('pre');
78
- await expect(codeBlock).toContainText('gemini mcp add cybermem npx @cybermem/mcp-core');
78
+ await expect(codeBlock).toContainText('gemini mcp add cybermem npx @cybermem/mcp');
79
79
  });
80
80
 
81
81
  test('Remote Mode: shows API Key management', async ({ page }) => {
@@ -89,10 +89,10 @@ test.describe('Dashboard Configuration UI', () => {
89
89
  // Master API Key should be visible
90
90
  await expect(page.getByText('Master API Key')).toBeVisible();
91
91
 
92
- // Gemini CLI should have header with API key
92
+ // Gemini CLI should have --url and --api-key args (universal stdio transport)
93
93
  await page.getByRole('button', { name: 'Gemini CLI' }).click();
94
94
  const codeBlock = page.locator('pre');
95
- await expect(codeBlock).toContainText('--header');
96
- await expect(codeBlock).toContainText('x-api-key');
95
+ await expect(codeBlock).toContainText('--url');
96
+ await expect(codeBlock).toContainText('--api-key');
97
97
  });
98
98
  });
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Infrastructure Pre-flight Checks
3
+ *
4
+ * Run BEFORE any happy path tests to validate:
5
+ * - Dashboard API responds
6
+ * - db-exporter /api/stats works
7
+ * - Client name normalization works
8
+ *
9
+ * Usage: npm run test:e2e -- infra-check.spec.ts
10
+ */
11
+
12
+ import { expect, test } from '@playwright/test';
13
+
14
+ const DASHBOARD_URL = process.env.BASE_URL || 'http://localhost:3000';
15
+ const DB_EXPORTER_URL = process.env.DB_EXPORTER_URL || 'http://localhost:8000';
16
+ const MCP_URL = process.env.MCP_URL || 'http://localhost:8626';
17
+
18
+ test.describe('Infrastructure Pre-flight Checks', () => {
19
+
20
+ test('1. MCP Health endpoint responds', async ({ request }) => {
21
+ const res = await request.get(`${MCP_URL}/health`);
22
+ expect(res.ok()).toBeTruthy();
23
+ const data = await res.json();
24
+ expect(data.ok).toBe(true);
25
+ console.log('✅ MCP Health: OK');
26
+ });
27
+
28
+ test('2. db-exporter /api/stats responds', async ({ request }) => {
29
+ const res = await request.get(`${DB_EXPORTER_URL}/api/stats`);
30
+ expect(res.ok()).toBeTruthy();
31
+ const data = await res.json();
32
+ expect(typeof data.memoryRecords).toBe('number');
33
+ expect(typeof data.totalClients).toBe('number');
34
+ expect(typeof data.successRate).toBe('number');
35
+ console.log(`✅ db-exporter: ${data.memoryRecords} memories, ${data.totalClients} clients`);
36
+ });
37
+
38
+ test('3. db-exporter /api/timeseries responds', async ({ request }) => {
39
+ const res = await request.get(`${DB_EXPORTER_URL}/api/timeseries?period=24h`);
40
+ expect(res.ok()).toBeTruthy();
41
+ const data = await res.json();
42
+ expect(Array.isArray(data.creates)).toBeTruthy();
43
+ expect(Array.isArray(data.reads)).toBeTruthy();
44
+ console.log('✅ db-exporter timeseries: OK');
45
+ });
46
+
47
+ test('4. Dashboard /api/metrics responds', async ({ request }) => {
48
+ const res = await request.get(`${DASHBOARD_URL}/api/metrics`);
49
+ expect(res.ok()).toBeTruthy();
50
+ const data = await res.json();
51
+ expect(data.stats).toBeDefined();
52
+ expect(typeof data.stats.memoryRecords).toBe('number');
53
+ console.log(`✅ Dashboard API: ${data.stats.memoryRecords} memories`);
54
+ });
55
+
56
+ test('5. Dashboard /api/audit-logs responds', async ({ request }) => {
57
+ const res = await request.get(`${DASHBOARD_URL}/api/audit-logs`);
58
+ expect(res.ok()).toBeTruthy();
59
+ const data = await res.json();
60
+ expect(Array.isArray(data.logs)).toBeTruthy();
61
+ console.log(`✅ Audit Logs API: ${data.logs.length} entries`);
62
+ });
63
+
64
+ test('6. Client name normalization works', async ({ request }) => {
65
+ // Check that clients.json is accessible
66
+ const configRes = await request.get(`${DASHBOARD_URL}/clients.json`);
67
+ expect(configRes.ok()).toBeTruthy();
68
+ const config = await configRes.json();
69
+
70
+ // Verify Antigravity mapping exists
71
+ const antigravity = config.find((c: any) => c.name === 'Antigravity');
72
+ expect(antigravity).toBeDefined();
73
+ expect(antigravity.match).toContain('antigravity');
74
+ console.log(`✅ Client normalization: Antigravity pattern = "${antigravity.match}"`);
75
+ });
76
+
77
+ test('7. Dashboard UI loads', async ({ page }) => {
78
+ await page.goto(DASHBOARD_URL);
79
+
80
+ // Handle login if needed
81
+ const passwordInput = page.getByPlaceholder('Enter admin password');
82
+ if (await passwordInput.isVisible({ timeout: 2000 }).catch(() => false)) {
83
+ await passwordInput.fill('admin');
84
+ await page.keyboard.press('Enter');
85
+ }
86
+
87
+ // Dismiss password warning if present
88
+ const dontShowBtn = page.locator('button:has-text("Don\'t show again")');
89
+ if (await dontShowBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
90
+ await dontShowBtn.click();
91
+ }
92
+
93
+ // Verify dashboard loaded
94
+ await expect(page.getByRole('heading', { name: 'CyberMem' })).toBeVisible({ timeout: 15000 });
95
+ console.log('✅ Dashboard UI: Loaded');
96
+ });
97
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cybermem/dashboard",
3
- "version": "0.5.3",
3
+ "version": "0.5.8",
4
4
  "description": "CyberMem Monitoring Dashboard",
5
5
  "homepage": "https://cybermem.dev",
6
6
  "repository": {
@@ -27,24 +27,24 @@
27
27
  "Navigate to **Features** > **MCP**",
28
28
  "Click **Add New MCP Server**",
29
29
  "Enter Name: `cybermem`",
30
- "Enter Type: `SSE`",
31
- "Enter the URL from the config below"
30
+ "Enter Type: `Command`",
31
+ "Copy the configuration from below"
32
32
  ],
33
33
  "configType": "json"
34
34
  },
35
35
  {
36
36
  "id": "antigravity",
37
37
  "name": "Antigravity",
38
- "match": "antigravity",
38
+ "match": "antigravity|antigravity-client",
39
39
  "color": "#d946ef",
40
40
  "icon": "/icons/antigravity.png",
41
- "description": "Antigravity supports MCP via raw JSON configuration. Note: uses 'serverUrl' instead of 'url'.",
41
+ "description": "Antigravity supports MCP via raw JSON configuration.",
42
42
  "steps": [
43
43
  "Open Antigravity and go to the **Agents** tab",
44
44
  "Click on the three dots menu and select **MCP Servers**",
45
45
  "Click **Manage MCP Servers**",
46
46
  "Click **View RAW Config**",
47
- "Paste the configuration block below (note: uses `serverUrl` key)"
47
+ "Paste the configuration block below"
48
48
  ],
49
49
  "configType": "json"
50
50
  },
@@ -107,8 +107,8 @@
107
107
  "The server will be available in all Claude Code sessions"
108
108
  ],
109
109
  "configType": "command",
110
- "localCommand": "claude mcp add cybermem npx @cybermem/mcp-core",
111
- "remoteCommand": "claude mcp add --transport sse cybermem {{ENDPOINT}}"
110
+ "localCommand": "claude mcp add cybermem npx @cybermem/mcp",
111
+ "remoteCommand": "claude mcp add cybermem -- npx @cybermem/mcp --url {{ENDPOINT}} --api-key {{API_KEY}}"
112
112
  },
113
113
  {
114
114
  "id": "chatgpt",
@@ -170,8 +170,8 @@
170
170
  "The server persists across sessions"
171
171
  ],
172
172
  "configType": "cmd",
173
- "localCommand": "gemini mcp add cybermem npx @cybermem/mcp-core",
174
- "remoteCommand": "gemini mcp add --transport sse cybermem {{ENDPOINT}}"
173
+ "localCommand": "gemini mcp add cybermem npx @cybermem/mcp",
174
+ "remoteCommand": "gemini mcp add cybermem -- npx @cybermem/mcp --url {{ENDPOINT}} --api-key {{API_KEY}}"
175
175
  },
176
176
  {
177
177
  "id": "other",
@@ -181,9 +181,8 @@
181
181
  "icon": null,
182
182
  "description": "For any other MCP-compliant client, use the following connection details:",
183
183
  "steps": [
184
- "**Transport**: SSE (Server-Sent Events)",
185
- "**Endpoint URL**: See config below",
186
- "**Authentication**: `x-api-key` header (if required)",
184
+ "**Transport**: stdio (via npx command)",
185
+ "**Command**: `npx @cybermem/mcp` with `--url` and `--api-key` args",
187
186
  "Refer to your client's documentation for config file location"
188
187
  ],
189
188
  "configType": "json"