apiforgejs 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +117 -0
- package/package.json +33 -0
- package/src/aggregator.js +91 -0
- package/src/dashboard.js +186 -0
- package/src/database.js +298 -0
- package/src/index.js +60 -0
- package/src/insights.js +185 -0
- package/src/interceptor.js +105 -0
- package/src/transport.js +33 -0
- package/src/ui.html +1648 -0
package/src/ui.html
ADDED
|
@@ -0,0 +1,1648 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>APIForge — Local Dashboard</title>
|
|
7
|
+
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='8' fill='%230a0a0a'/><text x='16' y='22' text-anchor='middle' fill='white' font-size='14' font-family='monospace' font-weight='bold'>AF</text></svg>">
|
|
8
|
+
<style>
|
|
9
|
+
/* APIForge Local Dashboard */
|
|
10
|
+
:root {
|
|
11
|
+
--bg: #ffffff; --bg-elev: #fafafa; --bg-sunken: #f5f5f4;
|
|
12
|
+
--surface: #ffffff; --border: #ececec; --border-strong: #d4d4d4;
|
|
13
|
+
--text: #0a0a0a; --text-muted: #525252; --text-dim: #8a8a8a; --text-faint: #b3b3b3;
|
|
14
|
+
--accent: #2563eb; --accent-hover: #1d4ed8;
|
|
15
|
+
--accent-soft: rgba(37,99,235,0.08); --accent-line: rgba(37,99,235,0.18);
|
|
16
|
+
--ok: #15803d; --ok-soft: rgba(21,128,61,0.08);
|
|
17
|
+
--warn: #b45309; --warn-soft: rgba(180,83,9,0.09);
|
|
18
|
+
--danger: #b91c1c; --danger-soft: rgba(185,28,28,0.08);
|
|
19
|
+
--info: #2563eb; --info-soft: rgba(37,99,235,0.08); --neutral: #525252;
|
|
20
|
+
--row-h: 40px; --pad-card: 20px;
|
|
21
|
+
--radius-sm: 5px; --radius: 8px; --radius-lg: 12px;
|
|
22
|
+
--shadow-sm: 0 1px 2px rgba(0,0,0,0.04);
|
|
23
|
+
--shadow: 0 4px 16px rgba(0,0,0,0.06);
|
|
24
|
+
--shadow-lg: 0 12px 40px rgba(0,0,0,0.12);
|
|
25
|
+
--sans: 'Geist', system-ui, -apple-system, 'Segoe UI', sans-serif;
|
|
26
|
+
--mono: 'Geist Mono','JetBrains Mono',ui-monospace,'SF Mono',Menlo,monospace;
|
|
27
|
+
}
|
|
28
|
+
* { box-sizing: border-box; }
|
|
29
|
+
html, body { margin: 0; padding: 0; height: 100%; }
|
|
30
|
+
body { font-family: var(--sans); font-size: 13px; line-height: 1.45; color: var(--text);
|
|
31
|
+
background: var(--bg); -webkit-font-smoothing: antialiased; }
|
|
32
|
+
#root { height: 100%; }
|
|
33
|
+
|
|
34
|
+
.app { display: grid; grid-template-columns: 232px 1fr; height: 100vh; overflow: hidden; }
|
|
35
|
+
.sidebar { border-right: 1px solid var(--border); background: var(--bg-elev);
|
|
36
|
+
display: flex; flex-direction: column; min-height: 0; }
|
|
37
|
+
.sb-brand { padding: 18px 18px 14px; display: flex; align-items: center; gap: 10px;
|
|
38
|
+
border-bottom: 1px solid var(--border); }
|
|
39
|
+
.sb-logo { width: 24px; height: 24px; border-radius: 6px; background: var(--text); color: white;
|
|
40
|
+
display: grid; place-items: center; font-family: var(--mono); font-size: 12px;
|
|
41
|
+
font-weight: 600; letter-spacing: -0.5px; }
|
|
42
|
+
.sb-name { font-weight: 600; font-size: 13.5px; letter-spacing: -0.2px; flex: 1; }
|
|
43
|
+
.sb-mode { font-family: var(--mono); font-size: 10px; text-transform: uppercase;
|
|
44
|
+
letter-spacing: 0.5px; padding: 2px 6px; border-radius: 3px;
|
|
45
|
+
background: var(--accent-soft); color: var(--accent); font-weight: 500; }
|
|
46
|
+
.sb-section-label { padding: 16px 18px 6px; font-size: 10.5px; font-weight: 500;
|
|
47
|
+
letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-dim); }
|
|
48
|
+
.sb-nav { padding: 4px 8px; display: flex; flex-direction: column; gap: 1px; }
|
|
49
|
+
.sb-item { display: flex; align-items: center; gap: 10px; padding: 7px 10px;
|
|
50
|
+
border-radius: 5px; cursor: pointer; color: var(--text-muted); font-size: 13px;
|
|
51
|
+
font-weight: 500; user-select: none; }
|
|
52
|
+
.sb-item:hover { background: rgba(0,0,0,0.04); color: var(--text); }
|
|
53
|
+
.sb-item.active { background: var(--surface); color: var(--text);
|
|
54
|
+
box-shadow: 0 0 0 1px var(--border), 0 1px 2px rgba(0,0,0,0.03); }
|
|
55
|
+
.sb-item .ico { width: 16px; height: 16px; opacity: 0.8; flex-shrink: 0; }
|
|
56
|
+
.sb-item .badge { margin-left: auto; font-family: var(--mono); font-size: 10.5px;
|
|
57
|
+
color: var(--text-dim); background: var(--bg-sunken); padding: 1px 6px;
|
|
58
|
+
border-radius: 999px; font-weight: 500; }
|
|
59
|
+
.sb-item.active .badge { background: var(--accent-soft); color: var(--accent); }
|
|
60
|
+
.sb-spacer { flex: 1; }
|
|
61
|
+
.sb-footer { border-top: 1px solid var(--border); padding: 12px 16px 14px;
|
|
62
|
+
display: flex; flex-direction: column; gap: 8px; }
|
|
63
|
+
.sb-recording { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-muted); }
|
|
64
|
+
.dot-pulse { width: 6px; height: 6px; border-radius: 50%; background: var(--ok); position: relative; flex-shrink: 0; }
|
|
65
|
+
.dot-pulse::after { content: ''; position: absolute; inset: -2px; border-radius: 50%;
|
|
66
|
+
background: var(--ok); opacity: 0.4; animation: pulse 1.8s ease-out infinite; }
|
|
67
|
+
@keyframes pulse { 0% { transform: scale(1); opacity: 0.4; } 100% { transform: scale(2.6); opacity: 0; } }
|
|
68
|
+
|
|
69
|
+
.main { display: flex; flex-direction: column; min-width: 0; min-height: 0; }
|
|
70
|
+
.topbar { height: 56px; border-bottom: 1px solid var(--border); background: var(--bg);
|
|
71
|
+
display: flex; align-items: center; gap: 14px; padding: 0 24px; flex-shrink: 0; }
|
|
72
|
+
.topbar-title { font-size: 15px; font-weight: 600; letter-spacing: -0.2px; }
|
|
73
|
+
.topbar-crumbs { font-size: 13px; color: var(--text-dim); display: flex; align-items: center; gap: 6px; }
|
|
74
|
+
.topbar-crumbs .sep { color: var(--text-faint); }
|
|
75
|
+
.topbar-crumbs button { background: none; border: 0; font: inherit; color: var(--text-dim); cursor: pointer; padding: 0; }
|
|
76
|
+
.topbar-crumbs button:hover { color: var(--text); }
|
|
77
|
+
.topbar-crumbs .current { color: var(--text); font-family: var(--mono); font-size: 12.5px; }
|
|
78
|
+
.topbar-spacer { flex: 1; }
|
|
79
|
+
.segmented { display: flex; background: var(--bg-sunken); border-radius: 6px; padding: 2px; gap: 1px; }
|
|
80
|
+
.segmented button { background: none; border: 0; padding: 5px 10px; border-radius: 4px; font: inherit;
|
|
81
|
+
font-size: 12px; font-weight: 500; color: var(--text-muted); cursor: pointer; font-family: var(--mono); }
|
|
82
|
+
.segmented button.active { background: var(--surface); color: var(--text); box-shadow: var(--shadow-sm); }
|
|
83
|
+
.select { background: var(--surface); border: 1px solid var(--border); border-radius: 6px;
|
|
84
|
+
padding: 6px 28px 6px 10px; font: inherit; font-size: 12.5px; color: var(--text); cursor: pointer;
|
|
85
|
+
appearance: none;
|
|
86
|
+
background-image: url("data:image/svg+xml,%3Csvg width='10' height='10' viewBox='0 0 10 10' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M2 4l3 3 3-3' stroke='%23737373' stroke-width='1.3' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
|
87
|
+
background-repeat: no-repeat; background-position: right 8px center; }
|
|
88
|
+
.select:hover { border-color: var(--border-strong); }
|
|
89
|
+
.btn { background: var(--surface); border: 1px solid var(--border); border-radius: 6px;
|
|
90
|
+
padding: 6px 12px; font: inherit; font-size: 12.5px; font-weight: 500; color: var(--text);
|
|
91
|
+
cursor: pointer; display: inline-flex; align-items: center; gap: 6px; }
|
|
92
|
+
.btn:hover { background: var(--bg-elev); border-color: var(--border-strong); }
|
|
93
|
+
.btn-primary { background: var(--text); color: white; border-color: var(--text); }
|
|
94
|
+
.btn-primary:hover { background: #333; border-color: #333; }
|
|
95
|
+
.btn-ghost { background: transparent; border-color: transparent; }
|
|
96
|
+
.btn-ghost:hover { background: var(--bg-elev); }
|
|
97
|
+
.btn-danger { color: var(--danger); border-color: var(--border); }
|
|
98
|
+
.btn-danger:hover { background: var(--danger-soft); border-color: var(--danger); }
|
|
99
|
+
|
|
100
|
+
.content { flex: 1; overflow-y: auto; background: var(--bg); }
|
|
101
|
+
.content-inner { max-width: 1400px; margin: 0 auto; padding: 24px 32px 48px; }
|
|
102
|
+
.content::-webkit-scrollbar { width: 10px; }
|
|
103
|
+
.content::-webkit-scrollbar-thumb { background: var(--bg-sunken); border-radius: 5px; border: 2px solid var(--bg); }
|
|
104
|
+
.content::-webkit-scrollbar-thumb:hover { background: var(--border-strong); }
|
|
105
|
+
|
|
106
|
+
.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
|
|
107
|
+
.card-h { padding: 14px 18px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 10px; }
|
|
108
|
+
.card-h-title { font-size: 13px; font-weight: 600; letter-spacing: -0.1px; }
|
|
109
|
+
.card-h-sub { font-size: 12px; color: var(--text-dim); font-family: var(--mono); }
|
|
110
|
+
.card-h-actions { margin-left: auto; display: flex; gap: 6px; }
|
|
111
|
+
.card-b { padding: var(--pad-card); }
|
|
112
|
+
.row { display: flex; align-items: center; }
|
|
113
|
+
.gap-2 { gap: 8px; }
|
|
114
|
+
.mono { font-family: var(--mono); }
|
|
115
|
+
.dim { color: var(--text-dim); }
|
|
116
|
+
|
|
117
|
+
.tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: 20px; }
|
|
118
|
+
.tab { background: none; border: 0; font: inherit; font-size: 13px; font-weight: 500;
|
|
119
|
+
color: var(--text-muted); padding: 10px 12px; cursor: pointer;
|
|
120
|
+
border-bottom: 2px solid transparent; margin-bottom: -1px; }
|
|
121
|
+
.tab:hover { color: var(--text); }
|
|
122
|
+
.tab.active { color: var(--text); border-bottom-color: var(--text); }
|
|
123
|
+
|
|
124
|
+
.page-h { display: flex; align-items: flex-end; justify-content: space-between;
|
|
125
|
+
margin-bottom: 24px; gap: 20px; }
|
|
126
|
+
.page-h h1 { font-size: 24px; font-weight: 600; letter-spacing: -0.4px; margin: 0 0 4px; }
|
|
127
|
+
.page-h .sub { font-size: 13px; color: var(--text-muted); }
|
|
128
|
+
|
|
129
|
+
.overview-grid { display: grid; grid-template-columns: 1.1fr 2fr; gap: 16px; margin-bottom: 16px; }
|
|
130
|
+
.health-card { padding: 24px; }
|
|
131
|
+
.health-row { display: flex; align-items: center; gap: 24px; }
|
|
132
|
+
.health-num { font-size: 64px; font-weight: 600; letter-spacing: -2.5px;
|
|
133
|
+
font-family: var(--mono); line-height: 1; color: var(--text); }
|
|
134
|
+
.health-num .denom { font-size: 22px; color: var(--text-faint); margin-left: 4px; }
|
|
135
|
+
.health-label { font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em;
|
|
136
|
+
color: var(--text-dim); font-weight: 500; margin-bottom: 6px; }
|
|
137
|
+
.health-trend { font-size: 12px; color: var(--ok); font-family: var(--mono);
|
|
138
|
+
display: inline-flex; align-items: center; gap: 4px; margin-top: 4px; }
|
|
139
|
+
.health-dims { display: grid; grid-template-columns: repeat(4,1fr); gap: 0;
|
|
140
|
+
margin-top: 22px; border-top: 1px solid var(--border); padding-top: 18px; }
|
|
141
|
+
.health-dim { padding-right: 12px; }
|
|
142
|
+
.health-dim + .health-dim { border-left: 1px solid var(--border); padding-left: 14px; }
|
|
143
|
+
.health-dim-label { font-size: 11px; color: var(--text-dim); text-transform: uppercase;
|
|
144
|
+
letter-spacing: 0.05em; font-weight: 500; }
|
|
145
|
+
.health-dim-val { font-size: 18px; font-family: var(--mono); font-weight: 500; margin-top: 4px; }
|
|
146
|
+
|
|
147
|
+
.stats-row { display: grid; grid-template-columns: repeat(4,1fr); gap: 0;
|
|
148
|
+
border: 1px solid var(--border); border-radius: var(--radius);
|
|
149
|
+
background: var(--surface); overflow: hidden; margin-bottom: 16px; }
|
|
150
|
+
.stat-cell { padding: 16px 20px; }
|
|
151
|
+
.stat-cell + .stat-cell { border-left: 1px solid var(--border); }
|
|
152
|
+
.stat-label { font-size: 11px; color: var(--text-dim); text-transform: uppercase;
|
|
153
|
+
letter-spacing: 0.06em; font-weight: 500; }
|
|
154
|
+
.stat-val { font-size: 22px; font-weight: 600; font-family: var(--mono);
|
|
155
|
+
letter-spacing: -0.5px; margin-top: 6px; display: flex; align-items: baseline; gap: 6px; }
|
|
156
|
+
.stat-val .unit { font-size: 12px; color: var(--text-dim); font-weight: 400; }
|
|
157
|
+
.stat-delta { font-size: 11.5px; font-family: var(--mono); margin-top: 4px;
|
|
158
|
+
display: inline-flex; align-items: center; gap: 3px; color: var(--text-dim); }
|
|
159
|
+
.stat-delta.up { color: var(--ok); }
|
|
160
|
+
.stat-delta.bad-up { color: var(--danger); }
|
|
161
|
+
.stat-delta.good-down { color: var(--ok); }
|
|
162
|
+
|
|
163
|
+
.two-col { display: grid; grid-template-columns: 1.6fr 1fr; gap: 16px; }
|
|
164
|
+
|
|
165
|
+
.tbl { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
166
|
+
.tbl th { font-weight: 500; font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em;
|
|
167
|
+
color: var(--text-dim); text-align: left; padding: 10px 14px;
|
|
168
|
+
border-bottom: 1px solid var(--border); background: var(--bg-elev); white-space: nowrap; }
|
|
169
|
+
.tbl td { padding: 0 14px; border-bottom: 1px solid var(--border); vertical-align: middle; height: var(--row-h); }
|
|
170
|
+
.tbl tr:last-child td { border-bottom: 0; }
|
|
171
|
+
.tbl tr.click { cursor: pointer; }
|
|
172
|
+
.tbl tr.click:hover td { background: var(--bg-elev); }
|
|
173
|
+
.tbl td.num, .tbl th.num { text-align: right; font-family: var(--mono); }
|
|
174
|
+
|
|
175
|
+
.method { font-family: var(--mono); font-size: 10.5px; font-weight: 600; padding: 2px 6px;
|
|
176
|
+
border-radius: 3px; letter-spacing: 0.02em; display: inline-block; min-width: 44px; text-align: center; }
|
|
177
|
+
.method-GET { background: rgba(37,99,235,0.1); color: #1d4ed8; }
|
|
178
|
+
.method-POST { background: rgba(21,128,61,0.1); color: #15803d; }
|
|
179
|
+
.method-PUT { background: rgba(180,83,9,0.1); color: #b45309; }
|
|
180
|
+
.method-PATCH { background: rgba(124,58,237,0.1); color: #6d28d9; }
|
|
181
|
+
.method-DELETE { background: rgba(185,28,28,0.1); color: #b91c1c; }
|
|
182
|
+
.route { font-family: var(--mono); font-size: 12.5px; }
|
|
183
|
+
|
|
184
|
+
.bar-inline { position: relative; height: 4px; background: var(--bg-sunken);
|
|
185
|
+
border-radius: 2px; overflow: hidden; width: 80px; display: inline-block; vertical-align: middle; }
|
|
186
|
+
.bar-inline > span { position: absolute; inset: 0 auto 0 0; background: var(--accent); border-radius: 2px; }
|
|
187
|
+
|
|
188
|
+
.insight-feed { display: flex; flex-direction: column; gap: 10px; }
|
|
189
|
+
.insight { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
|
|
190
|
+
padding: 14px 16px; display: grid; grid-template-columns: auto 1fr auto;
|
|
191
|
+
gap: 12px; align-items: flex-start; cursor: default; }
|
|
192
|
+
.insight:hover { border-color: var(--border-strong); }
|
|
193
|
+
.insight-ico { width: 28px; height: 28px; border-radius: 6px; display: grid; place-items: center;
|
|
194
|
+
flex-shrink: 0; font-family: var(--mono); font-size: 11px; font-weight: 600; }
|
|
195
|
+
.insight-PERF { background: var(--warn-soft); color: var(--warn); }
|
|
196
|
+
.insight-DRIFT { background: var(--danger-soft); color: var(--danger); }
|
|
197
|
+
.insight-DEAD { background: rgba(82,82,82,0.08); color: var(--neutral); }
|
|
198
|
+
.insight-UNTRACKED { background: rgba(82,82,82,0.08); color: var(--neutral); }
|
|
199
|
+
.insight-ANOMALY { background: var(--info-soft); color: var(--info); }
|
|
200
|
+
.insight-OK { background: var(--ok-soft); color: var(--ok); }
|
|
201
|
+
.insight-title { font-size: 13px; font-weight: 500; color: var(--text); line-height: 1.5; margin-bottom: 4px; }
|
|
202
|
+
.insight-meta { display: flex; align-items: center; gap: 10px; font-size: 11.5px;
|
|
203
|
+
color: var(--text-dim); font-family: var(--mono); }
|
|
204
|
+
.insight-meta .sep { color: var(--text-faint); }
|
|
205
|
+
.insight-action { font-size: 11.5px; color: var(--text-dim); font-family: var(--mono); white-space: nowrap; }
|
|
206
|
+
.insight-group { margin-bottom: 24px; }
|
|
207
|
+
.insight-group-h { display: flex; align-items: center; gap: 10px; margin: 0 0 10px;
|
|
208
|
+
font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-dim); font-weight: 500; }
|
|
209
|
+
.insight-group-h .cnt { font-family: var(--mono); color: var(--text-faint); }
|
|
210
|
+
|
|
211
|
+
.chart-wrap { padding: 8px 16px 14px; }
|
|
212
|
+
.chart-legend { display: flex; gap: 14px; padding: 0 18px 6px; font-size: 11.5px; color: var(--text-muted); }
|
|
213
|
+
.chart-legend .dot { width: 8px; height: 2px; display: inline-block; margin-right: 6px; vertical-align: middle; }
|
|
214
|
+
.chart-tip { position: absolute; background: var(--text); color: white; font-family: var(--mono);
|
|
215
|
+
font-size: 11px; padding: 5px 8px; border-radius: 4px; pointer-events: none;
|
|
216
|
+
transform: translate(-50%, -120%); white-space: nowrap; z-index: 5; }
|
|
217
|
+
.chart-tip::after { content: ''; position: absolute; bottom: -3px; left: 50%;
|
|
218
|
+
width: 6px; height: 6px; background: var(--text); transform: translateX(-50%) rotate(45deg); }
|
|
219
|
+
|
|
220
|
+
.release-list { display: flex; flex-direction: column; gap: 1px; }
|
|
221
|
+
.release-row { display: grid; grid-template-columns: 16px 110px 1fr auto auto;
|
|
222
|
+
align-items: center; gap: 14px; padding: 14px 16px;
|
|
223
|
+
border-bottom: 1px solid var(--border); cursor: pointer; }
|
|
224
|
+
.release-row:hover { background: var(--bg-elev); }
|
|
225
|
+
.release-row.selected { background: var(--accent-soft); }
|
|
226
|
+
.release-row:last-child { border-bottom: 0; }
|
|
227
|
+
.release-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--text);
|
|
228
|
+
border: 2px solid var(--bg); box-shadow: 0 0 0 1px var(--border-strong); }
|
|
229
|
+
.release-row.selected .release-dot { background: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
|
|
230
|
+
.release-tag { font-family: var(--mono); font-size: 12.5px; font-weight: 500; }
|
|
231
|
+
.release-meta { display: flex; gap: 12px; font-size: 12px; color: var(--text-dim); font-family: var(--mono); }
|
|
232
|
+
.release-badge { font-family: var(--mono); font-size: 10.5px; text-transform: uppercase;
|
|
233
|
+
padding: 2px 6px; border-radius: 3px; font-weight: 500; letter-spacing: 0.04em; }
|
|
234
|
+
.rb-ok { background: var(--ok-soft); color: var(--ok); }
|
|
235
|
+
.rb-warn { background: var(--warn-soft); color: var(--warn); }
|
|
236
|
+
.rb-bad { background: var(--danger-soft); color: var(--danger); }
|
|
237
|
+
|
|
238
|
+
.compare-deltas { display: grid; grid-template-columns: repeat(3,1fr); border-top: 1px solid var(--border); }
|
|
239
|
+
.compare-delta { padding: 14px 18px; }
|
|
240
|
+
.compare-delta + .compare-delta { border-left: 1px solid var(--border); }
|
|
241
|
+
|
|
242
|
+
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.45);
|
|
243
|
+
backdrop-filter: blur(4px); z-index: 100; display: grid; place-items: center; padding: 24px; }
|
|
244
|
+
.modal { background: var(--surface); border-radius: 12px; box-shadow: var(--shadow-lg);
|
|
245
|
+
width: 100%; max-width: 680px; overflow: hidden; display: flex; flex-direction: column; max-height: 90vh; }
|
|
246
|
+
.modal-h { padding: 20px 24px 16px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 12px; }
|
|
247
|
+
.modal-b { padding: 24px; overflow-y: auto; }
|
|
248
|
+
.modal-b::-webkit-scrollbar { width: 8px; }
|
|
249
|
+
.modal-b::-webkit-scrollbar-thumb { background: var(--bg-sunken); border-radius: 4px; }
|
|
250
|
+
.modal-f { padding: 14px 24px; border-top: 1px solid var(--border); display: flex; gap: 8px; justify-content: space-between; }
|
|
251
|
+
|
|
252
|
+
.code { background: #0a0a0a; color: #e4e4e7; border-radius: 8px; padding: 14px 16px;
|
|
253
|
+
font-family: var(--mono); font-size: 12.5px; line-height: 1.6; overflow-x: auto; position: relative; }
|
|
254
|
+
.code-copy { position: absolute; top: 8px; right: 8px; background: rgba(255,255,255,0.08);
|
|
255
|
+
border: 0; color: #e4e4e7; font: inherit; font-family: var(--mono); font-size: 11px;
|
|
256
|
+
padding: 3px 8px; border-radius: 4px; cursor: pointer; }
|
|
257
|
+
.code-copy:hover { background: rgba(255,255,255,0.16); }
|
|
258
|
+
|
|
259
|
+
.onb-step { display: flex; gap: 14px; margin-bottom: 22px; }
|
|
260
|
+
.onb-num { width: 26px; height: 26px; border-radius: 50%; background: var(--bg-sunken);
|
|
261
|
+
display: grid; place-items: center; font-family: var(--mono); font-size: 12px;
|
|
262
|
+
font-weight: 600; color: var(--text-muted); flex-shrink: 0; }
|
|
263
|
+
.onb-step.done .onb-num { background: var(--ok); color: white; }
|
|
264
|
+
.onb-step.active .onb-num { background: var(--text); color: white; }
|
|
265
|
+
.onb-content { flex: 1; }
|
|
266
|
+
.onb-title { font-size: 14px; font-weight: 600; margin-bottom: 4px; }
|
|
267
|
+
.onb-desc { font-size: 12.5px; color: var(--text-muted); margin-bottom: 10px; }
|
|
268
|
+
.waiting { display: flex; align-items: center; gap: 10px; font-size: 13px; color: var(--text-muted);
|
|
269
|
+
padding: 12px 14px; background: var(--bg-elev); border: 1px dashed var(--border-strong);
|
|
270
|
+
border-radius: 6px; font-family: var(--mono); }
|
|
271
|
+
.waiting.live { border-style: solid; border-color: var(--ok); background: var(--ok-soft); color: var(--ok); }
|
|
272
|
+
.spinner { width: 12px; height: 12px; border: 2px solid var(--border-strong);
|
|
273
|
+
border-top-color: var(--text-muted); border-radius: 50%; animation: spin 0.8s linear infinite; display: inline-block; }
|
|
274
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
275
|
+
|
|
276
|
+
.settings-list { display: flex; flex-direction: column; }
|
|
277
|
+
.setting-row { display: grid; grid-template-columns: 1fr auto; gap: 24px;
|
|
278
|
+
padding: 18px 20px; border-bottom: 1px solid var(--border); align-items: center; }
|
|
279
|
+
.setting-row:last-child { border-bottom: 0; }
|
|
280
|
+
.setting-title { font-size: 13px; font-weight: 500; margin-bottom: 3px; }
|
|
281
|
+
.setting-desc { font-size: 12px; color: var(--text-muted); max-width: 460px; line-height: 1.5; }
|
|
282
|
+
.toggle { position: relative; width: 34px; height: 20px; background: var(--bg-sunken);
|
|
283
|
+
border: 1px solid var(--border-strong); border-radius: 999px; cursor: pointer; transition: background 0.15s; }
|
|
284
|
+
.toggle::after { content: ''; position: absolute; top: 1px; left: 1px; width: 16px; height: 16px;
|
|
285
|
+
border-radius: 50%; background: white; box-shadow: 0 1px 2px rgba(0,0,0,0.15); transition: transform 0.15s; }
|
|
286
|
+
.toggle.on { background: var(--text); border-color: var(--text); }
|
|
287
|
+
.toggle.on::after { transform: translateX(14px); }
|
|
288
|
+
input.fld { background: var(--surface); border: 1px solid var(--border); border-radius: 6px;
|
|
289
|
+
padding: 6px 10px; font: inherit; font-size: 12.5px; font-family: var(--mono); color: var(--text); }
|
|
290
|
+
input.fld:focus { outline: 2px solid var(--accent-line); border-color: var(--accent); }
|
|
291
|
+
|
|
292
|
+
.filter-bar { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; }
|
|
293
|
+
.search { position: relative; flex: 1; max-width: 360px; }
|
|
294
|
+
.search input { width: 100%; background: var(--surface); border: 1px solid var(--border);
|
|
295
|
+
border-radius: 6px; padding: 7px 10px 7px 32px; font: inherit; font-size: 12.5px; color: var(--text); }
|
|
296
|
+
.search input:focus { outline: 2px solid var(--accent-line); border-color: var(--accent); }
|
|
297
|
+
.search::before { content: ''; position: absolute; left: 10px; top: 50%; width: 13px; height: 13px;
|
|
298
|
+
transform: translateY(-50%);
|
|
299
|
+
background: url("data:image/svg+xml,%3Csvg width='14' height='14' viewBox='0 0 14 14' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='6' cy='6' r='4.5' stroke='%23a3a3a3' stroke-width='1.4' fill='none'/%3E%3Cpath d='M9.5 9.5 L12 12' stroke='%23a3a3a3' stroke-width='1.4' stroke-linecap='round'/%3E%3C/svg%3E"); }
|
|
300
|
+
.chips { display: flex; gap: 6px; flex-wrap: wrap; }
|
|
301
|
+
.chip { font-size: 12px; padding: 4px 10px; border-radius: 999px; border: 1px solid var(--border);
|
|
302
|
+
background: var(--surface); cursor: pointer; color: var(--text-muted); font-weight: 500; }
|
|
303
|
+
.chip:hover { background: var(--bg-elev); }
|
|
304
|
+
.chip.on { background: var(--text); color: white; border-color: var(--text); }
|
|
305
|
+
|
|
306
|
+
.status-dist { display: flex; height: 8px; border-radius: 2px; overflow: hidden; width: 100%; background: var(--bg-sunken); }
|
|
307
|
+
.status-dist > span { display: block; }
|
|
308
|
+
.s2xx { background: var(--ok); }
|
|
309
|
+
.s4xx { background: var(--warn); }
|
|
310
|
+
.s5xx { background: var(--danger); }
|
|
311
|
+
|
|
312
|
+
.empty-state { text-align: center; padding: 48px 24px; color: var(--text-dim); }
|
|
313
|
+
</style>
|
|
314
|
+
</head>
|
|
315
|
+
<body>
|
|
316
|
+
<div id="root"></div>
|
|
317
|
+
|
|
318
|
+
<!-- React UMD served from node_modules by apiforgejs dashboard server -->
|
|
319
|
+
<script src="/assets/react.js"></script>
|
|
320
|
+
<script src="/assets/react-dom.js"></script>
|
|
321
|
+
<!-- Babel standalone for in-browser JSX transpilation -->
|
|
322
|
+
<script src="https://unpkg.com/@babel/standalone@7/babel.min.js" crossorigin></script>
|
|
323
|
+
|
|
324
|
+
<script type="text/babel" data-presets="react">
|
|
325
|
+
'use strict';
|
|
326
|
+
const { useState, useEffect, useRef, useMemo } = React;
|
|
327
|
+
|
|
328
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
329
|
+
const TIME_HOURS = { '1h': 1, '24h': 24, '7d': 168, '30d': 720 };
|
|
330
|
+
|
|
331
|
+
function fmtNum(n) {
|
|
332
|
+
if (n == null || isNaN(n)) return '—';
|
|
333
|
+
if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
|
|
334
|
+
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k';
|
|
335
|
+
return String(Math.round(n));
|
|
336
|
+
}
|
|
337
|
+
function fmtMs(n) { return n == null ? '—' : Math.round(n) + 'ms'; }
|
|
338
|
+
function fmtPct(n) { return n == null ? '—' : (n * 100).toFixed(2) + '%'; }
|
|
339
|
+
|
|
340
|
+
function formatAge(ts) {
|
|
341
|
+
const diff = Math.floor(Date.now() / 1000) - ts;
|
|
342
|
+
if (diff < 60) return 'just now';
|
|
343
|
+
if (diff < 3600) return `${Math.round(diff / 60)}m ago`;
|
|
344
|
+
if (diff < 86400) return `${Math.round(diff / 3600)}h ago`;
|
|
345
|
+
return `${Math.round(diff / 86400)}d ago`;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function seededRng(seed) {
|
|
349
|
+
let s = Math.abs(Math.round(seed)) % 2147483647 || 1;
|
|
350
|
+
return () => { s = (s * 16807) % 2147483647; return (s - 1) / 2147483646; };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function genSeries(ep, points, { seed = 0 } = {}) {
|
|
354
|
+
const rng = seededRng((ep.base_p90 || 100) * 100 + seed * 997);
|
|
355
|
+
const base = ep.base_p90 || 50;
|
|
356
|
+
return Array.from({ length: points }, (_, i) => {
|
|
357
|
+
const noise = (rng() - 0.5) * 0.35;
|
|
358
|
+
const wave = Math.sin((i / points) * Math.PI * 2) * 0.08;
|
|
359
|
+
return Math.max(1, base * (1 + noise + wave));
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
function genCallSeries(ep, points) {
|
|
363
|
+
const rng = seededRng((ep.calls24h || 100) * 13);
|
|
364
|
+
const base = (ep.calls24h || 0) / points;
|
|
365
|
+
return Array.from({ length: points }, () => Math.max(0, Math.round(base * (0.5 + rng()))));
|
|
366
|
+
}
|
|
367
|
+
function genErrorSeries(ep, points) {
|
|
368
|
+
return genCallSeries(ep, points).map(c => Math.round(c * (ep.err || 0)));
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function tsBucketsToChart(ts, hours) {
|
|
372
|
+
if (!ts || ts.length === 0) return null;
|
|
373
|
+
const fmt = t => {
|
|
374
|
+
const d = new Date(t * 1000);
|
|
375
|
+
if (hours <= 1) return `${d.getHours()}:${String(d.getMinutes()).padStart(2,'0')}`;
|
|
376
|
+
if (hours <= 24) return `${d.getHours()}h`;
|
|
377
|
+
if (hours <= 168) return ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][d.getDay()];
|
|
378
|
+
return `${d.getMonth()+1}/${d.getDate()}`;
|
|
379
|
+
};
|
|
380
|
+
return {
|
|
381
|
+
p50: ts.map(b => b.p50 || 0),
|
|
382
|
+
p90: ts.map(b => b.p90 || 0),
|
|
383
|
+
p99: ts.map(b => b.p99 || 0),
|
|
384
|
+
calls: ts.map(b => b.calls || 0),
|
|
385
|
+
errors: ts.map(b => b.errors || 0),
|
|
386
|
+
labels: ts.map(b => fmt(b.bucket_ts)),
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// ─── Data mapping ─────────────────────────────────────────────────────────────
|
|
391
|
+
function mapEndpoints(routes) {
|
|
392
|
+
return routes.map(r => ({
|
|
393
|
+
id: `${r.method}|${r.route}`,
|
|
394
|
+
method: r.method,
|
|
395
|
+
route: r.route,
|
|
396
|
+
calls24h: r.calls || 0,
|
|
397
|
+
base_p50: Math.round(r.p50 || 0),
|
|
398
|
+
base_p90: Math.round(r.p90 || 0),
|
|
399
|
+
base_p99: Math.round(r.p99 || 0),
|
|
400
|
+
err: r.calls > 0 ? ((r.calls_4xx || 0) + (r.calls_5xx || 0)) / r.calls : 0,
|
|
401
|
+
calls_2xx: r.calls_2xx || 0,
|
|
402
|
+
calls_4xx: r.calls_4xx || 0,
|
|
403
|
+
calls_5xx: r.calls_5xx || 0,
|
|
404
|
+
untracked: r.untracked || false,
|
|
405
|
+
drift: 1,
|
|
406
|
+
}));
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function mapInsights(insights) {
|
|
410
|
+
const sevMap = { error: 'high', warning: 'med', info: 'low', success: 'info' };
|
|
411
|
+
return (insights || []).map((ins, i) => ({
|
|
412
|
+
id: `i${i}`,
|
|
413
|
+
type: ins.type || 'OK',
|
|
414
|
+
severity: sevMap[ins.severity] || 'info',
|
|
415
|
+
title: ins.message || '',
|
|
416
|
+
endpoint: ins.route ? `${ins.method} ${ins.route}` : '',
|
|
417
|
+
endpoint_id: ins.route ? `${ins.method}|${ins.route}` : null,
|
|
418
|
+
meta: [],
|
|
419
|
+
ts: 'just now',
|
|
420
|
+
}));
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function mapReleases(releases) {
|
|
424
|
+
return (releases || []).map(r => ({
|
|
425
|
+
tag: r.release_tag,
|
|
426
|
+
summary: `${r.routes_affected || 0} route${r.routes_affected !== 1 ? 's' : ''} recorded`,
|
|
427
|
+
age: formatAge(r.release_ts),
|
|
428
|
+
by: 'local',
|
|
429
|
+
status: 'ok',
|
|
430
|
+
delta_p90: 0, delta_err: 0, delta_calls: 0,
|
|
431
|
+
}));
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// ─── Icons ────────────────────────────────────────────────────────────────────
|
|
435
|
+
const I = {
|
|
436
|
+
dashboard: () => (<svg className="ico" viewBox="0 0 16 16" fill="none"><rect x="2" y="2" width="5.5" height="6" rx="1" stroke="currentColor" strokeWidth="1.4"/><rect x="2" y="9.5" width="5.5" height="4.5" rx="1" stroke="currentColor" strokeWidth="1.4"/><rect x="8.5" y="2" width="5.5" height="3.5" rx="1" stroke="currentColor" strokeWidth="1.4"/><rect x="8.5" y="7" width="5.5" height="7" rx="1" stroke="currentColor" strokeWidth="1.4"/></svg>),
|
|
437
|
+
endpoints: () => (<svg className="ico" viewBox="0 0 16 16" fill="none"><path d="M2 4h12M2 8h12M2 12h12" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round"/><circle cx="4" cy="4" r="1.2" fill="currentColor"/><circle cx="6" cy="8" r="1.2" fill="currentColor"/><circle cx="3.5" cy="12" r="1.2" fill="currentColor"/></svg>),
|
|
438
|
+
insights: () => (<svg className="ico" viewBox="0 0 16 16" fill="none"><path d="M8 1.5L9.7 5.8L14 6.2L10.7 9.1L11.7 13.4L8 11.2L4.3 13.4L5.3 9.1L2 6.2L6.3 5.8L8 1.5Z" stroke="currentColor" strokeWidth="1.3" strokeLinejoin="round"/></svg>),
|
|
439
|
+
releases: () => (<svg className="ico" viewBox="0 0 16 16" fill="none"><path d="M8 1.5V10M8 10L4.5 6.5M8 10L11.5 6.5M3 13h10" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"/></svg>),
|
|
440
|
+
settings: () => (<svg className="ico" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="2.2" stroke="currentColor" strokeWidth="1.3"/><path d="M8 2v1.5M8 12.5V14M14 8h-1.5M3.5 8H2M12.2 3.8l-1 1M4.8 11.2l-1 1M12.2 12.2l-1-1M4.8 4.8l-1-1" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round"/></svg>),
|
|
441
|
+
arrowUp: () => (<svg width="9" height="9" viewBox="0 0 9 9" fill="none" style={{verticalAlign:'middle'}}><path d="M4.5 7.5V2M4.5 2L2 4.5M4.5 2L7 4.5" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round"/></svg>),
|
|
442
|
+
arrowDown: () => (<svg width="9" height="9" viewBox="0 0 9 9" fill="none" style={{verticalAlign:'middle'}}><path d="M4.5 1.5V7M4.5 7L2 4.5M4.5 7L7 4.5" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round"/></svg>),
|
|
443
|
+
arrowR: () => (<svg width="10" height="10" viewBox="0 0 10 10" fill="none"><path d="M2 5h6M8 5L5.5 2.5M8 5L5.5 7.5" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round"/></svg>),
|
|
444
|
+
external: () => (<svg width="11" height="11" viewBox="0 0 12 12" fill="none"><path d="M5 2H10V7M10 2L5.5 6.5M9 7v3H2V3h3" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round"/></svg>),
|
|
445
|
+
close: () => (<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M3 3L9 9M9 3L3 9" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round"/></svg>),
|
|
446
|
+
check: () => (<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M2.5 6.5L5 9L9.5 3.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>),
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
// ─── Shell ────────────────────────────────────────────────────────────────────
|
|
450
|
+
function Sidebar({ route, setRoute, onOpenOnboarding, counts }) {
|
|
451
|
+
const items = [
|
|
452
|
+
{ id: 'overview', label: 'Overview', ico: I.dashboard },
|
|
453
|
+
{ id: 'endpoints', label: 'Endpoints', ico: I.endpoints, badge: counts.endpoints },
|
|
454
|
+
{ id: 'insights', label: 'Insights', ico: I.insights, badge: counts.insights },
|
|
455
|
+
{ id: 'releases', label: 'Releases', ico: I.releases, badge: counts.releases },
|
|
456
|
+
{ id: 'settings', label: 'Settings', ico: I.settings },
|
|
457
|
+
];
|
|
458
|
+
return (
|
|
459
|
+
<aside className="sidebar">
|
|
460
|
+
<div className="sb-brand">
|
|
461
|
+
<div className="sb-logo">AF</div>
|
|
462
|
+
<div className="sb-name">APIForge</div>
|
|
463
|
+
<div className="sb-mode">local</div>
|
|
464
|
+
</div>
|
|
465
|
+
<div className="sb-section-label">Workspace</div>
|
|
466
|
+
<nav className="sb-nav">
|
|
467
|
+
{items.map(it => (
|
|
468
|
+
<div key={it.id}
|
|
469
|
+
className={`sb-item ${route === it.id || (route === 'endpoint' && it.id === 'endpoints') ? 'active' : ''}`}
|
|
470
|
+
onClick={() => setRoute(it.id)}>
|
|
471
|
+
<it.ico />
|
|
472
|
+
<span>{it.label}</span>
|
|
473
|
+
{it.badge != null && it.badge > 0 && <span className="badge">{it.badge}</span>}
|
|
474
|
+
</div>
|
|
475
|
+
))}
|
|
476
|
+
</nav>
|
|
477
|
+
<div className="sb-spacer" />
|
|
478
|
+
<div className="sb-footer">
|
|
479
|
+
<div className="sb-recording">
|
|
480
|
+
<span className="dot-pulse"></span>
|
|
481
|
+
<span>Recording</span>
|
|
482
|
+
</div>
|
|
483
|
+
<button className="btn btn-ghost"
|
|
484
|
+
style={{justifyContent:'flex-start',padding:'5px 6px',fontSize:12,color:'var(--text-muted)'}}
|
|
485
|
+
onClick={onOpenOnboarding}>
|
|
486
|
+
<I.external />
|
|
487
|
+
<span>Connect a service</span>
|
|
488
|
+
</button>
|
|
489
|
+
</div>
|
|
490
|
+
</aside>
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function useAgo(ts) {
|
|
495
|
+
const [, tick] = useState(0);
|
|
496
|
+
useEffect(() => {
|
|
497
|
+
if (!ts) return;
|
|
498
|
+
const id = setInterval(() => tick(n => n + 1), 5000);
|
|
499
|
+
return () => clearInterval(id);
|
|
500
|
+
}, [ts]);
|
|
501
|
+
if (!ts) return null;
|
|
502
|
+
const s = Math.round((Date.now() - ts) / 1000);
|
|
503
|
+
if (s < 5) return 'just now';
|
|
504
|
+
if (s < 60) return `${s}s ago`;
|
|
505
|
+
return `${Math.round(s / 60)}m ago`;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function Topbar({ route, params, setRoute, timeRange, setTimeRange, env, setEnv, getEndpoint, lastUpdated, onRefresh }) {
|
|
509
|
+
const titles = { overview:'Overview', endpoints:'Endpoints', insights:'Insights', releases:'Releases', settings:'Settings' };
|
|
510
|
+
const ep = route === 'endpoint' ? getEndpoint(params.id) : null;
|
|
511
|
+
const ago = useAgo(lastUpdated);
|
|
512
|
+
return (
|
|
513
|
+
<header className="topbar">
|
|
514
|
+
{route === 'endpoint' ? (
|
|
515
|
+
<div className="topbar-crumbs">
|
|
516
|
+
<button onClick={() => setRoute('endpoints')}>Endpoints</button>
|
|
517
|
+
<span className="sep">/</span>
|
|
518
|
+
<span className="current">
|
|
519
|
+
{ep && <span className={`method method-${ep.method}`} style={{marginRight:6}}>{ep.method}</span>}
|
|
520
|
+
{ep?.route || '…'}
|
|
521
|
+
</span>
|
|
522
|
+
</div>
|
|
523
|
+
) : (
|
|
524
|
+
<div className="topbar-title">{titles[route] || ''}</div>
|
|
525
|
+
)}
|
|
526
|
+
<div className="topbar-spacer" />
|
|
527
|
+
{ago && (
|
|
528
|
+
<button onClick={onRefresh} title="Refresh now" style={{
|
|
529
|
+
display:'flex', alignItems:'center', gap:5, padding:'4px 8px',
|
|
530
|
+
background:'none', border:'1px solid var(--border)', borderRadius:'var(--radius-sm)',
|
|
531
|
+
cursor:'pointer', color:'var(--text-muted)', fontSize:11,
|
|
532
|
+
}}>
|
|
533
|
+
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
534
|
+
<path d="M1 4s1-3 7-3a7 7 0 1 1-6.9 8"/><path d="M1 1v3h3"/>
|
|
535
|
+
</svg>
|
|
536
|
+
{ago}
|
|
537
|
+
</button>
|
|
538
|
+
)}
|
|
539
|
+
{route !== 'settings' && route !== 'releases' && (
|
|
540
|
+
<div className="segmented">
|
|
541
|
+
{['1h','24h','7d','30d'].map(r => (
|
|
542
|
+
<button key={r} className={timeRange === r ? 'active' : ''} onClick={() => setTimeRange(r)}>{r}</button>
|
|
543
|
+
))}
|
|
544
|
+
</div>
|
|
545
|
+
)}
|
|
546
|
+
{route !== 'settings' && (
|
|
547
|
+
<select className="select" value={env} onChange={e => setEnv(e.target.value)}>
|
|
548
|
+
<option value="production">production</option>
|
|
549
|
+
<option value="staging">staging</option>
|
|
550
|
+
<option value="development">development</option>
|
|
551
|
+
</select>
|
|
552
|
+
)}
|
|
553
|
+
</header>
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function Modal({ children, onClose, max = 680 }) {
|
|
558
|
+
return (
|
|
559
|
+
<div className="modal-overlay" onClick={e => { if (e.target === e.currentTarget) onClose?.(); }}>
|
|
560
|
+
<div className="modal" style={{ maxWidth: max }}>{children}</div>
|
|
561
|
+
</div>
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function CodeBlock({ children }) {
|
|
566
|
+
const [copied, setCopied] = useState(false);
|
|
567
|
+
return (
|
|
568
|
+
<div className="code">
|
|
569
|
+
<button className="code-copy" onClick={() => {
|
|
570
|
+
navigator.clipboard?.writeText(typeof children === 'string' ? children : '');
|
|
571
|
+
setCopied(true); setTimeout(() => setCopied(false), 1200);
|
|
572
|
+
}}>{copied ? '✓ COPIED' : 'COPY'}</button>
|
|
573
|
+
<pre style={{margin:0,fontFamily:'inherit',whiteSpace:'pre-wrap'}}>{children}</pre>
|
|
574
|
+
</div>
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// ─── Charts ───────────────────────────────────────────────────────────────────
|
|
579
|
+
function LineChart({ series, height = 220, padding = {top:16,right:16,bottom:24,left:36}, xLabels, markers = [], formatY = v => `${Math.round(v)}ms` }) {
|
|
580
|
+
const wrapRef = useRef(null);
|
|
581
|
+
const [w, setW] = useState(600);
|
|
582
|
+
const [hover, setHover] = useState(null);
|
|
583
|
+
useEffect(() => {
|
|
584
|
+
if (!wrapRef.current) return;
|
|
585
|
+
const ro = new ResizeObserver(() => wrapRef.current && setW(wrapRef.current.clientWidth));
|
|
586
|
+
ro.observe(wrapRef.current);
|
|
587
|
+
setW(wrapRef.current.clientWidth);
|
|
588
|
+
return () => ro.disconnect();
|
|
589
|
+
}, []);
|
|
590
|
+
const innerW = Math.max(50, w - padding.left - padding.right);
|
|
591
|
+
const innerH = height - padding.top - padding.bottom;
|
|
592
|
+
const allVals = series.flatMap(s => s.data).filter(v => v != null && isFinite(v));
|
|
593
|
+
if (allVals.length === 0) return (
|
|
594
|
+
<div style={{height,display:'flex',alignItems:'center',justifyContent:'center',color:'var(--text-faint)',fontSize:12}}>
|
|
595
|
+
No data for this period
|
|
596
|
+
</div>
|
|
597
|
+
);
|
|
598
|
+
const maxY = Math.max(...allVals, 1) * 1.1;
|
|
599
|
+
const N = series[0]?.data.length || 0;
|
|
600
|
+
if (N < 2) return <div style={{height,display:'flex',alignItems:'center',justifyContent:'center',color:'var(--text-faint)',fontSize:12}}>Insufficient data</div>;
|
|
601
|
+
const x = i => padding.left + (i / Math.max(N - 1, 1)) * innerW;
|
|
602
|
+
const y = v => padding.top + innerH - ((v - 0) / maxY) * innerH;
|
|
603
|
+
const gridVals = [0, 0.25, 0.5, 0.75, 1].map(f => f * maxY);
|
|
604
|
+
function pathFor(data) {
|
|
605
|
+
return data.map((v, i) => `${i === 0 ? 'M' : 'L'} ${x(i).toFixed(1)} ${y(v).toFixed(1)}`).join(' ');
|
|
606
|
+
}
|
|
607
|
+
function areaFor(data) {
|
|
608
|
+
const top = data.map((v, i) => `${i === 0 ? 'M' : 'L'} ${x(i).toFixed(1)} ${y(v).toFixed(1)}`).join(' ');
|
|
609
|
+
return `${top} L ${x(N-1).toFixed(1)} ${(padding.top+innerH).toFixed(1)} L ${x(0).toFixed(1)} ${(padding.top+innerH).toFixed(1)} Z`;
|
|
610
|
+
}
|
|
611
|
+
function onMove(e) {
|
|
612
|
+
const rect = e.currentTarget.getBoundingClientRect();
|
|
613
|
+
const px = e.clientX - rect.left;
|
|
614
|
+
const idx = Math.max(0, Math.min(N - 1, Math.round(((px - padding.left) / innerW) * (N - 1))));
|
|
615
|
+
setHover({ idx, px: x(idx) });
|
|
616
|
+
}
|
|
617
|
+
return (
|
|
618
|
+
<div ref={wrapRef} style={{position:'relative',width:'100%'}}>
|
|
619
|
+
<svg width={w} height={height} onMouseMove={onMove} onMouseLeave={() => setHover(null)} style={{display:'block'}}>
|
|
620
|
+
{gridVals.map((v, i) => (
|
|
621
|
+
<g key={i}>
|
|
622
|
+
<line x1={padding.left} x2={w-padding.right} y1={y(v)} y2={y(v)} stroke="#ececec" strokeWidth="1"/>
|
|
623
|
+
<text x={padding.left-8} y={y(v)+3} fontSize="10" fontFamily="var(--mono)" fill="#a3a3a3" textAnchor="end">{formatY(v)}</text>
|
|
624
|
+
</g>
|
|
625
|
+
))}
|
|
626
|
+
{xLabels && xLabels.map((lbl, i) => {
|
|
627
|
+
if (i % Math.ceil(xLabels.length / 6) !== 0 && i !== xLabels.length - 1) return null;
|
|
628
|
+
return <text key={i} x={x(i)} y={height-6} fontSize="10" fontFamily="var(--mono)" fill="#a3a3a3" textAnchor="middle">{lbl}</text>;
|
|
629
|
+
})}
|
|
630
|
+
{markers.map((m, i) => {
|
|
631
|
+
const mx = x(m.idx);
|
|
632
|
+
return (
|
|
633
|
+
<g key={i}>
|
|
634
|
+
<line x1={mx} x2={mx} y1={padding.top} y2={padding.top+innerH} stroke={m.color||'#525252'} strokeDasharray="3 3" strokeWidth="1"/>
|
|
635
|
+
<rect x={mx-22} y={padding.top-2} width={44} height={14} rx={3} fill={m.color||'#525252'}/>
|
|
636
|
+
<text x={mx} y={padding.top+8} fontSize="9.5" fontFamily="var(--mono)" fill="white" textAnchor="middle" fontWeight="500">{m.label}</text>
|
|
637
|
+
</g>
|
|
638
|
+
);
|
|
639
|
+
})}
|
|
640
|
+
{series[0]?.area && <path d={areaFor(series[0].data)} fill={series[0].color} opacity="0.07"/>}
|
|
641
|
+
{series.map((s, i) => (
|
|
642
|
+
<path key={i} d={pathFor(s.data)} stroke={s.color} strokeWidth={s.width||1.5} fill="none"
|
|
643
|
+
strokeLinejoin="round" strokeLinecap="round" strokeDasharray={s.dashed?'3 3':''}/>
|
|
644
|
+
))}
|
|
645
|
+
{hover && (
|
|
646
|
+
<g>
|
|
647
|
+
<line x1={hover.px} x2={hover.px} y1={padding.top} y2={padding.top+innerH} stroke="#0a0a0a" strokeWidth="1" opacity="0.4"/>
|
|
648
|
+
{series.map((s, i) => <circle key={i} cx={hover.px} cy={y(s.data[hover.idx])} r="3" fill="white" stroke={s.color} strokeWidth="1.5"/>)}
|
|
649
|
+
</g>
|
|
650
|
+
)}
|
|
651
|
+
</svg>
|
|
652
|
+
{hover && series[0] && (
|
|
653
|
+
<div className="chart-tip" style={{left:hover.px, top:y(series[0].data[hover.idx])}}>
|
|
654
|
+
{series.map(s => `${s.name}: ${formatY(s.data[hover.idx])}`).join(' · ')}
|
|
655
|
+
{xLabels?.[hover.idx] ? ` ${xLabels[hover.idx]}` : ''}
|
|
656
|
+
</div>
|
|
657
|
+
)}
|
|
658
|
+
</div>
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function Sparkline({ data, width = 80, height = 24, color = '#2563eb', stroke = 1.4 }) {
|
|
663
|
+
const d = (data || []).filter(v => isFinite(v));
|
|
664
|
+
if (d.length < 2) return <svg width={width} height={height} style={{display:'block'}}/>;
|
|
665
|
+
const max = Math.max(...d, 1), min = Math.min(...d, 0);
|
|
666
|
+
const range = Math.max(max - min, 1);
|
|
667
|
+
const xi = i => (i / Math.max(d.length - 1, 1)) * width;
|
|
668
|
+
const yi = v => height - 2 - ((v - min) / range) * (height - 4);
|
|
669
|
+
const path = d.map((v, i) => `${i === 0 ? 'M' : 'L'} ${xi(i).toFixed(1)} ${yi(v).toFixed(1)}`).join(' ');
|
|
670
|
+
return (
|
|
671
|
+
<svg width={width} height={height} style={{display:'block'}}>
|
|
672
|
+
<path d={path} fill="none" stroke={color} strokeWidth={stroke} strokeLinejoin="round" strokeLinecap="round"/>
|
|
673
|
+
</svg>
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function StatusStackChart({ data, height = 200 }) {
|
|
678
|
+
const wrapRef = useRef(null);
|
|
679
|
+
const [w, setW] = useState(600);
|
|
680
|
+
useEffect(() => {
|
|
681
|
+
if (!wrapRef.current) return;
|
|
682
|
+
const ro = new ResizeObserver(() => wrapRef.current && setW(wrapRef.current.clientWidth));
|
|
683
|
+
ro.observe(wrapRef.current);
|
|
684
|
+
setW(wrapRef.current.clientWidth);
|
|
685
|
+
return () => ro.disconnect();
|
|
686
|
+
}, []);
|
|
687
|
+
const pad = {top:12,right:16,bottom:24,left:36};
|
|
688
|
+
const innerW = Math.max(50, w - pad.left - pad.right);
|
|
689
|
+
const innerH = height - pad.top - pad.bottom;
|
|
690
|
+
const totals = data.s2xx.map((_, i) => data.s2xx[i] + data.s4xx[i] + data.s5xx[i]);
|
|
691
|
+
const max = Math.max(...totals, 1);
|
|
692
|
+
const N = totals.length;
|
|
693
|
+
if (N < 2) return <div style={{height,display:'flex',alignItems:'center',justifyContent:'center',color:'var(--text-faint)',fontSize:12}}>No data</div>;
|
|
694
|
+
const x = i => pad.left + (i / Math.max(N-1,1)) * innerW;
|
|
695
|
+
const ys = v => pad.top + innerH - (v / max) * innerH;
|
|
696
|
+
function stackedPath(values, offsets) {
|
|
697
|
+
const top = values.map((v, i) => `${i===0?'M':'L'} ${x(i).toFixed(1)} ${ys(offsets[i]+v).toFixed(1)}`).join(' ');
|
|
698
|
+
const bot = values.map((v, i) => `L ${x(N-1-i).toFixed(1)} ${ys(offsets[N-1-i]).toFixed(1)}`).join(' ');
|
|
699
|
+
return `${top} ${bot} Z`;
|
|
700
|
+
}
|
|
701
|
+
const off2 = new Array(N).fill(0);
|
|
702
|
+
const off4 = data.s2xx;
|
|
703
|
+
const off5 = data.s2xx.map((v, i) => v + data.s4xx[i]);
|
|
704
|
+
return (
|
|
705
|
+
<div ref={wrapRef} style={{position:'relative',width:'100%'}}>
|
|
706
|
+
<svg width={w} height={height} style={{display:'block'}}>
|
|
707
|
+
{[0,0.5,1].map((f,i) => (
|
|
708
|
+
<g key={i}>
|
|
709
|
+
<line x1={pad.left} x2={w-pad.right} y1={ys(f*max)} y2={ys(f*max)} stroke="#ececec"/>
|
|
710
|
+
<text x={pad.left-8} y={ys(f*max)+3} fontSize="10" fontFamily="var(--mono)" fill="#a3a3a3" textAnchor="end">{Math.round(f*max)}</text>
|
|
711
|
+
</g>
|
|
712
|
+
))}
|
|
713
|
+
<path d={stackedPath(data.s2xx, off2)} fill="#15803d" opacity="0.85"/>
|
|
714
|
+
<path d={stackedPath(data.s4xx, off4)} fill="#b45309" opacity="0.85"/>
|
|
715
|
+
<path d={stackedPath(data.s5xx, off5)} fill="#b91c1c" opacity="0.9"/>
|
|
716
|
+
</svg>
|
|
717
|
+
</div>
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// ─── Overview ─────────────────────────────────────────────────────────────────
|
|
722
|
+
function Overview({ timeRange, setRoute, setParams }) {
|
|
723
|
+
const { ENDPOINTS, RELEASES, INSIGHTS, SUMMARY } = window.AF_DATA;
|
|
724
|
+
const [globalTs, setGlobalTs] = useState(null);
|
|
725
|
+
const hours = TIME_HOURS[timeRange] || 24;
|
|
726
|
+
|
|
727
|
+
useEffect(() => {
|
|
728
|
+
setGlobalTs(null);
|
|
729
|
+
fetch(`/api/global-timeseries?hours=${hours}`)
|
|
730
|
+
.then(r => r.json()).then(d => setGlobalTs(d)).catch(() => setGlobalTs([]));
|
|
731
|
+
}, [hours]);
|
|
732
|
+
|
|
733
|
+
const chartData = globalTs ? tsBucketsToChart(globalTs, hours) : null;
|
|
734
|
+
const points = Math.max(chartData?.p90?.length || 0, 2);
|
|
735
|
+
const xLabels = chartData?.labels || [];
|
|
736
|
+
const fallbackPts = 24;
|
|
737
|
+
|
|
738
|
+
const globalP50 = chartData?.p50 || genSeries({base_p90:(SUMMARY.p90||100)*0.5}, fallbackPts, {seed:1});
|
|
739
|
+
const globalP90 = chartData?.p90 || genSeries({base_p90: SUMMARY.p90||100}, fallbackPts, {seed:2});
|
|
740
|
+
const globalP99 = chartData?.p99 || genSeries({base_p90:(SUMMARY.p90||100)*1.8}, fallbackPts, {seed:3});
|
|
741
|
+
const globalCalls = chartData?.calls || Array(fallbackPts).fill(0);
|
|
742
|
+
const xLabelsFinal = xLabels.length > 0 ? xLabels : Array.from({length:globalP90.length}, (_,i) => `${i}`);
|
|
743
|
+
|
|
744
|
+
const releaseMarkers = (RELEASES || []).slice(0,2).map((r, i) => ({
|
|
745
|
+
idx: Math.min(Math.floor(globalP90.length * (0.45 + i * 0.35)), globalP90.length - 1),
|
|
746
|
+
label: r.tag,
|
|
747
|
+
color: i === 0 ? '#b91c1c' : '#15803d',
|
|
748
|
+
}));
|
|
749
|
+
|
|
750
|
+
const topSlow = [...ENDPOINTS].filter(e => !e.untracked && e.base_p90 > 0).sort((a,b) => b.base_p90-a.base_p90).slice(0,5);
|
|
751
|
+
const topCalled = [...ENDPOINTS].sort((a,b) => b.calls24h-a.calls24h).slice(0,5);
|
|
752
|
+
const recentInsights = INSIGHTS.slice(0,4);
|
|
753
|
+
const availability = SUMMARY.err_rate < 0.001 ? '99.9' : SUMMARY.err_rate < 0.01 ? '99.1' : '98.5';
|
|
754
|
+
|
|
755
|
+
return (
|
|
756
|
+
<div>
|
|
757
|
+
<div className="page-h">
|
|
758
|
+
<div>
|
|
759
|
+
<h1>API Overview</h1>
|
|
760
|
+
<div className="sub">Behavioral snapshot · last {timeRange}</div>
|
|
761
|
+
</div>
|
|
762
|
+
</div>
|
|
763
|
+
|
|
764
|
+
<div className="overview-grid">
|
|
765
|
+
<div className="card health-card">
|
|
766
|
+
<div className="health-label">API Health Score</div>
|
|
767
|
+
<div className="health-row">
|
|
768
|
+
<div className="health-num">{SUMMARY.health}<span className="denom">/100</span></div>
|
|
769
|
+
<div style={{flex:1}}>
|
|
770
|
+
<Sparkline data={genSeries({base_p90:SUMMARY.health||80},12,{seed:5})} width={160} height={36} color="var(--accent)"/>
|
|
771
|
+
<div className="health-trend"><I.arrowUp /> Live</div>
|
|
772
|
+
</div>
|
|
773
|
+
</div>
|
|
774
|
+
<div className="health-dims">
|
|
775
|
+
<div className="health-dim">
|
|
776
|
+
<div className="health-dim-label">Availability</div>
|
|
777
|
+
<div className="health-dim-val">{availability}<span style={{fontSize:11,color:'#a3a3a3'}}>%</span></div>
|
|
778
|
+
</div>
|
|
779
|
+
<div className="health-dim">
|
|
780
|
+
<div className="health-dim-label">P90</div>
|
|
781
|
+
<div className="health-dim-val">{SUMMARY.p90||'—'}<span style={{fontSize:11,color:'#a3a3a3'}}>ms</span></div>
|
|
782
|
+
</div>
|
|
783
|
+
<div className="health-dim">
|
|
784
|
+
<div className="health-dim-label">Active</div>
|
|
785
|
+
<div className="health-dim-val">{SUMMARY.active_endpoints}<span style={{fontSize:11,color:'#a3a3a3'}}>/{SUMMARY.total_endpoints}</span></div>
|
|
786
|
+
</div>
|
|
787
|
+
<div className="health-dim">
|
|
788
|
+
<div className="health-dim-label">Insights</div>
|
|
789
|
+
<div className="health-dim-val">{SUMMARY.insights_open}<span style={{fontSize:11,color:'#a3a3a3'}}> ⚠</span></div>
|
|
790
|
+
</div>
|
|
791
|
+
</div>
|
|
792
|
+
</div>
|
|
793
|
+
|
|
794
|
+
<div className="stats-row" style={{margin:0,height:'100%'}}>
|
|
795
|
+
<div className="stat-cell">
|
|
796
|
+
<div className="stat-label">Requests</div>
|
|
797
|
+
<div className="stat-val">{fmtNum(SUMMARY.calls_24h)}<span className="unit">last {timeRange}</span></div>
|
|
798
|
+
<div className="stat-delta dim">total recorded</div>
|
|
799
|
+
</div>
|
|
800
|
+
<div className="stat-cell">
|
|
801
|
+
<div className="stat-label">Error rate</div>
|
|
802
|
+
<div className="stat-val">{fmtPct(SUMMARY.err_rate)}</div>
|
|
803
|
+
<div className={`stat-delta ${SUMMARY.err_rate < 0.01 ? 'up' : 'bad-up'}`}>
|
|
804
|
+
{SUMMARY.err_rate < 0.01 ? '✓ within threshold' : '⚠ above 1%'}
|
|
805
|
+
</div>
|
|
806
|
+
</div>
|
|
807
|
+
<div className="stat-cell">
|
|
808
|
+
<div className="stat-label">P90 latency</div>
|
|
809
|
+
<div className="stat-val">{SUMMARY.p90 || '—'}<span className="unit">ms</span></div>
|
|
810
|
+
<div className={`stat-delta ${(SUMMARY.p90||0) < 200 ? 'up' : 'bad-up'}`}>
|
|
811
|
+
{(SUMMARY.p90||0) < 200 ? '✓ good' : '⚠ above 200ms'}
|
|
812
|
+
</div>
|
|
813
|
+
</div>
|
|
814
|
+
<div className="stat-cell">
|
|
815
|
+
<div className="stat-label">Endpoints</div>
|
|
816
|
+
<div className="stat-val">{SUMMARY.active_endpoints}<span className="unit">/ {SUMMARY.total_endpoints}</span></div>
|
|
817
|
+
<div className="stat-delta dim">{SUMMARY.total_endpoints - SUMMARY.active_endpoints} not yet called</div>
|
|
818
|
+
</div>
|
|
819
|
+
</div>
|
|
820
|
+
</div>
|
|
821
|
+
|
|
822
|
+
{recentInsights.length > 0 && (
|
|
823
|
+
<div className="card" style={{marginBottom:16}}>
|
|
824
|
+
<div className="card-h">
|
|
825
|
+
<div className="card-h-title">Top insights</div>
|
|
826
|
+
<div className="card-h-sub">{INSIGHTS.length} total · {SUMMARY.insights_open} need attention</div>
|
|
827
|
+
<div className="card-h-actions">
|
|
828
|
+
<button className="btn btn-ghost" onClick={() => setRoute('insights')}>View all <I.arrowR /></button>
|
|
829
|
+
</div>
|
|
830
|
+
</div>
|
|
831
|
+
<div style={{display:'grid',gridTemplateColumns:recentInsights.length>1?'1fr 1fr':'1fr',gap:0}}>
|
|
832
|
+
{recentInsights.map((ins, i) => (
|
|
833
|
+
<div key={ins.id} className="insight"
|
|
834
|
+
style={{borderRadius:0,borderLeft:0,borderTop:0,
|
|
835
|
+
borderRight: i%2===0 && i<recentInsights.length-1 ? '1px solid var(--border)' : 0,
|
|
836
|
+
borderBottom: i<recentInsights.length-2 ? '1px solid var(--border)' : 0,
|
|
837
|
+
cursor: ins.endpoint_id ? 'pointer' : 'default'}}
|
|
838
|
+
onClick={() => {
|
|
839
|
+
if (ins.endpoint_id) { setRoute('endpoint'); setParams({id:ins.endpoint_id}); }
|
|
840
|
+
else setRoute('insights');
|
|
841
|
+
}}>
|
|
842
|
+
<div className={`insight-ico insight-${ins.type}`}>{ins.type[0]}</div>
|
|
843
|
+
<div>
|
|
844
|
+
<div className="insight-title">{ins.title}</div>
|
|
845
|
+
<div className="insight-meta">
|
|
846
|
+
{ins.endpoint && <span className="mono">{ins.endpoint}</span>}
|
|
847
|
+
<span className="sep">·</span><span>{ins.ts}</span>
|
|
848
|
+
</div>
|
|
849
|
+
</div>
|
|
850
|
+
<div className="insight-action">{ins.type}</div>
|
|
851
|
+
</div>
|
|
852
|
+
))}
|
|
853
|
+
</div>
|
|
854
|
+
</div>
|
|
855
|
+
)}
|
|
856
|
+
|
|
857
|
+
{recentInsights.length === 0 && (
|
|
858
|
+
<div className="card" style={{marginBottom:16,padding:'14px 18px',display:'flex',alignItems:'center',gap:10}}>
|
|
859
|
+
<I.check /><span style={{color:'var(--ok)',fontSize:13}}>No active insights — your API looks healthy.</span>
|
|
860
|
+
</div>
|
|
861
|
+
)}
|
|
862
|
+
|
|
863
|
+
<div className="two-col" style={{marginBottom:16}}>
|
|
864
|
+
<div className="card">
|
|
865
|
+
<div className="card-h">
|
|
866
|
+
<div className="card-h-title">Latency percentiles</div>
|
|
867
|
+
<div className="card-h-sub">p50 · p90 · p99</div>
|
|
868
|
+
{globalTs === null && <div className="card-h-actions"><span className="spinner"></span></div>}
|
|
869
|
+
</div>
|
|
870
|
+
<div className="chart-legend">
|
|
871
|
+
<span><span className="dot" style={{background:'#a3a3a3'}}></span>p50</span>
|
|
872
|
+
<span><span className="dot" style={{background:'#2563eb'}}></span>p90</span>
|
|
873
|
+
<span><span className="dot" style={{background:'#b91c1c'}}></span>p99</span>
|
|
874
|
+
{chartData && <span style={{marginLeft:'auto',color:'var(--text-faint)',fontFamily:'var(--mono)',fontSize:11}}>{chartData.p90.length} buckets</span>}
|
|
875
|
+
</div>
|
|
876
|
+
<div className="chart-wrap">
|
|
877
|
+
<LineChart series={[
|
|
878
|
+
{name:'p50', data:globalP50, color:'#a3a3a3', width:1.2},
|
|
879
|
+
{name:'p90', data:globalP90, color:'#2563eb', width:1.6, area:true},
|
|
880
|
+
{name:'p99', data:globalP99, color:'#b91c1c', width:1.2, dashed:true},
|
|
881
|
+
]} xLabels={xLabelsFinal} markers={releaseMarkers} height={240}/>
|
|
882
|
+
</div>
|
|
883
|
+
</div>
|
|
884
|
+
<div className="card">
|
|
885
|
+
<div className="card-h">
|
|
886
|
+
<div className="card-h-title">Traffic</div>
|
|
887
|
+
<div className="card-h-sub">total requests / bucket</div>
|
|
888
|
+
</div>
|
|
889
|
+
<div className="chart-wrap">
|
|
890
|
+
<LineChart series={[{name:'calls',data:globalCalls,color:'#0a0a0a',width:1.4,area:true}]}
|
|
891
|
+
xLabels={xLabelsFinal} height={240} formatY={v => fmtNum(v)}/>
|
|
892
|
+
</div>
|
|
893
|
+
</div>
|
|
894
|
+
</div>
|
|
895
|
+
|
|
896
|
+
<div className="two-col">
|
|
897
|
+
<div className="card">
|
|
898
|
+
<div className="card-h">
|
|
899
|
+
<div className="card-h-title">Slowest endpoints</div><div className="card-h-sub">by p90</div>
|
|
900
|
+
<div className="card-h-actions">
|
|
901
|
+
<button className="btn btn-ghost" onClick={() => setRoute('endpoints')}>All <I.arrowR /></button>
|
|
902
|
+
</div>
|
|
903
|
+
</div>
|
|
904
|
+
{topSlow.length === 0
|
|
905
|
+
? <div className="empty-state">No latency data yet</div>
|
|
906
|
+
: <table className="tbl">
|
|
907
|
+
<thead><tr><th>Route</th><th className="num">P50</th><th className="num">P90</th><th className="num">P99</th><th>Trend</th></tr></thead>
|
|
908
|
+
<tbody>
|
|
909
|
+
{topSlow.map(e => (
|
|
910
|
+
<tr key={e.id} className="click" onClick={() => { setRoute('endpoint'); setParams({id:e.id}); }}>
|
|
911
|
+
<td><span className={`method method-${e.method}`}>{e.method}</span>{' '}<span className="route">{e.route}</span></td>
|
|
912
|
+
<td className="num">{e.base_p50}<span className="dim"> ms</span></td>
|
|
913
|
+
<td className="num">{e.base_p90}<span className="dim"> ms</span></td>
|
|
914
|
+
<td className="num">{e.base_p99}<span className="dim"> ms</span></td>
|
|
915
|
+
<td><Sparkline data={genSeries(e,24)} color="#2563eb" width={64} height={20}/></td>
|
|
916
|
+
</tr>
|
|
917
|
+
))}
|
|
918
|
+
</tbody>
|
|
919
|
+
</table>
|
|
920
|
+
}
|
|
921
|
+
</div>
|
|
922
|
+
<div className="card">
|
|
923
|
+
<div className="card-h"><div className="card-h-title">Most called</div><div className="card-h-sub">last {timeRange}</div></div>
|
|
924
|
+
{topCalled.length === 0
|
|
925
|
+
? <div className="empty-state">No traffic yet</div>
|
|
926
|
+
: <table className="tbl">
|
|
927
|
+
<thead><tr><th>Route</th><th className="num">Calls</th><th>Status</th></tr></thead>
|
|
928
|
+
<tbody>
|
|
929
|
+
{topCalled.map(e => {
|
|
930
|
+
const tot = e.calls24h || 1;
|
|
931
|
+
return (
|
|
932
|
+
<tr key={e.id} className="click" onClick={() => { setRoute('endpoint'); setParams({id:e.id}); }}>
|
|
933
|
+
<td>
|
|
934
|
+
<span className={`method method-${e.method}`}>{e.method}</span>{' '}
|
|
935
|
+
<span className="route">{e.route}</span>
|
|
936
|
+
{e.untracked && <span className="release-badge rb-warn" style={{marginLeft:8}}>UNTRACKED</span>}
|
|
937
|
+
</td>
|
|
938
|
+
<td className="num">{fmtNum(e.calls24h)}</td>
|
|
939
|
+
<td>
|
|
940
|
+
<div className="status-dist">
|
|
941
|
+
<span className="s2xx" style={{flex:e.calls_2xx||1}}></span>
|
|
942
|
+
<span className="s4xx" style={{flex:e.calls_4xx||0}}></span>
|
|
943
|
+
<span className="s5xx" style={{flex:e.calls_5xx||0}}></span>
|
|
944
|
+
</div>
|
|
945
|
+
</td>
|
|
946
|
+
</tr>
|
|
947
|
+
);
|
|
948
|
+
})}
|
|
949
|
+
</tbody>
|
|
950
|
+
</table>
|
|
951
|
+
}
|
|
952
|
+
</div>
|
|
953
|
+
</div>
|
|
954
|
+
</div>
|
|
955
|
+
);
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// ─── Endpoints list ───────────────────────────────────────────────────────────
|
|
959
|
+
function Endpoints({ setRoute, setParams }) {
|
|
960
|
+
const { ENDPOINTS } = window.AF_DATA;
|
|
961
|
+
const [q, setQ] = useState('');
|
|
962
|
+
const [methodFilter, setMeth] = useState('ALL');
|
|
963
|
+
const [sortBy, setSortBy] = useState('calls');
|
|
964
|
+
const [showUnt, setShowUnt] = useState(true);
|
|
965
|
+
|
|
966
|
+
const filtered = useMemo(() => {
|
|
967
|
+
let list = ENDPOINTS.slice();
|
|
968
|
+
if (!showUnt) list = list.filter(e => !e.untracked);
|
|
969
|
+
if (methodFilter !== 'ALL') list = list.filter(e => e.method === methodFilter);
|
|
970
|
+
if (q) list = list.filter(e => (e.method + ' ' + e.route).toLowerCase().includes(q.toLowerCase()));
|
|
971
|
+
const sorters = {
|
|
972
|
+
calls: (a,b) => b.calls24h - a.calls24h,
|
|
973
|
+
p90: (a,b) => b.base_p90 - a.base_p90,
|
|
974
|
+
err: (a,b) => b.err - a.err,
|
|
975
|
+
route: (a,b) => a.route.localeCompare(b.route),
|
|
976
|
+
};
|
|
977
|
+
return list.sort(sorters[sortBy]);
|
|
978
|
+
}, [q, methodFilter, sortBy, showUnt, ENDPOINTS]);
|
|
979
|
+
|
|
980
|
+
const untrackedCount = ENDPOINTS.filter(e => e.untracked).length;
|
|
981
|
+
|
|
982
|
+
return (
|
|
983
|
+
<div>
|
|
984
|
+
<div className="page-h">
|
|
985
|
+
<div>
|
|
986
|
+
<h1>Endpoints</h1>
|
|
987
|
+
<div className="sub">{ENDPOINTS.length} discovered routes · {untrackedCount} never called</div>
|
|
988
|
+
</div>
|
|
989
|
+
</div>
|
|
990
|
+
<div className="filter-bar">
|
|
991
|
+
<div className="search">
|
|
992
|
+
<input placeholder="Search route or method…" value={q} onChange={e => setQ(e.target.value)}/>
|
|
993
|
+
</div>
|
|
994
|
+
<div className="chips">
|
|
995
|
+
{['ALL','GET','POST','PUT','PATCH','DELETE'].map(m => (
|
|
996
|
+
<button key={m} className={`chip ${methodFilter===m?'on':''}`} onClick={() => setMeth(m)}>{m}</button>
|
|
997
|
+
))}
|
|
998
|
+
</div>
|
|
999
|
+
<div style={{flex:1}}/>
|
|
1000
|
+
<select className="select" value={sortBy} onChange={e => setSortBy(e.target.value)}>
|
|
1001
|
+
<option value="calls">Sort: most called</option>
|
|
1002
|
+
<option value="p90">Sort: slowest (p90)</option>
|
|
1003
|
+
<option value="err">Sort: most errors</option>
|
|
1004
|
+
<option value="route">Sort: route a-z</option>
|
|
1005
|
+
</select>
|
|
1006
|
+
<button className={`btn ${showUnt?'':'btn-ghost'}`} onClick={() => setShowUnt(!showUnt)}>
|
|
1007
|
+
{showUnt?'✓ ':''}Untracked
|
|
1008
|
+
</button>
|
|
1009
|
+
</div>
|
|
1010
|
+
<div className="card">
|
|
1011
|
+
{filtered.length === 0
|
|
1012
|
+
? <div className="empty-state">No endpoints match the current filters.</div>
|
|
1013
|
+
: <table className="tbl">
|
|
1014
|
+
<thead>
|
|
1015
|
+
<tr><th style={{width:'36%'}}>Route</th><th className="num">Calls</th><th className="num">P50</th><th className="num">P90</th><th className="num">P99</th><th className="num">Error rate</th><th>Trend</th></tr>
|
|
1016
|
+
</thead>
|
|
1017
|
+
<tbody>
|
|
1018
|
+
{filtered.map(e => (
|
|
1019
|
+
<tr key={e.id} className="click"
|
|
1020
|
+
onClick={() => { setRoute('endpoint'); setParams({id:e.id}); }}
|
|
1021
|
+
style={e.untracked ? {opacity:0.6} : null}>
|
|
1022
|
+
<td>
|
|
1023
|
+
<span className={`method method-${e.method}`}>{e.method}</span>{' '}
|
|
1024
|
+
<span className="route">{e.route}</span>
|
|
1025
|
+
{e.untracked && <span className="release-badge rb-warn" style={{marginLeft:8}}>UNTRACKED</span>}
|
|
1026
|
+
</td>
|
|
1027
|
+
<td className="num">{e.untracked ? '—' : fmtNum(e.calls24h)}</td>
|
|
1028
|
+
<td className="num">{e.base_p50 > 0 ? e.base_p50+'ms' : '—'}</td>
|
|
1029
|
+
<td className="num">{e.base_p90 > 0 ? e.base_p90+'ms' : '—'}</td>
|
|
1030
|
+
<td className="num">{e.base_p99 > 0 ? e.base_p99+'ms' : '—'}</td>
|
|
1031
|
+
<td className="num">
|
|
1032
|
+
{e.untracked ? '—' : (
|
|
1033
|
+
<span style={{display:'inline-flex',alignItems:'center',gap:8,justifyContent:'flex-end'}}>
|
|
1034
|
+
<span className="bar-inline" style={{width:50}}>
|
|
1035
|
+
<span style={{width:`${Math.min(100,e.err*1000)}%`,background:e.err>0.02?'var(--danger)':e.err>0.01?'var(--warn)':'var(--accent)'}}></span>
|
|
1036
|
+
</span>
|
|
1037
|
+
<span>{(e.err*100).toFixed(2)}%</span>
|
|
1038
|
+
</span>
|
|
1039
|
+
)}
|
|
1040
|
+
</td>
|
|
1041
|
+
<td>{e.untracked ? '—' : <Sparkline data={genSeries(e,24)} color="#2563eb" width={72} height={20}/>}</td>
|
|
1042
|
+
</tr>
|
|
1043
|
+
))}
|
|
1044
|
+
</tbody>
|
|
1045
|
+
</table>
|
|
1046
|
+
}
|
|
1047
|
+
</div>
|
|
1048
|
+
</div>
|
|
1049
|
+
);
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// ─── Endpoint detail ──────────────────────────────────────────────────────────
|
|
1053
|
+
function EndpointDetail({ id, timeRange, setRoute, setParams }) {
|
|
1054
|
+
const { ENDPOINTS, INSIGHTS } = window.AF_DATA;
|
|
1055
|
+
const ep = ENDPOINTS.find(e => e.id === id) || ENDPOINTS[0];
|
|
1056
|
+
const [tab, setTab] = useState('performance');
|
|
1057
|
+
const [timeSeries, setTs] = useState(null);
|
|
1058
|
+
const hours = TIME_HOURS[timeRange] || 24;
|
|
1059
|
+
|
|
1060
|
+
useEffect(() => {
|
|
1061
|
+
if (!ep || !id) return;
|
|
1062
|
+
const pipeIdx = id.indexOf('|');
|
|
1063
|
+
const method = id.substring(0, pipeIdx);
|
|
1064
|
+
const route = id.substring(pipeIdx + 1);
|
|
1065
|
+
setTs(null);
|
|
1066
|
+
fetch(`/api/timeseries?route=${encodeURIComponent(route)}&method=${encodeURIComponent(method)}&hours=${hours}`)
|
|
1067
|
+
.then(r => r.json()).then(d => setTs(d)).catch(() => setTs([]));
|
|
1068
|
+
}, [id, hours]);
|
|
1069
|
+
|
|
1070
|
+
if (!ep) return <div className="empty-state">Endpoint not found.</div>;
|
|
1071
|
+
|
|
1072
|
+
const chartData = timeSeries ? tsBucketsToChart(timeSeries, hours) : null;
|
|
1073
|
+
const pts = Math.max(chartData?.p90?.length || 0, 24);
|
|
1074
|
+
const xLbls = chartData?.labels || Array.from({length:24}, (_,i) => `${i}h`);
|
|
1075
|
+
|
|
1076
|
+
const p50 = chartData?.p50 || genSeries({...ep, base_p90:ep.base_p50}, pts, {seed:1});
|
|
1077
|
+
const p90 = chartData?.p90 || genSeries(ep, pts, {seed:2});
|
|
1078
|
+
const p99 = chartData?.p99 || genSeries({...ep, base_p90:ep.base_p99}, pts, {seed:3});
|
|
1079
|
+
const calls = chartData?.calls || genCallSeries(ep, pts);
|
|
1080
|
+
const errs = chartData?.errors|| genErrorSeries(ep, pts);
|
|
1081
|
+
const ok2 = calls.map((c,i) => Math.max(0, c - (errs[i]||0)));
|
|
1082
|
+
const e4 = errs.map(v => Math.round(v * 0.65));
|
|
1083
|
+
const e5 = errs.map((v,i) => v - e4[i]);
|
|
1084
|
+
|
|
1085
|
+
const relatedInsights = INSIGHTS.filter(i => i.endpoint_id === ep.id);
|
|
1086
|
+
|
|
1087
|
+
return (
|
|
1088
|
+
<div>
|
|
1089
|
+
<div className="page-h">
|
|
1090
|
+
<div>
|
|
1091
|
+
<h1>
|
|
1092
|
+
<span className={`method method-${ep.method}`} style={{fontSize:12,verticalAlign:'middle',marginRight:10}}>{ep.method}</span>
|
|
1093
|
+
<span className="mono" style={{fontSize:22}}>{ep.route}</span>
|
|
1094
|
+
</h1>
|
|
1095
|
+
<div className="sub">
|
|
1096
|
+
{ep.untracked
|
|
1097
|
+
? 'Declared in router — no requests recorded yet'
|
|
1098
|
+
: `${fmtNum(ep.calls24h)} calls · ${fmtPct(ep.err)} error rate`}
|
|
1099
|
+
</div>
|
|
1100
|
+
</div>
|
|
1101
|
+
</div>
|
|
1102
|
+
|
|
1103
|
+
<div className="stats-row" style={{marginBottom:16}}>
|
|
1104
|
+
<div className="stat-cell">
|
|
1105
|
+
<div className="stat-label">Calls</div>
|
|
1106
|
+
<div className="stat-val">{ep.untracked ? '—' : fmtNum(ep.calls24h)}</div>
|
|
1107
|
+
<div className="stat-delta dim">last {timeRange}</div>
|
|
1108
|
+
</div>
|
|
1109
|
+
<div className="stat-cell">
|
|
1110
|
+
<div className="stat-label">P90 latency</div>
|
|
1111
|
+
<div className="stat-val">{ep.base_p90 || '—'}<span className="unit">ms</span></div>
|
|
1112
|
+
<div className={`stat-delta ${(ep.base_p90||0) < 200 ? 'up' : 'bad-up'}`}>
|
|
1113
|
+
{(ep.base_p90||0) < 200 ? '✓ good' : '⚠ slow'}
|
|
1114
|
+
</div>
|
|
1115
|
+
</div>
|
|
1116
|
+
<div className="stat-cell">
|
|
1117
|
+
<div className="stat-label">Error rate</div>
|
|
1118
|
+
<div className="stat-val">{ep.untracked ? '—' : fmtPct(ep.err)}</div>
|
|
1119
|
+
<div className={`stat-delta ${ep.err < 0.01 ? 'up' : 'bad-up'}`}>
|
|
1120
|
+
{ep.err < 0.01 ? '✓ good' : '⚠ above 1%'}
|
|
1121
|
+
</div>
|
|
1122
|
+
</div>
|
|
1123
|
+
<div className="stat-cell">
|
|
1124
|
+
<div className="stat-label">2xx / 4xx / 5xx</div>
|
|
1125
|
+
<div className="stat-val" style={{fontSize:16}}>{fmtNum(ep.calls_2xx)}<span className="unit">ok</span></div>
|
|
1126
|
+
<div className="stat-delta dim">{fmtNum(ep.calls_4xx)} client · {fmtNum(ep.calls_5xx)} server</div>
|
|
1127
|
+
</div>
|
|
1128
|
+
</div>
|
|
1129
|
+
|
|
1130
|
+
<div className="tabs">
|
|
1131
|
+
<button className={`tab ${tab==='performance'?'active':''}`} onClick={() => setTab('performance')}>Performance</button>
|
|
1132
|
+
<button className={`tab ${tab==='errors'?'active':''}`} onClick={() => setTab('errors')}>Errors & Status</button>
|
|
1133
|
+
<button className={`tab ${tab==='insights'?'active':''}`} onClick={() => setTab('insights')}>
|
|
1134
|
+
Insights{relatedInsights.length > 0 && <span className="mono dim" style={{marginLeft:4}}>({relatedInsights.length})</span>}
|
|
1135
|
+
</button>
|
|
1136
|
+
</div>
|
|
1137
|
+
|
|
1138
|
+
{tab === 'performance' && (
|
|
1139
|
+
<div>
|
|
1140
|
+
<div className="card" style={{marginBottom:16}}>
|
|
1141
|
+
<div className="card-h">
|
|
1142
|
+
<div className="card-h-title">Latency over {timeRange}</div>
|
|
1143
|
+
<div className="card-h-sub">percentiles</div>
|
|
1144
|
+
{timeSeries === null && <div className="card-h-actions"><span className="spinner"></span></div>}
|
|
1145
|
+
</div>
|
|
1146
|
+
<div className="chart-legend">
|
|
1147
|
+
<span><span className="dot" style={{background:'#a3a3a3'}}></span>p50 · {ep.base_p50||'—'}ms</span>
|
|
1148
|
+
<span><span className="dot" style={{background:'#2563eb'}}></span>p90 · {ep.base_p90||'—'}ms</span>
|
|
1149
|
+
<span><span className="dot" style={{background:'#b91c1c'}}></span>p99 · {ep.base_p99||'—'}ms</span>
|
|
1150
|
+
</div>
|
|
1151
|
+
<div className="chart-wrap">
|
|
1152
|
+
<LineChart series={[
|
|
1153
|
+
{name:'p50', data:p50, color:'#a3a3a3', width:1.2},
|
|
1154
|
+
{name:'p90', data:p90, color:'#2563eb', width:1.6, area:true},
|
|
1155
|
+
{name:'p99', data:p99, color:'#b91c1c', width:1.2, dashed:true},
|
|
1156
|
+
]} xLabels={xLbls} height={260}/>
|
|
1157
|
+
</div>
|
|
1158
|
+
</div>
|
|
1159
|
+
<div className="two-col">
|
|
1160
|
+
<div className="card">
|
|
1161
|
+
<div className="card-h"><div className="card-h-title">Traffic volume</div><div className="card-h-sub">requests / bucket</div></div>
|
|
1162
|
+
<div className="chart-wrap">
|
|
1163
|
+
<LineChart series={[{name:'calls',data:calls,color:'#0a0a0a',area:true}]} xLabels={xLbls} height={200} formatY={v => fmtNum(v)}/>
|
|
1164
|
+
</div>
|
|
1165
|
+
</div>
|
|
1166
|
+
<div className="card">
|
|
1167
|
+
<div className="card-h"><div className="card-h-title">5xx errors</div><div className="card-h-sub">over time</div></div>
|
|
1168
|
+
<div className="chart-wrap">
|
|
1169
|
+
<LineChart series={[{name:'errors',data:e5,color:'#b91c1c',area:true}]} xLabels={xLbls} height={200} formatY={v => String(Math.round(v))}/>
|
|
1170
|
+
</div>
|
|
1171
|
+
</div>
|
|
1172
|
+
</div>
|
|
1173
|
+
</div>
|
|
1174
|
+
)}
|
|
1175
|
+
|
|
1176
|
+
{tab === 'errors' && (
|
|
1177
|
+
<div>
|
|
1178
|
+
<div className="card" style={{marginBottom:16}}>
|
|
1179
|
+
<div className="card-h"><div className="card-h-title">Status code distribution</div><div className="card-h-sub">stacked over time</div></div>
|
|
1180
|
+
<div className="chart-legend">
|
|
1181
|
+
<span><span className="dot" style={{background:'#15803d'}}></span>2xx · {fmtNum(ep.calls_2xx)}</span>
|
|
1182
|
+
<span><span className="dot" style={{background:'#b45309'}}></span>4xx · {fmtNum(ep.calls_4xx)}</span>
|
|
1183
|
+
<span><span className="dot" style={{background:'#b91c1c'}}></span>5xx · {fmtNum(ep.calls_5xx)}</span>
|
|
1184
|
+
</div>
|
|
1185
|
+
<div className="chart-wrap">
|
|
1186
|
+
<StatusStackChart data={{s2xx:ok2, s4xx:e4, s5xx:e5}} height={220}/>
|
|
1187
|
+
</div>
|
|
1188
|
+
</div>
|
|
1189
|
+
<div className="card">
|
|
1190
|
+
<div className="card-h"><div className="card-h-title">Status breakdown</div><div className="card-h-sub">all time</div></div>
|
|
1191
|
+
<table className="tbl">
|
|
1192
|
+
<thead><tr><th>Category</th><th className="num">Count</th><th className="num">Rate</th></tr></thead>
|
|
1193
|
+
<tbody>
|
|
1194
|
+
{[['2xx','Success',ep.calls_2xx,'#15803d'],['4xx','Client error',ep.calls_4xx,'#b45309'],['5xx','Server error',ep.calls_5xx,'#b91c1c']].map(([code,desc,count,col]) => (
|
|
1195
|
+
<tr key={code}>
|
|
1196
|
+
<td><span className="release-badge" style={{background:'rgba(0,0,0,0.05)',color:col}}>{code}</span> <span className="dim">{desc}</span></td>
|
|
1197
|
+
<td className="num">{fmtNum(count)}</td>
|
|
1198
|
+
<td className="num">{ep.calls24h > 0 ? ((count/ep.calls24h)*100).toFixed(2)+'%' : '—'}</td>
|
|
1199
|
+
</tr>
|
|
1200
|
+
))}
|
|
1201
|
+
</tbody>
|
|
1202
|
+
</table>
|
|
1203
|
+
</div>
|
|
1204
|
+
</div>
|
|
1205
|
+
)}
|
|
1206
|
+
|
|
1207
|
+
{tab === 'insights' && (
|
|
1208
|
+
<div className="insight-feed">
|
|
1209
|
+
{relatedInsights.length === 0 && (
|
|
1210
|
+
<div className="card card-b dim" style={{textAlign:'center',padding:32}}>
|
|
1211
|
+
<I.check /> No active insights for this endpoint.
|
|
1212
|
+
</div>
|
|
1213
|
+
)}
|
|
1214
|
+
{relatedInsights.map(ins => (
|
|
1215
|
+
<div key={ins.id} className="insight">
|
|
1216
|
+
<div className={`insight-ico insight-${ins.type}`}>{ins.type[0]}</div>
|
|
1217
|
+
<div>
|
|
1218
|
+
<div className="insight-title">{ins.title}</div>
|
|
1219
|
+
<div className="insight-meta"><span className="mono">{ins.endpoint}</span><span className="sep">·</span><span>{ins.ts}</span></div>
|
|
1220
|
+
</div>
|
|
1221
|
+
<div className="insight-action">{ins.type}</div>
|
|
1222
|
+
</div>
|
|
1223
|
+
))}
|
|
1224
|
+
</div>
|
|
1225
|
+
)}
|
|
1226
|
+
</div>
|
|
1227
|
+
);
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
// ─── Insights ─────────────────────────────────────────────────────────────────
|
|
1231
|
+
function Insights({ setRoute, setParams }) {
|
|
1232
|
+
const { INSIGHTS } = window.AF_DATA;
|
|
1233
|
+
const [typeFilter, setTypeFilter] = useState('ALL');
|
|
1234
|
+
const [sevFilter, setSevFilter] = useState('ALL');
|
|
1235
|
+
|
|
1236
|
+
const types = [
|
|
1237
|
+
{id:'ALL',label:'All'},{id:'PERF',label:'Performance'},
|
|
1238
|
+
{id:'ANOMALY',label:'Anomaly'},{id:'DEAD',label:'Dead'},
|
|
1239
|
+
{id:'UNTRACKED',label:'Untracked'},{id:'OK',label:'OK'},
|
|
1240
|
+
];
|
|
1241
|
+
const filtered = INSIGHTS.filter(i =>
|
|
1242
|
+
(typeFilter === 'ALL' || i.type === typeFilter) &&
|
|
1243
|
+
(sevFilter === 'ALL' || i.severity === sevFilter)
|
|
1244
|
+
);
|
|
1245
|
+
const grouped = {
|
|
1246
|
+
high: filtered.filter(i => i.severity === 'high'),
|
|
1247
|
+
med: filtered.filter(i => i.severity === 'med'),
|
|
1248
|
+
low: filtered.filter(i => i.severity === 'low'),
|
|
1249
|
+
info: filtered.filter(i => i.severity === 'info'),
|
|
1250
|
+
};
|
|
1251
|
+
const sevLabel = {high:'Needs attention', med:'Watch closely', low:'Low priority', info:'For your info'};
|
|
1252
|
+
const sevColor = {high:'var(--danger)', med:'var(--warn)', low:'var(--neutral)', info:'var(--ok)'};
|
|
1253
|
+
|
|
1254
|
+
return (
|
|
1255
|
+
<div>
|
|
1256
|
+
<div className="page-h">
|
|
1257
|
+
<div><h1>Insights</h1><div className="sub">{INSIGHTS.length} automatic insights from your API behavior</div></div>
|
|
1258
|
+
</div>
|
|
1259
|
+
<div className="filter-bar">
|
|
1260
|
+
<div className="chips">
|
|
1261
|
+
{types.map(t => (
|
|
1262
|
+
<button key={t.id} className={`chip ${typeFilter===t.id?'on':''}`} onClick={() => setTypeFilter(t.id)}>{t.label}</button>
|
|
1263
|
+
))}
|
|
1264
|
+
</div>
|
|
1265
|
+
<div style={{flex:1}}/>
|
|
1266
|
+
<select className="select" value={sevFilter} onChange={e => setSevFilter(e.target.value)}>
|
|
1267
|
+
<option value="ALL">All severities</option>
|
|
1268
|
+
<option value="high">High</option>
|
|
1269
|
+
<option value="med">Medium</option>
|
|
1270
|
+
<option value="low">Low</option>
|
|
1271
|
+
<option value="info">Info</option>
|
|
1272
|
+
</select>
|
|
1273
|
+
</div>
|
|
1274
|
+
{Object.entries(grouped).map(([sev, items]) => {
|
|
1275
|
+
if (items.length === 0) return null;
|
|
1276
|
+
return (
|
|
1277
|
+
<div key={sev} className="insight-group">
|
|
1278
|
+
<h3 className="insight-group-h">
|
|
1279
|
+
<span style={{width:6,height:6,borderRadius:'50%',display:'inline-block',background:sevColor[sev]}}></span>
|
|
1280
|
+
{sevLabel[sev]}<span className="cnt">{items.length}</span>
|
|
1281
|
+
</h3>
|
|
1282
|
+
<div className="insight-feed">
|
|
1283
|
+
{items.map(ins => (
|
|
1284
|
+
<div key={ins.id} className="insight"
|
|
1285
|
+
style={{cursor:ins.endpoint_id?'pointer':'default'}}
|
|
1286
|
+
onClick={() => { if (ins.endpoint_id) { setRoute('endpoint'); setParams({id:ins.endpoint_id}); } }}>
|
|
1287
|
+
<div className={`insight-ico insight-${ins.type}`}>{ins.type[0]}</div>
|
|
1288
|
+
<div>
|
|
1289
|
+
<div className="insight-title">{ins.title}</div>
|
|
1290
|
+
<div className="insight-meta">
|
|
1291
|
+
{ins.endpoint && <span className="mono">{ins.endpoint}</span>}
|
|
1292
|
+
<span className="sep">·</span><span>{ins.ts}</span>
|
|
1293
|
+
</div>
|
|
1294
|
+
</div>
|
|
1295
|
+
<div style={{display:'flex',flexDirection:'column',alignItems:'flex-end',gap:4}}>
|
|
1296
|
+
<span className="insight-action">{ins.type}</span>
|
|
1297
|
+
<span className="mono dim" style={{fontSize:11}}>{ins.ts}</span>
|
|
1298
|
+
</div>
|
|
1299
|
+
</div>
|
|
1300
|
+
))}
|
|
1301
|
+
</div>
|
|
1302
|
+
</div>
|
|
1303
|
+
);
|
|
1304
|
+
})}
|
|
1305
|
+
{filtered.length === 0 && <div className="card card-b dim" style={{textAlign:'center',padding:48}}>No insights match the current filters.</div>}
|
|
1306
|
+
</div>
|
|
1307
|
+
);
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
// ─── Releases ─────────────────────────────────────────────────────────────────
|
|
1311
|
+
function Releases() {
|
|
1312
|
+
const { RELEASES } = window.AF_DATA;
|
|
1313
|
+
const [selected, setSelected] = useState(RELEASES[0]?.tag || '');
|
|
1314
|
+
const sel = RELEASES.find(r => r.tag === selected);
|
|
1315
|
+
|
|
1316
|
+
if (RELEASES.length === 0) {
|
|
1317
|
+
return (
|
|
1318
|
+
<div>
|
|
1319
|
+
<div className="page-h"><div><h1>Releases</h1><div className="sub">Auto-correlation between deploys and API behavior</div></div></div>
|
|
1320
|
+
<div className="card card-b" style={{textAlign:'center',padding:48,color:'var(--text-dim)'}}>
|
|
1321
|
+
<div style={{marginBottom:12}}>No releases tracked yet.</div>
|
|
1322
|
+
<div style={{fontSize:12,marginBottom:12}}>Set the <span className="mono">release</span> option when initializing the SDK:</div>
|
|
1323
|
+
<CodeBlock>{`apiforge({ mode: 'local', release: 'v1.0.0' })`}</CodeBlock>
|
|
1324
|
+
</div>
|
|
1325
|
+
</div>
|
|
1326
|
+
);
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
return (
|
|
1330
|
+
<div>
|
|
1331
|
+
<div className="page-h">
|
|
1332
|
+
<div>
|
|
1333
|
+
<h1>Releases</h1>
|
|
1334
|
+
<div className="sub">Auto-correlation between deploys and API behavior · {RELEASES.length} releases tracked</div>
|
|
1335
|
+
</div>
|
|
1336
|
+
</div>
|
|
1337
|
+
<div className="card" style={{marginBottom:16}}>
|
|
1338
|
+
<div className="card-h"><div className="card-h-title">Deploy history</div><div className="card-h-sub">{RELEASES.length} releases</div></div>
|
|
1339
|
+
<div className="release-list">
|
|
1340
|
+
{RELEASES.map(r => (
|
|
1341
|
+
<div key={r.tag} className={`release-row ${selected===r.tag?'selected':''}`} onClick={() => setSelected(r.tag)}>
|
|
1342
|
+
<span className="release-dot"></span>
|
|
1343
|
+
<span className="release-tag">{r.tag}</span>
|
|
1344
|
+
<span className="dim" style={{fontSize:12,overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap'}}>{r.summary}</span>
|
|
1345
|
+
<span className="release-meta"><span>{r.age}</span><span>·</span><span>{r.by}</span></span>
|
|
1346
|
+
<span className={`release-badge rb-${r.status}`}>{r.status==='bad'?'REGRESSION':r.status==='warn'?'WATCH':'OK'}</span>
|
|
1347
|
+
</div>
|
|
1348
|
+
))}
|
|
1349
|
+
</div>
|
|
1350
|
+
</div>
|
|
1351
|
+
{sel && (
|
|
1352
|
+
<div className="card">
|
|
1353
|
+
<div className="card-h">
|
|
1354
|
+
<div className="card-h-title">{sel.tag}</div>
|
|
1355
|
+
<div className="card-h-sub">{sel.age} · {sel.summary}</div>
|
|
1356
|
+
<div className="card-h-actions"><span className="release-badge rb-ok">NO REGRESSION</span></div>
|
|
1357
|
+
</div>
|
|
1358
|
+
<div style={{padding:'14px 18px',fontSize:12,color:'var(--text-muted)'}}>
|
|
1359
|
+
Comparison data requires traffic both before and after the release window. Keep sending requests to build up data for richer release analytics.
|
|
1360
|
+
</div>
|
|
1361
|
+
</div>
|
|
1362
|
+
)}
|
|
1363
|
+
</div>
|
|
1364
|
+
);
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
// ─── Settings ─────────────────────────────────────────────────────────────────
|
|
1368
|
+
function Settings() {
|
|
1369
|
+
const [retention, setRetention] = useState(30);
|
|
1370
|
+
const [sampling, setSampling] = useState(100);
|
|
1371
|
+
const [autoStart, setAutoStart] = useState(true);
|
|
1372
|
+
const [saasSync, setSaasSync] = useState(false);
|
|
1373
|
+
|
|
1374
|
+
return (
|
|
1375
|
+
<div>
|
|
1376
|
+
<div className="page-h"><div><h1>Settings</h1><div className="sub">Local-only configuration</div></div></div>
|
|
1377
|
+
<div className="card" style={{marginBottom:16}}>
|
|
1378
|
+
<div className="card-h"><div className="card-h-title">Storage</div></div>
|
|
1379
|
+
<div className="settings-list">
|
|
1380
|
+
<div className="setting-row">
|
|
1381
|
+
<div>
|
|
1382
|
+
<div className="setting-title">Database location</div>
|
|
1383
|
+
<div className="setting-desc">Single SQLite file. Configured via the <span className="mono">dbPath</span> SDK option.</div>
|
|
1384
|
+
</div>
|
|
1385
|
+
<span className="mono" style={{fontSize:12,color:'var(--text-muted)'}}>see SDK config</span>
|
|
1386
|
+
</div>
|
|
1387
|
+
<div className="setting-row">
|
|
1388
|
+
<div>
|
|
1389
|
+
<div className="setting-title">Retention</div>
|
|
1390
|
+
<div className="setting-desc">How long to keep aggregated metrics buckets.</div>
|
|
1391
|
+
</div>
|
|
1392
|
+
<div style={{display:'flex',gap:8,alignItems:'center'}}>
|
|
1393
|
+
<input className="fld" type="number" value={retention} onChange={e => setRetention(+e.target.value)} style={{width:80}}/>
|
|
1394
|
+
<span className="dim">days</span>
|
|
1395
|
+
</div>
|
|
1396
|
+
</div>
|
|
1397
|
+
</div>
|
|
1398
|
+
</div>
|
|
1399
|
+
<div className="card" style={{marginBottom:16}}>
|
|
1400
|
+
<div className="card-h"><div className="card-h-title">SDK behavior</div></div>
|
|
1401
|
+
<div className="settings-list">
|
|
1402
|
+
<div className="setting-row">
|
|
1403
|
+
<div>
|
|
1404
|
+
<div className="setting-title">Sampling rate</div>
|
|
1405
|
+
<div className="setting-desc">Percentage of requests instrumented. Reduce at very high throughput.</div>
|
|
1406
|
+
</div>
|
|
1407
|
+
<div style={{display:'flex',gap:8,alignItems:'center'}}>
|
|
1408
|
+
<input className="fld" type="number" value={sampling} min={1} max={100} onChange={e => setSampling(+e.target.value)} style={{width:80}}/>
|
|
1409
|
+
<span className="dim">%</span>
|
|
1410
|
+
</div>
|
|
1411
|
+
</div>
|
|
1412
|
+
<div className="setting-row">
|
|
1413
|
+
<div>
|
|
1414
|
+
<div className="setting-title">Start dashboard with SDK</div>
|
|
1415
|
+
<div className="setting-desc">Expose dashboard automatically in dev mode.</div>
|
|
1416
|
+
</div>
|
|
1417
|
+
<div className={`toggle ${autoStart?'on':''}`} onClick={() => setAutoStart(!autoStart)}></div>
|
|
1418
|
+
</div>
|
|
1419
|
+
</div>
|
|
1420
|
+
</div>
|
|
1421
|
+
<div className="card" style={{marginBottom:16}}>
|
|
1422
|
+
<div className="card-h">
|
|
1423
|
+
<div className="card-h-title">Cloud sync</div>
|
|
1424
|
+
<div className="card-h-sub">optional · disabled by default</div>
|
|
1425
|
+
</div>
|
|
1426
|
+
<div className="settings-list">
|
|
1427
|
+
<div className="setting-row">
|
|
1428
|
+
<div>
|
|
1429
|
+
<div className="setting-title">Sync aggregates to APIForge Cloud</div>
|
|
1430
|
+
<div className="setting-desc">Sends <strong>anonymized aggregates only</strong>. No payloads, headers, IPs.</div>
|
|
1431
|
+
</div>
|
|
1432
|
+
<div className={`toggle ${saasSync?'on':''}`} onClick={() => setSaasSync(!saasSync)}></div>
|
|
1433
|
+
</div>
|
|
1434
|
+
{saasSync && (
|
|
1435
|
+
<div className="setting-row">
|
|
1436
|
+
<div><div className="setting-title">API key</div></div>
|
|
1437
|
+
<input className="fld" placeholder="apf_…" style={{width:280}}/>
|
|
1438
|
+
</div>
|
|
1439
|
+
)}
|
|
1440
|
+
</div>
|
|
1441
|
+
</div>
|
|
1442
|
+
<div className="card">
|
|
1443
|
+
<div className="card-h"><div className="card-h-title">Privacy</div></div>
|
|
1444
|
+
<div className="card-b" style={{fontSize:12,color:'var(--text-muted)',lineHeight:1.7}}>
|
|
1445
|
+
<strong style={{color:'var(--text)'}}>Collected</strong> · route patterns, HTTP methods, status codes, latency percentiles, timestamps.<br/>
|
|
1446
|
+
<strong style={{color:'var(--text)'}}>Never collected</strong> · request/response bodies, headers, query parameter values, IP addresses, user agents.<br/>
|
|
1447
|
+
All data stays on your machine by default. Cloud sync is opt-in.
|
|
1448
|
+
</div>
|
|
1449
|
+
</div>
|
|
1450
|
+
</div>
|
|
1451
|
+
);
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
// ─── Onboarding modal ─────────────────────────────────────────────────────────
|
|
1455
|
+
function Onboarding({ onClose }) {
|
|
1456
|
+
const [step, setStep] = useState(0);
|
|
1457
|
+
const [installed, setInstalled] = useState(false);
|
|
1458
|
+
const [integrated, setIntegrated] = useState(false);
|
|
1459
|
+
const [receiving, setReceiving] = useState(false);
|
|
1460
|
+
|
|
1461
|
+
useEffect(() => {
|
|
1462
|
+
if (step === 0) { const t = setTimeout(() => setInstalled(true), 1800); return () => clearTimeout(t); }
|
|
1463
|
+
if (step === 1) { const t = setTimeout(() => setIntegrated(true), 1400); return () => clearTimeout(t); }
|
|
1464
|
+
if (step === 2) { const t = setTimeout(() => setReceiving(true), 2200); return () => clearTimeout(t); }
|
|
1465
|
+
}, [step]);
|
|
1466
|
+
|
|
1467
|
+
const integrationCode = `const express = require('express');
|
|
1468
|
+
const apiforge = require('apiforgejs');
|
|
1469
|
+
|
|
1470
|
+
const app = express();
|
|
1471
|
+
app.use(apiforge({ mode: 'local' }));
|
|
1472
|
+
|
|
1473
|
+
app.get('/users/:id', (req, res) => {
|
|
1474
|
+
res.json({ id: req.params.id });
|
|
1475
|
+
});
|
|
1476
|
+
|
|
1477
|
+
app.listen(3000);`;
|
|
1478
|
+
|
|
1479
|
+
return (
|
|
1480
|
+
<Modal onClose={onClose}>
|
|
1481
|
+
<div className="modal-h">
|
|
1482
|
+
<div className="sb-logo" style={{width:28,height:28,fontSize:13}}>AF</div>
|
|
1483
|
+
<div>
|
|
1484
|
+
<div style={{fontSize:15,fontWeight:600}}>Connect your first service</div>
|
|
1485
|
+
<div className="dim" style={{fontSize:12}}>3 steps · ~2 minutes · no account needed</div>
|
|
1486
|
+
</div>
|
|
1487
|
+
<div style={{flex:1}}/>
|
|
1488
|
+
<button className="btn btn-ghost" onClick={onClose}><I.close /></button>
|
|
1489
|
+
</div>
|
|
1490
|
+
<div className="modal-b">
|
|
1491
|
+
<div className={`onb-step ${installed?'done':step===0?'active':''}`}>
|
|
1492
|
+
<div className="onb-num">{installed ? <I.check /> : '1'}</div>
|
|
1493
|
+
<div className="onb-content">
|
|
1494
|
+
<div className="onb-title">Install the SDK</div>
|
|
1495
|
+
<div className="onb-desc">Express.js · Node.js 22.5+ required.</div>
|
|
1496
|
+
<CodeBlock>npm install apiforgejs</CodeBlock>
|
|
1497
|
+
<div className={`waiting ${installed?'live':''}`} style={{marginTop:10}}>
|
|
1498
|
+
{installed ? <><I.check /> Installed apiforgejs</> : <><span className="spinner"></span> Waiting for npm install…</>}
|
|
1499
|
+
</div>
|
|
1500
|
+
</div>
|
|
1501
|
+
</div>
|
|
1502
|
+
<div className={`onb-step ${integrated?'done':installed?'active':''}`} style={{opacity:installed?1:0.45}}>
|
|
1503
|
+
<div className="onb-num">{integrated ? <I.check /> : '2'}</div>
|
|
1504
|
+
<div className="onb-content">
|
|
1505
|
+
<div className="onb-title">Add one line to your app</div>
|
|
1506
|
+
<div className="onb-desc">No agent, no daemon. Runs inside your process.</div>
|
|
1507
|
+
<CodeBlock>{integrationCode}</CodeBlock>
|
|
1508
|
+
{installed && !integrated && (
|
|
1509
|
+
<button className="btn btn-primary" style={{marginTop:10}} onClick={() => { setIntegrated(true); setStep(2); }}>I've added it →</button>
|
|
1510
|
+
)}
|
|
1511
|
+
{integrated && <div className="waiting live" style={{marginTop:10}}><I.check /> Code committed</div>}
|
|
1512
|
+
</div>
|
|
1513
|
+
</div>
|
|
1514
|
+
<div className={`onb-step ${receiving?'done':integrated?'active':''}`} style={{opacity:integrated?1:0.45}}>
|
|
1515
|
+
<div className="onb-num">{receiving ? <I.check /> : '3'}</div>
|
|
1516
|
+
<div className="onb-content">
|
|
1517
|
+
<div className="onb-title">Receive your first event</div>
|
|
1518
|
+
<div className="onb-desc">Make any request to your API.</div>
|
|
1519
|
+
<div className={`waiting ${receiving?'live':''}`} style={{marginTop:4}}>
|
|
1520
|
+
{receiving
|
|
1521
|
+
? <><I.check /> First request received!</>
|
|
1522
|
+
: integrated ? <><span className="spinner"></span> Listening… make a request</>
|
|
1523
|
+
: <span className="dim">Waiting for previous step</span>}
|
|
1524
|
+
</div>
|
|
1525
|
+
</div>
|
|
1526
|
+
</div>
|
|
1527
|
+
<div style={{marginTop:12,padding:14,background:'var(--bg-elev)',borderRadius:6,fontSize:12,color:'var(--text-muted)',lineHeight:1.6}}>
|
|
1528
|
+
<strong style={{color:'var(--text)'}}>Collected</strong> · route patterns, status codes, latency, timestamps.<br/>
|
|
1529
|
+
<strong style={{color:'var(--text)'}}>Never collected</strong> · request/response bodies, headers, query values, IPs. Ever.
|
|
1530
|
+
</div>
|
|
1531
|
+
</div>
|
|
1532
|
+
<div className="modal-f">
|
|
1533
|
+
<span className="dim" style={{fontSize:12,alignSelf:'center'}}>No account or cloud required.</span>
|
|
1534
|
+
<div className="row gap-2">
|
|
1535
|
+
<button className="btn" onClick={onClose}>Close</button>
|
|
1536
|
+
{receiving && <button className="btn btn-primary" onClick={onClose}>Go to dashboard <I.arrowR /></button>}
|
|
1537
|
+
</div>
|
|
1538
|
+
</div>
|
|
1539
|
+
</Modal>
|
|
1540
|
+
);
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
// ─── App root ─────────────────────────────────────────────────────────────────
|
|
1544
|
+
function App() {
|
|
1545
|
+
const [route, setRoute] = useState('overview');
|
|
1546
|
+
const [params, setParams] = useState({});
|
|
1547
|
+
const [timeRange, setTimeRange]= useState('24h');
|
|
1548
|
+
const [env, setEnv] = useState('production');
|
|
1549
|
+
const [onboarding,setOnboarding]=useState(false);
|
|
1550
|
+
const [loading, setLoading] = useState(true);
|
|
1551
|
+
const [error, setError] = useState(null);
|
|
1552
|
+
const [data, setData] = useState(null);
|
|
1553
|
+
const [lastUpdated, setLastUpdated] = useState(null);
|
|
1554
|
+
const hours = TIME_HOURS[timeRange] || 24;
|
|
1555
|
+
const hoursRef = useRef(hours);
|
|
1556
|
+
hoursRef.current = hours;
|
|
1557
|
+
|
|
1558
|
+
const fetchData = useRef(null);
|
|
1559
|
+
fetchData.current = async function() {
|
|
1560
|
+
try {
|
|
1561
|
+
const h = hoursRef.current;
|
|
1562
|
+
const [summary, routes, releases] = await Promise.all([
|
|
1563
|
+
fetch('/api/summary').then(r => r.json()),
|
|
1564
|
+
fetch(`/api/routes?hours=${h}`).then(r => r.json()),
|
|
1565
|
+
fetch('/api/releases').then(r => r.json()).catch(() => []),
|
|
1566
|
+
]);
|
|
1567
|
+
const endpoints = mapEndpoints(routes);
|
|
1568
|
+
const insights = mapInsights(summary.insights || []);
|
|
1569
|
+
const releasesData = mapReleases(releases);
|
|
1570
|
+
const summaryData = {
|
|
1571
|
+
health: Math.round(summary.health_score || 0),
|
|
1572
|
+
calls_24h: summary.calls_24h || 0,
|
|
1573
|
+
err_rate: (summary.error_rate_24h || 0) / 100,
|
|
1574
|
+
p90: Math.round(summary.avg_p90_24h || 0),
|
|
1575
|
+
active_endpoints: summary.active_routes || 0,
|
|
1576
|
+
total_endpoints: endpoints.length,
|
|
1577
|
+
insights_open: insights.filter(i => i.severity === 'high').length,
|
|
1578
|
+
};
|
|
1579
|
+
window.AF_DATA = { ENDPOINTS: endpoints, INSIGHTS: insights, RELEASES: releasesData, SUMMARY: summaryData };
|
|
1580
|
+
setData({ endpoints, insights, releases: releasesData, summary: summaryData });
|
|
1581
|
+
setLastUpdated(Date.now());
|
|
1582
|
+
setError(null);
|
|
1583
|
+
} catch (err) {
|
|
1584
|
+
setError(err.message);
|
|
1585
|
+
} finally {
|
|
1586
|
+
setLoading(false);
|
|
1587
|
+
}
|
|
1588
|
+
};
|
|
1589
|
+
|
|
1590
|
+
useEffect(() => {
|
|
1591
|
+
fetchData.current();
|
|
1592
|
+
const id = setInterval(() => fetchData.current(), 30_000);
|
|
1593
|
+
return () => clearInterval(id);
|
|
1594
|
+
}, [hours]);
|
|
1595
|
+
|
|
1596
|
+
if (loading) return (
|
|
1597
|
+
<div style={{display:'flex',alignItems:'center',justifyContent:'center',height:'100vh',gap:12,flexDirection:'column'}}>
|
|
1598
|
+
<span className="spinner" style={{width:20,height:20}}></span>
|
|
1599
|
+
<span style={{color:'var(--text-muted)',fontSize:13}}>Loading dashboard…</span>
|
|
1600
|
+
</div>
|
|
1601
|
+
);
|
|
1602
|
+
|
|
1603
|
+
if (error) return (
|
|
1604
|
+
<div style={{display:'flex',alignItems:'center',justifyContent:'center',height:'100vh',flexDirection:'column',gap:12}}>
|
|
1605
|
+
<div style={{color:'var(--danger)',fontWeight:500}}>Failed to load dashboard data</div>
|
|
1606
|
+
<div style={{color:'var(--text-muted)',fontSize:12,fontFamily:'var(--mono)'}}>{error}</div>
|
|
1607
|
+
<button className="btn btn-primary" onClick={() => { setLoading(true); fetchData(); }}>Retry</button>
|
|
1608
|
+
</div>
|
|
1609
|
+
);
|
|
1610
|
+
|
|
1611
|
+
const counts = {
|
|
1612
|
+
endpoints: data.endpoints.length,
|
|
1613
|
+
insights: data.insights.filter(i => i.severity === 'high').length || 0,
|
|
1614
|
+
releases: data.releases.length || 0,
|
|
1615
|
+
};
|
|
1616
|
+
const getEndpoint = id => data.endpoints.find(e => e.id === id);
|
|
1617
|
+
|
|
1618
|
+
return (
|
|
1619
|
+
<div className="app">
|
|
1620
|
+
<Sidebar route={route}
|
|
1621
|
+
setRoute={r => { setRoute(r); setParams({}); }}
|
|
1622
|
+
onOpenOnboarding={() => setOnboarding(true)}
|
|
1623
|
+
counts={counts}/>
|
|
1624
|
+
<div className="main">
|
|
1625
|
+
<Topbar route={route} params={params} setRoute={setRoute}
|
|
1626
|
+
timeRange={timeRange} setTimeRange={setTimeRange}
|
|
1627
|
+
env={env} setEnv={setEnv} getEndpoint={getEndpoint}
|
|
1628
|
+
lastUpdated={lastUpdated} onRefresh={() => fetchData.current()}/>
|
|
1629
|
+
<div className="content">
|
|
1630
|
+
<div className="content-inner">
|
|
1631
|
+
{route === 'overview' && <Overview timeRange={timeRange} setRoute={setRoute} setParams={setParams}/>}
|
|
1632
|
+
{route === 'endpoints' && <Endpoints setRoute={setRoute} setParams={setParams}/>}
|
|
1633
|
+
{route === 'endpoint' && <EndpointDetail id={params.id} timeRange={timeRange} setRoute={setRoute} setParams={setParams}/>}
|
|
1634
|
+
{route === 'insights' && <Insights setRoute={setRoute} setParams={setParams}/>}
|
|
1635
|
+
{route === 'releases' && <Releases/>}
|
|
1636
|
+
{route === 'settings' && <Settings/>}
|
|
1637
|
+
</div>
|
|
1638
|
+
</div>
|
|
1639
|
+
</div>
|
|
1640
|
+
{onboarding && <Onboarding onClose={() => setOnboarding(false)}/>}
|
|
1641
|
+
</div>
|
|
1642
|
+
);
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
|
|
1646
|
+
</script>
|
|
1647
|
+
</body>
|
|
1648
|
+
</html>
|