@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.
@@ -0,0 +1,214 @@
1
+ /**
2
+ * CRUD Happy Path E2E Test
3
+ *
4
+ * Tests the complete CRUD flow via MCP API and verifies
5
+ * that X-Client-Name header propagates to dashboard metrics.
6
+ *
7
+ * Run with: npm run test:e2e -- crud-happy-path.spec.ts
8
+ */
9
+
10
+ import { expect, test } from '@playwright/test';
11
+ import { execSync } from 'child_process';
12
+
13
+ const MCP_URL = 'http://127.0.0.1:8626/mcp';
14
+ const CLIENT_NAME = `e2e-crud-${Date.now()}`;
15
+
16
+ // Helpers
17
+ const RPC = (method: string, params: any = {}, id: number) => ({
18
+ jsonrpc: "2.0",
19
+ id,
20
+ method,
21
+ params
22
+ });
23
+
24
+ const resetDB = async () => {
25
+ try {
26
+ // Remove database files
27
+ try {
28
+ execSync("docker exec cybermem-openmemory sh -c 'rm -f /data/openmemory.sqlite*'", { stdio: 'ignore' });
29
+ } catch (e) { /* ignore - container might not be running */ }
30
+
31
+ // Fix permissions on data directory to prevent SQLITE_READONLY after restart
32
+ try {
33
+ execSync("docker run --rm -v cybermem-openmemory-data:/data alpine sh -c 'chown -R 1001:1001 /data && chmod 777 /data'", { stdio: 'ignore' });
34
+ } catch (e) { /* ignore */ }
35
+
36
+ // Restart container
37
+ execSync('docker restart cybermem-openmemory', { stdio: 'ignore' });
38
+
39
+ // Poll for health AND MCP routing (up to 60s)
40
+ const start = Date.now();
41
+ while (Date.now() - start < 60000) {
42
+ try {
43
+ const healthRes = await fetch('http://127.0.0.1:8626/health');
44
+ if (healthRes.ok) {
45
+ // Also verify MCP routing is working (405 is fine for GET, 404 means not ready)
46
+ const mcpRes = await fetch('http://127.0.0.1:8626/mcp');
47
+ if (mcpRes.status !== 404) {
48
+ // Give additional time for MCP to stabilize
49
+ await new Promise(r => setTimeout(r, 3000));
50
+ return true;
51
+ }
52
+ }
53
+ } catch (e) { /* retry */ }
54
+ await new Promise(r => setTimeout(r, 1000));
55
+ }
56
+ console.log('⚠️ DB reset timeout, but proceeding');
57
+ return true;
58
+ } catch (e) {
59
+ console.error('DB Reset failed:', e);
60
+ return false;
61
+ }
62
+ };
63
+
64
+ const mcpCall = async (method: string, params: any, id: number) => {
65
+ const res = await fetch(MCP_URL, {
66
+ method: 'POST',
67
+ headers: {
68
+ 'Content-Type': 'application/json',
69
+ 'Accept': 'application/json, text/event-stream',
70
+ 'X-Client-Name': CLIENT_NAME
71
+ },
72
+ body: JSON.stringify(RPC(method, params, id))
73
+ });
74
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
75
+ return res.json();
76
+ };
77
+
78
+ // Skip DB reset if SKIP_DB_RESET=true (for faster runs when stack is clean)
79
+ const SKIP_DB_RESET = process.env.SKIP_DB_RESET === 'true';
80
+
81
+ // Run tests in serial mode since they share state (memoryId)
82
+ test.describe.configure({ mode: 'serial' });
83
+
84
+ test.describe('CRUD Happy Path with X-Client-Name', () => {
85
+ let memoryId: string;
86
+
87
+ test.beforeAll(async () => {
88
+ if (SKIP_DB_RESET) {
89
+ console.log('⏭️ Skipping DB reset (SKIP_DB_RESET=true)');
90
+ return;
91
+ }
92
+ console.log(`🧹 Resetting DB before test suite...`);
93
+ await resetDB();
94
+ });
95
+
96
+ test.afterAll(async () => {
97
+ if (SKIP_DB_RESET) {
98
+ console.log('⏭️ Skipping DB reset (SKIP_DB_RESET=true)');
99
+ return;
100
+ }
101
+ console.log(`🧹 Resetting DB after test suite...`);
102
+ await resetDB();
103
+ });
104
+
105
+ test('1. Initialize MCP connection', async () => {
106
+ const initRes: any = await mcpCall("initialize", {
107
+ protocolVersion: "2024-11-05",
108
+ capabilities: { roots: { listChanged: true } },
109
+ clientInfo: { name: "e2e-crud-tester", version: "1.0.0" }
110
+ }, 1);
111
+
112
+ expect(initRes.result?.serverInfo?.name).toBe("openmemory-mcp");
113
+ await mcpCall("notifications/initialized", {}, 2);
114
+ });
115
+
116
+ test('2. CREATE - Store memory', async () => {
117
+ const storeRes: any = await mcpCall("tools/call", {
118
+ name: "openmemory_store",
119
+ arguments: {
120
+ content: `CRUD Happy Path Test Memory ${CLIENT_NAME}`,
121
+ tags: ["e2e", "crud-test"]
122
+ }
123
+ }, 3);
124
+
125
+ expect(storeRes.error).toBeUndefined();
126
+ const payload = JSON.parse(storeRes.result.content[1].text);
127
+ memoryId = payload.id;
128
+ expect(memoryId).toBeTruthy();
129
+ console.log(` ✅ Created memory: ${memoryId}`);
130
+ });
131
+
132
+ test('3. READ - Get memory by ID', async () => {
133
+ const getRes: any = await mcpCall("tools/call", {
134
+ name: "openmemory_get",
135
+ arguments: { id: memoryId }
136
+ }, 4);
137
+
138
+ expect(getRes.error).toBeUndefined();
139
+ const payload = JSON.parse(getRes.result.content[0].text);
140
+ expect(payload.content).toContain('CRUD Happy Path Test Memory');
141
+ });
142
+
143
+ test('4. READ - List memories', async () => {
144
+ const listRes: any = await mcpCall("tools/call", {
145
+ name: "openmemory_list",
146
+ arguments: { limit: 10 }
147
+ }, 5);
148
+
149
+ expect(listRes.error).toBeUndefined();
150
+ const payload = JSON.parse(listRes.result.content[1].text);
151
+ const found = payload.items.some((m: any) => m.id === memoryId);
152
+ expect(found).toBe(true);
153
+ });
154
+
155
+ test('5. READ - Query memories (semantic search)', async () => {
156
+ const queryRes: any = await mcpCall("tools/call", {
157
+ name: "openmemory_query",
158
+ arguments: { query: "CRUD Happy Path" }
159
+ }, 6);
160
+
161
+ expect(queryRes.error).toBeUndefined();
162
+ });
163
+
164
+ test('6. DELETE - Remove memory', async () => {
165
+ const deleteRes: any = await mcpCall("tools/call", {
166
+ name: "openmemory_delete",
167
+ arguments: { id: memoryId }
168
+ }, 7);
169
+
170
+ // DELETE may not exist in all OpenMemory versions - skip if not available
171
+ if (deleteRes.error?.message?.includes('not found')) {
172
+ test.skip();
173
+ }
174
+ });
175
+
176
+ test('7. Verify client appears in Dashboard', async ({ page }) => {
177
+ // Navigate to dashboard
178
+ await page.goto('http://localhost:3000');
179
+
180
+ // Login if needed
181
+ const passwordInput = page.getByPlaceholder('Enter admin password');
182
+ if (await passwordInput.isVisible()) {
183
+ await passwordInput.fill('admin');
184
+ await page.keyboard.press('Enter');
185
+ }
186
+
187
+ // Handle password alert modal if appears
188
+ const dontShowAgainButton = page.locator('button:has-text("Don\'t show again")');
189
+ if (await dontShowAgainButton.isVisible({ timeout: 2000 }).catch(() => false)) {
190
+ await dontShowAgainButton.click();
191
+ }
192
+
193
+ // Wait for dashboard to load
194
+ await expect(page.getByRole('heading', { name: 'CyberMem' })).toBeVisible({ timeout: 15000 });
195
+
196
+ // Wait for metrics to propagate (Prometheus scrape interval)
197
+ await page.waitForTimeout(5000);
198
+
199
+ // Scroll to find Audit Log section
200
+ const auditHeader = page.locator('h3:has-text("Audit Log")');
201
+ await auditHeader.scrollIntoViewIfNeeded();
202
+
203
+ // Verify audit log table is visible
204
+ await expect(page.locator('th:has-text("Client")')).toBeVisible({ timeout: 10000 });
205
+
206
+ // Look for our client in the audit log (partial match on e2e-crud)
207
+ // The client name should appear in audit log entries
208
+ const pageContent = await page.content();
209
+ const clientVisible = pageContent.includes('e2e-crud') || pageContent.includes('E2E CRUD');
210
+
211
+ console.log(` Client ${CLIENT_NAME} visible in dashboard: ${clientVisible}`);
212
+ // Note: Client might show up as display name if mapped in clients.json
213
+ });
214
+ });
@@ -0,0 +1,28 @@
1
+
2
+ import { expect, test } from '@playwright/test';
3
+
4
+ test.describe('Dashboard Metrics & Viz', () => {
5
+
6
+ test.beforeEach(async ({ page }) => {
7
+ // Login before each test
8
+ await page.goto('/');
9
+ const loginInput = page.getByPlaceholder('Enter admin password');
10
+ if (await loginInput.isVisible()) {
11
+ await loginInput.fill('admin');
12
+ await page.getByRole('button', { name: 'Login' }).click();
13
+ await expect(page.getByRole('heading', { name: 'CyberMem' })).toBeVisible();
14
+ }
15
+ });
16
+
17
+ test('should display core metrics', async ({ page }) => {
18
+ await expect(page.getByText('Memory Records')).toBeVisible();
19
+ await expect(page.getByText('Success Rate')).toBeVisible();
20
+ });
21
+
22
+ test('should check responsiveness', async ({ page }) => {
23
+ // Test already runs in desktop and mobile projects config
24
+ // Just verify critical components are visible
25
+ const nav = page.locator('header');
26
+ await expect(nav).toBeVisible();
27
+ });
28
+ });
@@ -0,0 +1,15 @@
1
+ import nextConfig from 'eslint-config-next';
2
+
3
+ const eslintConfig = [
4
+ ...nextConfig,
5
+ {
6
+ rules: {
7
+ // Disable false-positive for sessionStorage reads in useEffect
8
+ 'react-hooks/set-state-in-effect': 'off',
9
+ // Disable for shadcn/ui components that use Math.random() in useMemo
10
+ 'react-hooks/purity': 'off',
11
+ },
12
+ },
13
+ ];
14
+
15
+ export default eslintConfig;
@@ -17,12 +17,26 @@ interface ClientConfig {
17
17
  configType: string
18
18
  }
19
19
 
20
+ interface ServiceStatus {
21
+ name: string
22
+ status: 'ok' | 'error' | 'warning'
23
+ message?: string
24
+ latencyMs?: number
25
+ }
26
+
27
+ interface SystemHealth {
28
+ overall: 'ok' | 'degraded' | 'error'
29
+ services: ServiceStatus[]
30
+ timestamp: string
31
+ }
32
+
20
33
  interface DashboardContextType {
21
34
  strategy: DataSourceStrategy
22
35
  isDemo: boolean
23
36
  toggleDemo: () => void
24
37
  refreshSignal: number
25
38
  clientConfigs: ClientConfig[]
39
+ systemHealth: SystemHealth | null
26
40
  }
27
41
 
28
42
  const DashboardContext = createContext<DashboardContextType | undefined>(undefined)
@@ -32,6 +46,7 @@ export function DashboardProvider({ children }: { children: React.ReactNode }) {
32
46
  const [strategy, setStrategy] = useState<DataSourceStrategy>(new ProductionDataSource())
33
47
  const [refreshSignal, setRefreshSignal] = useState(0)
34
48
  const [clientConfigs, setClientConfigs] = useState<ClientConfig[]>([])
49
+ const [systemHealth, setSystemHealth] = useState<SystemHealth | null>(null)
35
50
 
36
51
  // Load configuration on mount
37
52
  useEffect(() => {
@@ -42,6 +57,34 @@ export function DashboardProvider({ children }: { children: React.ReactNode }) {
42
57
  .catch(err => console.error("Failed to load client configs:", err))
43
58
  }, [])
44
59
 
60
+ // Check system health
61
+ useEffect(() => {
62
+ const checkHealth = async () => {
63
+ try {
64
+ const res = await fetch("/api/health", { signal: AbortSignal.timeout(5000) })
65
+ if (res.ok) {
66
+ const data = await res.json()
67
+ setSystemHealth(data)
68
+ } else {
69
+ setSystemHealth({
70
+ overall: 'error',
71
+ services: [{ name: 'Dashboard API', status: 'error', message: `HTTP ${res.status}` }],
72
+ timestamp: new Date().toISOString()
73
+ })
74
+ }
75
+ } catch (error: any) {
76
+ setSystemHealth({
77
+ overall: 'error',
78
+ services: [{ name: 'Dashboard API', status: 'error', message: error.message || 'Connection failed' }],
79
+ timestamp: new Date().toISOString()
80
+ })
81
+ }
82
+ }
83
+ checkHealth()
84
+ const interval = setInterval(checkHealth, 30000) // Check every 30s
85
+ return () => clearInterval(interval)
86
+ }, [])
87
+
45
88
  const toggleDemo = () => {
46
89
  const newState = !isDemo
47
90
  setIsDemo(newState)
@@ -60,7 +103,7 @@ export function DashboardProvider({ children }: { children: React.ReactNode }) {
60
103
  }, [isDemo])
61
104
 
62
105
  return (
63
- <DashboardContext.Provider value={{ strategy, isDemo, toggleDemo, refreshSignal, clientConfigs }}>
106
+ <DashboardContext.Provider value={{ strategy, isDemo, toggleDemo, refreshSignal, clientConfigs, systemHealth }}>
64
107
  {children}
65
108
  </DashboardContext.Provider>
66
109
  )
@@ -0,0 +1,77 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+
3
+ interface RateLimitEntry {
4
+ count: number;
5
+ resetTime: number;
6
+ }
7
+
8
+ // In-memory rate limiter (for single-instance deployments)
9
+ // For production clusters, consider using Redis
10
+ const rateLimitMap = new Map<string, RateLimitEntry>();
11
+
12
+ const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute
13
+ const RATE_LIMIT_MAX_REQUESTS = 100; // 100 requests per minute
14
+
15
+ function getClientIP(request: NextRequest): string {
16
+ const forwarded = request.headers.get('x-forwarded-for');
17
+ if (forwarded) {
18
+ return forwarded.split(',')[0].trim();
19
+ }
20
+ return request.headers.get('x-real-ip') || 'unknown';
21
+ }
22
+
23
+ export function checkRateLimit(request: NextRequest): { allowed: boolean; remaining: number; resetIn: number } {
24
+ const clientIP = getClientIP(request);
25
+ const now = Date.now();
26
+
27
+ const entry = rateLimitMap.get(clientIP);
28
+
29
+ if (!entry || now > entry.resetTime) {
30
+ // New window
31
+ rateLimitMap.set(clientIP, {
32
+ count: 1,
33
+ resetTime: now + RATE_LIMIT_WINDOW_MS
34
+ });
35
+ return { allowed: true, remaining: RATE_LIMIT_MAX_REQUESTS - 1, resetIn: RATE_LIMIT_WINDOW_MS };
36
+ }
37
+
38
+ if (entry.count >= RATE_LIMIT_MAX_REQUESTS) {
39
+ return {
40
+ allowed: false,
41
+ remaining: 0,
42
+ resetIn: entry.resetTime - now
43
+ };
44
+ }
45
+
46
+ entry.count++;
47
+ return {
48
+ allowed: true,
49
+ remaining: RATE_LIMIT_MAX_REQUESTS - entry.count,
50
+ resetIn: entry.resetTime - now
51
+ };
52
+ }
53
+
54
+ export function rateLimitResponse(resetIn: number): NextResponse {
55
+ return NextResponse.json(
56
+ { error: 'Too many requests. Please try again later.' },
57
+ {
58
+ status: 429,
59
+ headers: {
60
+ 'Retry-After': String(Math.ceil(resetIn / 1000)),
61
+ 'X-RateLimit-Limit': String(RATE_LIMIT_MAX_REQUESTS),
62
+ 'X-RateLimit-Remaining': '0',
63
+ 'X-RateLimit-Reset': String(Math.ceil(resetIn / 1000))
64
+ }
65
+ }
66
+ );
67
+ }
68
+
69
+ // Periodic cleanup of expired entries (every 5 minutes)
70
+ setInterval(() => {
71
+ const now = Date.now();
72
+ for (const [key, entry] of rateLimitMap.entries()) {
73
+ if (now > entry.resetTime) {
74
+ rateLimitMap.delete(key);
75
+ }
76
+ }
77
+ }, 5 * 60 * 1000);
package/middleware.ts ADDED
@@ -0,0 +1,43 @@
1
+ import { NextResponse } from 'next/server'
2
+ import type { NextRequest } from 'next/server'
3
+
4
+ export function middleware(request: NextRequest) {
5
+ // CSRF Protection for mutating requests
6
+ if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(request.method)) {
7
+ const origin = request.headers.get('origin')
8
+ const referer = request.headers.get('referer')
9
+ const host = request.headers.get('host')
10
+
11
+ // If no origin/referer, or they don't match the host, block it.
12
+ // NOTE: This is a strict check. For local dev, internal API calls might need bypass if no origin set.
13
+ // Browsers ALWAYS send Origin for cross-origin POSTs.
14
+ // For same-origin, they usually send it too, but we can fall back to Referer.
15
+
16
+ // Allow server-side calls (no origin/referer) ONLY if coming from trusted internal network?
17
+ // Actually, for a dashboard, we expect browser interaction.
18
+ // If strict compliance is needed: logic below.
19
+
20
+ if (origin) {
21
+ const originHost = origin.replace(/^https?:\/\//, '')
22
+ if (originHost !== host) {
23
+ return new NextResponse('CSRF Validation Failed', { status: 403 })
24
+ }
25
+ } else if (referer) {
26
+ const refererHost = new URL(referer).host
27
+ if (refererHost !== host) {
28
+ return new NextResponse('CSRF Validation Failed', { status: 403 })
29
+ }
30
+ } else {
31
+ // Ideally we block requests without Origin/Referer in modern browsers for mutations
32
+ // But to be safe for non-browser tooling (if used): header check
33
+ // We'll enforce that the request must have come from our UI
34
+ // return new NextResponse('Missing Origin/Referer', { status: 403 })
35
+ }
36
+ }
37
+
38
+ return NextResponse.next()
39
+ }
40
+
41
+ export const config = {
42
+ matcher: '/api/:path*',
43
+ }
package/package.json CHANGED
@@ -1,14 +1,23 @@
1
1
  {
2
2
  "name": "@cybermem/dashboard",
3
- "version": "0.1.0",
3
+ "version": "0.5.1",
4
+ "description": "CyberMem Monitoring Dashboard",
5
+ "homepage": "https://cybermem.dev",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/mikhailkogan17/cybermem.git",
9
+ "directory": "packages/dashboard"
10
+ },
11
+ "license": "MIT",
4
12
  "publishConfig": {
5
13
  "access": "public"
6
14
  },
7
15
  "scripts": {
8
16
  "build": "next build --webpack",
9
- "dev": "next dev",
17
+ "dev": "next dev --webpack",
10
18
  "lint": "eslint .",
11
- "start": "next start"
19
+ "start": "next start",
20
+ "test:e2e": "playwright test"
12
21
  },
13
22
  "dependencies": {
14
23
  "@headlessui/react": "^2.2.9",
@@ -76,13 +85,15 @@
76
85
  "zod": "3.25.76"
77
86
  },
78
87
  "devDependencies": {
88
+ "@eslint/eslintrc": "^3.3.3",
89
+ "@playwright/test": "^1.57.0",
79
90
  "@tailwindcss/postcss": "^4.1.9",
80
91
  "@types/dockerode": "^3.3.47",
81
92
  "@types/node": "^22",
82
93
  "@types/react": "^19",
83
94
  "@types/react-dom": "^19",
84
- "eslint": "^8.57.0",
85
- "eslint-config-next": "14.2.3",
95
+ "eslint": "^9.0.0",
96
+ "eslint-config-next": "^16.1.1",
86
97
  "postcss": "^8.5",
87
98
  "tailwindcss": "^4.1.9",
88
99
  "tw-animate-css": "1.3.3",
@@ -0,0 +1,35 @@
1
+
2
+ import { defineConfig, devices } from '@playwright/test';
3
+
4
+ export default defineConfig({
5
+ testDir: './e2e',
6
+ fullyParallel: true,
7
+ forbidOnly: !!process.env.CI,
8
+ retries: 3,
9
+ timeout: 10000, // 10s max per test
10
+ expect: {
11
+ timeout: 5000, // 5s for assertions
12
+ },
13
+ workers: process.env.CI ? 1 : undefined,
14
+ reporter: 'html',
15
+ use: {
16
+ baseURL: process.env.BASE_URL || 'http://localhost:3000',
17
+ trace: 'on-first-retry',
18
+ ignoreHTTPSErrors: true,
19
+ },
20
+ projects: [
21
+ {
22
+ name: 'chromium',
23
+ use: { ...devices['Desktop Chrome'] },
24
+ },
25
+ ],
26
+ webServer: {
27
+ // Kill any existing process on 3000 before starting dev server
28
+ command: 'lsof -ti:3000 | xargs kill -9 2>/dev/null || true; npm run dev -- -p 3000',
29
+ url: 'http://localhost:3000',
30
+ reuseExistingServer: !process.env.CI,
31
+ stdout: 'pipe',
32
+ stderr: 'pipe',
33
+ timeout: 60000,
34
+ },
35
+ });