@logboard/cli 1.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/.env.example +37 -0
- package/README.md +200 -0
- package/bin/logboard +536 -0
- package/client/logger.js +309 -0
- package/config/index.js +142 -0
- package/config.js +2 -0
- package/controllers/AnalyticsController.js +46 -0
- package/controllers/ApiAnalyticsController.js +129 -0
- package/controllers/ApiKeyController.js +58 -0
- package/controllers/AuthController.js +131 -0
- package/controllers/HealthController.js +56 -0
- package/controllers/LogController.js +197 -0
- package/controllers/OrgController.js +152 -0
- package/controllers/RoleConfigController.js +20 -0
- package/controllers/SettingsController.js +39 -0
- package/controllers/StreamController.js +55 -0
- package/controllers/UiController.js +789 -0
- package/controllers/UserController.js +79 -0
- package/lib/batchWriter.js +57 -0
- package/lib/cleanup.js +67 -0
- package/lib/ejs.js +103 -0
- package/lib/emitter.js +5 -0
- package/lib/healthMonitor.js +245 -0
- package/lib/logger.js +21 -0
- package/lib/streams.js +32 -0
- package/lib/theme.js +77 -0
- package/lib/userStore.js +13 -0
- package/lib/utils.js +44 -0
- package/middleware/apiKey.js +82 -0
- package/middleware/auth.js +55 -0
- package/middleware/ipWhitelist.js +59 -0
- package/middleware/org.js +85 -0
- package/middleware/pageAccess.js +20 -0
- package/middleware/rateLimit.js +29 -0
- package/middleware/roles.js +11 -0
- package/package.json +77 -0
- package/routes/alerts.js +18 -0
- package/routes/analytics.js +26 -0
- package/routes/api-analytics.js +30 -0
- package/routes/api-keys.js +12 -0
- package/routes/archive.js +91 -0
- package/routes/audit.js +50 -0
- package/routes/auth.js +22 -0
- package/routes/bookmarks.js +13 -0
- package/routes/health.js +11 -0
- package/routes/logs.js +88 -0
- package/routes/metrics.js +66 -0
- package/routes/notifications.js +14 -0
- package/routes/orgs.js +98 -0
- package/routes/registration.js +202 -0
- package/routes/role-config.js +97 -0
- package/routes/saved-searches.js +12 -0
- package/routes/server.js +151 -0
- package/routes/settings.js +28 -0
- package/routes/status.js +21 -0
- package/routes/stream.js +11 -0
- package/routes/super.js +129 -0
- package/routes/ui.js +120 -0
- package/routes/users.js +13 -0
- package/server.js +172 -0
- package/services/AlertRulesService.js +323 -0
- package/services/AnalyticsService.js +665 -0
- package/services/ApiAnalyticsService.js +471 -0
- package/services/ApiKeyService.js +166 -0
- package/services/AuditService.js +249 -0
- package/services/AuthService.js +234 -0
- package/services/BookmarkService.js +49 -0
- package/services/GlobalSettingsService.js +44 -0
- package/services/LogService.js +1066 -0
- package/services/MetricsService.js +116 -0
- package/services/NotificationService.js +70 -0
- package/services/OrgService.js +217 -0
- package/services/ReportService.js +247 -0
- package/services/RoleConfigService.js +201 -0
- package/services/SavedSearchService.js +63 -0
- package/services/SettingsService.js +220 -0
- package/services/UserService.js +121 -0
- package/setup.js +132 -0
- package/views/404.ejs +8 -0
- package/views/alerts.ejs +190 -0
- package/views/analytics.ejs +209 -0
- package/views/api-analytics.ejs +660 -0
- package/views/api-keys.ejs +150 -0
- package/views/archive.ejs +123 -0
- package/views/audit.ejs +314 -0
- package/views/bookmarks.ejs +54 -0
- package/views/custom-dashboard.ejs +162 -0
- package/views/dashboard.ejs +186 -0
- package/views/diff.ejs +98 -0
- package/views/health.ejs +269 -0
- package/views/heatmap.ejs +126 -0
- package/views/insights.ejs +334 -0
- package/views/invite.ejs +74 -0
- package/views/live.ejs +299 -0
- package/views/login.ejs +64 -0
- package/views/logo.png +0 -0
- package/views/logs.ejs +754 -0
- package/views/notifications.ejs +58 -0
- package/views/partials/head.ejs +282 -0
- package/views/partials/sidebar.ejs +168 -0
- package/views/register.ejs +100 -0
- package/views/roles.ejs +279 -0
- package/views/saved-searches.ejs +51 -0
- package/views/service-map.ejs +142 -0
- package/views/settings.ejs +1159 -0
- package/views/sidebar.ejs +129 -0
- package/views/status.ejs +100 -0
- package/views/super-admin-admins.ejs +58 -0
- package/views/super-admin-analytics.ejs +49 -0
- package/views/super-admin-orgs.ejs +310 -0
- package/views/super-admin-profile.ejs +77 -0
- package/views/super-admin-settings.ejs +108 -0
- package/views/super-admin-system.ejs +46 -0
- package/views/users.ejs +153 -0
|
@@ -0,0 +1,660 @@
|
|
|
1
|
+
<%- include('partials/head', { title: 'API Analytics' }) %>
|
|
2
|
+
<style>
|
|
3
|
+
/* ── page-specific ── */
|
|
4
|
+
.method-badge {
|
|
5
|
+
display:inline-flex;align-items:center;padding:1px 7px;border-radius:4px;
|
|
6
|
+
font-size:10px;font-weight:700;letter-spacing:.5px;font-family:'JetBrains Mono',monospace;
|
|
7
|
+
white-space:nowrap;
|
|
8
|
+
}
|
|
9
|
+
.method-GET { background:rgba(34,197,94,.15); color:var(--green); }
|
|
10
|
+
.method-POST { background:rgba(59,130,246,.15); color:var(--blue); }
|
|
11
|
+
.method-PUT { background:rgba(245,158,11,.15); color:var(--yellow); }
|
|
12
|
+
.method-DELETE { background:rgba(239,68,68,.15); color:var(--red); }
|
|
13
|
+
.method-PATCH { background:rgba(99,102,241,.15); color:var(--accent-l); }
|
|
14
|
+
|
|
15
|
+
.dur-pill {
|
|
16
|
+
display:inline-block;padding:2px 8px;border-radius:4px;
|
|
17
|
+
font-size:11px;font-family:'JetBrains Mono',monospace;font-weight:600;
|
|
18
|
+
}
|
|
19
|
+
.dur-ok { background:rgba(34,197,94,.12); color:var(--green); }
|
|
20
|
+
.dur-med { background:rgba(245,158,11,.12); color:var(--yellow); }
|
|
21
|
+
.dur-slow { background:rgba(239,68,68,.12); color:var(--red); }
|
|
22
|
+
|
|
23
|
+
.status-2xx { color:var(--green); }
|
|
24
|
+
.status-4xx { color:var(--yellow); }
|
|
25
|
+
.status-5xx { color:var(--red); }
|
|
26
|
+
|
|
27
|
+
.sort-btn { background:none;border:none;color:var(--text3);cursor:pointer;padding:0 4px;font-size:11px; }
|
|
28
|
+
.sort-btn.asc::after { content:' ↑'; }
|
|
29
|
+
.sort-btn.desc::after { content:' ↓'; }
|
|
30
|
+
|
|
31
|
+
.empty-api { text-align:center;padding:40px 20px;color:var(--text3); }
|
|
32
|
+
|
|
33
|
+
.top-bar-item { display:flex;flex-direction:column;gap:2px; }
|
|
34
|
+
.top-bar-label { font-size:10px;color:var(--text3);text-transform:uppercase;letter-spacing:.5px; }
|
|
35
|
+
.top-bar-val { font-size:13px;font-weight:600;color:var(--text);font-family:'JetBrains Mono',monospace; }
|
|
36
|
+
</style>
|
|
37
|
+
|
|
38
|
+
<div class="app-shell">
|
|
39
|
+
<%- include('partials/sidebar') %>
|
|
40
|
+
<div class="main-area">
|
|
41
|
+
|
|
42
|
+
<header class="top-header">
|
|
43
|
+
<div class="page-title">API Analytics</div>
|
|
44
|
+
<div class="header-actions" style="gap:10px;">
|
|
45
|
+
<select id="svc-select" class="form-select" style="max-width:200px;" onchange="loadAll()">
|
|
46
|
+
<% if (services && services.length) { %>
|
|
47
|
+
<% services.forEach(function(s){ %>
|
|
48
|
+
<option value="<%= s.appName %>" <%= (selected.service === s.appName) ? 'selected' : '' %>>
|
|
49
|
+
<%= s.baseApp %>
|
|
50
|
+
</option>
|
|
51
|
+
<% }) %>
|
|
52
|
+
<% } else { %>
|
|
53
|
+
<option value="">No services yet</option>
|
|
54
|
+
<% } %>
|
|
55
|
+
</select>
|
|
56
|
+
<input type="date" id="date-pick" class="form-input" style="max-width:140px;"
|
|
57
|
+
value="<%= selected.date %>" max="<%= today %>" onchange="loadAll()"/>
|
|
58
|
+
<button class="btn btn-secondary btn-sm" onclick="loadAll()">
|
|
59
|
+
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23,4 23,10 17,10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
|
|
60
|
+
Refresh
|
|
61
|
+
</button>
|
|
62
|
+
</div>
|
|
63
|
+
</header>
|
|
64
|
+
|
|
65
|
+
<div class="page-content">
|
|
66
|
+
|
|
67
|
+
<!-- No services state -->
|
|
68
|
+
<% if (!services || !services.length) { %>
|
|
69
|
+
<div class="card">
|
|
70
|
+
<div class="empty-api">
|
|
71
|
+
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="margin:0 auto 12px;opacity:.4"><polyline points="22,12 18,12 15,21 9,3 6,12 2,12"/></svg>
|
|
72
|
+
<div style="font-size:14px;font-weight:600;color:var(--text);margin-bottom:8px;">No API request data yet</div>
|
|
73
|
+
<div style="font-size:12px;max-width:440px;margin:0 auto;line-height:1.7;">
|
|
74
|
+
Add <code style="background:var(--surface3);padding:2px 6px;border-radius:4px;font-family:'JetBrains Mono',monospace;">Logger.requestLogger()</code>
|
|
75
|
+
middleware to your Express app — it will start shipping request metrics here automatically.
|
|
76
|
+
</div>
|
|
77
|
+
<pre style="margin:16px auto 0;max-width:440px;text-align:left;font-family:'JetBrains Mono',monospace;font-size:11px;background:var(--surface3);padding:12px;border-radius:8px;color:var(--text2);line-height:1.8;border:1px solid var(--border);">const Logger = require('./Utils/Logger');
|
|
78
|
+
|
|
79
|
+
// In app.js, BEFORE your routes:
|
|
80
|
+
app.use(Logger.requestLogger());</pre>
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
<% } else { %>
|
|
84
|
+
|
|
85
|
+
<!-- ── Stat Cards ──────────────────────────────────────────── -->
|
|
86
|
+
<div class="stats-grid" id="stats-grid" style="grid-template-columns:repeat(auto-fit,minmax(165px,1fr));margin-bottom:16px;">
|
|
87
|
+
<div class="stat-card">
|
|
88
|
+
<div class="stat-icon blue"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22,12 18,12 15,21 9,3 6,12 2,12"/></svg></div>
|
|
89
|
+
<div><div class="stat-value" id="sc-total">–</div><div class="stat-label">Total Requests</div><div class="stat-sub" id="sc-total-sub">today</div></div>
|
|
90
|
+
</div>
|
|
91
|
+
<div class="stat-card">
|
|
92
|
+
<div class="stat-icon purple"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12,6 12,12 16,14"/></svg></div>
|
|
93
|
+
<div><div class="stat-value" id="sc-avg">–</div><div class="stat-label">Avg Response</div><div class="stat-sub" id="sc-max-sub">–</div></div>
|
|
94
|
+
</div>
|
|
95
|
+
<div class="stat-card">
|
|
96
|
+
<div class="stat-icon red"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg></div>
|
|
97
|
+
<div><div class="stat-value" id="sc-err">–</div><div class="stat-label">Error Rate</div><div class="stat-sub" id="sc-err-sub">–</div></div>
|
|
98
|
+
</div>
|
|
99
|
+
<div class="stat-card">
|
|
100
|
+
<div class="stat-icon green"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg></div>
|
|
101
|
+
<div><div class="stat-value" id="sc-data">–</div><div class="stat-label">Data Transferred</div><div class="stat-sub" id="sc-data-sub">req + res</div></div>
|
|
102
|
+
</div>
|
|
103
|
+
<div class="stat-card">
|
|
104
|
+
<div class="stat-icon yellow"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13,2 13,9 20,9"/></svg></div>
|
|
105
|
+
<div><div class="stat-value" id="sc-max">–</div><div class="stat-label">Slowest Request</div><div class="stat-sub">max duration</div></div>
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
|
|
109
|
+
<!-- ── Row 2: Hourly chart + Status doughnut ──────────────── -->
|
|
110
|
+
<div class="grid-2" style="margin-bottom:12px;">
|
|
111
|
+
|
|
112
|
+
<div class="card">
|
|
113
|
+
<div class="card-title">
|
|
114
|
+
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>
|
|
115
|
+
Hourly Request Volume
|
|
116
|
+
</div>
|
|
117
|
+
<div class="chart-wrap"><canvas id="hourly-chart"></canvas></div>
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
<div class="card">
|
|
121
|
+
<div class="card-title">
|
|
122
|
+
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 8v4l3 3"/></svg>
|
|
123
|
+
Status Code Distribution
|
|
124
|
+
</div>
|
|
125
|
+
<div style="display:flex;align-items:center;gap:20px;">
|
|
126
|
+
<div style="width:140px;height:140px;flex-shrink:0;"><canvas id="status-chart"></canvas></div>
|
|
127
|
+
<div id="status-legend" style="display:flex;flex-direction:column;gap:8px;flex:1;"></div>
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
|
|
132
|
+
<!-- ── Row 3: Avg response time per endpoint ──────────────── -->
|
|
133
|
+
<div class="card" style="margin-bottom:12px;">
|
|
134
|
+
<div class="card-title">
|
|
135
|
+
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12,6 12,12 16,14"/></svg>
|
|
136
|
+
Top 10 Slowest Endpoints — Avg Response Time
|
|
137
|
+
</div>
|
|
138
|
+
<div style="height:220px;"><canvas id="slowest-chart"></canvas></div>
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
<!-- ── Row 4: Full endpoint table ────────────────────────── -->
|
|
142
|
+
<div class="card" style="margin-bottom:12px;">
|
|
143
|
+
<div class="card-title" style="justify-content:space-between;">
|
|
144
|
+
<span style="display:flex;align-items:center;gap:8px;">
|
|
145
|
+
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
|
|
146
|
+
All Endpoints
|
|
147
|
+
</span>
|
|
148
|
+
<div style="display:flex;gap:8px;align-items:center;">
|
|
149
|
+
<div class="search-wrap" style="width:200px;">
|
|
150
|
+
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
|
151
|
+
<input type="text" id="ep-search" class="form-input search-input" placeholder="Filter endpoints…" oninput="filterTable()"/>
|
|
152
|
+
</div>
|
|
153
|
+
<button class="btn btn-secondary btn-xs" onclick="downloadCsv()">
|
|
154
|
+
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7,10 12,15 17,10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
|
155
|
+
CSV
|
|
156
|
+
</button>
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
<div class="table-wrap">
|
|
160
|
+
<table id="ep-table">
|
|
161
|
+
<thead>
|
|
162
|
+
<tr>
|
|
163
|
+
<th>Method</th>
|
|
164
|
+
<th>Endpoint <button class="sort-btn" data-col="path" onclick="sortTable(this)">↕</button></th>
|
|
165
|
+
<th>Calls <button class="sort-btn" data-col="count" onclick="sortTable(this)">↕</button></th>
|
|
166
|
+
<th>Avg <button class="sort-btn" data-col="avgDuration" onclick="sortTable(this)">↕</button></th>
|
|
167
|
+
<th>P95</th>
|
|
168
|
+
<th>Max</th>
|
|
169
|
+
<th>Avg Res</th>
|
|
170
|
+
<th>Errors <button class="sort-btn" data-col="errors" onclick="sortTable(this)">↕</button></th>
|
|
171
|
+
<th>Err %</th>
|
|
172
|
+
</tr>
|
|
173
|
+
</thead>
|
|
174
|
+
<tbody id="ep-tbody">
|
|
175
|
+
<tr><td colspan="9" style="text-align:center;color:var(--text3);padding:24px;">Loading…</td></tr>
|
|
176
|
+
</tbody>
|
|
177
|
+
</table>
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
|
|
181
|
+
<!-- ── Row 5: Error hot-spots ─────────────────────────────── -->
|
|
182
|
+
<div class="card">
|
|
183
|
+
<div class="card-title">
|
|
184
|
+
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
|
185
|
+
Error Hot-spots
|
|
186
|
+
<span id="error-count-badge" class="badge" style="margin-left:4px;"></span>
|
|
187
|
+
</div>
|
|
188
|
+
<div class="table-wrap">
|
|
189
|
+
<table>
|
|
190
|
+
<thead>
|
|
191
|
+
<tr>
|
|
192
|
+
<th>Method</th>
|
|
193
|
+
<th>Endpoint</th>
|
|
194
|
+
<th>Total Errors</th>
|
|
195
|
+
<th>5xx</th>
|
|
196
|
+
<th>Error Rate</th>
|
|
197
|
+
<th>Status Codes</th>
|
|
198
|
+
<th>Last Seen</th>
|
|
199
|
+
</tr>
|
|
200
|
+
</thead>
|
|
201
|
+
<tbody id="err-tbody">
|
|
202
|
+
<tr><td colspan="7" style="text-align:center;color:var(--text3);padding:24px;">Loading…</td></tr>
|
|
203
|
+
</tbody>
|
|
204
|
+
</table>
|
|
205
|
+
</div>
|
|
206
|
+
</div>
|
|
207
|
+
|
|
208
|
+
<% } %>
|
|
209
|
+
</div><!-- /page-content -->
|
|
210
|
+
</div>
|
|
211
|
+
</div>
|
|
212
|
+
|
|
213
|
+
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
|
214
|
+
<script>
|
|
215
|
+
const isDark = () => document.documentElement.dataset.theme !== 'light';
|
|
216
|
+
const gc = (a) => isDark() ? `rgba(255,255,255,${a})` : `rgba(0,0,0,${a})`;
|
|
217
|
+
const gridColor = () => isDark() ? 'rgba(255,255,255,.05)' : 'rgba(0,0,0,.06)';
|
|
218
|
+
const textColor = () => isDark() ? '#5a5a78' : '#9494b8';
|
|
219
|
+
|
|
220
|
+
let _endpoints = [];
|
|
221
|
+
let _sortCol = 'count';
|
|
222
|
+
let _sortDir = 'desc';
|
|
223
|
+
let _hourlyChrt = null;
|
|
224
|
+
let _statusChrt = null;
|
|
225
|
+
let _slowChrt = null;
|
|
226
|
+
|
|
227
|
+
function fmtMs(ms) {
|
|
228
|
+
if (ms == null) return '–';
|
|
229
|
+
if (ms < 1) return '<1ms';
|
|
230
|
+
if (ms >= 1000) return (ms/1000).toFixed(2)+'s';
|
|
231
|
+
return ms+'ms';
|
|
232
|
+
}
|
|
233
|
+
function fmtBytes(b) {
|
|
234
|
+
if (!b) return '0 B';
|
|
235
|
+
const k=1024,s=['B','KB','MB','GB'];
|
|
236
|
+
const i=Math.floor(Math.log(b)/Math.log(k));
|
|
237
|
+
return (b/Math.pow(k,i)).toFixed(1)+' '+s[i];
|
|
238
|
+
}
|
|
239
|
+
function durClass(ms) {
|
|
240
|
+
if (ms < 200) return 'dur-ok';
|
|
241
|
+
if (ms < 800) return 'dur-med';
|
|
242
|
+
return 'dur-slow';
|
|
243
|
+
}
|
|
244
|
+
function errClass(rate) {
|
|
245
|
+
return parseFloat(rate) > 5 ? 'color:var(--red)' : parseFloat(rate) > 1 ? 'color:var(--yellow)' : 'color:var(--text2)';
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ── API helpers ──────────────────────────────────────────────
|
|
249
|
+
async function api(url) {
|
|
250
|
+
const r = await fetch(url);
|
|
251
|
+
if (!r.ok) throw new Error(await r.text());
|
|
252
|
+
return r.json();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function getParams() {
|
|
256
|
+
const svc = document.getElementById('svc-select')?.value;
|
|
257
|
+
const date = document.getElementById('date-pick')?.value;
|
|
258
|
+
return { svc, date };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ── Load all panels ──────────────────────────────────────────
|
|
262
|
+
async function loadAll() {
|
|
263
|
+
const { svc, date } = getParams();
|
|
264
|
+
if (!svc) return;
|
|
265
|
+
await Promise.all([
|
|
266
|
+
loadOverview(svc, date),
|
|
267
|
+
loadHourly(svc, date),
|
|
268
|
+
loadStatus(svc, date),
|
|
269
|
+
loadEndpoints(svc, date),
|
|
270
|
+
loadErrors(svc, date),
|
|
271
|
+
loadSlowest(svc, date),
|
|
272
|
+
]);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ── Overview cards ───────────────────────────────────────────
|
|
276
|
+
async function loadOverview(svc, date) {
|
|
277
|
+
try {
|
|
278
|
+
const d = await api(`/api/api-analytics/overview?service=${encodeURIComponent(svc)}&date=${date}`);
|
|
279
|
+
set('sc-total', d.totalRequests.toLocaleString());
|
|
280
|
+
set('sc-total-sub', date);
|
|
281
|
+
set('sc-avg', fmtMs(d.avgDuration));
|
|
282
|
+
set('sc-max-sub', `max: ${fmtMs(d.maxDuration)}`);
|
|
283
|
+
const errEl = document.getElementById('sc-err');
|
|
284
|
+
errEl.textContent = d.errorRate + '%';
|
|
285
|
+
errEl.style.color = parseFloat(d.errorRate) > 5 ? 'var(--red)' : parseFloat(d.errorRate) > 1 ? 'var(--yellow)' : 'var(--green)';
|
|
286
|
+
set('sc-err-sub', `${d.totalErrors} total (${d.totalErrors5xx} 5xx)`);
|
|
287
|
+
set('sc-data', d.totalDataHuman);
|
|
288
|
+
set('sc-data-sub', `↑ ${d.totalReqHuman} ↓ ${d.totalResHuman}`);
|
|
289
|
+
set('sc-max', fmtMs(d.maxDuration));
|
|
290
|
+
} catch {}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ── Hourly chart ─────────────────────────────────────────────
|
|
294
|
+
async function loadHourly(svc, date) {
|
|
295
|
+
try {
|
|
296
|
+
const d = await api(`/api/api-analytics/hourly?service=${encodeURIComponent(svc)}&date=${date}`);
|
|
297
|
+
const h = d.hours;
|
|
298
|
+
const labels = h.map(x => x.hour + ':00');
|
|
299
|
+
if (_hourlyChrt) _hourlyChrt.destroy();
|
|
300
|
+
_hourlyChrt = new Chart(document.getElementById('hourly-chart'), {
|
|
301
|
+
type: 'bar',
|
|
302
|
+
data: {
|
|
303
|
+
labels,
|
|
304
|
+
datasets: [
|
|
305
|
+
{ label:'2xx', data: h.map(x=>x.ok), backgroundColor:'rgba(34,197,94,.55)', stack:'s' },
|
|
306
|
+
{ label:'4xx', data: h.map(x=>x.warn), backgroundColor:'rgba(245,158,11,.55)', stack:'s' },
|
|
307
|
+
{ label:'5xx', data: h.map(x=>x.error), backgroundColor:'rgba(239,68,68,.75)', stack:'s' },
|
|
308
|
+
]
|
|
309
|
+
},
|
|
310
|
+
options: {
|
|
311
|
+
responsive:true, maintainAspectRatio:false,
|
|
312
|
+
plugins:{ legend:{ labels:{ color:textColor(), font:{ size:11 } } } },
|
|
313
|
+
scales:{
|
|
314
|
+
x:{ stacked:true, ticks:{ color:textColor(),font:{size:10},maxTicksLimit:12 }, grid:{color:gridColor()} },
|
|
315
|
+
y:{ stacked:true, ticks:{ color:textColor(),font:{size:10} }, grid:{color:gridColor()} }
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
} catch {}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ── Status doughnut ──────────────────────────────────────────
|
|
323
|
+
async function loadStatus(svc, date) {
|
|
324
|
+
try {
|
|
325
|
+
const d = await api(`/api/api-analytics/status?service=${encodeURIComponent(svc)}&date=${date}`);
|
|
326
|
+
const dist = d.distribution;
|
|
327
|
+
const labels = Object.keys(dist).map(k => k+'xx');
|
|
328
|
+
const vals = Object.values(dist);
|
|
329
|
+
const colors = {
|
|
330
|
+
'2xx':'rgba(34,197,94,.8)', '3xx':'rgba(59,130,246,.8)',
|
|
331
|
+
'4xx':'rgba(245,158,11,.8)','5xx':'rgba(239,68,68,.8)',
|
|
332
|
+
};
|
|
333
|
+
const bgColors = labels.map(l => colors[l] || 'rgba(107,114,128,.5)');
|
|
334
|
+
|
|
335
|
+
if (_statusChrt) _statusChrt.destroy();
|
|
336
|
+
_statusChrt = new Chart(document.getElementById('status-chart'), {
|
|
337
|
+
type: 'doughnut',
|
|
338
|
+
data: { labels, datasets:[{ data:vals, backgroundColor:bgColors, borderColor:'transparent', borderWidth:0 }] },
|
|
339
|
+
options: {
|
|
340
|
+
responsive:true, maintainAspectRatio:false, cutout:'70%',
|
|
341
|
+
plugins:{ legend:{ display:false } }
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
const leg = document.getElementById('status-legend');
|
|
346
|
+
leg.innerHTML = labels.map((l,i) => `
|
|
347
|
+
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;">
|
|
348
|
+
<div style="display:flex;align-items:center;gap:6px;">
|
|
349
|
+
<div style="width:10px;height:10px;border-radius:2px;background:${bgColors[i]};flex-shrink:0;"></div>
|
|
350
|
+
<span style="font-size:12px;color:var(--text2);">${l}</span>
|
|
351
|
+
</div>
|
|
352
|
+
<span style="font-size:12px;font-weight:600;font-family:'JetBrains Mono',monospace;color:var(--text);">${vals[i].toLocaleString()}</span>
|
|
353
|
+
</div>`).join('');
|
|
354
|
+
} catch {}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ── Slowest bar chart ────────────────────────────────────────
|
|
358
|
+
async function loadSlowest(svc, date) {
|
|
359
|
+
try {
|
|
360
|
+
const data = await api(`/api/api-analytics/slowest?service=${encodeURIComponent(svc)}&date=${date}&top=10`);
|
|
361
|
+
if (!data.length) return;
|
|
362
|
+
const labels = data.map(e => `${e.method} ${e.path.length > 30 ? '…'+e.path.slice(-28) : e.path}`);
|
|
363
|
+
const avgs = data.map(e => e.avgDuration);
|
|
364
|
+
const p95s = data.map(e => e.p95Duration);
|
|
365
|
+
if (_slowChrt) _slowChrt.destroy();
|
|
366
|
+
_slowChrt = new Chart(document.getElementById('slowest-chart'), {
|
|
367
|
+
type: 'bar',
|
|
368
|
+
data: {
|
|
369
|
+
labels,
|
|
370
|
+
datasets: [
|
|
371
|
+
{ label:'Avg (ms)', data:avgs, backgroundColor:'rgba(99,102,241,.65)', borderRadius:3 },
|
|
372
|
+
{ label:'P95 (ms)', data:p95s, backgroundColor:'rgba(245,158,11,.5)', borderRadius:3 },
|
|
373
|
+
]
|
|
374
|
+
},
|
|
375
|
+
options: {
|
|
376
|
+
indexAxis:'y', responsive:true, maintainAspectRatio:false,
|
|
377
|
+
plugins:{ legend:{ labels:{ color:textColor(),font:{size:11} } } },
|
|
378
|
+
scales:{
|
|
379
|
+
x:{ ticks:{ color:textColor(),font:{size:10},callback:v=>v+'ms' }, grid:{color:gridColor()} },
|
|
380
|
+
y:{ ticks:{ color:textColor(),font:{size:10},font:{family:"'JetBrains Mono',monospace"} }, grid:{display:false} }
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
} catch {}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ── Endpoint table ───────────────────────────────────────────
|
|
388
|
+
async function loadEndpoints(svc, date) {
|
|
389
|
+
try {
|
|
390
|
+
_endpoints = await api(`/api/api-analytics/endpoints?service=${encodeURIComponent(svc)}&date=${date}`);
|
|
391
|
+
renderTable();
|
|
392
|
+
} catch { document.getElementById('ep-tbody').innerHTML = '<tr><td colspan="9" style="text-align:center;color:var(--red);padding:16px;">Failed to load</td></tr>'; }
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function renderTable() {
|
|
396
|
+
const q = (document.getElementById('ep-search')?.value || '').toLowerCase();
|
|
397
|
+
let rows = _endpoints.filter(e => !q || (e.method+' '+e.path).toLowerCase().includes(q));
|
|
398
|
+
|
|
399
|
+
// sort
|
|
400
|
+
rows.sort((a,b) => {
|
|
401
|
+
const av = a[_sortCol], bv = b[_sortCol];
|
|
402
|
+
if (typeof av === 'string') return _sortDir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av);
|
|
403
|
+
return _sortDir === 'asc' ? av - bv : bv - av;
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
document.getElementById('ep-tbody').innerHTML = rows.length
|
|
407
|
+
? rows.map(e => `
|
|
408
|
+
<tr>
|
|
409
|
+
<td><span class="method-badge method-${e.method}">${e.method}</span></td>
|
|
410
|
+
<td style="font-family:'JetBrains Mono',monospace;font-size:11.5px;max-width:260px;" class="truncate">${esc(e.path)}</td>
|
|
411
|
+
<td style="font-weight:600;color:var(--text);">${e.count.toLocaleString()}</td>
|
|
412
|
+
<td><span class="dur-pill ${durClass(e.avgDuration)}">${fmtMs(e.avgDuration)}</span></td>
|
|
413
|
+
<td style="font-family:'JetBrains Mono',monospace;font-size:11.5px;color:var(--text2);">${fmtMs(e.p95Duration)}</td>
|
|
414
|
+
<td style="font-family:'JetBrains Mono',monospace;font-size:11.5px;color:var(--text2);">${fmtMs(e.maxDuration)}</td>
|
|
415
|
+
<td style="font-family:'JetBrains Mono',monospace;font-size:11.5px;color:var(--text3);">${fmtBytes(e.avgResBytes)}</td>
|
|
416
|
+
<td style="font-family:'JetBrains Mono',monospace;font-size:12px;font-weight:600;color:${e.errors > 0 ? 'var(--red)' : 'var(--text3)'};">${e.errors}</td>
|
|
417
|
+
<td style="font-size:12px;${errClass(e.errorRate)}">${e.errorRate}%</td>
|
|
418
|
+
</tr>`).join('')
|
|
419
|
+
: '<tr><td colspan="9" style="text-align:center;color:var(--text3);padding:24px;">No endpoints found</td></tr>';
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function filterTable() { renderTable(); }
|
|
423
|
+
|
|
424
|
+
function sortTable(btn) {
|
|
425
|
+
const col = btn.dataset.col;
|
|
426
|
+
document.querySelectorAll('.sort-btn').forEach(b => b.className = 'sort-btn');
|
|
427
|
+
if (_sortCol === col) {
|
|
428
|
+
_sortDir = _sortDir === 'asc' ? 'desc' : 'asc';
|
|
429
|
+
} else {
|
|
430
|
+
_sortCol = col; _sortDir = 'desc';
|
|
431
|
+
}
|
|
432
|
+
btn.className = `sort-btn ${_sortDir}`;
|
|
433
|
+
renderTable();
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// ── Error table ──────────────────────────────────────────────
|
|
437
|
+
async function loadErrors(svc, date) {
|
|
438
|
+
try {
|
|
439
|
+
const data = await api(`/api/api-analytics/errors?service=${encodeURIComponent(svc)}&date=${date}&top=20`);
|
|
440
|
+
const badge = document.getElementById('error-count-badge');
|
|
441
|
+
if (badge) {
|
|
442
|
+
badge.textContent = data.length ? data.reduce((a,e)=>a+e.errors,0)+' errors' : '';
|
|
443
|
+
badge.className = `badge ${data.length ? 'badge-error' : ''}`;
|
|
444
|
+
}
|
|
445
|
+
document.getElementById('err-tbody').innerHTML = data.length
|
|
446
|
+
? data.map(e => {
|
|
447
|
+
const codes = Object.entries(e.statusCodes)
|
|
448
|
+
.map(([sc, cnt]) => `<span class="badge badge-${sc>=500?'error':sc>=400?'warn':'info'}" style="font-size:9px;">${sc}×${cnt}</span>`)
|
|
449
|
+
.join(' ');
|
|
450
|
+
const ago = e.lastSeen ? new Date(e.lastSeen).toLocaleTimeString() : '–';
|
|
451
|
+
return `<tr>
|
|
452
|
+
<td><span class="method-badge method-${e.method}">${e.method}</span></td>
|
|
453
|
+
<td style="font-family:'JetBrains Mono',monospace;font-size:11.5px;max-width:240px;" class="truncate">${esc(e.path)}</td>
|
|
454
|
+
<td style="font-weight:700;color:var(--red);">${e.errors}</td>
|
|
455
|
+
<td style="color:${e.errors5xx > 0 ? 'var(--red)' : 'var(--text3)'};">${e.errors5xx}</td>
|
|
456
|
+
<td style="font-weight:600;${errClass(e.errorRate)}">${e.errorRate}%</td>
|
|
457
|
+
<td>${codes}</td>
|
|
458
|
+
<td style="font-size:11px;color:var(--text3);">${ago}</td>
|
|
459
|
+
</tr>`;
|
|
460
|
+
}).join('')
|
|
461
|
+
: '<tr><td colspan="7" style="text-align:center;color:var(--green);padding:24px;">No errors — all endpoints are healthy 🎉</td></tr>';
|
|
462
|
+
} catch {}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// ── Helpers ──────────────────────────────────────────────────
|
|
466
|
+
function set(id, val) { const el = document.getElementById(id); if (el) el.textContent = val; }
|
|
467
|
+
function esc(s) { return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
|
468
|
+
|
|
469
|
+
function downloadCsv() {
|
|
470
|
+
const headers = ['Method','Path','Calls','Avg(ms)','P95(ms)','Max(ms)','AvgResBytes','Errors','ErrorRate%'];
|
|
471
|
+
const rows = _endpoints.map(e => [e.method,e.path,e.count,e.avgDuration,e.p95Duration,e.maxDuration,e.avgResBytes,e.errors,e.errorRate]);
|
|
472
|
+
const csv = [headers, ...rows].map(r => r.map(v => `"${String(v||'').replace(/"/g,'""')}"`).join(',')).join('\n');
|
|
473
|
+
const a = document.createElement('a');
|
|
474
|
+
a.href = 'data:text/csv;charset=utf-8,' + encodeURIComponent(csv);
|
|
475
|
+
a.download = `api-analytics-${getParams().date}.csv`;
|
|
476
|
+
a.click();
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// ── Init ─────────────────────────────────────────────────────
|
|
480
|
+
<% if (services && services.length) { %>
|
|
481
|
+
loadAll();
|
|
482
|
+
<% } %>
|
|
483
|
+
// ── Trend Analysis ────────────────────────────────────────────
|
|
484
|
+
let _slowTrendChrt = null, _patternChrt = null, _errTrendChrt = null;
|
|
485
|
+
let _activeTrendTab = 0;
|
|
486
|
+
|
|
487
|
+
// Set default: last 7 days
|
|
488
|
+
(function() {
|
|
489
|
+
const end = new Date();
|
|
490
|
+
const start = new Date(end);
|
|
491
|
+
start.setDate(start.getDate() - 6);
|
|
492
|
+
const fmt = d => d.toISOString().slice(0, 10);
|
|
493
|
+
document.getElementById('trend-end').value = fmt(end);
|
|
494
|
+
document.getElementById('trend-start').value = fmt(start);
|
|
495
|
+
})();
|
|
496
|
+
|
|
497
|
+
function switchTrendTab(idx) {
|
|
498
|
+
_activeTrendTab = idx;
|
|
499
|
+
[0,1,2].forEach(i => {
|
|
500
|
+
document.getElementById('tpanel-'+i).style.display = i===idx ? '' : 'none';
|
|
501
|
+
});
|
|
502
|
+
document.querySelectorAll('.ttab').forEach((b,i) => {
|
|
503
|
+
b.style.borderBottomColor = i===idx ? 'var(--accent)' : 'transparent';
|
|
504
|
+
b.style.color = i===idx ? 'var(--accent-l)' : 'var(--text2)';
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
switchTrendTab(0);
|
|
508
|
+
|
|
509
|
+
async function loadTrends() {
|
|
510
|
+
const { svc } = getParams();
|
|
511
|
+
const start = document.getElementById('trend-start').value;
|
|
512
|
+
const end = document.getElementById('trend-end').value;
|
|
513
|
+
if (!svc || !start || !end) return;
|
|
514
|
+
|
|
515
|
+
const label = document.getElementById('trend-daterange-label');
|
|
516
|
+
if (label) label.textContent = `${start} → ${end}`;
|
|
517
|
+
|
|
518
|
+
document.getElementById('trend-loading').style.display = '';
|
|
519
|
+
document.getElementById('trend-empty').style.display = 'none';
|
|
520
|
+
|
|
521
|
+
try {
|
|
522
|
+
const [slowData, patternData, errData] = await Promise.all([
|
|
523
|
+
api(`/api/api-analytics/slow-trend?service=${encodeURIComponent(svc)}&start=${start}&end=${end}`),
|
|
524
|
+
api(`/api/api-analytics/hourly-pattern?service=${encodeURIComponent(svc)}&start=${start}&end=${end}`),
|
|
525
|
+
api(`/api/api-analytics/error-trend?service=${encodeURIComponent(svc)}&start=${start}&end=${end}`),
|
|
526
|
+
]);
|
|
527
|
+
|
|
528
|
+
const hasData = slowData.some(d => d.totalRequests > 0);
|
|
529
|
+
document.getElementById('trend-empty').style.display = hasData ? 'none' : '';
|
|
530
|
+
if (!hasData) return;
|
|
531
|
+
|
|
532
|
+
renderSlowTrend(slowData);
|
|
533
|
+
renderHourlyPattern(patternData);
|
|
534
|
+
renderErrorTrend(errData);
|
|
535
|
+
} catch(e) {
|
|
536
|
+
document.getElementById('trend-loading').textContent = 'Failed: ' + e.message;
|
|
537
|
+
} finally {
|
|
538
|
+
document.getElementById('trend-loading').style.display = 'none';
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function renderSlowTrend(data) {
|
|
543
|
+
const labels = data.map(d => d.date.slice(5)); // MM-DD
|
|
544
|
+
const avgs = data.map(d => d.avgDuration);
|
|
545
|
+
const p95s = data.map(d => d.p95Duration);
|
|
546
|
+
|
|
547
|
+
if (_slowTrendChrt) _slowTrendChrt.destroy();
|
|
548
|
+
_slowTrendChrt = new Chart(document.getElementById('slow-trend-chart'), {
|
|
549
|
+
type: 'line',
|
|
550
|
+
data: {
|
|
551
|
+
labels,
|
|
552
|
+
datasets: [
|
|
553
|
+
{ label: 'Avg (ms)', data: avgs, borderColor:'rgba(99,102,241,.9)', backgroundColor:'rgba(99,102,241,.1)', borderWidth:2, tension:.3, pointRadius:3, fill:true },
|
|
554
|
+
{ label: 'P95 (ms)', data: p95s, borderColor:'rgba(245,158,11,.8)', backgroundColor:'transparent', borderWidth:2, tension:.3, pointRadius:3, borderDash:[4,3] },
|
|
555
|
+
]
|
|
556
|
+
},
|
|
557
|
+
options: {
|
|
558
|
+
responsive:true, maintainAspectRatio:false,
|
|
559
|
+
plugins:{ legend:{ labels:{ color:textColor(), font:{ size:11 } } } },
|
|
560
|
+
scales:{
|
|
561
|
+
x:{ ticks:{ color:textColor(), font:{size:10} }, grid:{ color:gridColor() } },
|
|
562
|
+
y:{ ticks:{ color:textColor(), font:{size:10}, callback: v => v+'ms' }, grid:{ color:gridColor() } }
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
// Meta: worst day, best day
|
|
568
|
+
const withData = data.filter(d => d.totalRequests > 0);
|
|
569
|
+
if (!withData.length) return;
|
|
570
|
+
const worst = withData.reduce((a, b) => a.avgDuration > b.avgDuration ? a : b);
|
|
571
|
+
const best = withData.reduce((a, b) => a.avgDuration < b.avgDuration ? a : b);
|
|
572
|
+
const trend = avgs.filter(Boolean);
|
|
573
|
+
const isGetting = trend.length > 1 && trend[trend.length-1] > trend[0] * 1.1 ? 'slower' : trend[trend.length-1] < trend[0] * 0.9 ? 'faster' : 'stable';
|
|
574
|
+
const trendColor = isGetting === 'slower' ? 'var(--red)' : isGetting === 'faster' ? 'var(--green)' : 'var(--text2)';
|
|
575
|
+
|
|
576
|
+
document.getElementById('slow-trend-meta').innerHTML = [
|
|
577
|
+
`<div style="padding:8px 12px;background:var(--surface2);border-radius:6px;border:1px solid var(--border);"><div style="font-size:10px;color:var(--text3);margin-bottom:2px;">Slowest day</div><div style="font-size:13px;font-weight:600;color:var(--red);">${worst.date}</div><div style="font-size:11px;color:var(--text2);">${fmtMs(worst.avgDuration)} avg</div></div>`,
|
|
578
|
+
`<div style="padding:8px 12px;background:var(--surface2);border-radius:6px;border:1px solid var(--border);"><div style="font-size:10px;color:var(--text3);margin-bottom:2px;">Fastest day</div><div style="font-size:13px;font-weight:600;color:var(--green);">${best.date}</div><div style="font-size:11px;color:var(--text2);">${fmtMs(best.avgDuration)} avg</div></div>`,
|
|
579
|
+
`<div style="padding:8px 12px;background:var(--surface2);border-radius:6px;border:1px solid var(--border);"><div style="font-size:10px;color:var(--text3);margin-bottom:2px;">Trend</div><div style="font-size:13px;font-weight:600;color:${trendColor};">${isGetting}</div><div style="font-size:11px;color:var(--text2);">over the period</div></div>`,
|
|
580
|
+
worst.slowestPath ? `<div style="padding:8px 12px;background:var(--surface2);border-radius:6px;border:1px solid var(--border);min-width:0;flex:1;"><div style="font-size:10px;color:var(--text3);margin-bottom:2px;">Slowest endpoint (worst day)</div><div style="font-size:12px;font-weight:600;color:var(--text);font-family:'JetBrains Mono',monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${esc(worst.slowestPath)}</div><div style="font-size:11px;color:var(--text2);">${fmtMs(worst.slowestAvg)} avg on ${worst.date}</div></div>` : '',
|
|
581
|
+
].filter(Boolean).join('');
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function renderHourlyPattern(data) {
|
|
585
|
+
const labels = data.map(d => d.hour + ':00');
|
|
586
|
+
const durations = data.map(d => d.avgDuration);
|
|
587
|
+
const maxDur = Math.max(...durations, 1);
|
|
588
|
+
|
|
589
|
+
// Color each bar by intensity: green → yellow → red
|
|
590
|
+
const bgColors = durations.map(v => {
|
|
591
|
+
const pct = v / maxDur;
|
|
592
|
+
if (pct < 0.4) return 'rgba(34,197,94,.65)';
|
|
593
|
+
if (pct < 0.7) return 'rgba(245,158,11,.65)';
|
|
594
|
+
return 'rgba(239,68,68,.75)';
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
if (_patternChrt) _patternChrt.destroy();
|
|
598
|
+
_patternChrt = new Chart(document.getElementById('pattern-chart'), {
|
|
599
|
+
type: 'bar',
|
|
600
|
+
data: { labels, datasets: [{ label:'Avg (ms)', data: durations, backgroundColor: bgColors, borderRadius:2 }] },
|
|
601
|
+
options: {
|
|
602
|
+
responsive:true, maintainAspectRatio:false,
|
|
603
|
+
plugins:{ legend:{ display:false } },
|
|
604
|
+
scales:{
|
|
605
|
+
x:{ ticks:{ color:textColor(), font:{size:10}, maxTicksLimit:12 }, grid:{ color:gridColor() } },
|
|
606
|
+
y:{ ticks:{ color:textColor(), font:{size:10}, callback: v => v+'ms' }, grid:{ color:gridColor() } }
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
const peakHour = data.reduce((a, b) => a.avgDuration > b.avgDuration ? a : b);
|
|
612
|
+
const quietHour = data.filter(d => d.count > 0).reduce((a, b) => a.avgDuration < b.avgDuration ? a : b, data.find(d => d.count > 0) || data[0]);
|
|
613
|
+
const insight = document.getElementById('pattern-insight');
|
|
614
|
+
if (insight && peakHour.avgDuration > 0) {
|
|
615
|
+
insight.innerHTML = `Peak hour: <strong style="color:var(--red);">${peakHour.hour}:00 UTC</strong> (avg ${fmtMs(peakHour.avgDuration)}, ${peakHour.count.toLocaleString()} requests)` +
|
|
616
|
+
(quietHour ? ` · Quietest: <strong style="color:var(--green);">${quietHour.hour}:00 UTC</strong> (avg ${fmtMs(quietHour.avgDuration)})` : '');
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function renderErrorTrend(data) {
|
|
621
|
+
const labels = data.map(d => d.date.slice(5));
|
|
622
|
+
const rates = data.map(d => parseFloat(d.errorRate) || 0);
|
|
623
|
+
const counts = data.map(d => d.errors);
|
|
624
|
+
|
|
625
|
+
if (_errTrendChrt) _errTrendChrt.destroy();
|
|
626
|
+
_errTrendChrt = new Chart(document.getElementById('error-trend-chart'), {
|
|
627
|
+
type: 'line',
|
|
628
|
+
data: {
|
|
629
|
+
labels,
|
|
630
|
+
datasets: [
|
|
631
|
+
{ label:'Error rate (%)', data: rates, borderColor:'rgba(239,68,68,.9)', backgroundColor:'rgba(239,68,68,.1)', borderWidth:2, tension:.3, pointRadius:3, fill:true, yAxisID:'y' },
|
|
632
|
+
{ label:'Error count', data: counts, borderColor:'rgba(245,158,11,.7)', backgroundColor:'transparent', borderWidth:1.5, tension:.3, pointRadius:2, borderDash:[4,3], yAxisID:'y2' },
|
|
633
|
+
]
|
|
634
|
+
},
|
|
635
|
+
options: {
|
|
636
|
+
responsive:true, maintainAspectRatio:false,
|
|
637
|
+
plugins:{ legend:{ labels:{ color:textColor(), font:{ size:11 } } } },
|
|
638
|
+
scales:{
|
|
639
|
+
x: { ticks:{ color:textColor(), font:{size:10} }, grid:{ color:gridColor() } },
|
|
640
|
+
y: { position:'left', ticks:{ color:textColor(), font:{size:10}, callback: v => v+'%' }, grid:{ color:gridColor() } },
|
|
641
|
+
y2: { position:'right', ticks:{ color:textColor(), font:{size:10} }, grid:{ display:false } }
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
const withData = data.filter(d => d.totalRequests > 0);
|
|
647
|
+
const worstErr = withData.reduce((a,b) => parseFloat(a.errorRate) > parseFloat(b.errorRate) ? a : b, withData[0]);
|
|
648
|
+
const totalReq = withData.reduce((a,b) => a + b.totalRequests, 0);
|
|
649
|
+
const totalErr = withData.reduce((a,b) => a + b.errors, 0);
|
|
650
|
+
const overallRate = totalReq ? ((totalErr / totalReq) * 100).toFixed(1) : '0.0';
|
|
651
|
+
|
|
652
|
+
document.getElementById('error-trend-meta').innerHTML = worstErr ? [
|
|
653
|
+
`<div style="padding:8px 12px;background:var(--surface2);border-radius:6px;border:1px solid var(--border);"><div style="font-size:10px;color:var(--text3);margin-bottom:2px;">Worst error day</div><div style="font-size:13px;font-weight:600;color:var(--red);">${worstErr.date}</div><div style="font-size:11px;color:var(--text2);">${worstErr.errorRate}% error rate</div></div>`,
|
|
654
|
+
`<div style="padding:8px 12px;background:var(--surface2);border-radius:6px;border:1px solid var(--border);"><div style="font-size:10px;color:var(--text3);margin-bottom:2px;">Period avg error rate</div><div style="font-size:13px;font-weight:600;color:${parseFloat(overallRate)>5?'var(--red)':parseFloat(overallRate)>1?'var(--yellow)':'var(--green)'};">${overallRate}%</div><div style="font-size:11px;color:var(--text2);">${totalErr.toLocaleString()} errors of ${totalReq.toLocaleString()}</div></div>`,
|
|
655
|
+
worstErr.topErrPath ? `<div style="padding:8px 12px;background:var(--surface2);border-radius:6px;border:1px solid var(--border);min-width:0;flex:1;"><div style="font-size:10px;color:var(--text3);margin-bottom:2px;">Top error endpoint (worst day)</div><div style="font-size:12px;font-weight:600;color:var(--text);font-family:'JetBrains Mono',monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${esc(worstErr.topErrPath)}</div></div>` : '',
|
|
656
|
+
].filter(Boolean).join('') : '';
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
</script>
|
|
660
|
+
</body></html>
|