@cybermem/dashboard 0.1.0 โ 0.5.1
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/README.md +5 -0
- package/app/api/audit-logs/route.ts +4 -1
- package/app/api/health/route.ts +94 -0
- package/app/api/settings/regenerate/route.ts +32 -3
- package/app/api/settings/route.ts +30 -4
- package/app/client-connect/page.tsx +45 -0
- package/app/page.tsx +28 -1
- package/components/dashboard/audit-log-table.tsx +102 -9
- package/components/dashboard/chart-card.tsx +5 -4
- package/components/dashboard/header.tsx +79 -5
- package/components/dashboard/login-modal.tsx +0 -4
- package/components/dashboard/mcp-config-modal.tsx +56 -12
- package/components/dashboard/metrics-grid.tsx +17 -4
- package/components/dashboard/password-alert-modal.tsx +72 -0
- package/components/dashboard/settings-modal.tsx +82 -217
- package/e2e/audit-export.spec.ts +111 -0
- package/e2e/auth.spec.ts +37 -0
- package/e2e/config-ui.spec.ts +98 -0
- package/e2e/crud-happy-path.spec.ts +214 -0
- package/e2e/metrics.spec.ts +28 -0
- package/eslint.config.mjs +15 -0
- package/lib/data/dashboard-context.tsx +44 -1
- package/lib/rate-limit.ts +77 -0
- package/middleware.ts +43 -0
- package/package.json +16 -5
- package/playwright.config.ts +35 -0
- package/public/clients.json +61 -35
- package/.eslintrc.json +0 -3
- package/shared.env +0 -0
package/README.md
ADDED
|
@@ -2,6 +2,9 @@ import { NextResponse } from 'next/server'
|
|
|
2
2
|
|
|
3
3
|
export const dynamic = 'force-dynamic'
|
|
4
4
|
|
|
5
|
+
// Use env var for db-exporter URL (Docker internal vs local dev)
|
|
6
|
+
const DB_EXPORTER_URL = process.env.DB_EXPORTER_URL || 'http://localhost:8000'
|
|
7
|
+
|
|
5
8
|
const CLIENTS = ["Claude Code", "v0", "Cursor", "GitHub Copilot", "Windsurf"]
|
|
6
9
|
const OPERATIONS = ["Read", "Write", "Update", "Delete", "Create"]
|
|
7
10
|
const STATUSES = ["Success", "Success", "Success", "Warning", "Error"]
|
|
@@ -18,7 +21,7 @@ export async function GET(request: Request) {
|
|
|
18
21
|
const controller = new AbortController()
|
|
19
22
|
const timeoutId = setTimeout(() => controller.abort(), 2000)
|
|
20
23
|
|
|
21
|
-
const res = await fetch(
|
|
24
|
+
const res = await fetch(`${DB_EXPORTER_URL}/api/logs?limit=100`, {
|
|
22
25
|
signal: controller.signal,
|
|
23
26
|
cache: 'no-store'
|
|
24
27
|
})
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { checkRateLimit, rateLimitResponse } from '@/lib/rate-limit'
|
|
2
|
+
import { NextRequest, NextResponse } from 'next/server'
|
|
3
|
+
|
|
4
|
+
export const dynamic = 'force-dynamic'
|
|
5
|
+
|
|
6
|
+
interface ServiceStatus {
|
|
7
|
+
name: string
|
|
8
|
+
status: 'ok' | 'error' | 'warning'
|
|
9
|
+
message?: string
|
|
10
|
+
latencyMs?: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface SystemHealth {
|
|
14
|
+
overall: 'ok' | 'degraded' | 'error'
|
|
15
|
+
services: ServiceStatus[]
|
|
16
|
+
timestamp: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function checkService(name: string, url: string, timeout = 3000): Promise<ServiceStatus> {
|
|
20
|
+
const start = Date.now()
|
|
21
|
+
try {
|
|
22
|
+
const controller = new AbortController()
|
|
23
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout)
|
|
24
|
+
|
|
25
|
+
const res = await fetch(url, {
|
|
26
|
+
signal: controller.signal,
|
|
27
|
+
cache: 'no-store',
|
|
28
|
+
headers: {
|
|
29
|
+
'X-Client-Name': 'CyberMem-Dashboard'
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
clearTimeout(timeoutId)
|
|
33
|
+
|
|
34
|
+
const latencyMs = Date.now() - start
|
|
35
|
+
|
|
36
|
+
if (res.ok) {
|
|
37
|
+
return { name, status: 'ok', latencyMs }
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
name,
|
|
41
|
+
status: 'warning',
|
|
42
|
+
message: `HTTP ${res.status}`,
|
|
43
|
+
latencyMs
|
|
44
|
+
}
|
|
45
|
+
} catch (error: any) {
|
|
46
|
+
return {
|
|
47
|
+
name,
|
|
48
|
+
status: 'error',
|
|
49
|
+
message: error.name === 'AbortError' ? 'Timeout' : (error.message || 'Connection failed')
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function GET(request: NextRequest) {
|
|
55
|
+
// Rate limiting
|
|
56
|
+
const rateLimit = checkRateLimit(request)
|
|
57
|
+
if (!rateLimit.allowed) {
|
|
58
|
+
return rateLimitResponse(rateLimit.resetIn)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Use environment variables with sensible defaults for local Docker stack
|
|
62
|
+
const prometheusUrl = process.env.PROMETHEUS_URL || 'http://localhost:9092'
|
|
63
|
+
const openMemoryUrl = process.env.CYBERMEM_URL || 'http://localhost:8626'
|
|
64
|
+
const vectorUrl = process.env.VECTOR_URL // Vector is optional
|
|
65
|
+
|
|
66
|
+
const checks: Promise<ServiceStatus>[] = [
|
|
67
|
+
checkService('OpenMemory API', `${openMemoryUrl}/health`),
|
|
68
|
+
checkService('Prometheus', `${prometheusUrl}/-/ready`),
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
// Only check Vector if configured
|
|
72
|
+
if (vectorUrl) {
|
|
73
|
+
checks.push(checkService('Vector', `${vectorUrl}/health`))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const services = await Promise.all(checks)
|
|
77
|
+
|
|
78
|
+
// Determine overall status
|
|
79
|
+
const hasError = services.some(s => s.status === 'error')
|
|
80
|
+
const hasWarning = services.some(s => s.status === 'warning')
|
|
81
|
+
|
|
82
|
+
const health: SystemHealth = {
|
|
83
|
+
overall: hasError ? 'error' : hasWarning ? 'degraded' : 'ok',
|
|
84
|
+
services,
|
|
85
|
+
timestamp: new Date().toISOString()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return NextResponse.json(health, {
|
|
89
|
+
headers: {
|
|
90
|
+
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
|
91
|
+
'X-RateLimit-Remaining': String(rateLimit.remaining)
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
}
|
|
@@ -1,18 +1,47 @@
|
|
|
1
|
+
import { checkRateLimit, rateLimitResponse } from '@/lib/rate-limit'
|
|
1
2
|
import crypto from 'crypto'
|
|
2
3
|
import fs from 'fs'
|
|
3
|
-
import { NextResponse } from 'next/server'
|
|
4
|
+
import { NextRequest, NextResponse } from 'next/server'
|
|
4
5
|
|
|
5
6
|
export const dynamic = 'force-dynamic'
|
|
6
7
|
|
|
7
|
-
export async function POST(req:
|
|
8
|
+
export async function POST(req: NextRequest) {
|
|
9
|
+
// Rate limiting check
|
|
10
|
+
const rateLimit = checkRateLimit(req);
|
|
11
|
+
if (!rateLimit.allowed) {
|
|
12
|
+
return rateLimitResponse(rateLimit.resetIn);
|
|
13
|
+
}
|
|
14
|
+
|
|
8
15
|
try {
|
|
9
16
|
const apiKey = `sk-${crypto.randomBytes(16).toString('hex')}`
|
|
10
17
|
const sharedEnvPath = '/app/shared.env'
|
|
18
|
+
const configPath = '/data/config.json'
|
|
11
19
|
|
|
12
20
|
// Write OM_API_KEY=... to shared file mounted at /.env in OpenMemory
|
|
13
21
|
fs.writeFileSync(sharedEnvPath, `OM_API_KEY=${apiKey}\n`)
|
|
14
22
|
|
|
15
|
-
|
|
23
|
+
// Also persist to config.json
|
|
24
|
+
let config: Record<string, any> = {}
|
|
25
|
+
try {
|
|
26
|
+
if (fs.existsSync(configPath)) {
|
|
27
|
+
config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
|
|
28
|
+
}
|
|
29
|
+
} catch {}
|
|
30
|
+
config.api_key = apiKey
|
|
31
|
+
fs.mkdirSync('/data', { recursive: true })
|
|
32
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2))
|
|
33
|
+
|
|
34
|
+
// Create response with HttpOnly cookie
|
|
35
|
+
const response = NextResponse.json({ success: true, apiKey })
|
|
36
|
+
response.cookies.set('cybermem_api_key', apiKey, {
|
|
37
|
+
httpOnly: true,
|
|
38
|
+
secure: process.env.NODE_ENV === 'production',
|
|
39
|
+
sameSite: 'strict',
|
|
40
|
+
path: '/',
|
|
41
|
+
maxAge: 60 * 60 * 24 * 365 // 1 year
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
return response
|
|
16
45
|
} catch (error) {
|
|
17
46
|
console.error('[Settings] Failed to regenerate API Key:', error)
|
|
18
47
|
return NextResponse.json({ error: 'Failed to regenerate API Key' }, { status: 500 })
|
|
@@ -1,25 +1,51 @@
|
|
|
1
|
+
import { checkRateLimit, rateLimitResponse } from '@/lib/rate-limit'
|
|
1
2
|
import fs from 'fs'
|
|
2
|
-
import { NextResponse } from 'next/server'
|
|
3
|
+
import { NextRequest, NextResponse } from 'next/server'
|
|
3
4
|
|
|
4
5
|
export const dynamic = 'force-dynamic'
|
|
5
6
|
|
|
6
7
|
const CONFIG_PATH = '/data/config.json'
|
|
7
8
|
|
|
8
|
-
export async function GET() {
|
|
9
|
+
export async function GET(request: NextRequest) {
|
|
10
|
+
// Rate limiting check
|
|
11
|
+
const rateLimit = checkRateLimit(request);
|
|
12
|
+
if (!rateLimit.allowed) {
|
|
13
|
+
return rateLimitResponse(rateLimit.resetIn);
|
|
14
|
+
}
|
|
15
|
+
|
|
9
16
|
let apiKey = process.env.OM_API_KEY || 'not-set'
|
|
10
17
|
|
|
18
|
+
// Try to read from HttpOnly cookie first
|
|
19
|
+
const cookieKey = request.cookies.get('cybermem_api_key')?.value
|
|
20
|
+
if (cookieKey) {
|
|
21
|
+
apiKey = cookieKey
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Fallback to config file
|
|
11
25
|
try {
|
|
12
26
|
if (fs.existsSync(CONFIG_PATH)) {
|
|
13
27
|
const raw = fs.readFileSync(CONFIG_PATH, 'utf-8')
|
|
14
28
|
const conf = JSON.parse(raw)
|
|
15
|
-
if (conf.api_key) apiKey = conf.api_key
|
|
29
|
+
if (conf.api_key && apiKey === 'not-set') apiKey = conf.api_key
|
|
16
30
|
}
|
|
17
31
|
} catch (e) {
|
|
18
32
|
// ignore
|
|
19
33
|
}
|
|
20
34
|
|
|
35
|
+
// Endpoint resolution:
|
|
36
|
+
// 1. Env var CYBERMEM_URL
|
|
37
|
+
// 2. Default to localhost:8088/memory (Managed Mode)
|
|
38
|
+
const rawEndpoint = process.env.CYBERMEM_URL;
|
|
39
|
+
const endpoint = rawEndpoint || 'http://localhost:8088/memory';
|
|
40
|
+
const isManaged = !rawEndpoint;
|
|
41
|
+
|
|
21
42
|
return NextResponse.json({
|
|
22
43
|
apiKey: apiKey,
|
|
23
|
-
endpoint
|
|
44
|
+
endpoint,
|
|
45
|
+
isManaged
|
|
46
|
+
}, {
|
|
47
|
+
headers: {
|
|
48
|
+
'X-RateLimit-Remaining': String(rateLimit.remaining)
|
|
49
|
+
}
|
|
24
50
|
})
|
|
25
51
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import LoginModal from "@/components/dashboard/login-modal"
|
|
4
|
+
import MCPConfigModal from "@/components/dashboard/mcp-config-modal"
|
|
5
|
+
import { useRouter } from "next/navigation"
|
|
6
|
+
import { useEffect, useState } from "react"
|
|
7
|
+
|
|
8
|
+
export default function ClientConnectPage() {
|
|
9
|
+
const router = useRouter()
|
|
10
|
+
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null)
|
|
11
|
+
|
|
12
|
+
// Check auth on mount
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
const auth = sessionStorage.getItem("authenticated")
|
|
15
|
+
setIsAuthenticated(auth === "true")
|
|
16
|
+
}, [])
|
|
17
|
+
|
|
18
|
+
const handleLogin = () => {
|
|
19
|
+
sessionStorage.setItem("authenticated", "true")
|
|
20
|
+
setIsAuthenticated(true)
|
|
21
|
+
// Mark that we came from /client-connect for first-run experience
|
|
22
|
+
sessionStorage.setItem("fromClientConnect", "true")
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const handleClose = () => {
|
|
26
|
+
router.push("/")
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Loading state
|
|
30
|
+
if (isAuthenticated === null) {
|
|
31
|
+
return <div className="min-h-screen bg-[#0a0a0a]" />
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Not authenticated - show login
|
|
35
|
+
if (!isAuthenticated) {
|
|
36
|
+
return <LoginModal onLogin={handleLogin} />
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Authenticated - show MCP modal
|
|
40
|
+
return (
|
|
41
|
+
<div className="min-h-screen bg-[#0a0a0a]">
|
|
42
|
+
<MCPConfigModal onClose={handleClose} />
|
|
43
|
+
</div>
|
|
44
|
+
)
|
|
45
|
+
}
|
package/app/page.tsx
CHANGED
|
@@ -6,6 +6,7 @@ import DashboardHeader from "@/components/dashboard/header"
|
|
|
6
6
|
import LoginModal from "@/components/dashboard/login-modal"
|
|
7
7
|
import MCPConfigModal from "@/components/dashboard/mcp-config-modal"
|
|
8
8
|
import MetricsGrid from "@/components/dashboard/metrics-grid"
|
|
9
|
+
import PasswordAlertModal from "@/components/dashboard/password-alert-modal"
|
|
9
10
|
import SettingsModal from "@/components/dashboard/settings-modal"
|
|
10
11
|
import { useDashboard } from "@/lib/data/dashboard-context"
|
|
11
12
|
import { DashboardData } from "@/lib/data/types"
|
|
@@ -19,6 +20,8 @@ export default function Dashboard() {
|
|
|
19
20
|
|
|
20
21
|
const [showMCPConfig, setShowMCPConfig] = useState(false)
|
|
21
22
|
const [showSettings, setShowSettings] = useState(false)
|
|
23
|
+
const [showPasswordAlert, setShowPasswordAlert] = useState(false)
|
|
24
|
+
const [focusPasswordField, setFocusPasswordField] = useState(false)
|
|
22
25
|
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
|
23
26
|
|
|
24
27
|
// Data State
|
|
@@ -53,6 +56,24 @@ export default function Dashboard() {
|
|
|
53
56
|
const handleLogin = (password: string) => {
|
|
54
57
|
sessionStorage.setItem("authenticated", "true")
|
|
55
58
|
setIsAuthenticated(true)
|
|
59
|
+
|
|
60
|
+
// Check if using default password and warning not dismissed
|
|
61
|
+
const hasCustomPassword = localStorage.getItem("adminPassword") !== null
|
|
62
|
+
const warningDismissed = localStorage.getItem("hidePasswordWarning") === "true"
|
|
63
|
+
|
|
64
|
+
if (!hasCustomPassword && !warningDismissed) {
|
|
65
|
+
setShowPasswordAlert(true)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const handlePasswordAlertChange = () => {
|
|
70
|
+
setShowPasswordAlert(false)
|
|
71
|
+
setFocusPasswordField(true)
|
|
72
|
+
setShowSettings(true)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const handlePasswordAlertDismiss = () => {
|
|
76
|
+
setShowPasswordAlert(false)
|
|
56
77
|
}
|
|
57
78
|
|
|
58
79
|
// Fetch Data Effect - Reacts to strategy change or refresh signal
|
|
@@ -144,7 +165,13 @@ export default function Dashboard() {
|
|
|
144
165
|
</main>
|
|
145
166
|
|
|
146
167
|
{showMCPConfig && <MCPConfigModal onClose={() => setShowMCPConfig(false)} />}
|
|
147
|
-
{showSettings && <SettingsModal onClose={() => { setShowSettings(false); }} />}
|
|
168
|
+
{showSettings && <SettingsModal onClose={() => { setShowSettings(false); setFocusPasswordField(false); }} />}
|
|
169
|
+
{showPasswordAlert && (
|
|
170
|
+
<PasswordAlertModal
|
|
171
|
+
onChangePassword={handlePasswordAlertChange}
|
|
172
|
+
onDismiss={handlePasswordAlertDismiss}
|
|
173
|
+
/>
|
|
174
|
+
)}
|
|
148
175
|
</div>
|
|
149
176
|
)
|
|
150
177
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { useDashboard } from "@/lib/data/dashboard-context"
|
|
4
|
+
import { ArrowDown, ArrowUp, ArrowUpDown, ChevronDown, ChevronLeft, ChevronRight, Download, RefreshCw } from "lucide-react"
|
|
4
5
|
import { useState } from "react"
|
|
5
6
|
|
|
6
7
|
interface AuditLogTableProps {
|
|
@@ -40,6 +41,61 @@ export default function AuditLogTable({
|
|
|
40
41
|
onSort
|
|
41
42
|
}: AuditLogTableProps) {
|
|
42
43
|
const [period, setPeriod] = useState("all")
|
|
44
|
+
const [showExportMenu, setShowExportMenu] = useState(false)
|
|
45
|
+
const { clientConfigs } = useDashboard()
|
|
46
|
+
|
|
47
|
+
const getClientConfig = (rawName: string) => {
|
|
48
|
+
if (!rawName) return undefined
|
|
49
|
+
const nameLower = rawName.toLowerCase()
|
|
50
|
+
return clientConfigs.find((c: any) => nameLower.includes(c.match))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const getClientDisplayName = (rawName: string) => {
|
|
54
|
+
const config = getClientConfig(rawName)
|
|
55
|
+
return config ? config.name : rawName
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const exportToCSV = () => {
|
|
59
|
+
const headers = ['Timestamp', 'Client', 'Operation', 'Status', 'Description']
|
|
60
|
+
const csvContent = [
|
|
61
|
+
headers.join(','),
|
|
62
|
+
...logs.map(log => [
|
|
63
|
+
`"${log.date}"`,
|
|
64
|
+
`"${getClientDisplayName(log.client)}"`,
|
|
65
|
+
`"${log.operation}"`,
|
|
66
|
+
`"${log.status}"`,
|
|
67
|
+
`"${log.description}"`
|
|
68
|
+
].join(','))
|
|
69
|
+
].join('\n')
|
|
70
|
+
|
|
71
|
+
const blob = new Blob([csvContent], { type: 'text/csv' })
|
|
72
|
+
const url = URL.createObjectURL(blob)
|
|
73
|
+
const a = document.createElement('a')
|
|
74
|
+
a.href = url
|
|
75
|
+
a.download = `cybermem-audit-${new Date().toISOString().split('T')[0]}.csv`
|
|
76
|
+
a.click()
|
|
77
|
+
URL.revokeObjectURL(url)
|
|
78
|
+
setShowExportMenu(false)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const exportToJSON = () => {
|
|
82
|
+
const jsonContent = JSON.stringify(logs.map(log => ({
|
|
83
|
+
timestamp: log.date,
|
|
84
|
+
client: getClientDisplayName(log.client),
|
|
85
|
+
operation: log.operation,
|
|
86
|
+
status: log.status,
|
|
87
|
+
description: log.description
|
|
88
|
+
})), null, 2)
|
|
89
|
+
|
|
90
|
+
const blob = new Blob([jsonContent], { type: 'application/json' })
|
|
91
|
+
const url = URL.createObjectURL(blob)
|
|
92
|
+
const a = document.createElement('a')
|
|
93
|
+
a.href = url
|
|
94
|
+
a.download = `cybermem-audit-${new Date().toISOString().split('T')[0]}.json`
|
|
95
|
+
a.click()
|
|
96
|
+
URL.revokeObjectURL(url)
|
|
97
|
+
setShowExportMenu(false)
|
|
98
|
+
}
|
|
43
99
|
|
|
44
100
|
return (
|
|
45
101
|
<div className="group relative overflow-hidden rounded-2xl bg-white/5 border border-white/10 backdrop-blur-md shadow-lg p-6 transition-all duration-300">
|
|
@@ -80,11 +136,9 @@ export default function AuditLogTable({
|
|
|
80
136
|
</div>
|
|
81
137
|
|
|
82
138
|
<div className="relative z-10">
|
|
83
|
-
<div className="flex items-center
|
|
84
|
-
<
|
|
85
|
-
|
|
86
|
-
{loading && <RefreshCw className="w-4 h-4 text-emerald-500 animate-spin" />}
|
|
87
|
-
</div>
|
|
139
|
+
<div className="flex items-center gap-3 mb-6">
|
|
140
|
+
<h3 className="text-lg font-semibold text-white">Audit Log</h3>
|
|
141
|
+
{loading && <RefreshCw className="w-4 h-4 text-emerald-500 animate-spin" />}
|
|
88
142
|
</div>
|
|
89
143
|
|
|
90
144
|
|
|
@@ -137,10 +191,19 @@ export default function AuditLogTable({
|
|
|
137
191
|
) : (
|
|
138
192
|
logs.map((log) => {
|
|
139
193
|
const config = statusConfig[log.status] || statusConfig.Success
|
|
194
|
+
const clientConf = getClientConfig(log.client)
|
|
195
|
+
const displayName = clientConf ? clientConf.name : log.client
|
|
196
|
+
const icon = clientConf?.icon
|
|
197
|
+
|
|
140
198
|
return (
|
|
141
199
|
<tr key={log.id} className="border-b border-white/5 hover:bg-white/10 transition-colors even:bg-white/[0.02] group/row">
|
|
142
200
|
<td className="py-4 px-4 text-neutral-300 group-hover/row:text-white transition-colors">{log.date}</td>
|
|
143
|
-
<td className="py-4 px-4 text-white font-medium">
|
|
201
|
+
<td className="py-4 px-4 text-white font-medium">
|
|
202
|
+
<div className="flex items-center gap-2">
|
|
203
|
+
{icon && <img src={icon} alt={displayName} className="w-5 h-5 object-contain" />}
|
|
204
|
+
<span>{displayName}</span>
|
|
205
|
+
</div>
|
|
206
|
+
</td>
|
|
144
207
|
<td className="py-4 px-4 text-neutral-300">{log.operation}</td>
|
|
145
208
|
<td className="py-4 px-4">
|
|
146
209
|
<span
|
|
@@ -166,10 +229,40 @@ export default function AuditLogTable({
|
|
|
166
229
|
</table>
|
|
167
230
|
</div>
|
|
168
231
|
|
|
169
|
-
<div className="mt-6 flex items-center justify-between">
|
|
170
|
-
|
|
232
|
+
<div className="mt-6 flex items-center justify-between relative">
|
|
233
|
+
{/* Export Button (Left) */}
|
|
234
|
+
<div className="relative">
|
|
235
|
+
<button
|
|
236
|
+
onClick={() => setShowExportMenu(!showExportMenu)}
|
|
237
|
+
className="h-8 px-3 rounded-lg bg-white/5 border border-white/10 hover:bg-white/10 text-neutral-400 hover:text-white text-xs font-medium flex items-center gap-2 transition-all"
|
|
238
|
+
>
|
|
239
|
+
<Download className="w-3 h-3" />
|
|
240
|
+
Export
|
|
241
|
+
<ChevronDown className="w-3 h-3" />
|
|
242
|
+
</button>
|
|
243
|
+
|
|
244
|
+
{showExportMenu && (
|
|
245
|
+
<div className="absolute left-0 bottom-full mb-2 w-32 bg-[#0B1116]/95 border border-white/10 rounded-lg shadow-xl z-30 backdrop-blur-xl overflow-hidden">
|
|
246
|
+
<button
|
|
247
|
+
onClick={exportToCSV}
|
|
248
|
+
className="w-full text-left px-3 py-2 text-xs text-neutral-300 hover:bg-white/5 hover:text-white transition-colors"
|
|
249
|
+
>
|
|
250
|
+
Export CSV
|
|
251
|
+
</button>
|
|
252
|
+
<button
|
|
253
|
+
onClick={exportToJSON}
|
|
254
|
+
className="w-full text-left px-3 py-2 text-xs text-neutral-300 hover:bg-white/5 hover:text-white transition-colors"
|
|
255
|
+
>
|
|
256
|
+
Export JSON
|
|
257
|
+
</button>
|
|
258
|
+
</div>
|
|
259
|
+
)}
|
|
260
|
+
</div>
|
|
261
|
+
|
|
262
|
+
<p className="absolute left-1/2 -translate-x-1/2 text-sm text-neutral-500">
|
|
171
263
|
Page {currentPage} of {Math.max(1, totalPages)}
|
|
172
264
|
</p>
|
|
265
|
+
|
|
173
266
|
<div className="flex gap-2">
|
|
174
267
|
<button
|
|
175
268
|
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
|
|
@@ -4,7 +4,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
|
4
4
|
import { useDashboard } from "@/lib/data/dashboard-context";
|
|
5
5
|
import { ChevronDown } from "lucide-react";
|
|
6
6
|
import dynamic from "next/dynamic";
|
|
7
|
-
import { useEffect, useState } from "react";
|
|
7
|
+
import { useEffect, useRef, useState } from "react";
|
|
8
8
|
|
|
9
9
|
// Dynamic import with SSR disabled
|
|
10
10
|
const MetricsChart = dynamic(() => import("./metrics-chart"), { ssr: false });
|
|
@@ -44,11 +44,11 @@ export default function ChartCard({ service }: ChartCardProps) {
|
|
|
44
44
|
const [clientMetadata, setClientMetadata] = useState<Record<string, any>>({})
|
|
45
45
|
const [loading, setLoading] = useState(true)
|
|
46
46
|
|
|
47
|
+
const isInitialLoad = useRef(true)
|
|
47
48
|
useEffect(() => {
|
|
48
49
|
async function fetchData() {
|
|
49
|
-
// Only show loading state on initial load
|
|
50
|
-
|
|
51
|
-
if (data.length === 0) setLoading(true)
|
|
50
|
+
// Only show loading state on initial load, not background refresh
|
|
51
|
+
if (isInitialLoad.current) setLoading(true)
|
|
52
52
|
|
|
53
53
|
try {
|
|
54
54
|
const timeSeriesData = await strategy.getChartData(period)
|
|
@@ -120,6 +120,7 @@ export default function ChartCard({ service }: ChartCardProps) {
|
|
|
120
120
|
console.error("Failed to fetch chart data:", e)
|
|
121
121
|
} finally {
|
|
122
122
|
setLoading(false)
|
|
123
|
+
isInitialLoad.current = false
|
|
123
124
|
}
|
|
124
125
|
}
|
|
125
126
|
fetchData()
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import { Button } from "@/components/ui/button";
|
|
4
|
-
import {
|
|
4
|
+
import { useDashboard } from "@/lib/data/dashboard-context";
|
|
5
|
+
import { AlertCircle, Book, CheckCircle2, Loader2, Settings, XCircle } from "lucide-react";
|
|
6
|
+
import Image from "next/image";
|
|
5
7
|
import { useEffect, useState } from "react";
|
|
6
8
|
|
|
7
9
|
export default function DashboardHeader({
|
|
@@ -12,6 +14,8 @@ export default function DashboardHeader({
|
|
|
12
14
|
onShowSettings: () => void;
|
|
13
15
|
}) {
|
|
14
16
|
const [isScrolled, setIsScrolled] = useState(false)
|
|
17
|
+
const [showHealthPopup, setShowHealthPopup] = useState(false)
|
|
18
|
+
const { systemHealth } = useDashboard()
|
|
15
19
|
|
|
16
20
|
useEffect(() => {
|
|
17
21
|
// Check initial scroll position on mount
|
|
@@ -24,6 +28,23 @@ export default function DashboardHeader({
|
|
|
24
28
|
return () => window.removeEventListener("scroll", handleScroll)
|
|
25
29
|
}, [])
|
|
26
30
|
|
|
31
|
+
const getStatusConfig = () => {
|
|
32
|
+
if (!systemHealth) {
|
|
33
|
+
return { bg: "bg-neutral-500/10", text: "text-neutral-400", border: "border-neutral-500/20", icon: Loader2, label: "Checking..." }
|
|
34
|
+
}
|
|
35
|
+
switch (systemHealth.overall) {
|
|
36
|
+
case 'ok':
|
|
37
|
+
return { bg: "bg-emerald-500/10", text: "text-emerald-400", border: "border-emerald-500/20", icon: CheckCircle2, label: "All Systems OK" }
|
|
38
|
+
case 'degraded':
|
|
39
|
+
return { bg: "bg-amber-500/10", text: "text-amber-400", border: "border-amber-500/20", icon: AlertCircle, label: "Degraded" }
|
|
40
|
+
case 'error':
|
|
41
|
+
return { bg: "bg-red-500/10", text: "text-red-400", border: "border-red-500/20", icon: XCircle, label: "System Error" }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const statusConfig = getStatusConfig()
|
|
46
|
+
const StatusIcon = statusConfig.icon
|
|
47
|
+
|
|
27
48
|
return (
|
|
28
49
|
<header className={`sticky top-0 z-50 transition-all duration-300 ${
|
|
29
50
|
isScrolled
|
|
@@ -34,22 +55,76 @@ export default function DashboardHeader({
|
|
|
34
55
|
<div className="flex items-center justify-between">
|
|
35
56
|
<div className="flex items-center gap-4">
|
|
36
57
|
<div className="relative w-10 h-10 flex-shrink-0">
|
|
37
|
-
<
|
|
58
|
+
<Image src="/logo.svg" alt="CyberMem Logo" width={40} height={40} className="object-contain" />
|
|
38
59
|
</div>
|
|
39
60
|
<div>
|
|
40
|
-
<
|
|
61
|
+
<div className="flex items-center gap-3">
|
|
62
|
+
<h1 className="text-3xl font-bold tracking-tight text-white leading-none font-exo">CyberMem</h1>
|
|
63
|
+
|
|
64
|
+
{/* System Health Status Badge with Hover Popup */}
|
|
65
|
+
<div
|
|
66
|
+
className="relative mt-1.5"
|
|
67
|
+
onMouseEnter={() => setShowHealthPopup(true)}
|
|
68
|
+
onMouseLeave={() => setShowHealthPopup(false)}
|
|
69
|
+
>
|
|
70
|
+
<div className={`px-2 py-[2px] rounded-full text-[10px] font-medium flex items-center gap-1 cursor-pointer ${statusConfig.bg} ${statusConfig.text} border ${statusConfig.border}`}>
|
|
71
|
+
<StatusIcon className={`w-3 h-3 ${!systemHealth ? 'animate-spin' : ''}`} />
|
|
72
|
+
{statusConfig.label}
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
{/* Hover Popup */}
|
|
76
|
+
{showHealthPopup && systemHealth && (
|
|
77
|
+
<div className="absolute top-full left-0 mt-2 w-64 bg-[#0B1116]/95 border border-white/10 rounded-lg shadow-xl z-50 backdrop-blur-xl overflow-hidden animate-in fade-in slide-in-from-top-2 duration-200">
|
|
78
|
+
<div className="p-3 border-b border-white/5">
|
|
79
|
+
<p className="text-xs text-neutral-400">System Health</p>
|
|
80
|
+
<p className="text-[10px] text-neutral-500 mt-0.5">Updated: {new Date(systemHealth.timestamp).toLocaleTimeString()}</p>
|
|
81
|
+
</div>
|
|
82
|
+
<div className="p-2 space-y-1">
|
|
83
|
+
{systemHealth.services.map((service, i) => (
|
|
84
|
+
<div key={i} className="flex items-center justify-between px-2 py-1.5 rounded hover:bg-white/5">
|
|
85
|
+
<span className="text-xs text-neutral-300">{service.name}</span>
|
|
86
|
+
<div className="flex items-center gap-2">
|
|
87
|
+
{service.latencyMs && (
|
|
88
|
+
<span className="text-[10px] text-neutral-500">{service.latencyMs}ms</span>
|
|
89
|
+
)}
|
|
90
|
+
{service.status === 'ok' ? (
|
|
91
|
+
<CheckCircle2 className="w-3 h-3 text-emerald-400" />
|
|
92
|
+
) : service.status === 'warning' ? (
|
|
93
|
+
<AlertCircle className="w-3 h-3 text-amber-400" />
|
|
94
|
+
) : (
|
|
95
|
+
<XCircle className="w-3 h-3 text-red-400" />
|
|
96
|
+
)}
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
))}
|
|
100
|
+
{systemHealth.services.some(s => s.status !== 'ok' && s.message) && (
|
|
101
|
+
<div className="mt-2 p-2 rounded bg-red-500/10 border border-red-500/20">
|
|
102
|
+
<p className="text-xs text-red-300 font-medium">Issues:</p>
|
|
103
|
+
{systemHealth.services.filter(s => s.status !== 'ok' && s.message).map((s, i) => (
|
|
104
|
+
<p key={i} className="text-[10px] text-red-400 mt-1">โข {s.name}: {s.message}</p>
|
|
105
|
+
))}
|
|
106
|
+
</div>
|
|
107
|
+
)}
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
)}
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
41
113
|
<p className="text-sm text-neutral-400 mt-1">Memory MCP Server</p>
|
|
42
114
|
</div>
|
|
115
|
+
|
|
43
116
|
</div>
|
|
44
117
|
|
|
45
118
|
<div className="flex items-center gap-3">
|
|
119
|
+
|
|
120
|
+
|
|
46
121
|
<Button
|
|
47
122
|
variant="ghost"
|
|
48
123
|
size="sm"
|
|
49
124
|
onClick={onShowMCPConfig}
|
|
50
125
|
className="hidden md:flex h-10 px-4 text-sm font-medium bg-emerald-500/10 text-emerald-400 hover:text-emerald-300 hover:bg-emerald-500/20 border border-emerald-500/20 hover:border-emerald-500/40 rounded-lg"
|
|
51
126
|
>
|
|
52
|
-
<
|
|
127
|
+
<Image src="/icons/mcp.png" alt="MCP" width={16} height={16} className="mr-2" />
|
|
53
128
|
Connect MCP
|
|
54
129
|
</Button>
|
|
55
130
|
|
|
@@ -79,4 +154,3 @@ export default function DashboardHeader({
|
|
|
79
154
|
</header>
|
|
80
155
|
)
|
|
81
156
|
}
|
|
82
|
-
|
|
@@ -76,10 +76,6 @@ export default function LoginModal({ onLogin }: LoginModalProps) {
|
|
|
76
76
|
<LogIn className="w-4 h-4" />
|
|
77
77
|
Login
|
|
78
78
|
</Button>
|
|
79
|
-
|
|
80
|
-
<p className="text-xs text-neutral-500 text-center mt-4">
|
|
81
|
-
Default password: <code className="text-emerald-400 bg-emerald-500/10 px-1 py-0.5 rounded">admin</code>
|
|
82
|
-
</p>
|
|
83
79
|
</form>
|
|
84
80
|
</div>
|
|
85
81
|
</div>
|