@cybermem/dashboard 0.5.14 → 0.8.5
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 +2 -1
- package/app/api/audit-logs/route.ts +79 -56
- package/app/api/auth/token/route.ts +59 -0
- package/app/api/health/route.ts +49 -22
- package/app/api/metrics/route.ts +272 -86
- package/app/api/reset/route.ts +1 -1
- package/app/api/restore/route.ts +1 -1
- package/app/api/settings/route.ts +37 -26
- package/app/api/system/restart/route.ts +1 -1
- package/app/layout.tsx +25 -17
- package/app/page.tsx +135 -110
- package/components/dashboard/audit-log-table.tsx +3 -3
- package/components/dashboard/mcp-config-modal.tsx +356 -247
- package/components/dashboard/metric-card.tsx +4 -7
- package/components/dashboard/settings-modal.tsx +53 -58
- package/e2e/crud-happy-path.spec.ts +243 -173
- package/lib/auth.ts +50 -0
- package/lib/data/dashboard-context.tsx +117 -70
- package/lib/data/production-strategy.ts +124 -85
- package/middleware.ts +53 -31
- package/next.config.mjs +12 -20
- package/package.json +4 -1
- package/playwright.config.ts +50 -15
- package/public/clients.json +7 -23
|
@@ -1,118 +1,165 @@
|
|
|
1
|
+
"use client";
|
|
1
2
|
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
6
|
-
import { ProductionDataSource } from "./production-strategy"
|
|
7
|
-
import { DataSourceStrategy } from "./types"
|
|
3
|
+
import React, { createContext, useContext, useEffect, useState } from "react";
|
|
4
|
+
import { DemoDataSource } from "./demo-strategy";
|
|
5
|
+
import { ProductionDataSource } from "./production-strategy";
|
|
6
|
+
import { DataSourceStrategy } from "./types";
|
|
8
7
|
|
|
9
8
|
interface ClientConfig {
|
|
10
|
-
id: string
|
|
11
|
-
name: string
|
|
12
|
-
match: string
|
|
13
|
-
color: string
|
|
14
|
-
icon: string | null
|
|
15
|
-
description: string
|
|
16
|
-
steps: string[]
|
|
17
|
-
configType: string
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
match: string;
|
|
12
|
+
color: string;
|
|
13
|
+
icon: string | null;
|
|
14
|
+
description: string;
|
|
15
|
+
steps: string[];
|
|
16
|
+
configType: string;
|
|
18
17
|
}
|
|
19
18
|
|
|
20
19
|
interface ServiceStatus {
|
|
21
|
-
name: string
|
|
22
|
-
status:
|
|
23
|
-
message?: string
|
|
24
|
-
latencyMs?: number
|
|
20
|
+
name: string;
|
|
21
|
+
status: "ok" | "error" | "warning";
|
|
22
|
+
message?: string;
|
|
23
|
+
latencyMs?: number;
|
|
25
24
|
}
|
|
26
25
|
|
|
27
26
|
interface SystemHealth {
|
|
28
|
-
overall:
|
|
29
|
-
services: ServiceStatus[]
|
|
30
|
-
timestamp: string
|
|
27
|
+
overall: "ok" | "degraded" | "error";
|
|
28
|
+
services: ServiceStatus[];
|
|
29
|
+
timestamp: string;
|
|
31
30
|
}
|
|
32
31
|
|
|
33
32
|
interface DashboardContextType {
|
|
34
|
-
strategy: DataSourceStrategy
|
|
35
|
-
isDemo: boolean
|
|
36
|
-
toggleDemo: () => void
|
|
37
|
-
refreshSignal: number
|
|
38
|
-
clientConfigs: ClientConfig[]
|
|
39
|
-
systemHealth: SystemHealth | null
|
|
33
|
+
strategy: DataSourceStrategy;
|
|
34
|
+
isDemo: boolean;
|
|
35
|
+
toggleDemo: () => void;
|
|
36
|
+
refreshSignal: number;
|
|
37
|
+
clientConfigs: ClientConfig[];
|
|
38
|
+
systemHealth: SystemHealth | null;
|
|
39
|
+
isAuthenticated: boolean;
|
|
40
|
+
login: () => void;
|
|
40
41
|
}
|
|
41
42
|
|
|
42
|
-
const DashboardContext = createContext<DashboardContextType | undefined>(
|
|
43
|
+
const DashboardContext = createContext<DashboardContextType | undefined>(
|
|
44
|
+
undefined,
|
|
45
|
+
);
|
|
43
46
|
|
|
44
|
-
export function DashboardProvider({
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
47
|
+
export function DashboardProvider({
|
|
48
|
+
children,
|
|
49
|
+
initialAuth = false,
|
|
50
|
+
}: {
|
|
51
|
+
children: React.ReactNode;
|
|
52
|
+
initialAuth?: boolean;
|
|
53
|
+
}) {
|
|
54
|
+
const [isDemo, setIsDemo] = useState(false);
|
|
55
|
+
const [strategy, setStrategy] = useState<DataSourceStrategy>(
|
|
56
|
+
new ProductionDataSource(),
|
|
57
|
+
);
|
|
58
|
+
const [refreshSignal, setRefreshSignal] = useState(0);
|
|
59
|
+
const [clientConfigs, setClientConfigs] = useState<ClientConfig[]>([]);
|
|
60
|
+
const [systemHealth, setSystemHealth] = useState<SystemHealth | null>(null);
|
|
61
|
+
const [isAuthenticated, setIsAuthenticated] = useState(initialAuth);
|
|
50
62
|
|
|
51
63
|
// Load configuration on mount
|
|
52
64
|
useEffect(() => {
|
|
53
65
|
// Load client config
|
|
54
66
|
fetch("/clients.json")
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
67
|
+
.then((res) => res.json())
|
|
68
|
+
.then((data) => setClientConfigs(data))
|
|
69
|
+
.catch((err) => console.error("Failed to load client configs:", err));
|
|
70
|
+
|
|
71
|
+
// Check session storage
|
|
72
|
+
if (sessionStorage.getItem("authenticated") === "true") {
|
|
73
|
+
setIsAuthenticated(true);
|
|
74
|
+
}
|
|
75
|
+
}, []);
|
|
59
76
|
|
|
60
77
|
// Check system health
|
|
61
78
|
useEffect(() => {
|
|
62
79
|
const checkHealth = async () => {
|
|
63
80
|
try {
|
|
64
|
-
const res = await fetch("/api/health", {
|
|
81
|
+
const res = await fetch("/api/health", {
|
|
82
|
+
signal: AbortSignal.timeout(5000),
|
|
83
|
+
});
|
|
65
84
|
if (res.ok) {
|
|
66
|
-
const data = await res.json()
|
|
67
|
-
setSystemHealth(data)
|
|
85
|
+
const data = await res.json();
|
|
86
|
+
setSystemHealth(data);
|
|
68
87
|
} else {
|
|
69
88
|
setSystemHealth({
|
|
70
|
-
overall:
|
|
71
|
-
services: [
|
|
72
|
-
|
|
73
|
-
|
|
89
|
+
overall: "error",
|
|
90
|
+
services: [
|
|
91
|
+
{
|
|
92
|
+
name: "Dashboard API",
|
|
93
|
+
status: "error",
|
|
94
|
+
message: `HTTP ${res.status}`,
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
timestamp: new Date().toISOString(),
|
|
98
|
+
});
|
|
74
99
|
}
|
|
75
100
|
} catch (error: any) {
|
|
76
101
|
setSystemHealth({
|
|
77
|
-
overall:
|
|
78
|
-
services: [
|
|
79
|
-
|
|
80
|
-
|
|
102
|
+
overall: "error",
|
|
103
|
+
services: [
|
|
104
|
+
{
|
|
105
|
+
name: "Dashboard API",
|
|
106
|
+
status: "error",
|
|
107
|
+
message: error.message || "Connection failed",
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
timestamp: new Date().toISOString(),
|
|
111
|
+
});
|
|
81
112
|
}
|
|
82
|
-
}
|
|
83
|
-
checkHealth()
|
|
84
|
-
const interval = setInterval(checkHealth, 30000) // Check every 30s
|
|
85
|
-
return () => clearInterval(interval)
|
|
86
|
-
}, [])
|
|
113
|
+
};
|
|
114
|
+
checkHealth();
|
|
115
|
+
const interval = setInterval(checkHealth, 30000); // Check every 30s
|
|
116
|
+
return () => clearInterval(interval);
|
|
117
|
+
}, []);
|
|
87
118
|
|
|
88
119
|
const toggleDemo = () => {
|
|
89
|
-
const newState = !isDemo
|
|
90
|
-
setIsDemo(newState)
|
|
91
|
-
setStrategy(newState ? new DemoDataSource() : new ProductionDataSource())
|
|
92
|
-
setRefreshSignal(prev => prev + 1)
|
|
93
|
-
}
|
|
120
|
+
const newState = !isDemo;
|
|
121
|
+
setIsDemo(newState);
|
|
122
|
+
setStrategy(newState ? new DemoDataSource() : new ProductionDataSource());
|
|
123
|
+
setRefreshSignal((prev) => prev + 1);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const login = () => {
|
|
127
|
+
setIsAuthenticated(true);
|
|
128
|
+
sessionStorage.setItem("authenticated", "true");
|
|
129
|
+
};
|
|
94
130
|
|
|
95
131
|
// Refresh data periodically (centralized trigger)
|
|
96
132
|
useEffect(() => {
|
|
97
|
-
|
|
133
|
+
if (isDemo) return; // No auto-refresh in Demo Mode (static data)
|
|
98
134
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
}, [isDemo])
|
|
135
|
+
const interval = setInterval(() => {
|
|
136
|
+
setRefreshSignal((prev) => prev + 1);
|
|
137
|
+
}, 5000);
|
|
138
|
+
return () => clearInterval(interval);
|
|
139
|
+
}, [isDemo]);
|
|
104
140
|
|
|
105
141
|
return (
|
|
106
|
-
<DashboardContext.Provider
|
|
142
|
+
<DashboardContext.Provider
|
|
143
|
+
value={{
|
|
144
|
+
strategy,
|
|
145
|
+
isDemo,
|
|
146
|
+
toggleDemo,
|
|
147
|
+
refreshSignal,
|
|
148
|
+
clientConfigs,
|
|
149
|
+
systemHealth,
|
|
150
|
+
isAuthenticated,
|
|
151
|
+
login,
|
|
152
|
+
}}
|
|
153
|
+
>
|
|
107
154
|
{children}
|
|
108
155
|
</DashboardContext.Provider>
|
|
109
|
-
)
|
|
156
|
+
);
|
|
110
157
|
}
|
|
111
158
|
|
|
112
159
|
export function useDashboard() {
|
|
113
|
-
const context = useContext(DashboardContext)
|
|
160
|
+
const context = useContext(DashboardContext);
|
|
114
161
|
if (context === undefined) {
|
|
115
|
-
throw new Error("useDashboard must be used within a DashboardProvider")
|
|
162
|
+
throw new Error("useDashboard must be used within a DashboardProvider");
|
|
116
163
|
}
|
|
117
|
-
return context
|
|
164
|
+
return context;
|
|
118
165
|
}
|
|
@@ -1,39 +1,52 @@
|
|
|
1
|
-
|
|
2
|
-
import { DashboardData, DataSourceStrategy, TimeSeriesData } from "./types"
|
|
1
|
+
import { DashboardData, DataSourceStrategy, TimeSeriesData } from "./types";
|
|
3
2
|
|
|
4
3
|
export class ProductionDataSource implements DataSourceStrategy {
|
|
5
4
|
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()
|
|
5
|
+
const res = await fetch(`/api/metrics`);
|
|
6
|
+
if (!res.ok) throw new Error("Failed to fetch metrics");
|
|
7
|
+
const data = await res.json();
|
|
9
8
|
|
|
10
|
-
const logsRes = await fetch(`/api/audit-logs`)
|
|
11
|
-
const logsData = logsRes.ok ? await logsRes.json() : { logs: [] }
|
|
9
|
+
const logsRes = await fetch(`/api/audit-logs`);
|
|
10
|
+
const logsData = logsRes.ok ? await logsRes.json() : { logs: [] };
|
|
12
11
|
|
|
13
12
|
// Helper to resolve logs
|
|
14
13
|
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
|
-
}
|
|
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
|
+
};
|
|
26
25
|
|
|
27
26
|
const mappedLogs = (logsData.logs || []).map((log: any, index: number) => {
|
|
28
|
-
const operation = resolveOperation(log)
|
|
27
|
+
const operation = resolveOperation(log);
|
|
29
28
|
// 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) {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
else if (
|
|
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
|
+
}
|
|
37
50
|
|
|
38
51
|
return {
|
|
39
52
|
id: index,
|
|
@@ -42,69 +55,95 @@ export class ProductionDataSource implements DataSourceStrategy {
|
|
|
42
55
|
operation,
|
|
43
56
|
status,
|
|
44
57
|
description,
|
|
45
|
-
timestamp: new Date(log.timestamp).getTime()
|
|
46
|
-
}
|
|
47
|
-
})
|
|
58
|
+
timestamp: new Date(log.timestamp).getTime(),
|
|
59
|
+
};
|
|
60
|
+
});
|
|
48
61
|
|
|
49
62
|
// Calculate Latest & Tops from logs if available
|
|
50
|
-
const sortedByDate = [...mappedLogs].sort(
|
|
63
|
+
const sortedByDate = [...mappedLogs].sort(
|
|
64
|
+
(a, b) => b.timestamp - a.timestamp,
|
|
65
|
+
);
|
|
51
66
|
|
|
52
67
|
// Writers
|
|
53
|
-
const wLog = sortedByDate.find(l =>
|
|
54
|
-
|
|
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 };
|
|
55
74
|
|
|
56
75
|
// Readers
|
|
57
|
-
const rLog = sortedByDate.find(l => l.operation === "Read")
|
|
58
|
-
const lastReader = rLog
|
|
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 };
|
|
59
80
|
|
|
60
81
|
// Tops
|
|
61
|
-
const writerCounts: Record<string, number> = {}
|
|
62
|
-
const readerCounts: Record<string, number> = {}
|
|
82
|
+
const writerCounts: Record<string, number> = {};
|
|
83
|
+
const readerCounts: Record<string, number> = {};
|
|
63
84
|
mappedLogs.forEach((log: any) => {
|
|
64
85
|
if (["Write", "Update", "Delete", "Create"].includes(log.operation)) {
|
|
65
|
-
writerCounts[log.client] = (writerCounts[log.client] || 0) + 1
|
|
86
|
+
writerCounts[log.client] = (writerCounts[log.client] || 0) + 1;
|
|
66
87
|
} else if (log.operation === "Read") {
|
|
67
|
-
readerCounts[log.client] = (readerCounts[log.client] || 0) + 1
|
|
88
|
+
readerCounts[log.client] = (readerCounts[log.client] || 0) + 1;
|
|
68
89
|
}
|
|
69
|
-
})
|
|
90
|
+
});
|
|
70
91
|
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
|
|
78
|
-
|
|
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 });
|
|
79
104
|
|
|
80
105
|
// Trends calculation
|
|
81
106
|
const calculateTrend = (series: number[]) => {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
+
};
|
|
94
128
|
|
|
95
129
|
// Success Rate Trend
|
|
96
|
-
let successTrend = {
|
|
130
|
+
let successTrend = {
|
|
131
|
+
change: "0%",
|
|
132
|
+
trend: "neutral" as "neutral" | "up" | "down",
|
|
133
|
+
hasData: false,
|
|
134
|
+
data: [] as number[],
|
|
135
|
+
};
|
|
97
136
|
if (data.sparklines?.successRate) {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
+
};
|
|
108
147
|
}
|
|
109
148
|
|
|
110
149
|
return {
|
|
@@ -119,34 +158,34 @@ export class ProductionDataSource implements DataSourceStrategy {
|
|
|
119
158
|
lastReader,
|
|
120
159
|
},
|
|
121
160
|
trends: {
|
|
122
|
-
memory: calculateTrend(data.sparklines?.memoryRecords),
|
|
123
|
-
clients: calculateTrend(data.sparklines?.totalClients),
|
|
161
|
+
memory: calculateTrend(data.sparklines?.memoryRecords || []),
|
|
162
|
+
clients: calculateTrend(data.sparklines?.totalClients || []),
|
|
124
163
|
success: successTrend,
|
|
125
|
-
requests: calculateTrend(data.sparklines?.totalRequests),
|
|
164
|
+
requests: calculateTrend(data.sparklines?.totalRequests || []),
|
|
126
165
|
},
|
|
127
166
|
logs: mappedLogs,
|
|
128
|
-
}
|
|
167
|
+
};
|
|
129
168
|
}
|
|
130
169
|
|
|
131
170
|
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()
|
|
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();
|
|
135
174
|
|
|
136
175
|
// Fetch clients metadata separately or use what's in apiData
|
|
137
176
|
// Ideally we merge them here
|
|
138
|
-
let metadata = {}
|
|
177
|
+
let metadata = {};
|
|
139
178
|
if (apiData.metadata) {
|
|
140
|
-
|
|
179
|
+
metadata = apiData.metadata;
|
|
141
180
|
}
|
|
142
181
|
|
|
143
182
|
// apiData.timeSeries needs to be returned.
|
|
144
183
|
return {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
}
|
|
184
|
+
creates: apiData.timeSeries?.creates || [],
|
|
185
|
+
reads: apiData.timeSeries?.reads || [],
|
|
186
|
+
updates: apiData.timeSeries?.updates || [],
|
|
187
|
+
deletes: apiData.timeSeries?.deletes || [],
|
|
188
|
+
metadata,
|
|
189
|
+
};
|
|
151
190
|
}
|
|
152
191
|
}
|
package/middleware.ts
CHANGED
|
@@ -1,43 +1,65 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
1
|
+
import type { NextRequest } from "next/server";
|
|
2
|
+
import { NextResponse } from "next/server";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Dashboard Middleware
|
|
6
|
+
*
|
|
7
|
+
* 1. LOCAL BYPASS: localhost, 127.0.0.1, raspberrypi.local skip auth
|
|
8
|
+
* 2. REMOTE: Check cybermem_token cookie
|
|
9
|
+
* 3. CSRF Protection for mutations
|
|
10
|
+
*/
|
|
11
|
+
export async function middleware(request: NextRequest) {
|
|
12
|
+
const host = request.headers.get("host") || "";
|
|
13
|
+
|
|
14
|
+
// LOCAL BYPASS: Skip auth for local development and trusted networks
|
|
15
|
+
const isLocal =
|
|
16
|
+
host.includes("localhost") ||
|
|
17
|
+
host.includes("127.0.0.1") ||
|
|
18
|
+
host.includes("raspberrypi.local");
|
|
19
|
+
|
|
20
|
+
if (!isLocal) {
|
|
21
|
+
// REMOTE: Check cookie token
|
|
22
|
+
const token = request.cookies.get("cybermem_token")?.value;
|
|
23
|
+
|
|
24
|
+
if (!token) {
|
|
25
|
+
// Redirect to token auth page with error
|
|
26
|
+
const authUrl = new URL("/api/auth/token", request.url);
|
|
27
|
+
authUrl.searchParams.set("error", "no_token");
|
|
28
|
+
return NextResponse.redirect(authUrl);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Note: Full token verification happens in the token endpoint
|
|
32
|
+
// Cookie existence is enough for middleware (token was validated when set)
|
|
33
|
+
}
|
|
3
34
|
|
|
4
|
-
export function middleware(request: NextRequest) {
|
|
5
35
|
// CSRF Protection for mutating requests
|
|
6
|
-
if ([
|
|
7
|
-
const origin = request.headers.get(
|
|
8
|
-
const referer = request.headers.get(
|
|
9
|
-
|
|
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
|
-
|
|
36
|
+
if (["POST", "PUT", "DELETE", "PATCH"].includes(request.method)) {
|
|
37
|
+
const origin = request.headers.get("origin");
|
|
38
|
+
const referer = request.headers.get("referer");
|
|
39
|
+
|
|
20
40
|
if (origin) {
|
|
21
|
-
const originHost = origin.replace(/^https?:\/\//,
|
|
41
|
+
const originHost = origin.replace(/^https?:\/\//, "");
|
|
22
42
|
if (originHost !== host) {
|
|
23
|
-
|
|
43
|
+
return new NextResponse("CSRF Validation Failed", { status: 403 });
|
|
24
44
|
}
|
|
25
45
|
} else if (referer) {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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 })
|
|
46
|
+
const refererHost = new URL(referer).host;
|
|
47
|
+
if (refererHost !== host) {
|
|
48
|
+
return new NextResponse("CSRF Validation Failed", { status: 403 });
|
|
49
|
+
}
|
|
35
50
|
}
|
|
36
51
|
}
|
|
37
52
|
|
|
38
|
-
return NextResponse.next()
|
|
53
|
+
return NextResponse.next();
|
|
39
54
|
}
|
|
40
55
|
|
|
41
56
|
export const config = {
|
|
42
|
-
matcher:
|
|
43
|
-
|
|
57
|
+
matcher: [
|
|
58
|
+
// Match all routes except:
|
|
59
|
+
// - API auth routes
|
|
60
|
+
// - Next.js internals
|
|
61
|
+
// - Static files
|
|
62
|
+
// - Health check (for monitoring)
|
|
63
|
+
"/((?!api/auth|api/health|_next|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
|
|
64
|
+
],
|
|
65
|
+
};
|
package/next.config.mjs
CHANGED
|
@@ -1,36 +1,28 @@
|
|
|
1
|
-
|
|
2
1
|
/** @type {import('next').NextConfig} */
|
|
3
2
|
const nextConfig = {
|
|
4
|
-
typescript: {
|
|
5
|
-
ignoreBuildErrors: true,
|
|
6
|
-
},
|
|
7
|
-
eslint: {
|
|
8
|
-
ignoreDuringBuilds: true,
|
|
9
|
-
},
|
|
10
3
|
images: {
|
|
11
4
|
unoptimized: true,
|
|
12
5
|
},
|
|
13
|
-
output:
|
|
14
|
-
transpilePackages: [
|
|
15
|
-
serverExternalPackages: [
|
|
16
|
-
experimental: {
|
|
17
|
-
},
|
|
6
|
+
output: "standalone",
|
|
7
|
+
transpilePackages: ["recharts"],
|
|
8
|
+
serverExternalPackages: ["dockerode", "ssh2"],
|
|
9
|
+
experimental: {},
|
|
18
10
|
webpack: (config) => {
|
|
19
|
-
config.externals = [...(config.externals || []),
|
|
11
|
+
config.externals = [...(config.externals || []), "ssh2", "dockerode"];
|
|
20
12
|
return config;
|
|
21
13
|
},
|
|
22
14
|
async rewrites() {
|
|
23
15
|
return [
|
|
24
16
|
{
|
|
25
|
-
source:
|
|
26
|
-
destination:
|
|
17
|
+
source: "/docs",
|
|
18
|
+
destination: "/docs/index.html",
|
|
27
19
|
},
|
|
28
20
|
{
|
|
29
|
-
source:
|
|
30
|
-
destination:
|
|
21
|
+
source: "/docs/:slug",
|
|
22
|
+
destination: "/docs/:slug.html",
|
|
31
23
|
},
|
|
32
|
-
]
|
|
24
|
+
];
|
|
33
25
|
},
|
|
34
|
-
}
|
|
26
|
+
};
|
|
35
27
|
|
|
36
|
-
export default nextConfig
|
|
28
|
+
export default nextConfig;
|