@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.
Files changed (123) hide show
  1. package/.dockerignore +11 -0
  2. package/.eslintrc.json +3 -0
  3. package/Dockerfile +48 -0
  4. package/app/api/audit-logs/route.ts +60 -0
  5. package/app/api/metrics/route.ts +141 -0
  6. package/app/api/prometheus/route.ts +65 -0
  7. package/app/api/settings/regenerate/route.ts +20 -0
  8. package/app/api/settings/route.ts +25 -0
  9. package/app/api/system/restart/route.ts +18 -0
  10. package/app/globals.css +148 -0
  11. package/app/layout.tsx +37 -0
  12. package/app/page.tsx +150 -0
  13. package/components/dashboard/audit-log-table.tsx +195 -0
  14. package/components/dashboard/chart-card.tsx +196 -0
  15. package/components/dashboard/charts-section.tsx +16 -0
  16. package/components/dashboard/header.tsx +82 -0
  17. package/components/dashboard/login-modal.tsx +87 -0
  18. package/components/dashboard/mcp-config-modal.tsx +397 -0
  19. package/components/dashboard/metric-card.tsx +23 -0
  20. package/components/dashboard/metrics-chart.tsx +134 -0
  21. package/components/dashboard/metrics-grid.tsx +136 -0
  22. package/components/dashboard/settings-modal.tsx +345 -0
  23. package/components/theme-provider.tsx +11 -0
  24. package/components/ui/accordion.tsx +66 -0
  25. package/components/ui/alert-dialog.tsx +157 -0
  26. package/components/ui/alert.tsx +66 -0
  27. package/components/ui/aspect-ratio.tsx +11 -0
  28. package/components/ui/avatar.tsx +53 -0
  29. package/components/ui/badge.tsx +46 -0
  30. package/components/ui/breadcrumb.tsx +109 -0
  31. package/components/ui/button-group.tsx +83 -0
  32. package/components/ui/button.tsx +60 -0
  33. package/components/ui/calendar.tsx +213 -0
  34. package/components/ui/card.tsx +92 -0
  35. package/components/ui/carousel.tsx +241 -0
  36. package/components/ui/chart.tsx +353 -0
  37. package/components/ui/checkbox.tsx +32 -0
  38. package/components/ui/collapsible.tsx +33 -0
  39. package/components/ui/command.tsx +184 -0
  40. package/components/ui/context-menu.tsx +252 -0
  41. package/components/ui/dialog.tsx +143 -0
  42. package/components/ui/drawer.tsx +135 -0
  43. package/components/ui/dropdown-menu.tsx +257 -0
  44. package/components/ui/empty.tsx +104 -0
  45. package/components/ui/field.tsx +244 -0
  46. package/components/ui/form.tsx +167 -0
  47. package/components/ui/hover-card.tsx +44 -0
  48. package/components/ui/input-group.tsx +169 -0
  49. package/components/ui/input-otp.tsx +77 -0
  50. package/components/ui/input.tsx +21 -0
  51. package/components/ui/item.tsx +193 -0
  52. package/components/ui/kbd.tsx +28 -0
  53. package/components/ui/label.tsx +24 -0
  54. package/components/ui/menubar.tsx +276 -0
  55. package/components/ui/navigation-menu.tsx +166 -0
  56. package/components/ui/pagination.tsx +127 -0
  57. package/components/ui/popover.tsx +48 -0
  58. package/components/ui/progress.tsx +31 -0
  59. package/components/ui/radio-group.tsx +45 -0
  60. package/components/ui/resizable.tsx +56 -0
  61. package/components/ui/scroll-area.tsx +58 -0
  62. package/components/ui/select.tsx +185 -0
  63. package/components/ui/separator.tsx +28 -0
  64. package/components/ui/sheet.tsx +139 -0
  65. package/components/ui/sidebar.tsx +726 -0
  66. package/components/ui/skeleton.tsx +13 -0
  67. package/components/ui/slider.tsx +63 -0
  68. package/components/ui/sonner.tsx +25 -0
  69. package/components/ui/spinner.tsx +16 -0
  70. package/components/ui/switch.tsx +31 -0
  71. package/components/ui/table.tsx +116 -0
  72. package/components/ui/tabs.tsx +66 -0
  73. package/components/ui/textarea.tsx +18 -0
  74. package/components/ui/toast.tsx +129 -0
  75. package/components/ui/toaster.tsx +35 -0
  76. package/components/ui/toggle-group.tsx +73 -0
  77. package/components/ui/toggle.tsx +47 -0
  78. package/components/ui/tooltip.tsx +61 -0
  79. package/components/ui/use-mobile.tsx +19 -0
  80. package/components/ui/use-toast.ts +191 -0
  81. package/components.json +21 -0
  82. package/hooks/use-mobile.ts +19 -0
  83. package/hooks/use-toast.ts +191 -0
  84. package/lib/data/dashboard-context.tsx +75 -0
  85. package/lib/data/demo-strategy.ts +110 -0
  86. package/lib/data/production-strategy.ts +152 -0
  87. package/lib/data/types.ts +52 -0
  88. package/lib/prometheus/client.ts +58 -0
  89. package/lib/prometheus/index.ts +6 -0
  90. package/lib/prometheus/metrics.ts +234 -0
  91. package/lib/prometheus/sparklines.ts +71 -0
  92. package/lib/prometheus/timeseries.ts +305 -0
  93. package/lib/prometheus/utils.ts +176 -0
  94. package/lib/utils.ts +6 -0
  95. package/next.config.mjs +36 -0
  96. package/package.json +91 -0
  97. package/postcss.config.mjs +8 -0
  98. package/public/clients.json +165 -0
  99. package/public/favicon-dark.svg +1 -0
  100. package/public/favicon-light.svg +1 -0
  101. package/public/icons/antigravity.png +0 -0
  102. package/public/icons/chatgpt.png +0 -0
  103. package/public/icons/claude-code.png +0 -0
  104. package/public/icons/claude.png +0 -0
  105. package/public/icons/codex.png +0 -0
  106. package/public/icons/cursor.png +0 -0
  107. package/public/icons/gemini.png +0 -0
  108. package/public/icons/images.jpeg +0 -0
  109. package/public/icons/mcp.png +0 -0
  110. package/public/icons/mono.png +0 -0
  111. package/public/icons/perplexity.png +0 -0
  112. package/public/icons/vscode.png +0 -0
  113. package/public/icons/warp.png +0 -0
  114. package/public/icons/windsurf.png +0 -0
  115. package/public/logo.png +0 -0
  116. package/public/logo.svg +7 -0
  117. package/public/manifest.json +21 -0
  118. package/public/site.webmanifest +21 -0
  119. package/public/web-app-manifest-192x192.png +0 -0
  120. package/public/web-app-manifest-512x512.png +0 -0
  121. package/shared.env +0 -0
  122. package/styles/globals.css +125 -0
  123. 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,6 @@
1
+ export * from './client'
2
+ export * from './metrics'
3
+ export * from './sparklines'
4
+ export * from './timeseries'
5
+ export * from './utils'
6
+
@@ -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
+ }