@cybermem/dashboard 0.5.6 → 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.
- package/app/api/audit-logs/route.ts +26 -1
- package/app/api/backup/route.ts +67 -0
- package/app/api/metrics/route.ts +102 -118
- package/app/api/reset/route.ts +83 -0
- package/app/api/restore/route.ts +101 -0
- package/components/dashboard/audit-log-table.tsx +1 -1
- package/components/dashboard/metrics-chart.tsx +2 -2
- package/components/dashboard/metrics-grid.tsx +5 -15
- package/e2e/infra-check.spec.ts +97 -0
- package/package.json +1 -1
- package/public/clients.json +1 -1
|
@@ -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
|
|
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
|
+
}
|
package/app/api/metrics/route.ts
CHANGED
|
@@ -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') || '
|
|
50
|
+
const period = searchParams.get('period') || '24h'
|
|
51
|
+
|
|
52
|
+
const controller = new AbortController()
|
|
53
|
+
const timeoutId = setTimeout(() => controller.abort(), 5000)
|
|
37
54
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
103
|
-
stats: {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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:
|
|
132
|
-
totalRequests:
|
|
133
|
-
totalClients:
|
|
134
|
-
successRate:
|
|
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) =>
|
|
50
|
+
return clientConfigs.find((c: any) => new RegExp(c.match, 'i').test(nameLower))
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
const getClientDisplayName = (rawName: string) => {
|
|
@@ -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) =>
|
|
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
|
-
|
|
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">{
|
|
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 ?
|
|
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" ?
|
|
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" ?
|
|
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"}
|
|
@@ -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
package/public/clients.json
CHANGED
|
@@ -35,7 +35,7 @@
|
|
|
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
41
|
"description": "Antigravity supports MCP via raw JSON configuration.",
|