express-performance-toolkit 1.0.0 → 2.0.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/README.md +119 -76
- package/dashboard-ui/README.md +73 -0
- package/dashboard-ui/eslint.config.js +23 -0
- package/dashboard-ui/index.html +13 -0
- package/dashboard-ui/package-lock.json +3382 -0
- package/dashboard-ui/package.json +32 -0
- package/dashboard-ui/src/App.css +184 -0
- package/dashboard-ui/src/App.tsx +182 -0
- package/dashboard-ui/src/components/BlockedModal.tsx +108 -0
- package/dashboard-ui/src/components/CachePanel.tsx +45 -0
- package/dashboard-ui/src/components/HealthCharts.tsx +142 -0
- package/dashboard-ui/src/components/InsightsPanel.tsx +49 -0
- package/dashboard-ui/src/components/KpiGrid.tsx +178 -0
- package/dashboard-ui/src/components/LiveLogs.tsx +76 -0
- package/dashboard-ui/src/components/Login.tsx +83 -0
- package/dashboard-ui/src/components/RoutesTable.tsx +110 -0
- package/dashboard-ui/src/hooks/useMetrics.ts +131 -0
- package/dashboard-ui/src/index.css +652 -0
- package/dashboard-ui/src/main.tsx +10 -0
- package/dashboard-ui/src/pages/InsightsPage.tsx +42 -0
- package/dashboard-ui/src/pages/LogsPage.tsx +26 -0
- package/dashboard-ui/src/pages/OverviewPage.tsx +32 -0
- package/dashboard-ui/src/pages/RoutesPage.tsx +26 -0
- package/dashboard-ui/src/utils/formatters.ts +27 -0
- package/dashboard-ui/tsconfig.app.json +28 -0
- package/dashboard-ui/tsconfig.json +7 -0
- package/dashboard-ui/tsconfig.node.json +26 -0
- package/dashboard-ui/vite.config.ts +12 -0
- package/dist/analyzer.d.ts +6 -0
- package/dist/analyzer.d.ts.map +1 -0
- package/dist/analyzer.js +70 -0
- package/dist/analyzer.js.map +1 -0
- package/dist/dashboard/dashboardRouter.d.ts +4 -4
- package/dist/dashboard/dashboardRouter.d.ts.map +1 -1
- package/dist/dashboard/dashboardRouter.js +67 -21
- package/dist/dashboard/dashboardRouter.js.map +1 -1
- package/dist/dashboard-ui/assets/index-CX-zE-Qy.css +1 -0
- package/dist/dashboard-ui/assets/index-Q9TGkd8n.js +41 -0
- package/dist/dashboard-ui/index.html +14 -0
- package/dist/index.d.ts +11 -10
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +35 -11
- package/dist/index.js.map +1 -1
- package/dist/logger.d.ts +3 -3
- package/dist/logger.d.ts.map +1 -1
- package/dist/logger.js +167 -9
- package/dist/logger.js.map +1 -1
- package/dist/queryHelper.d.ts.map +1 -1
- package/dist/queryHelper.js +1 -0
- package/dist/queryHelper.js.map +1 -1
- package/dist/rateLimit.d.ts +5 -0
- package/dist/rateLimit.d.ts.map +1 -0
- package/dist/rateLimit.js +67 -0
- package/dist/rateLimit.js.map +1 -0
- package/dist/store.d.ts +9 -2
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +147 -25
- package/dist/store.js.map +1 -1
- package/dist/types.d.ts +93 -0
- package/dist/types.d.ts.map +1 -1
- package/example/server.ts +68 -37
- package/package.json +9 -6
- package/src/analyzer.ts +78 -0
- package/src/dashboard/dashboardRouter.ts +88 -23
- package/src/index.ts +70 -30
- package/src/logger.ts +177 -13
- package/src/queryHelper.ts +2 -0
- package/src/rateLimit.ts +86 -0
- package/src/store.ts +136 -27
- package/src/types.ts +98 -0
- package/tests/analyzer.test.ts +108 -0
- package/tests/auth.test.ts +79 -0
- package/tests/bandwidth.test.ts +72 -0
- package/tests/integration.test.ts +51 -54
- package/tests/rateLimit.test.ts +57 -0
- package/tests/store.test.ts +37 -18
- package/tsconfig.json +1 -0
- package/src/dashboard/dashboard.html +0 -756
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { Insight } from "../hooks/useMetrics";
|
|
2
|
+
import { Lightbulb, AlertTriangle, AlertCircle, Info, ArrowRight } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
export function InsightsPanel({ insights }: { insights: Insight[] }) {
|
|
5
|
+
if (!insights || insights.length === 0) {
|
|
6
|
+
return (
|
|
7
|
+
<div className="panel animate-in delay-3" style={{ marginTop: '20px' }}>
|
|
8
|
+
<div className="panel-header">
|
|
9
|
+
<div className="panel-title">✨ Smart Recommendations</div>
|
|
10
|
+
</div>
|
|
11
|
+
<div className="panel-body" style={{ textAlign: 'center', padding: '40px', color: 'var(--text-400)' }}>
|
|
12
|
+
<Lightbulb size={24} style={{ marginBottom: '12px', opacity: 0.5 }} />
|
|
13
|
+
<p>No performance issues detected. Everything looks optimal!</p>
|
|
14
|
+
</div>
|
|
15
|
+
</div>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<div className="panel animate-in delay-3" style={{ marginTop: '20px' }}>
|
|
21
|
+
<div className="panel-header">
|
|
22
|
+
<div className="panel-title">✨ Smart Recommendations ({insights.length})</div>
|
|
23
|
+
</div>
|
|
24
|
+
<div className="panel-body" style={{ padding: '0px' }}>
|
|
25
|
+
<div className="insights-list">
|
|
26
|
+
{insights.map((insight, idx) => (
|
|
27
|
+
<div key={idx} className={`insight-item type-${insight.type}`}>
|
|
28
|
+
<div className="insight-icon">
|
|
29
|
+
{insight.type === 'error' && <AlertCircle size={18} className="val-rose" />}
|
|
30
|
+
{insight.type === 'warning' && <AlertTriangle size={18} className="val-amber" />}
|
|
31
|
+
{insight.type === 'info' && <Info size={18} className="val-emerald" />}
|
|
32
|
+
</div>
|
|
33
|
+
<div className="insight-content">
|
|
34
|
+
<div className="insight-title">{insight.title}</div>
|
|
35
|
+
<div className="insight-message">{insight.message}</div>
|
|
36
|
+
{insight.action && (
|
|
37
|
+
<div className="insight-action">
|
|
38
|
+
<ArrowRight size={12} style={{ marginRight: 6 }} />
|
|
39
|
+
{insight.action}
|
|
40
|
+
</div>
|
|
41
|
+
)}
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
))}
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { Activity, Zap, AlertTriangle, Database, ShieldAlert, Server, HardDrive } from "lucide-react";
|
|
2
|
+
import type { MetricsData } from "../hooks/useMetrics";
|
|
3
|
+
import { fNum, fPct, fBytes } from "../utils/formatters";
|
|
4
|
+
import { useState } from "react";
|
|
5
|
+
import { BlockedModal } from "./BlockedModal";
|
|
6
|
+
|
|
7
|
+
export function KpiGrid({ data }: { data: MetricsData }) {
|
|
8
|
+
const [isModalOpen, setModalOpen] = useState(false);
|
|
9
|
+
|
|
10
|
+
const rps =
|
|
11
|
+
data.uptime > 0
|
|
12
|
+
? (data.totalRequests / (data.uptime / 1000)).toFixed(1)
|
|
13
|
+
: "0";
|
|
14
|
+
return (
|
|
15
|
+
<div className="kpi-grid">
|
|
16
|
+
<div className="kpi-card grad-1 animate-in delay-1">
|
|
17
|
+
<div className="kpi-title">
|
|
18
|
+
<Activity
|
|
19
|
+
size={14}
|
|
20
|
+
style={{
|
|
21
|
+
display: "inline",
|
|
22
|
+
marginRight: 6,
|
|
23
|
+
verticalAlign: "text-bottom",
|
|
24
|
+
}}
|
|
25
|
+
/>{" "}
|
|
26
|
+
Total Requests
|
|
27
|
+
</div>
|
|
28
|
+
<div className="kpi-value">{fNum(data.totalRequests)}</div>
|
|
29
|
+
<div className="kpi-subtext">{rps} req/s</div>
|
|
30
|
+
</div>
|
|
31
|
+
<div className="kpi-card grad-2 animate-in delay-2">
|
|
32
|
+
<div className="kpi-title">
|
|
33
|
+
<Zap
|
|
34
|
+
size={14}
|
|
35
|
+
style={{
|
|
36
|
+
display: "inline",
|
|
37
|
+
marginRight: 6,
|
|
38
|
+
verticalAlign: "text-bottom",
|
|
39
|
+
}}
|
|
40
|
+
/>{" "}
|
|
41
|
+
Avg Response Time
|
|
42
|
+
</div>
|
|
43
|
+
<div className="kpi-value">
|
|
44
|
+
{data.avgResponseTime}
|
|
45
|
+
<span style={{ fontSize: "1.25rem", color: "var(--text-300)" }}>
|
|
46
|
+
ms
|
|
47
|
+
</span>
|
|
48
|
+
</div>
|
|
49
|
+
<div className="kpi-subtext" style={{ color: "var(--accent-emerald)" }}>
|
|
50
|
+
Optimal
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
<div className="kpi-card grad-4 animate-in delay-3">
|
|
54
|
+
<div className="kpi-title">
|
|
55
|
+
<AlertTriangle
|
|
56
|
+
size={14}
|
|
57
|
+
style={{
|
|
58
|
+
display: "inline",
|
|
59
|
+
marginRight: 6,
|
|
60
|
+
verticalAlign: "text-bottom",
|
|
61
|
+
}}
|
|
62
|
+
/>{" "}
|
|
63
|
+
Slow Requests
|
|
64
|
+
</div>
|
|
65
|
+
<div className="kpi-value val-rose">{fNum(data.slowRequests)}</div>
|
|
66
|
+
<div className="kpi-subtext">
|
|
67
|
+
{fPct(data.slowRequests, data.totalRequests)}% of total
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
<div className="kpi-card grad-1 animate-in delay-4">
|
|
71
|
+
<div className="kpi-title">Cache Hit Rate</div>
|
|
72
|
+
<div className="kpi-value val-emerald">
|
|
73
|
+
{data.cacheHitRate}
|
|
74
|
+
<span style={{ fontSize: "1.25rem", color: "var(--text-300)" }}>
|
|
75
|
+
%
|
|
76
|
+
</span>
|
|
77
|
+
</div>
|
|
78
|
+
<div className="kpi-subtext">{data.cacheSize} entries active</div>
|
|
79
|
+
</div>
|
|
80
|
+
<div className="kpi-card grad-3 animate-in delay-5">
|
|
81
|
+
<div className="kpi-title">
|
|
82
|
+
<Database
|
|
83
|
+
size={14}
|
|
84
|
+
style={{
|
|
85
|
+
display: "inline",
|
|
86
|
+
marginRight: 6,
|
|
87
|
+
verticalAlign: "text-bottom",
|
|
88
|
+
}}
|
|
89
|
+
/>{" "}
|
|
90
|
+
N+1 Query Alerts
|
|
91
|
+
</div>
|
|
92
|
+
<div className="kpi-value val-amber">
|
|
93
|
+
{fNum(data.highQueryRequests)}
|
|
94
|
+
</div>
|
|
95
|
+
<div className="kpi-subtext">
|
|
96
|
+
<span style={{ color: "var(--text-300)" }}>
|
|
97
|
+
{fPct(data.highQueryRequests, data.totalRequests)}% of total
|
|
98
|
+
</span>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
<div className="kpi-card grad-1 animate-in delay-5">
|
|
102
|
+
<div className="kpi-title">
|
|
103
|
+
<Server
|
|
104
|
+
size={14}
|
|
105
|
+
style={{
|
|
106
|
+
display: "inline",
|
|
107
|
+
marginRight: 6,
|
|
108
|
+
verticalAlign: "text-bottom",
|
|
109
|
+
}}
|
|
110
|
+
/>{" "}
|
|
111
|
+
Network Egress
|
|
112
|
+
</div>
|
|
113
|
+
<div className="kpi-value val-emerald">
|
|
114
|
+
{fBytes(data.totalBytesSent)}
|
|
115
|
+
</div>
|
|
116
|
+
<div className="kpi-subtext">Total bytes sent</div>
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
<div className="kpi-card grad-1 animate-in delay-6">
|
|
120
|
+
<div className="kpi-title">
|
|
121
|
+
<HardDrive
|
|
122
|
+
size={14}
|
|
123
|
+
style={{
|
|
124
|
+
display: "inline",
|
|
125
|
+
marginRight: 6,
|
|
126
|
+
verticalAlign: "text-bottom",
|
|
127
|
+
}}
|
|
128
|
+
/>{" "}
|
|
129
|
+
Avg Payload
|
|
130
|
+
</div>
|
|
131
|
+
<div className="kpi-value">
|
|
132
|
+
{fBytes(data.avgResponseSize)}
|
|
133
|
+
</div>
|
|
134
|
+
<div className="kpi-subtext">Per request average</div>
|
|
135
|
+
</div>
|
|
136
|
+
|
|
137
|
+
<div className="kpi-card grad-4 animate-in delay-6" style={{ position: 'relative' }}>
|
|
138
|
+
<div className="kpi-title">
|
|
139
|
+
<ShieldAlert
|
|
140
|
+
size={14}
|
|
141
|
+
style={{
|
|
142
|
+
display: "inline",
|
|
143
|
+
marginRight: 6,
|
|
144
|
+
verticalAlign: "text-bottom",
|
|
145
|
+
}}
|
|
146
|
+
/>{" "}
|
|
147
|
+
Blocked Traffic
|
|
148
|
+
</div>
|
|
149
|
+
<div className="kpi-value val-rose">{fNum(data.rateLimitHits)}</div>
|
|
150
|
+
<div className="kpi-subtext" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
151
|
+
<span>Rate limit 429</span>
|
|
152
|
+
{data.rateLimitHits > 0 && (
|
|
153
|
+
<button
|
|
154
|
+
onClick={() => setModalOpen(true)}
|
|
155
|
+
style={{
|
|
156
|
+
background: 'rgba(244, 63, 94, 0.1)',
|
|
157
|
+
border: '1px solid rgba(244, 63, 94, 0.2)',
|
|
158
|
+
borderRadius: '4px',
|
|
159
|
+
color: 'var(--accent-rose)',
|
|
160
|
+
padding: '2px 8px',
|
|
161
|
+
fontSize: '0.7rem',
|
|
162
|
+
cursor: 'pointer'
|
|
163
|
+
}}
|
|
164
|
+
>
|
|
165
|
+
View Details
|
|
166
|
+
</button>
|
|
167
|
+
)}
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
|
|
171
|
+
<BlockedModal
|
|
172
|
+
isOpen={isModalOpen}
|
|
173
|
+
onClose={() => setModalOpen(false)}
|
|
174
|
+
events={data.blockedEvents || []}
|
|
175
|
+
/>
|
|
176
|
+
</div>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import type { LogEntry } from '../hooks/useMetrics';
|
|
3
|
+
import { fTime, getStatusClass, getTimeClass } from '../utils/formatters';
|
|
4
|
+
|
|
5
|
+
export function LiveLogs({ logs }: { logs: LogEntry[] }) {
|
|
6
|
+
const [filter, setFilter] = useState<'all' | 'slow' | 'cached' | 'errors'>('all');
|
|
7
|
+
|
|
8
|
+
let filtered = logs;
|
|
9
|
+
if (filter === "slow") filtered = logs.filter((l) => l.slow);
|
|
10
|
+
else if (filter === "cached") filtered = logs.filter((l) => l.cached);
|
|
11
|
+
else if (filter === "errors") filtered = logs.filter((l) => l.statusCode >= 400);
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<div className="panel animate-in delay-5">
|
|
15
|
+
<div className="panel-header">
|
|
16
|
+
<div className="panel-title">📋 Live Request Stream</div>
|
|
17
|
+
<div className="filters">
|
|
18
|
+
<button className={`filter-btn ${filter === 'all' ? 'active' : ''}`} onClick={() => setFilter('all')}>All</button>
|
|
19
|
+
<button className={`filter-btn ${filter === 'slow' ? 'active' : ''}`} onClick={() => setFilter('slow')}>
|
|
20
|
+
<span className="fire-icon">🔥</span> Slow
|
|
21
|
+
</button>
|
|
22
|
+
<button className={`filter-btn ${filter === 'cached' ? 'active' : ''}`} onClick={() => setFilter('cached')}>Cached</button>
|
|
23
|
+
<button className={`filter-btn ${filter === 'errors' ? 'active' : ''}`} onClick={() => setFilter('errors')}>Errors</button>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
<div className="panel-body" style={{ padding: 0, maxHeight: "500px" }}>
|
|
27
|
+
<div className="table-container">
|
|
28
|
+
<table>
|
|
29
|
+
<thead>
|
|
30
|
+
<tr>
|
|
31
|
+
<th>Timestamp</th>
|
|
32
|
+
<th>Method</th>
|
|
33
|
+
<th style={{ width: "40%" }}>Path</th>
|
|
34
|
+
<th>Status</th>
|
|
35
|
+
<th>LATENCY</th>
|
|
36
|
+
<th>Cache</th>
|
|
37
|
+
<th>Flags</th>
|
|
38
|
+
</tr>
|
|
39
|
+
</thead>
|
|
40
|
+
<tbody>
|
|
41
|
+
{filtered.length === 0 ? (
|
|
42
|
+
<tr>
|
|
43
|
+
<td colSpan={7} className="empty-state">
|
|
44
|
+
<div className="empty-icon">✨</div><p>No matching requests found</p>
|
|
45
|
+
</td>
|
|
46
|
+
</tr>
|
|
47
|
+
) : (
|
|
48
|
+
filtered.sort((a,b) => b.timestamp - a.timestamp).map((log, i) => {
|
|
49
|
+
const flag = log.slow
|
|
50
|
+
? <span className="fire-icon" title="Slow Request">🔥</span>
|
|
51
|
+
: log.highQueries
|
|
52
|
+
? <span style={{color:"var(--accent-amber)"}} title="High DB Queries">⚠️</span>
|
|
53
|
+
: "-";
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<tr key={i} style={log.slow ? { background: "rgba(244,63,94,0.03)" } : {}}>
|
|
57
|
+
<td style={{ color: "var(--text-300)", fontFamily: "var(--font-mono)" }}>{fTime(log.timestamp)}</td>
|
|
58
|
+
<td><span className={`badge badge-${log.method}`}>{log.method}</span></td>
|
|
59
|
+
<td className="route-path">{log.path}</td>
|
|
60
|
+
<td className={`status-code ${getStatusClass(log.statusCode)}`} style={{ textAlign: "left" }}>{log.statusCode}</td>
|
|
61
|
+
<td className={getTimeClass(log.responseTime)} style={{ fontWeight: 600 }}>{log.responseTime}ms</td>
|
|
62
|
+
<td>
|
|
63
|
+
{log.cached ? <span className="badge cache-hit">HIT</span> : <span className="badge cache-miss">MISS</span>}
|
|
64
|
+
</td>
|
|
65
|
+
<td>{flag}</td>
|
|
66
|
+
</tr>
|
|
67
|
+
)
|
|
68
|
+
})
|
|
69
|
+
)}
|
|
70
|
+
</tbody>
|
|
71
|
+
</table>
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { Zap, Lock, User, Loader2 } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
interface LoginProps {
|
|
5
|
+
onLoginSuccess: () => void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function Login({ onLoginSuccess }: LoginProps) {
|
|
9
|
+
const [username, setUsername] = useState("");
|
|
10
|
+
const [password, setPassword] = useState("");
|
|
11
|
+
const [error, setError] = useState("");
|
|
12
|
+
const [loading, setLoading] = useState(false);
|
|
13
|
+
|
|
14
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
15
|
+
e.preventDefault();
|
|
16
|
+
setLoading(true);
|
|
17
|
+
setError("");
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const resp = await fetch("./api/login", {
|
|
21
|
+
method: "POST",
|
|
22
|
+
headers: { "Content-Type": "application/json" },
|
|
23
|
+
body: JSON.stringify({ username, password }),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const data = await resp.json();
|
|
27
|
+
|
|
28
|
+
if (data.success) {
|
|
29
|
+
onLoginSuccess();
|
|
30
|
+
} else {
|
|
31
|
+
setError(data.message || "Invalid credentials");
|
|
32
|
+
}
|
|
33
|
+
} catch (err) {
|
|
34
|
+
setError("Failed to connect to server");
|
|
35
|
+
} finally {
|
|
36
|
+
setLoading(false);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div className="login-wrapper animate-in">
|
|
42
|
+
<div className="login-card panel">
|
|
43
|
+
<div className="login-header">
|
|
44
|
+
<div className="brand-icon" style={{ margin: '0 auto 1.5rem', width: '48px', height: '48px' }}>
|
|
45
|
+
<Zap size={24} />
|
|
46
|
+
</div>
|
|
47
|
+
<h1>Dashboard Access</h1>
|
|
48
|
+
<p>Please enter your credentials to continue</p>
|
|
49
|
+
</div>
|
|
50
|
+
|
|
51
|
+
<form onSubmit={handleSubmit} className="login-form">
|
|
52
|
+
<div className="input-group">
|
|
53
|
+
<User size={18} className="input-icon" />
|
|
54
|
+
<input
|
|
55
|
+
type="text"
|
|
56
|
+
placeholder="Username"
|
|
57
|
+
value={username}
|
|
58
|
+
onChange={(e) => setUsername(e.target.value)}
|
|
59
|
+
required
|
|
60
|
+
/>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<div className="input-group">
|
|
64
|
+
<Lock size={18} className="input-icon" />
|
|
65
|
+
<input
|
|
66
|
+
type="password"
|
|
67
|
+
placeholder="Password"
|
|
68
|
+
value={password}
|
|
69
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
70
|
+
required
|
|
71
|
+
/>
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
{error && <div className="login-error">{error}</div>}
|
|
75
|
+
|
|
76
|
+
<button type="submit" className="login-button" disabled={loading}>
|
|
77
|
+
{loading ? <Loader2 className="animate-spin" size={20} /> : "Unlock Dashboard"}
|
|
78
|
+
</button>
|
|
79
|
+
</form>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { RouteStats } from "../hooks/useMetrics";
|
|
2
|
+
import { fNum, getTimeClass, fBytes } from "../utils/formatters";
|
|
3
|
+
|
|
4
|
+
export function RoutesTable({
|
|
5
|
+
routes,
|
|
6
|
+
}: {
|
|
7
|
+
routes: Record<string, RouteStats>;
|
|
8
|
+
}) {
|
|
9
|
+
const routeEntries = Object.entries(routes)
|
|
10
|
+
.sort(([, a], [, b]) => b.avgTime - a.avgTime)
|
|
11
|
+
.slice(0, 8); // top 8 slowest
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<div className="panel animate-in delay-2" style={{ maxHeight: "500px" }}>
|
|
15
|
+
<div className="panel-header">
|
|
16
|
+
<div className="panel-title">📉 Route Performance</div>
|
|
17
|
+
</div>
|
|
18
|
+
<div className="panel-body" style={{ padding: 0 }}>
|
|
19
|
+
<div className="table-container">
|
|
20
|
+
<table className="routes-table">
|
|
21
|
+
<thead>
|
|
22
|
+
<tr>
|
|
23
|
+
<th>Route Path</th>
|
|
24
|
+
<th>Calls</th>
|
|
25
|
+
<th>Avg Latency</th>
|
|
26
|
+
<th>Payload</th>
|
|
27
|
+
<th>Anomalies</th>
|
|
28
|
+
</tr>
|
|
29
|
+
</thead>
|
|
30
|
+
<tbody>
|
|
31
|
+
{routeEntries.length === 0 ? (
|
|
32
|
+
<tr>
|
|
33
|
+
<td colSpan={5} className="empty-state">
|
|
34
|
+
No route data collected yet.
|
|
35
|
+
</td>
|
|
36
|
+
</tr>
|
|
37
|
+
) : (
|
|
38
|
+
routeEntries.map(([route, stats]) => {
|
|
39
|
+
const anomalies = [];
|
|
40
|
+
if (stats.slowCount > 0)
|
|
41
|
+
anomalies.push(
|
|
42
|
+
<span key="slow">
|
|
43
|
+
<span className="fire-icon">🔥</span>{" "}
|
|
44
|
+
<span style={{ color: "var(--accent-rose)" }}>
|
|
45
|
+
{stats.slowCount} Slow
|
|
46
|
+
</span>
|
|
47
|
+
</span>,
|
|
48
|
+
);
|
|
49
|
+
if (stats.highQueryCount > 0)
|
|
50
|
+
anomalies.push(
|
|
51
|
+
<span key="n1">
|
|
52
|
+
<span style={{ color: "var(--accent-amber)" }}>⚠️</span>{" "}
|
|
53
|
+
<span style={{ color: "var(--accent-amber)" }}>
|
|
54
|
+
{stats.highQueryCount} N+1
|
|
55
|
+
</span>
|
|
56
|
+
</span>,
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<tr key={route}>
|
|
61
|
+
<td className="route-path">{route}</td>
|
|
62
|
+
<td style={{ color: "var(--text-300)" }}>
|
|
63
|
+
{fNum(stats.count)}
|
|
64
|
+
</td>
|
|
65
|
+
<td className={getTimeClass(stats.avgTime)}>
|
|
66
|
+
{stats.avgTime}ms
|
|
67
|
+
</td>
|
|
68
|
+
<td style={{ color: "var(--text-300)" }}>
|
|
69
|
+
{fBytes(stats.avgSize)}
|
|
70
|
+
</td>
|
|
71
|
+
<td>
|
|
72
|
+
{anomalies.length > 0 ? (
|
|
73
|
+
<div
|
|
74
|
+
style={{
|
|
75
|
+
display: "flex",
|
|
76
|
+
gap: "8px",
|
|
77
|
+
alignItems: "center",
|
|
78
|
+
}}
|
|
79
|
+
>
|
|
80
|
+
{anomalies.map((a, i) => (
|
|
81
|
+
<span key={i}>
|
|
82
|
+
{i > 0 && (
|
|
83
|
+
<span
|
|
84
|
+
style={{
|
|
85
|
+
color: "var(--border)",
|
|
86
|
+
margin: "0 4px",
|
|
87
|
+
}}
|
|
88
|
+
>
|
|
89
|
+
|
|
|
90
|
+
</span>
|
|
91
|
+
)}
|
|
92
|
+
{a}
|
|
93
|
+
</span>
|
|
94
|
+
))}
|
|
95
|
+
</div>
|
|
96
|
+
) : (
|
|
97
|
+
<span style={{ color: "var(--text-400)" }}>-</span>
|
|
98
|
+
)}
|
|
99
|
+
</td>
|
|
100
|
+
</tr>
|
|
101
|
+
);
|
|
102
|
+
})
|
|
103
|
+
)}
|
|
104
|
+
</tbody>
|
|
105
|
+
</table>
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { useState, useEffect } from "react";
|
|
2
|
+
|
|
3
|
+
export interface RouteStats {
|
|
4
|
+
count: number;
|
|
5
|
+
totalTime: number;
|
|
6
|
+
slowCount: number;
|
|
7
|
+
highQueryCount: number;
|
|
8
|
+
rateLimitHits: number;
|
|
9
|
+
avgTime: number;
|
|
10
|
+
totalBytes: number;
|
|
11
|
+
avgSize: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface BlockedEvent {
|
|
15
|
+
ip: string;
|
|
16
|
+
path: string;
|
|
17
|
+
timestamp: number;
|
|
18
|
+
method: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface LogEntry {
|
|
22
|
+
method: string;
|
|
23
|
+
path: string;
|
|
24
|
+
statusCode: number;
|
|
25
|
+
responseTime: number;
|
|
26
|
+
timestamp: number;
|
|
27
|
+
slow: boolean;
|
|
28
|
+
cached: boolean;
|
|
29
|
+
queryCount?: number;
|
|
30
|
+
highQueries?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface Insight {
|
|
34
|
+
type: "info" | "warning" | "error";
|
|
35
|
+
title: string;
|
|
36
|
+
message: string;
|
|
37
|
+
action?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface HistoryData {
|
|
41
|
+
time: string;
|
|
42
|
+
lag: number;
|
|
43
|
+
memory: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface MetricsData {
|
|
47
|
+
uptime: number;
|
|
48
|
+
totalRequests: number;
|
|
49
|
+
avgResponseTime: number;
|
|
50
|
+
slowRequests: number;
|
|
51
|
+
highQueryRequests: number;
|
|
52
|
+
rateLimitHits: number;
|
|
53
|
+
cacheHits: number;
|
|
54
|
+
cacheMisses: number;
|
|
55
|
+
cacheHitRate: number;
|
|
56
|
+
cacheSize: number;
|
|
57
|
+
totalBytesSent: number;
|
|
58
|
+
avgResponseSize: number;
|
|
59
|
+
insights: Insight[];
|
|
60
|
+
eventLoopLag: number;
|
|
61
|
+
memoryUsage: {
|
|
62
|
+
rss: number;
|
|
63
|
+
heapTotal: number;
|
|
64
|
+
heapUsed: number;
|
|
65
|
+
external: number;
|
|
66
|
+
};
|
|
67
|
+
statusCodes: Record<number, number>;
|
|
68
|
+
routes: Record<string, RouteStats>;
|
|
69
|
+
recentLogs: LogEntry[];
|
|
70
|
+
blockedEvents: BlockedEvent[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function useMetrics(enabled: boolean = true) {
|
|
74
|
+
const [data, setData] = useState<MetricsData | null>(null);
|
|
75
|
+
const [history, setHistory] = useState<HistoryData[]>([]);
|
|
76
|
+
const [error, setError] = useState<Error | null>(null);
|
|
77
|
+
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (!enabled) return;
|
|
80
|
+
|
|
81
|
+
let mounted = true;
|
|
82
|
+
|
|
83
|
+
const fetchMetrics = async () => {
|
|
84
|
+
try {
|
|
85
|
+
const response = await fetch("./api/metrics");
|
|
86
|
+
if (!response.ok) {
|
|
87
|
+
if (response.status === 401) {
|
|
88
|
+
throw new Error("unauthorized");
|
|
89
|
+
}
|
|
90
|
+
throw new Error("Failed to fetch metrics");
|
|
91
|
+
}
|
|
92
|
+
const metrics: MetricsData = await response.json();
|
|
93
|
+
|
|
94
|
+
if (mounted) {
|
|
95
|
+
setData(metrics);
|
|
96
|
+
setError(null);
|
|
97
|
+
|
|
98
|
+
// Append to history (keep last 50 data points)
|
|
99
|
+
setHistory((prev) => {
|
|
100
|
+
const timeStr = new Date().toLocaleTimeString([], {
|
|
101
|
+
hour12: false,
|
|
102
|
+
hour: "2-digit",
|
|
103
|
+
minute: "2-digit",
|
|
104
|
+
second: "2-digit",
|
|
105
|
+
});
|
|
106
|
+
const newPoint = {
|
|
107
|
+
time: timeStr,
|
|
108
|
+
lag: metrics.eventLoopLag,
|
|
109
|
+
memory: Math.round(metrics.memoryUsage.heapUsed / 1024 / 1024),
|
|
110
|
+
};
|
|
111
|
+
return [...prev, newPoint].slice(-30);
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
} catch (e: unknown) {
|
|
115
|
+
if (mounted) {
|
|
116
|
+
const errorObj = e instanceof Error ? e : new Error(String(e));
|
|
117
|
+
setError(errorObj);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
fetchMetrics();
|
|
123
|
+
const id = setInterval(fetchMetrics, 3000);
|
|
124
|
+
return () => {
|
|
125
|
+
mounted = false;
|
|
126
|
+
clearInterval(id);
|
|
127
|
+
};
|
|
128
|
+
}, [enabled]);
|
|
129
|
+
|
|
130
|
+
return { data, history, error };
|
|
131
|
+
}
|