@cybermem/dashboard 0.9.12 → 0.13.4
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/Dockerfile +3 -3
- package/app/api/audit-logs/route.ts +12 -6
- package/app/api/health/route.ts +2 -1
- package/app/api/mcp-config/route.ts +128 -0
- package/app/api/metrics/route.ts +22 -70
- package/app/api/settings/route.ts +125 -30
- package/app/page.tsx +105 -127
- package/components/dashboard/{chart-card.tsx → charts/chart-card.tsx} +13 -19
- package/components/dashboard/{metrics-chart.tsx → charts/memory-chart.tsx} +1 -1
- package/components/dashboard/charts-section.tsx +3 -3
- package/components/dashboard/header.tsx +177 -176
- package/components/dashboard/{audit-log-table.tsx → logs/log-viewer.tsx} +12 -7
- package/components/dashboard/mcp/config-preview.tsx +246 -0
- package/components/dashboard/mcp/platform-selector.tsx +96 -0
- package/components/dashboard/mcp-config-modal.tsx +97 -503
- package/components/dashboard/{metric-card.tsx → metrics/stat-card.tsx} +4 -2
- package/components/dashboard/metrics-grid.tsx +10 -2
- package/components/dashboard/settings/access-token-section.tsx +131 -0
- package/components/dashboard/settings/data-management-section.tsx +122 -0
- package/components/dashboard/settings/system-info-section.tsx +98 -0
- package/components/dashboard/settings-modal.tsx +55 -299
- package/e2e/api.spec.ts +219 -0
- package/e2e/routing.spec.ts +39 -0
- package/e2e/ui.spec.ts +373 -0
- package/lib/data/dashboard-context.tsx +96 -29
- package/lib/data/types.ts +32 -38
- package/middleware.ts +31 -13
- package/package.json +6 -1
- package/playwright.config.ts +23 -58
- package/public/clients.json +5 -3
- package/release-reports/assets/local/1_dashboard.png +0 -0
- package/release-reports/assets/local/2_audit_logs.png +0 -0
- package/release-reports/assets/local/3_charts.png +0 -0
- package/release-reports/assets/local/4_mcp_modal.png +0 -0
- package/release-reports/assets/local/5_settings_modal.png +0 -0
- package/lib/data/demo-strategy.ts +0 -110
- package/lib/data/production-strategy.ts +0 -191
- package/lib/prometheus/client.ts +0 -58
- package/lib/prometheus/index.ts +0 -6
- package/lib/prometheus/metrics.ts +0 -234
- package/lib/prometheus/sparklines.ts +0 -71
- package/lib/prometheus/timeseries.ts +0 -305
- package/lib/prometheus/utils.ts +0 -176
package/middleware.ts
CHANGED
|
@@ -11,25 +11,43 @@ import { NextResponse } from "next/server";
|
|
|
11
11
|
export async function middleware(request: NextRequest) {
|
|
12
12
|
const host = request.headers.get("host") || "";
|
|
13
13
|
|
|
14
|
-
// LOCAL BYPASS: Skip auth for local development and trusted networks
|
|
14
|
+
// 1. LOCAL BYPASS: Skip auth for local development and trusted networks
|
|
15
|
+
const authMethod = request.headers.get("x-auth-method");
|
|
16
|
+
const userId = request.headers.get("x-user-id");
|
|
17
|
+
|
|
18
|
+
// Check if host is a private IP address (10.x.x.x)
|
|
19
|
+
const isPrivateIP = /^10\.\d{1,3}\.\d{1,3}\.\d{1,3}(:\d+)?$/.test(host);
|
|
20
|
+
|
|
15
21
|
const isLocal =
|
|
16
22
|
host.includes("localhost") ||
|
|
17
23
|
host.includes("127.0.0.1") ||
|
|
18
|
-
host.includes("raspberrypi.local")
|
|
24
|
+
host.includes("raspberrypi.local") ||
|
|
25
|
+
isPrivateIP ||
|
|
26
|
+
authMethod === "local" ||
|
|
27
|
+
userId === "local";
|
|
19
28
|
|
|
20
|
-
if (
|
|
21
|
-
|
|
22
|
-
|
|
29
|
+
if (isLocal) {
|
|
30
|
+
const responseHeaders = new Headers(request.headers);
|
|
31
|
+
responseHeaders.set("X-User-Id", "local");
|
|
32
|
+
return NextResponse.next({
|
|
33
|
+
request: {
|
|
34
|
+
headers: responseHeaders,
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
}
|
|
23
38
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const authUrl = new URL("/api/auth/token", request.url);
|
|
27
|
-
authUrl.searchParams.set("error", "no_token");
|
|
28
|
-
return NextResponse.redirect(authUrl);
|
|
29
|
-
}
|
|
39
|
+
// REMOTE: Check cookie token
|
|
40
|
+
const token = request.cookies.get("cybermem_token")?.value;
|
|
30
41
|
|
|
31
|
-
|
|
32
|
-
|
|
42
|
+
// We do NOT redirect here anymore to avoid 307 loops and respect Law #6.
|
|
43
|
+
// Unauthorized requests will simply not have the X-User-Id header,
|
|
44
|
+
// and the UI (app/page.tsx) will render the LoginModal with a 200 OK.
|
|
45
|
+
if (
|
|
46
|
+
!token &&
|
|
47
|
+
!userId &&
|
|
48
|
+
!request.nextUrl.pathname.startsWith("/api/auth")
|
|
49
|
+
) {
|
|
50
|
+
console.log("MiddleWare: No token/userId found for remote request");
|
|
33
51
|
}
|
|
34
52
|
|
|
35
53
|
// CSRF Protection for mutating requests
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cybermem/dashboard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.4",
|
|
4
4
|
"description": "CyberMem Monitoring Dashboard",
|
|
5
5
|
"homepage": "https://cybermem.dev",
|
|
6
6
|
"repository": {
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"scripts": {
|
|
16
16
|
"build": "next build --webpack",
|
|
17
17
|
"dev": "next dev --webpack",
|
|
18
|
+
"dev:turbo": "next dev --turbopack",
|
|
18
19
|
"lint": "eslint .",
|
|
19
20
|
"start": "next start",
|
|
20
21
|
"test:e2e": "playwright test"
|
|
@@ -76,7 +77,9 @@
|
|
|
76
77
|
"react-day-picker": "9.8.0",
|
|
77
78
|
"react-dom": "19.2.0",
|
|
78
79
|
"react-hook-form": "^7.60.0",
|
|
80
|
+
"react-remove-scroll-bar": "2.3.8",
|
|
79
81
|
"react-resizable-panels": "^2.1.7",
|
|
82
|
+
"react-style-singleton": "2.2.3",
|
|
80
83
|
"recharts": "2.15.4",
|
|
81
84
|
"recharts-scale": "^0.4.5",
|
|
82
85
|
"sonner": "^1.7.4",
|
|
@@ -85,6 +88,8 @@
|
|
|
85
88
|
"ssh2": "^1.17.0",
|
|
86
89
|
"tailwind-merge": "^3.3.1",
|
|
87
90
|
"tailwindcss-animate": "^1.0.7",
|
|
91
|
+
"use-callback-ref": "1.3.3",
|
|
92
|
+
"use-sidecar": "1.1.3",
|
|
88
93
|
"vaul": "^1.1.2",
|
|
89
94
|
"zod": "3.25.76"
|
|
90
95
|
},
|
package/playwright.config.ts
CHANGED
|
@@ -1,70 +1,35 @@
|
|
|
1
1
|
import { defineConfig, devices } from "@playwright/test";
|
|
2
|
-
import fs from "fs";
|
|
3
|
-
import path from "path";
|
|
4
|
-
|
|
5
|
-
// Load clients to pick a random one for realistic testing
|
|
6
|
-
// This ensures E2E tests generate traffic that looks like real clients (Claude, VS Code, etc.)
|
|
7
|
-
const clientsPath = path.join(__dirname, "public", "clients.json");
|
|
8
|
-
let randomClient = { name: "Playwright Desktop", match: "playwright" };
|
|
9
|
-
|
|
10
|
-
try {
|
|
11
|
-
if (fs.existsSync(clientsPath)) {
|
|
12
|
-
const clients = JSON.parse(fs.readFileSync(clientsPath, "utf-8"));
|
|
13
|
-
const validClients = clients.filter(
|
|
14
|
-
(c: any) => c.match && c.match !== "other",
|
|
15
|
-
);
|
|
16
|
-
if (validClients.length > 0) {
|
|
17
|
-
randomClient =
|
|
18
|
-
validClients[Math.floor(Math.random() * validClients.length)];
|
|
19
|
-
// Handle regex matchers (take first option)
|
|
20
|
-
if (randomClient.match.includes("|")) {
|
|
21
|
-
randomClient.match = randomClient.match.split("|")[0];
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
} catch (e) {
|
|
26
|
-
console.warn("Failed to load clients.json, using default.");
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
console.log(
|
|
30
|
-
`🤖 E2E Test Identity: ${randomClient.name} (UA: ${randomClient.match})`,
|
|
31
|
-
);
|
|
32
2
|
|
|
33
3
|
export default defineConfig({
|
|
34
4
|
testDir: "./e2e",
|
|
35
|
-
|
|
36
|
-
forbidOnly: !!process.env.CI,
|
|
37
|
-
retries: 3,
|
|
38
|
-
timeout: 10000, // 10s max per test
|
|
39
|
-
expect: {
|
|
40
|
-
timeout: 5000, // 5s for assertions
|
|
41
|
-
},
|
|
42
|
-
workers: process.env.CI ? 1 : undefined,
|
|
5
|
+
outputDir: "./test-results",
|
|
43
6
|
reporter: "html",
|
|
44
7
|
use: {
|
|
45
|
-
baseURL: process.env.
|
|
46
|
-
|
|
47
|
-
ignoreHTTPSErrors: true,
|
|
48
|
-
userAgent: `${randomClient.match}/1.0 (E2E Test)`,
|
|
49
|
-
extraHTTPHeaders: {
|
|
50
|
-
"X-Client-Name": randomClient.name,
|
|
51
|
-
},
|
|
8
|
+
baseURL: process.env.DASHBOARD_URL || "http://localhost:3000",
|
|
9
|
+
screenshot: "only-on-failure",
|
|
52
10
|
},
|
|
11
|
+
webServer: process.env.DASHBOARD_URL
|
|
12
|
+
? undefined
|
|
13
|
+
: {
|
|
14
|
+
command: "npm run dev",
|
|
15
|
+
port: 3000,
|
|
16
|
+
reuseExistingServer: !process.env.CI,
|
|
17
|
+
stdout: "ignore",
|
|
18
|
+
stderr: "pipe",
|
|
19
|
+
},
|
|
53
20
|
projects: [
|
|
54
21
|
{
|
|
55
|
-
name: "
|
|
56
|
-
|
|
22
|
+
name: "api",
|
|
23
|
+
testMatch: ["api.spec.ts", "routing.spec.ts"],
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
name: "ui",
|
|
27
|
+
testMatch: "ui.spec.ts",
|
|
28
|
+
use: {
|
|
29
|
+
...devices["Desktop Chrome"],
|
|
30
|
+
trace: "on",
|
|
31
|
+
screenshot: "on",
|
|
32
|
+
},
|
|
57
33
|
},
|
|
58
34
|
],
|
|
59
|
-
webServer: {
|
|
60
|
-
// In CI: dashboard runs via docker-compose, no need to start new server
|
|
61
|
-
// Locally: starts dev server if not already running
|
|
62
|
-
command:
|
|
63
|
-
"lsof -ti:3000 | xargs kill -9 2>/dev/null || true; npm run dev -- -p 3000 -H 127.0.0.1",
|
|
64
|
-
url: "http://127.0.0.1:3000",
|
|
65
|
-
reuseExistingServer: true, // Always reuse - docker-compose provides in CI
|
|
66
|
-
stdout: "pipe",
|
|
67
|
-
stderr: "pipe",
|
|
68
|
-
timeout: 60000,
|
|
69
|
-
},
|
|
70
35
|
});
|
package/public/clients.json
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
"match": "claude|antigravity",
|
|
6
6
|
"color": "#e65c40",
|
|
7
7
|
"icon": "/icons/claude.png",
|
|
8
|
+
"filename": "claude_desktop_config.json",
|
|
8
9
|
"description": "Configure Claude Desktop to use CyberMem for persistent memory across conversations.",
|
|
9
10
|
"steps": [
|
|
10
11
|
"Open Claude Desktop",
|
|
@@ -54,6 +55,7 @@
|
|
|
54
55
|
"match": "windsurf",
|
|
55
56
|
"color": "#3b82f6",
|
|
56
57
|
"icon": "/icons/windsurf.png",
|
|
58
|
+
"filename": "mcp_config.json",
|
|
57
59
|
"description": "Windsurf (Codeium) supports MCP via a dedicated config file.",
|
|
58
60
|
"steps": [
|
|
59
61
|
"Open the config file: `~/.codeium/windsurf/mcp_config.json`",
|
|
@@ -116,6 +118,7 @@
|
|
|
116
118
|
"match": "codex",
|
|
117
119
|
"color": "#6366f1",
|
|
118
120
|
"icon": "/icons/codex.png",
|
|
121
|
+
"filename": "config.toml",
|
|
119
122
|
"description": "Codex CLI uses TOML format for MCP configuration.",
|
|
120
123
|
"steps": [
|
|
121
124
|
"Edit `~/.codex/config.toml`",
|
|
@@ -165,9 +168,8 @@
|
|
|
165
168
|
"icon": null,
|
|
166
169
|
"description": "For any other MCP-compliant client, use the following connection details:",
|
|
167
170
|
"steps": [
|
|
168
|
-
"
|
|
169
|
-
"
|
|
170
|
-
"Refer to your client's documentation for config file location"
|
|
171
|
+
"Locate the configuration file for your client",
|
|
172
|
+
"Insert the following JSON configuration:"
|
|
171
173
|
],
|
|
172
174
|
"configType": "json"
|
|
173
175
|
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
|
|
2
|
-
import { DashboardData, DataSourceStrategy, TimeSeriesData } from "./types"
|
|
3
|
-
|
|
4
|
-
export class DemoDataSource implements DataSourceStrategy {
|
|
5
|
-
async fetchGlobalStats(): Promise<DashboardData> {
|
|
6
|
-
// 1. Generate full time-series history for sparklines
|
|
7
|
-
const generateSeries = (start: number, count: number, variance: number) => {
|
|
8
|
-
const series = [start]
|
|
9
|
-
for (let i = 1; i < count; i++) {
|
|
10
|
-
const change = (Math.random() - 0.5) * variance
|
|
11
|
-
series.push(Math.max(0, series[i - 1] + change))
|
|
12
|
-
}
|
|
13
|
-
return series
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
const exactData = {
|
|
17
|
-
memory: generateSeries(12000, 20, 500),
|
|
18
|
-
clients: generateSeries(40, 20, 2),
|
|
19
|
-
success: generateSeries(98, 20, 0.5).map((v) => Math.min(100, v)),
|
|
20
|
-
requests: generateSeries(85000, 20, 1000),
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
// 2. Generate stable, rich Audit Log history
|
|
24
|
-
const mockClients = ["Antigravity", "Claude", "Cursor", "ChatGPT", "Copilot"]
|
|
25
|
-
const mockOps = ["Create", "Read", "Update", "Delete"]
|
|
26
|
-
const demoLogs = Array.from({ length: 50 }).map((_, i) => ({
|
|
27
|
-
id: i,
|
|
28
|
-
date: new Date(Date.now() - i * 1000 * 60 * 5),
|
|
29
|
-
client: mockClients[i % mockClients.length],
|
|
30
|
-
operation: mockOps[i % mockOps.length],
|
|
31
|
-
status: i % 10 === 0 ? "Error" : "Success",
|
|
32
|
-
description:
|
|
33
|
-
i % 10 === 0 ? "Rate limit exceeded" : "Operation completed successfully",
|
|
34
|
-
timestamp: Date.now() - i * 1000 * 60 * 5,
|
|
35
|
-
}))
|
|
36
|
-
|
|
37
|
-
return {
|
|
38
|
-
stats: {
|
|
39
|
-
memoryRecords: Math.round(exactData.memory[exactData.memory.length - 1]),
|
|
40
|
-
totalClients: Math.round(exactData.clients[exactData.clients.length - 1]),
|
|
41
|
-
successRate: Number(
|
|
42
|
-
exactData.success[exactData.success.length - 1].toFixed(1)
|
|
43
|
-
),
|
|
44
|
-
totalRequests: Math.round(
|
|
45
|
-
exactData.requests[exactData.requests.length - 1]
|
|
46
|
-
),
|
|
47
|
-
topWriter: { name: "Antigravity", count: 4200 },
|
|
48
|
-
topReader: { name: "Claude", count: 3100 },
|
|
49
|
-
lastWriter: { name: "VS Code", timestamp: Date.now() - 1000 * 60 * 2 },
|
|
50
|
-
lastReader: { name: "Cursor", timestamp: Date.now() - 1000 * 30 },
|
|
51
|
-
},
|
|
52
|
-
trends: {
|
|
53
|
-
memory: {
|
|
54
|
-
change: "+450",
|
|
55
|
-
trend: "up",
|
|
56
|
-
hasData: true,
|
|
57
|
-
data: exactData.memory,
|
|
58
|
-
},
|
|
59
|
-
clients: {
|
|
60
|
-
change: "+5",
|
|
61
|
-
trend: "up",
|
|
62
|
-
hasData: true,
|
|
63
|
-
data: exactData.clients,
|
|
64
|
-
},
|
|
65
|
-
success: {
|
|
66
|
-
change: "+0.5%",
|
|
67
|
-
trend: "up",
|
|
68
|
-
hasData: true,
|
|
69
|
-
data: exactData.success,
|
|
70
|
-
},
|
|
71
|
-
requests: {
|
|
72
|
-
change: "+1.2k",
|
|
73
|
-
trend: "up",
|
|
74
|
-
hasData: true,
|
|
75
|
-
data: exactData.requests,
|
|
76
|
-
},
|
|
77
|
-
},
|
|
78
|
-
logs: demoLogs,
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
async getChartData(period: string): Promise<TimeSeriesData> {
|
|
83
|
-
const clients = ["Antigravity", "Claude", "Cursor", "ChatGPT"]
|
|
84
|
-
const now = Math.floor(Date.now() / 1000)
|
|
85
|
-
// Generate 20 points
|
|
86
|
-
const points = 20
|
|
87
|
-
const interval = 300 // 5 mins
|
|
88
|
-
|
|
89
|
-
const generateSeries = () => {
|
|
90
|
-
return Array.from({ length: points }).map((_, i) => {
|
|
91
|
-
const time = now - (points - 1 - i) * interval
|
|
92
|
-
const point: any = { time }
|
|
93
|
-
clients.forEach(c => {
|
|
94
|
-
// Random value between 0 and 10
|
|
95
|
-
point[c] = Math.floor(Math.random() * 10)
|
|
96
|
-
})
|
|
97
|
-
return point
|
|
98
|
-
})
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
return {
|
|
102
|
-
creates: generateSeries(),
|
|
103
|
-
reads: generateSeries(),
|
|
104
|
-
updates: generateSeries(),
|
|
105
|
-
deletes: generateSeries(),
|
|
106
|
-
// Metadata is now handled globally via clients.json, so we don't return partial overrides here
|
|
107
|
-
metadata: {}
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
}
|
|
@@ -1,191 +0,0 @@
|
|
|
1
|
-
import { DashboardData, DataSourceStrategy, TimeSeriesData } from "./types";
|
|
2
|
-
|
|
3
|
-
export class ProductionDataSource implements DataSourceStrategy {
|
|
4
|
-
async fetchGlobalStats(): Promise<DashboardData> {
|
|
5
|
-
const res = await fetch(`/api/metrics`);
|
|
6
|
-
if (!res.ok) throw new Error("Failed to fetch metrics");
|
|
7
|
-
const data = await res.json();
|
|
8
|
-
|
|
9
|
-
const logsRes = await fetch(`/api/audit-logs`);
|
|
10
|
-
const logsData = logsRes.ok ? await logsRes.json() : { logs: [] };
|
|
11
|
-
|
|
12
|
-
// Helper to resolve logs
|
|
13
|
-
const resolveOperation = (log: any) => {
|
|
14
|
-
const normalizedOp = (log.operation || "").toString().toLowerCase();
|
|
15
|
-
if (normalizedOp === "read") return "Read";
|
|
16
|
-
if (normalizedOp === "write" || normalizedOp === "create") return "Write";
|
|
17
|
-
if (normalizedOp === "update") return "Update";
|
|
18
|
-
if (normalizedOp === "delete") return "Delete";
|
|
19
|
-
const method = (log.method || "").toString().toUpperCase();
|
|
20
|
-
if (method === "GET") return "Read";
|
|
21
|
-
if (method === "DELETE") return "Delete";
|
|
22
|
-
if (method === "PATCH" || method === "PUT") return "Update";
|
|
23
|
-
return "Write";
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
const mappedLogs = (logsData.logs || []).map((log: any, index: number) => {
|
|
27
|
-
const operation = resolveOperation(log);
|
|
28
|
-
// Use rawStatus if available (from our API), otherwise fallback to status
|
|
29
|
-
const statusCode = parseInt(log.rawStatus || log.status);
|
|
30
|
-
let status = "Success";
|
|
31
|
-
let description = "";
|
|
32
|
-
if (statusCode >= 500) {
|
|
33
|
-
status = "Error";
|
|
34
|
-
description = "Server error";
|
|
35
|
-
} else if (statusCode >= 400) {
|
|
36
|
-
status = "Error";
|
|
37
|
-
description =
|
|
38
|
-
statusCode === 401
|
|
39
|
-
? "Unauthorized"
|
|
40
|
-
: statusCode === 403
|
|
41
|
-
? "Forbidden"
|
|
42
|
-
: "Client error";
|
|
43
|
-
} else if (statusCode >= 300) {
|
|
44
|
-
status = "Warning";
|
|
45
|
-
description = "Redirect";
|
|
46
|
-
} else if (log.status === "Error") {
|
|
47
|
-
status = "Error";
|
|
48
|
-
description = "Error";
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
return {
|
|
52
|
-
id: index,
|
|
53
|
-
date: new Date(log.timestamp),
|
|
54
|
-
client: log.client || "Unknown",
|
|
55
|
-
operation,
|
|
56
|
-
status,
|
|
57
|
-
description,
|
|
58
|
-
timestamp: new Date(log.timestamp).getTime(),
|
|
59
|
-
};
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
// Calculate Latest & Tops from logs if available
|
|
63
|
-
const sortedByDate = [...mappedLogs].sort(
|
|
64
|
-
(a, b) => b.timestamp - a.timestamp,
|
|
65
|
-
);
|
|
66
|
-
|
|
67
|
-
// Writers
|
|
68
|
-
const wLog = sortedByDate.find((l) =>
|
|
69
|
-
["Write", "Update", "Delete", "Create"].includes(l.operation),
|
|
70
|
-
);
|
|
71
|
-
const lastWriter = wLog
|
|
72
|
-
? { name: wLog.client, timestamp: wLog.timestamp }
|
|
73
|
-
: { name: "N/A", timestamp: 0 };
|
|
74
|
-
|
|
75
|
-
// Readers
|
|
76
|
-
const rLog = sortedByDate.find((l) => l.operation === "Read");
|
|
77
|
-
const lastReader = rLog
|
|
78
|
-
? { name: rLog.client, timestamp: rLog.timestamp }
|
|
79
|
-
: { name: "N/A", timestamp: 0 };
|
|
80
|
-
|
|
81
|
-
// Tops
|
|
82
|
-
const writerCounts: Record<string, number> = {};
|
|
83
|
-
const readerCounts: Record<string, number> = {};
|
|
84
|
-
mappedLogs.forEach((log: any) => {
|
|
85
|
-
if (["Write", "Update", "Delete", "Create"].includes(log.operation)) {
|
|
86
|
-
writerCounts[log.client] = (writerCounts[log.client] || 0) + 1;
|
|
87
|
-
} else if (log.operation === "Read") {
|
|
88
|
-
readerCounts[log.client] = (readerCounts[log.client] || 0) + 1;
|
|
89
|
-
}
|
|
90
|
-
});
|
|
91
|
-
const getTop = (counts: Record<string, number>) => {
|
|
92
|
-
const entries = Object.entries(counts);
|
|
93
|
-
if (entries.length === 0) return { name: "N/A", count: 0 };
|
|
94
|
-
entries.sort((a, b) => b[1] - a[1]);
|
|
95
|
-
return { name: entries[0][0], count: entries[0][1] };
|
|
96
|
-
};
|
|
97
|
-
|
|
98
|
-
const topWriter = logsRes.ok
|
|
99
|
-
? getTop(writerCounts)
|
|
100
|
-
: (data.stats.topWriter ?? { name: "N/A", count: 0 });
|
|
101
|
-
const topReader = logsRes.ok
|
|
102
|
-
? getTop(readerCounts)
|
|
103
|
-
: (data.stats.topReader ?? { name: "N/A", count: 0 });
|
|
104
|
-
|
|
105
|
-
// Trends calculation
|
|
106
|
-
const calculateTrend = (series: number[]) => {
|
|
107
|
-
if (!series || series.length < 2)
|
|
108
|
-
return {
|
|
109
|
-
change: "0",
|
|
110
|
-
trend: "neutral" as const,
|
|
111
|
-
hasData: false,
|
|
112
|
-
data: [],
|
|
113
|
-
};
|
|
114
|
-
const first = series[0];
|
|
115
|
-
const last = series[series.length - 1];
|
|
116
|
-
const diff = last - first;
|
|
117
|
-
const prefix = diff > 0 ? "+" : "";
|
|
118
|
-
return {
|
|
119
|
-
change: `${prefix}${diff.toLocaleString()}`,
|
|
120
|
-
trend: (diff > 0 ? "up" : diff < 0 ? "down" : "neutral") as
|
|
121
|
-
| "up"
|
|
122
|
-
| "down"
|
|
123
|
-
| "neutral",
|
|
124
|
-
hasData: true,
|
|
125
|
-
data: series,
|
|
126
|
-
};
|
|
127
|
-
};
|
|
128
|
-
|
|
129
|
-
// Success Rate Trend
|
|
130
|
-
let successTrend = {
|
|
131
|
-
change: "0%",
|
|
132
|
-
trend: "neutral" as "neutral" | "up" | "down",
|
|
133
|
-
hasData: false,
|
|
134
|
-
data: [] as number[],
|
|
135
|
-
};
|
|
136
|
-
if (data.sparklines?.successRate) {
|
|
137
|
-
const sData = data.sparklines.successRate;
|
|
138
|
-
const sFirst = sData[0] || 0;
|
|
139
|
-
const sLast = sData[sData.length - 1] || 0;
|
|
140
|
-
const sDiff = sLast - sFirst;
|
|
141
|
-
successTrend = {
|
|
142
|
-
change: `${sDiff > 0 ? "+" : ""}${sDiff.toFixed(1)}%`,
|
|
143
|
-
trend: sDiff >= 0 ? "up" : "down",
|
|
144
|
-
hasData: true,
|
|
145
|
-
data: sData,
|
|
146
|
-
};
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
return {
|
|
150
|
-
stats: {
|
|
151
|
-
memoryRecords: data.stats.memoryRecords ?? 0,
|
|
152
|
-
totalClients: data.stats.totalClients ?? 0,
|
|
153
|
-
successRate: data.stats.successRate ?? 0,
|
|
154
|
-
totalRequests: data.stats.totalRequests ?? 0,
|
|
155
|
-
topWriter,
|
|
156
|
-
topReader,
|
|
157
|
-
lastWriter,
|
|
158
|
-
lastReader,
|
|
159
|
-
},
|
|
160
|
-
trends: {
|
|
161
|
-
memory: calculateTrend(data.sparklines?.memoryRecords || []),
|
|
162
|
-
clients: calculateTrend(data.sparklines?.totalClients || []),
|
|
163
|
-
success: successTrend,
|
|
164
|
-
requests: calculateTrend(data.sparklines?.totalRequests || []),
|
|
165
|
-
},
|
|
166
|
-
logs: mappedLogs,
|
|
167
|
-
};
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
async getChartData(period: string): Promise<TimeSeriesData> {
|
|
171
|
-
const res = await fetch(`/api/metrics?period=${period}`);
|
|
172
|
-
if (!res.ok) throw new Error("Failed to fetch chart data");
|
|
173
|
-
const apiData = await res.json();
|
|
174
|
-
|
|
175
|
-
// Fetch clients metadata separately or use what's in apiData
|
|
176
|
-
// Ideally we merge them here
|
|
177
|
-
let metadata = {};
|
|
178
|
-
if (apiData.metadata) {
|
|
179
|
-
metadata = apiData.metadata;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// apiData.timeSeries needs to be returned.
|
|
183
|
-
return {
|
|
184
|
-
creates: apiData.timeSeries?.creates || [],
|
|
185
|
-
reads: apiData.timeSeries?.reads || [],
|
|
186
|
-
updates: apiData.timeSeries?.updates || [],
|
|
187
|
-
deletes: apiData.timeSeries?.deletes || [],
|
|
188
|
-
metadata,
|
|
189
|
-
};
|
|
190
|
-
}
|
|
191
|
-
}
|
package/lib/prometheus/client.ts
DELETED
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
// Prefer explicit PROMETHEUS_URL, fall back to NEXT_PUBLIC, then local Prometheus default port (mapped to 9092 in docker-compose)
|
|
2
|
-
export const PROMETHEUS_URL = process.env.PROMETHEUS_URL || process.env.NEXT_PUBLIC_PROMETHEUS_URL || 'http://localhost:9092'
|
|
3
|
-
|
|
4
|
-
export interface PrometheusQueryResult {
|
|
5
|
-
status: string
|
|
6
|
-
data: {
|
|
7
|
-
resultType: string
|
|
8
|
-
result: Array<{
|
|
9
|
-
metric: Record<string, string>
|
|
10
|
-
value?: [number, string]
|
|
11
|
-
values?: Array<[number, string]>
|
|
12
|
-
}>
|
|
13
|
-
}
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export async function query(promql: string): Promise<PrometheusQueryResult> {
|
|
17
|
-
try {
|
|
18
|
-
const controller = new AbortController()
|
|
19
|
-
const id = setTimeout(() => controller.abort(), 1500)
|
|
20
|
-
|
|
21
|
-
const response = await fetch(`${PROMETHEUS_URL}/api/v1/query?query=${encodeURIComponent(promql)}`, {
|
|
22
|
-
signal: controller.signal,
|
|
23
|
-
cache: 'no-store'
|
|
24
|
-
})
|
|
25
|
-
clearTimeout(id)
|
|
26
|
-
|
|
27
|
-
if (!response.ok) {
|
|
28
|
-
throw new Error(`Prometheus query failed: ${response.statusText}`)
|
|
29
|
-
}
|
|
30
|
-
return response.json()
|
|
31
|
-
} catch (error) {
|
|
32
|
-
console.error('Prometheus query failed:', error)
|
|
33
|
-
return { status: 'error', data: { resultType: 'vector', result: [] } }
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export async function queryRange(promql: string, start: number, end: number, step: string = '1m'): Promise<PrometheusQueryResult> {
|
|
38
|
-
try {
|
|
39
|
-
const url = `${PROMETHEUS_URL}/api/v1/query_range?query=${encodeURIComponent(promql)}&start=${start}&end=${end}&step=${step}`
|
|
40
|
-
|
|
41
|
-
const controller = new AbortController()
|
|
42
|
-
const id = setTimeout(() => controller.abort(), 1500)
|
|
43
|
-
|
|
44
|
-
const response = await fetch(url, {
|
|
45
|
-
signal: controller.signal,
|
|
46
|
-
cache: 'no-store'
|
|
47
|
-
})
|
|
48
|
-
clearTimeout(id)
|
|
49
|
-
|
|
50
|
-
if (!response.ok) {
|
|
51
|
-
throw new Error(`Prometheus range query failed: ${response.statusText}`)
|
|
52
|
-
}
|
|
53
|
-
return response.json()
|
|
54
|
-
} catch (error) {
|
|
55
|
-
console.error('Prometheus range query failed:', error)
|
|
56
|
-
return { status: 'error', data: { resultType: 'matrix', result: [] } }
|
|
57
|
-
}
|
|
58
|
-
}
|