@cybermem/dashboard 0.1.0
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/.dockerignore +11 -0
- package/.eslintrc.json +3 -0
- package/Dockerfile +48 -0
- package/app/api/audit-logs/route.ts +60 -0
- package/app/api/metrics/route.ts +141 -0
- package/app/api/prometheus/route.ts +65 -0
- package/app/api/settings/regenerate/route.ts +20 -0
- package/app/api/settings/route.ts +25 -0
- package/app/api/system/restart/route.ts +18 -0
- package/app/globals.css +148 -0
- package/app/layout.tsx +37 -0
- package/app/page.tsx +150 -0
- package/components/dashboard/audit-log-table.tsx +195 -0
- package/components/dashboard/chart-card.tsx +196 -0
- package/components/dashboard/charts-section.tsx +16 -0
- package/components/dashboard/header.tsx +82 -0
- package/components/dashboard/login-modal.tsx +87 -0
- package/components/dashboard/mcp-config-modal.tsx +397 -0
- package/components/dashboard/metric-card.tsx +23 -0
- package/components/dashboard/metrics-chart.tsx +134 -0
- package/components/dashboard/metrics-grid.tsx +136 -0
- package/components/dashboard/settings-modal.tsx +345 -0
- package/components/theme-provider.tsx +11 -0
- package/components/ui/accordion.tsx +66 -0
- package/components/ui/alert-dialog.tsx +157 -0
- package/components/ui/alert.tsx +66 -0
- package/components/ui/aspect-ratio.tsx +11 -0
- package/components/ui/avatar.tsx +53 -0
- package/components/ui/badge.tsx +46 -0
- package/components/ui/breadcrumb.tsx +109 -0
- package/components/ui/button-group.tsx +83 -0
- package/components/ui/button.tsx +60 -0
- package/components/ui/calendar.tsx +213 -0
- package/components/ui/card.tsx +92 -0
- package/components/ui/carousel.tsx +241 -0
- package/components/ui/chart.tsx +353 -0
- package/components/ui/checkbox.tsx +32 -0
- package/components/ui/collapsible.tsx +33 -0
- package/components/ui/command.tsx +184 -0
- package/components/ui/context-menu.tsx +252 -0
- package/components/ui/dialog.tsx +143 -0
- package/components/ui/drawer.tsx +135 -0
- package/components/ui/dropdown-menu.tsx +257 -0
- package/components/ui/empty.tsx +104 -0
- package/components/ui/field.tsx +244 -0
- package/components/ui/form.tsx +167 -0
- package/components/ui/hover-card.tsx +44 -0
- package/components/ui/input-group.tsx +169 -0
- package/components/ui/input-otp.tsx +77 -0
- package/components/ui/input.tsx +21 -0
- package/components/ui/item.tsx +193 -0
- package/components/ui/kbd.tsx +28 -0
- package/components/ui/label.tsx +24 -0
- package/components/ui/menubar.tsx +276 -0
- package/components/ui/navigation-menu.tsx +166 -0
- package/components/ui/pagination.tsx +127 -0
- package/components/ui/popover.tsx +48 -0
- package/components/ui/progress.tsx +31 -0
- package/components/ui/radio-group.tsx +45 -0
- package/components/ui/resizable.tsx +56 -0
- package/components/ui/scroll-area.tsx +58 -0
- package/components/ui/select.tsx +185 -0
- package/components/ui/separator.tsx +28 -0
- package/components/ui/sheet.tsx +139 -0
- package/components/ui/sidebar.tsx +726 -0
- package/components/ui/skeleton.tsx +13 -0
- package/components/ui/slider.tsx +63 -0
- package/components/ui/sonner.tsx +25 -0
- package/components/ui/spinner.tsx +16 -0
- package/components/ui/switch.tsx +31 -0
- package/components/ui/table.tsx +116 -0
- package/components/ui/tabs.tsx +66 -0
- package/components/ui/textarea.tsx +18 -0
- package/components/ui/toast.tsx +129 -0
- package/components/ui/toaster.tsx +35 -0
- package/components/ui/toggle-group.tsx +73 -0
- package/components/ui/toggle.tsx +47 -0
- package/components/ui/tooltip.tsx +61 -0
- package/components/ui/use-mobile.tsx +19 -0
- package/components/ui/use-toast.ts +191 -0
- package/components.json +21 -0
- package/hooks/use-mobile.ts +19 -0
- package/hooks/use-toast.ts +191 -0
- package/lib/data/dashboard-context.tsx +75 -0
- package/lib/data/demo-strategy.ts +110 -0
- package/lib/data/production-strategy.ts +152 -0
- package/lib/data/types.ts +52 -0
- package/lib/prometheus/client.ts +58 -0
- package/lib/prometheus/index.ts +6 -0
- package/lib/prometheus/metrics.ts +234 -0
- package/lib/prometheus/sparklines.ts +71 -0
- package/lib/prometheus/timeseries.ts +305 -0
- package/lib/prometheus/utils.ts +176 -0
- package/lib/utils.ts +6 -0
- package/next.config.mjs +36 -0
- package/package.json +91 -0
- package/postcss.config.mjs +8 -0
- package/public/clients.json +165 -0
- package/public/favicon-dark.svg +1 -0
- package/public/favicon-light.svg +1 -0
- package/public/icons/antigravity.png +0 -0
- package/public/icons/chatgpt.png +0 -0
- package/public/icons/claude-code.png +0 -0
- package/public/icons/claude.png +0 -0
- package/public/icons/codex.png +0 -0
- package/public/icons/cursor.png +0 -0
- package/public/icons/gemini.png +0 -0
- package/public/icons/images.jpeg +0 -0
- package/public/icons/mcp.png +0 -0
- package/public/icons/mono.png +0 -0
- package/public/icons/perplexity.png +0 -0
- package/public/icons/vscode.png +0 -0
- package/public/icons/warp.png +0 -0
- package/public/icons/windsurf.png +0 -0
- package/public/logo.png +0 -0
- package/public/logo.svg +7 -0
- package/public/manifest.json +21 -0
- package/public/site.webmanifest +21 -0
- package/public/web-app-manifest-192x192.png +0 -0
- package/public/web-app-manifest-512x512.png +0 -0
- package/shared.env +0 -0
- package/styles/globals.css +125 -0
- package/tsconfig.json +41 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
|
|
2
|
+
import { DashboardData, DataSourceStrategy, TimeSeriesData } from "./types"
|
|
3
|
+
|
|
4
|
+
export class ProductionDataSource implements DataSourceStrategy {
|
|
5
|
+
async fetchGlobalStats(): Promise<DashboardData> {
|
|
6
|
+
const res = await fetch(`/api/metrics`)
|
|
7
|
+
if (!res.ok) throw new Error("Failed to fetch metrics")
|
|
8
|
+
const data = await res.json()
|
|
9
|
+
|
|
10
|
+
const logsRes = await fetch(`/api/audit-logs`)
|
|
11
|
+
const logsData = logsRes.ok ? await logsRes.json() : { logs: [] }
|
|
12
|
+
|
|
13
|
+
// Helper to resolve logs
|
|
14
|
+
const resolveOperation = (log: any) => {
|
|
15
|
+
const normalizedOp = (log.operation || "").toString().toLowerCase()
|
|
16
|
+
if (normalizedOp === "read") return "Read"
|
|
17
|
+
if (normalizedOp === "write" || normalizedOp === "create") return "Write"
|
|
18
|
+
if (normalizedOp === "update") return "Update"
|
|
19
|
+
if (normalizedOp === "delete") return "Delete"
|
|
20
|
+
const method = (log.method || "").toString().toUpperCase()
|
|
21
|
+
if (method === "GET") return "Read"
|
|
22
|
+
if (method === "DELETE") return "Delete"
|
|
23
|
+
if (method === "PATCH" || method === "PUT") return "Update"
|
|
24
|
+
return "Write"
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const mappedLogs = (logsData.logs || []).map((log: any, index: number) => {
|
|
28
|
+
const operation = resolveOperation(log)
|
|
29
|
+
// Use rawStatus if available (from our API), otherwise fallback to status
|
|
30
|
+
const statusCode = parseInt(log.rawStatus || log.status)
|
|
31
|
+
let status = "Success"
|
|
32
|
+
let description = ""
|
|
33
|
+
if (statusCode >= 500) { status = "Error"; description = "Server error" }
|
|
34
|
+
else if (statusCode >= 400) { status = "Error"; description = statusCode === 401 ? "Unauthorized" : statusCode === 403 ? "Forbidden" : "Client error" }
|
|
35
|
+
else if (statusCode >= 300) { status = "Warning"; description = "Redirect" }
|
|
36
|
+
else if (log.status === "Error") { status = "Error"; description = "Error" }
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
id: index,
|
|
40
|
+
date: new Date(log.timestamp),
|
|
41
|
+
client: log.client || "Unknown",
|
|
42
|
+
operation,
|
|
43
|
+
status,
|
|
44
|
+
description,
|
|
45
|
+
timestamp: new Date(log.timestamp).getTime()
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
// Calculate Latest & Tops from logs if available
|
|
50
|
+
const sortedByDate = [...mappedLogs].sort((a, b) => b.timestamp - a.timestamp)
|
|
51
|
+
|
|
52
|
+
// Writers
|
|
53
|
+
const wLog = sortedByDate.find(l => ["Write", "Update", "Delete", "Create"].includes(l.operation))
|
|
54
|
+
const lastWriter = wLog ? { name: wLog.client, timestamp: wLog.timestamp } : { name: "N/A", timestamp: 0 }
|
|
55
|
+
|
|
56
|
+
// Readers
|
|
57
|
+
const rLog = sortedByDate.find(l => l.operation === "Read")
|
|
58
|
+
const lastReader = rLog ? { name: rLog.client, timestamp: rLog.timestamp } : { name: "N/A", timestamp: 0 }
|
|
59
|
+
|
|
60
|
+
// Tops
|
|
61
|
+
const writerCounts: Record<string, number> = {}
|
|
62
|
+
const readerCounts: Record<string, number> = {}
|
|
63
|
+
mappedLogs.forEach((log: any) => {
|
|
64
|
+
if (["Write", "Update", "Delete", "Create"].includes(log.operation)) {
|
|
65
|
+
writerCounts[log.client] = (writerCounts[log.client] || 0) + 1
|
|
66
|
+
} else if (log.operation === "Read") {
|
|
67
|
+
readerCounts[log.client] = (readerCounts[log.client] || 0) + 1
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
const getTop = (counts: Record<string, number>) => {
|
|
71
|
+
const entries = Object.entries(counts)
|
|
72
|
+
if (entries.length === 0) return { name: "N/A", count: 0 }
|
|
73
|
+
entries.sort((a, b) => b[1] - a[1])
|
|
74
|
+
return { name: entries[0][0], count: entries[0][1] }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const topWriter = logsRes.ok ? getTop(writerCounts) : (data.stats.topWriter ?? { name: "N/A", count: 0 })
|
|
78
|
+
const topReader = logsRes.ok ? getTop(readerCounts) : (data.stats.topReader ?? { name: "N/A", count: 0 })
|
|
79
|
+
|
|
80
|
+
// Trends calculation
|
|
81
|
+
const calculateTrend = (series: number[]) => {
|
|
82
|
+
if (!series || series.length < 2) return { change: "0", trend: "neutral" as const, hasData: false, data: [] }
|
|
83
|
+
const first = series[0]
|
|
84
|
+
const last = series[series.length - 1]
|
|
85
|
+
const diff = last - first
|
|
86
|
+
const prefix = diff > 0 ? "+" : ""
|
|
87
|
+
return {
|
|
88
|
+
change: `${prefix}${diff.toLocaleString()}`,
|
|
89
|
+
trend: (diff > 0 ? "up" : diff < 0 ? "down" : "neutral") as "up" | "down" | "neutral",
|
|
90
|
+
hasData: true,
|
|
91
|
+
data: series
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Success Rate Trend
|
|
96
|
+
let successTrend = { change: "0%", trend: "neutral" as "neutral" | "up" | "down", hasData: false, data: [] as number[] }
|
|
97
|
+
if (data.sparklines?.successRate) {
|
|
98
|
+
const sData = data.sparklines.successRate
|
|
99
|
+
const sFirst = sData[0] || 0
|
|
100
|
+
const sLast = sData[sData.length - 1] || 0
|
|
101
|
+
const sDiff = sLast - sFirst
|
|
102
|
+
successTrend = {
|
|
103
|
+
change: `${sDiff > 0 ? "+" : ""}${sDiff.toFixed(1)}%`,
|
|
104
|
+
trend: (sDiff >= 0 ? "up" : "down"),
|
|
105
|
+
hasData: true,
|
|
106
|
+
data: sData
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
stats: {
|
|
112
|
+
memoryRecords: data.stats.memoryRecords ?? 0,
|
|
113
|
+
totalClients: data.stats.totalClients ?? 0,
|
|
114
|
+
successRate: data.stats.successRate ?? 0,
|
|
115
|
+
totalRequests: data.stats.totalRequests ?? 0,
|
|
116
|
+
topWriter,
|
|
117
|
+
topReader,
|
|
118
|
+
lastWriter,
|
|
119
|
+
lastReader,
|
|
120
|
+
},
|
|
121
|
+
trends: {
|
|
122
|
+
memory: calculateTrend(data.sparklines?.memoryRecords),
|
|
123
|
+
clients: calculateTrend(data.sparklines?.totalClients),
|
|
124
|
+
success: successTrend,
|
|
125
|
+
requests: calculateTrend(data.sparklines?.totalRequests),
|
|
126
|
+
},
|
|
127
|
+
logs: mappedLogs,
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async getChartData(period: string): Promise<TimeSeriesData> {
|
|
132
|
+
const res = await fetch(`/api/metrics?period=${period}`)
|
|
133
|
+
if (!res.ok) throw new Error("Failed to fetch chart data")
|
|
134
|
+
const apiData = await res.json()
|
|
135
|
+
|
|
136
|
+
// Fetch clients metadata separately or use what's in apiData
|
|
137
|
+
// Ideally we merge them here
|
|
138
|
+
let metadata = {}
|
|
139
|
+
if (apiData.metadata) {
|
|
140
|
+
metadata = apiData.metadata
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// apiData.timeSeries needs to be returned.
|
|
144
|
+
return {
|
|
145
|
+
creates: apiData.timeSeries?.creates || [],
|
|
146
|
+
reads: apiData.timeSeries?.reads || [],
|
|
147
|
+
updates: apiData.timeSeries?.updates || [],
|
|
148
|
+
deletes: apiData.timeSeries?.deletes || [],
|
|
149
|
+
metadata
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export interface TrendState {
|
|
2
|
+
change: string
|
|
3
|
+
trend: "up" | "down" | "neutral"
|
|
4
|
+
hasData: boolean
|
|
5
|
+
data: number[]
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface DashboardStats {
|
|
9
|
+
memoryRecords: number
|
|
10
|
+
totalClients: number
|
|
11
|
+
successRate: number
|
|
12
|
+
totalRequests: number
|
|
13
|
+
topWriter: { name: string; count: number }
|
|
14
|
+
topReader: { name: string; count: number }
|
|
15
|
+
lastWriter: { name: string; timestamp: number }
|
|
16
|
+
lastReader: { name: string; timestamp: number }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface AuditLogEntry {
|
|
20
|
+
id: number
|
|
21
|
+
date: Date
|
|
22
|
+
client: string
|
|
23
|
+
operation: string
|
|
24
|
+
status: string
|
|
25
|
+
description: string
|
|
26
|
+
timestamp: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface DashboardData {
|
|
30
|
+
stats: DashboardStats
|
|
31
|
+
trends: {
|
|
32
|
+
memory: TrendState
|
|
33
|
+
clients: TrendState
|
|
34
|
+
success: TrendState
|
|
35
|
+
requests: TrendState
|
|
36
|
+
}
|
|
37
|
+
logs: AuditLogEntry[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
export interface TimeSeriesData {
|
|
42
|
+
creates: any[]
|
|
43
|
+
reads: any[]
|
|
44
|
+
updates: any[]
|
|
45
|
+
deletes: any[]
|
|
46
|
+
metadata?: Record<string, any>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface DataSourceStrategy {
|
|
50
|
+
fetchGlobalStats(): Promise<DashboardData>
|
|
51
|
+
getChartData(period: string): Promise<TimeSeriesData>
|
|
52
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { query } from './client'
|
|
2
|
+
import { toPromDuration } from './utils'
|
|
3
|
+
|
|
4
|
+
export async function getTotalRequests(duration: string = '15m'): Promise<number> {
|
|
5
|
+
const result = await query('sum(openmemory_requests_total)')
|
|
6
|
+
return result.data.result[0]?.value ? parseFloat(result.data.result[0].value[1]) : 0
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function getRequestsByClient(duration: string = '15m'): Promise<Record<string, number>> {
|
|
10
|
+
const result = await query('sum by (client_name) (openmemory_requests_total)')
|
|
11
|
+
const byClient: Record<string, number> = {}
|
|
12
|
+
result.data.result.forEach((item) => {
|
|
13
|
+
const clientId = item.metric.client_name || 'unknown'
|
|
14
|
+
byClient[clientId] = item.value ? parseFloat(item.value[1]) : 0
|
|
15
|
+
})
|
|
16
|
+
return byClient
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function getRequestsByMethod(duration: string = '15m'): Promise<{ reads: Record<string, number>, writes: Record<string, number> }> {
|
|
20
|
+
// Reads = operation="read", Writes = operation="create"/"update"/"delete"
|
|
21
|
+
const readsResult = await query('sum by (client_name) (openmemory_requests_total{operation="read"})')
|
|
22
|
+
const writesResult = await query('sum by (client_name) (openmemory_requests_total{operation=~"create|update|delete"})')
|
|
23
|
+
const writes: Record<string, number> = {}
|
|
24
|
+
const reads: Record<string, number> = {}
|
|
25
|
+
|
|
26
|
+
readsResult.data.result.forEach((item) => {
|
|
27
|
+
const clientId = item.metric.client_name || 'unknown'
|
|
28
|
+
reads[clientId] = item.value ? parseFloat(item.value[1]) : 0
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
writesResult.data.result.forEach((item) => {
|
|
32
|
+
const clientId = item.metric.client_name || 'unknown'
|
|
33
|
+
writes[clientId] = item.value ? parseFloat(item.value[1]) : 0
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
return { reads, writes }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function getSuccessRate(): Promise<number> {
|
|
40
|
+
const result = await query('openmemory_success_rate_aggregate')
|
|
41
|
+
return result.data.result[0]?.value ? parseFloat(result.data.result[0].value[1]) : 100
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function getMemoryRecordsCount(): Promise<number> {
|
|
45
|
+
// Get total memories across all clients
|
|
46
|
+
const result = await query('sum(openmemory_memories_total)')
|
|
47
|
+
return result.data.result[0]?.value ? parseFloat(result.data.result[0].value[1]) : 0
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function getClientCount(): Promise<number> {
|
|
51
|
+
const result = await query('count(count by (client_name) (openmemory_memories_total))')
|
|
52
|
+
return result.data.result[0]?.value ? parseFloat(result.data.result[0].value[1]) : 0
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function getSuccessRateByClient(): Promise<Record<string, number>> {
|
|
56
|
+
// Calculate success rate from request status codes
|
|
57
|
+
// Success = not 4xx or 5xx, Failure = status 4xx or 5xx
|
|
58
|
+
const [totalResult, successResult] = await Promise.all([
|
|
59
|
+
query('sum by (client_name) (openmemory_requests_total)'),
|
|
60
|
+
query('sum by (client_name) (openmemory_requests_total{status=~"2..|3.."})')
|
|
61
|
+
])
|
|
62
|
+
|
|
63
|
+
const rates: Record<string, number> = {}
|
|
64
|
+
|
|
65
|
+
// First, initialize all clients with 0% (in case they have no successful requests)
|
|
66
|
+
totalResult.data.result.forEach((item) => {
|
|
67
|
+
const clientId = item.metric.client_name || 'unknown'
|
|
68
|
+
rates[clientId] = 0
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
// Then calculate success rate for clients with successful requests
|
|
72
|
+
successResult.data.result.forEach((item) => {
|
|
73
|
+
const clientId = item.metric.client_name || 'unknown'
|
|
74
|
+
const successCount = item.value ? parseFloat(item.value[1]) : 0
|
|
75
|
+
|
|
76
|
+
// Find total count for this client
|
|
77
|
+
const totalItem = totalResult.data.result.find(t => (t.metric.client_name || 'unknown') === clientId)
|
|
78
|
+
const totalCount = totalItem?.value ? parseFloat(totalItem.value[1]) : 0
|
|
79
|
+
|
|
80
|
+
if (totalCount > 0) {
|
|
81
|
+
rates[clientId] = (successCount / totalCount) * 100
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
return rates
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function getTopWriter(duration: string = '15m'): Promise<{ name: string, count: number }> {
|
|
89
|
+
// Get client with most write requests in the specified duration
|
|
90
|
+
const promDuration = toPromDuration(duration)
|
|
91
|
+
|
|
92
|
+
// Try with increase() first (deltas)
|
|
93
|
+
let queryStr = `topk(1, sum by (client_name) (increase(openmemory_requests_total{operation=~"create|update|delete"}[${promDuration}])))`
|
|
94
|
+
let result = await query(queryStr)
|
|
95
|
+
|
|
96
|
+
// Fallback: If no increase detected (singular data point or very low traffic),
|
|
97
|
+
if (result.data.result.length === 0) {
|
|
98
|
+
// First fallback: Check raw total requests
|
|
99
|
+
queryStr = `topk(1, sum by (client_name) (openmemory_requests_total{operation=~"create|update|delete"}))`
|
|
100
|
+
result = await query(queryStr)
|
|
101
|
+
|
|
102
|
+
// Second fallback: Check actual memories stored (from db-exporter, reliable)
|
|
103
|
+
if (result.data.result.length === 0) {
|
|
104
|
+
queryStr = `topk(1, sum by (client) (openmemory_memories_total))`
|
|
105
|
+
result = await query(queryStr)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (result.data.result.length === 0) {
|
|
110
|
+
return { name: 'N/A', count: 0 }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const item = result.data.result[0]
|
|
114
|
+
let count = item.value ? Math.round(parseFloat(item.value[1])) : 0
|
|
115
|
+
const rawName = item.metric.client_name || item.metric.client || 'unknown'
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
name: rawName,
|
|
119
|
+
count
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function getTopReader(duration: string = '15m'): Promise<{ name: string, count: number }> {
|
|
124
|
+
// Get client with most read requests in the specified duration
|
|
125
|
+
const promDuration = toPromDuration(duration)
|
|
126
|
+
const result = await query(`topk(1, sum by (client_name) (increase(openmemory_requests_total{operation="read"}[${promDuration}])))`)
|
|
127
|
+
|
|
128
|
+
if (result.data.result.length === 0) {
|
|
129
|
+
// Fallback to raw total requests (Traefik) only
|
|
130
|
+
const valResult = await query(`topk(1, sum by (client_name) (openmemory_requests_total{operation="read"}))`)
|
|
131
|
+
result.data.result = valResult.data.result
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (result.data.result.length === 0) {
|
|
135
|
+
return { name: 'N/A', count: 0 }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const item = result.data.result[0]
|
|
139
|
+
const count = item.value ? Math.round(parseFloat(item.value[1])) : 0
|
|
140
|
+
const rawName = item.metric.client_name || 'unknown'
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
name: rawName,
|
|
144
|
+
count
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function getLastWriter(): Promise<{ name: string, timestamp: number }> {
|
|
149
|
+
try {
|
|
150
|
+
// Look for writers in the last 1h
|
|
151
|
+
const result = await query('topk(1, sum by (client_name) (increase(openmemory_requests_total{operation=~"create|update|delete"}[1h])))')
|
|
152
|
+
|
|
153
|
+
if (result.data.result.length > 0 && result.data.result[0].value) {
|
|
154
|
+
const item = result.data.result[0]
|
|
155
|
+
const count = item.value ? parseFloat(item.value[1]) : 0
|
|
156
|
+
if (count > 0) {
|
|
157
|
+
const rawName = item.metric.client_name || 'unknown'
|
|
158
|
+
return {
|
|
159
|
+
name: rawName,
|
|
160
|
+
timestamp: Date.now()
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Fallback: Check total requests (lifetime) - still within Traefik source
|
|
166
|
+
const totalResult = await query('topk(1, sum by (client_name) (openmemory_requests_total{operation=~"create|update|delete"}))')
|
|
167
|
+
if (totalResult.data.result.length > 0) {
|
|
168
|
+
const item = totalResult.data.result[0]
|
|
169
|
+
if (item?.value) {
|
|
170
|
+
const rawName = item.metric.client_name || 'unknown'
|
|
171
|
+
return {
|
|
172
|
+
name: rawName,
|
|
173
|
+
timestamp: Date.now() // Approximate
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
} catch (e) {
|
|
179
|
+
console.error('getLastWriter error:', e)
|
|
180
|
+
}
|
|
181
|
+
return { name: 'N/A', timestamp: 0 }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export async function getLastReader(): Promise<{ name: string, timestamp: number }> {
|
|
185
|
+
try {
|
|
186
|
+
// Look for readers in the last 5 minutes to identify "current/last" activity
|
|
187
|
+
const result = await query('topk(1, sum by (client_name) (increase(openmemory_requests_total{operation="read"}[5m])))')
|
|
188
|
+
|
|
189
|
+
if (result.data.result.length > 0 && result.data.result[0].value) {
|
|
190
|
+
const count = parseFloat(result.data.result[0].value[1])
|
|
191
|
+
if (count > 0) {
|
|
192
|
+
const rawName = result.data.result[0].metric.client_name || 'unknown'
|
|
193
|
+
return {
|
|
194
|
+
name: rawName,
|
|
195
|
+
timestamp: Date.now()
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Fallback: look at total reads (lifetime) if no recent increase
|
|
201
|
+
const totalResult = await query('topk(1, sum by (client_name) (openmemory_requests_total{operation="read"}))')
|
|
202
|
+
if (totalResult.data.result.length > 0) {
|
|
203
|
+
const item = totalResult.data.result[0]
|
|
204
|
+
if (item?.value) {
|
|
205
|
+
const rawName = item.metric.client_name || 'unknown'
|
|
206
|
+
return {
|
|
207
|
+
name: rawName,
|
|
208
|
+
timestamp: Date.now() // Approximate
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
} catch (e) {
|
|
213
|
+
console.error('getLastReader error:', e)
|
|
214
|
+
}
|
|
215
|
+
return { name: 'N/A', timestamp: 0 }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Get all active clients in a period
|
|
219
|
+
export async function getAllActiveClients(duration: string = '1h'): Promise<string[]> {
|
|
220
|
+
const promDuration = toPromDuration(duration)
|
|
221
|
+
|
|
222
|
+
// Use instant query with increase() over the whole duration to find clients with ANY activity
|
|
223
|
+
const result = await query(
|
|
224
|
+
`sum by (client_name) (increase(openmemory_requests_total[${promDuration}])) > 0`
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
const clients = new Set<string>()
|
|
228
|
+
result.data.result.forEach((series) => {
|
|
229
|
+
const clientName = series.metric.client_name || 'unknown'
|
|
230
|
+
clients.add(clientName)
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
return Array.from(clients)
|
|
234
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { queryRange } from './client'
|
|
2
|
+
import { fillSparklineData, parseDuration } from './utils'
|
|
3
|
+
|
|
4
|
+
// Sparkline data - cumulative counts over time
|
|
5
|
+
export async function getMemoryRecordsSparkline(duration: string = '15m'): Promise<number[]> {
|
|
6
|
+
const now = Math.floor(Date.now() / 1000)
|
|
7
|
+
const start = now - parseDuration(duration)
|
|
8
|
+
|
|
9
|
+
const result = await queryRange(
|
|
10
|
+
'sum(openmemory_memories_total)',
|
|
11
|
+
start,
|
|
12
|
+
now,
|
|
13
|
+
'30s'
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
const series = result.data.result[0]
|
|
17
|
+
if (!series || !series.values) return Array(Math.ceil((now - start) / 30)).fill(0)
|
|
18
|
+
|
|
19
|
+
return fillSparklineData(series.values, start, now, 30)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function getTotalRequestsSparkline(duration: string = '15m'): Promise<number[]> {
|
|
23
|
+
const now = Math.floor(Date.now() / 1000)
|
|
24
|
+
const start = now - parseDuration(duration)
|
|
25
|
+
|
|
26
|
+
const result = await queryRange(
|
|
27
|
+
'sum(openmemory_requests_total)',
|
|
28
|
+
start,
|
|
29
|
+
now,
|
|
30
|
+
'30s'
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
const series = result.data.result[0]
|
|
34
|
+
if (!series || !series.values) return Array(Math.ceil((now - start) / 30)).fill(0)
|
|
35
|
+
|
|
36
|
+
return fillSparklineData(series.values, start, now, 30)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function getTotalClientsSparkline(duration: string = '15m'): Promise<number[]> {
|
|
40
|
+
const now = Math.floor(Date.now() / 1000)
|
|
41
|
+
const start = now - parseDuration(duration)
|
|
42
|
+
|
|
43
|
+
const result = await queryRange(
|
|
44
|
+
'count(count by (client_name) (openmemory_memories_total))',
|
|
45
|
+
start,
|
|
46
|
+
now,
|
|
47
|
+
'30s'
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
const series = result.data.result[0]
|
|
51
|
+
if (!series || !series.values) return Array(Math.ceil((now - start) / 30)).fill(0)
|
|
52
|
+
|
|
53
|
+
return fillSparklineData(series.values, start, now, 30)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function getSuccessRateSparkline(duration: string = '15m'): Promise<number[]> {
|
|
57
|
+
const now = Math.floor(Date.now() / 1000)
|
|
58
|
+
const start = now - parseDuration(duration)
|
|
59
|
+
|
|
60
|
+
const result = await queryRange(
|
|
61
|
+
'openmemory_success_rate_aggregate',
|
|
62
|
+
start,
|
|
63
|
+
now,
|
|
64
|
+
'30s'
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
const series = result.data.result[0]
|
|
68
|
+
if (!series || !series.values) return Array(Math.ceil((now - start) / 30)).fill(100)
|
|
69
|
+
|
|
70
|
+
return fillSparklineData(series.values, start, now, 30, 100)
|
|
71
|
+
}
|