create-interview-cockpit 0.11.0 → 0.13.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/package.json +1 -1
- package/template/client/src/App.tsx +30 -1
- package/template/client/src/api.ts +119 -0
- package/template/client/src/components/CodeContextPanel.tsx +0 -622
- package/template/client/src/components/CodeRunnerModal.tsx +426 -240
- package/template/client/src/components/DeploymentLabModal.tsx +1941 -0
- package/template/client/src/components/LabsPanel.tsx +565 -0
- package/template/client/src/components/Sidebar.tsx +97 -55
- package/template/client/src/reactLab.ts +96 -31
- package/template/client/src/store.ts +52 -1
- package/template/client/src/types.ts +2 -0
- package/template/cockpit.json +1 -1
- package/template/server/src/index.ts +310 -1
- package/template/server/src/storage.ts +31 -0
|
@@ -0,0 +1,1941 @@
|
|
|
1
|
+
import { useState, useEffect, useRef } from "react";
|
|
2
|
+
import {
|
|
3
|
+
X,
|
|
4
|
+
Plus,
|
|
5
|
+
Trash2,
|
|
6
|
+
Play,
|
|
7
|
+
Pause,
|
|
8
|
+
RotateCcw,
|
|
9
|
+
Server,
|
|
10
|
+
ArrowRight,
|
|
11
|
+
ChevronLeft,
|
|
12
|
+
ChevronRight,
|
|
13
|
+
Network,
|
|
14
|
+
} from "lucide-react";
|
|
15
|
+
import { useStore } from "../store";
|
|
16
|
+
|
|
17
|
+
// ═══════════════════════════════════════════════════════════════════ Types
|
|
18
|
+
|
|
19
|
+
type Strategy =
|
|
20
|
+
| "recreate"
|
|
21
|
+
| "rolling"
|
|
22
|
+
| "blue-green"
|
|
23
|
+
| "canary"
|
|
24
|
+
| "ab"
|
|
25
|
+
| "shadow";
|
|
26
|
+
type InstState = "old" | "new" | "updating" | "down" | "shadow";
|
|
27
|
+
type Tab = "viz" | "metrics" | "mechanism" | "timeline";
|
|
28
|
+
|
|
29
|
+
type EventType =
|
|
30
|
+
| "start"
|
|
31
|
+
| "downtime-start"
|
|
32
|
+
| "downtime-end"
|
|
33
|
+
| "deploy-start"
|
|
34
|
+
| "health-check"
|
|
35
|
+
| "traffic-switch"
|
|
36
|
+
| "rollback-point"
|
|
37
|
+
| "observation"
|
|
38
|
+
| "complete";
|
|
39
|
+
|
|
40
|
+
interface Svc {
|
|
41
|
+
id: string;
|
|
42
|
+
name: string;
|
|
43
|
+
fromVer: string;
|
|
44
|
+
toVer: string;
|
|
45
|
+
instances: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface Inst {
|
|
49
|
+
id: string;
|
|
50
|
+
svcId: string;
|
|
51
|
+
state: InstState;
|
|
52
|
+
env: "blue" | "green" | "main";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface Metrics {
|
|
56
|
+
errorRate: number;
|
|
57
|
+
p50: number;
|
|
58
|
+
p99: number;
|
|
59
|
+
rps: number;
|
|
60
|
+
healthyOld: number;
|
|
61
|
+
healthyNew: number;
|
|
62
|
+
total: number;
|
|
63
|
+
trafficOldPct: number;
|
|
64
|
+
trafficNewPct: number;
|
|
65
|
+
rolloutPct: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface Snapshot {
|
|
69
|
+
label: string;
|
|
70
|
+
insts: Inst[];
|
|
71
|
+
metrics: Metrics;
|
|
72
|
+
note?: string;
|
|
73
|
+
// timeline fields
|
|
74
|
+
timestamp: number; // seconds from T+0
|
|
75
|
+
eventType: EventType;
|
|
76
|
+
infraCount: number; // total running instances (old + new + shadow)
|
|
77
|
+
isDowntime?: boolean;
|
|
78
|
+
downtimeDeltaSecs?: number; // how many seconds of downtime this step adds
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface Cfg {
|
|
82
|
+
batchSize: number;
|
|
83
|
+
canaryInitPct: number;
|
|
84
|
+
canaryStepPct: number;
|
|
85
|
+
abSplitPct: number;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ═══════════════════════════════════════════════════════════════════ Constants
|
|
89
|
+
|
|
90
|
+
const STRATEGIES: Strategy[] = [
|
|
91
|
+
"recreate",
|
|
92
|
+
"rolling",
|
|
93
|
+
"blue-green",
|
|
94
|
+
"canary",
|
|
95
|
+
"ab",
|
|
96
|
+
"shadow",
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
const STRAT_LABELS: Record<Strategy, [string, string]> = {
|
|
100
|
+
recreate: ["Recreate", "All down → all new"],
|
|
101
|
+
rolling: ["Rolling", "Instance by instance"],
|
|
102
|
+
"blue-green": ["Blue-Green", "Full env switch"],
|
|
103
|
+
canary: ["Canary", "Gradual % rollout"],
|
|
104
|
+
ab: ["A/B", "Split by segment"],
|
|
105
|
+
shadow: ["Shadow", "Mirror traffic"],
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const STRAT_ACTIVE_COLOR: Record<Strategy, string> = {
|
|
109
|
+
recreate: "border-red-500/50 bg-red-500/10 text-red-300",
|
|
110
|
+
rolling: "border-amber-500/50 bg-amber-500/10 text-amber-300",
|
|
111
|
+
"blue-green": "border-emerald-500/50 bg-emerald-500/10 text-emerald-300",
|
|
112
|
+
canary: "border-yellow-500/50 bg-yellow-500/10 text-yellow-300",
|
|
113
|
+
ab: "border-violet-500/50 bg-violet-500/10 text-violet-300",
|
|
114
|
+
shadow: "border-slate-500/50 bg-slate-500/15 text-slate-300",
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// scores 1-5 per dimension
|
|
118
|
+
const STRAT_TRADEOFFS: Record<
|
|
119
|
+
Strategy,
|
|
120
|
+
{
|
|
121
|
+
speed: number;
|
|
122
|
+
safety: number;
|
|
123
|
+
downtime: number;
|
|
124
|
+
rollback: number;
|
|
125
|
+
cost: number;
|
|
126
|
+
}
|
|
127
|
+
> = {
|
|
128
|
+
recreate: { speed: 5, safety: 1, downtime: 1, rollback: 2, cost: 1 },
|
|
129
|
+
rolling: { speed: 3, safety: 3, downtime: 4, rollback: 3, cost: 2 },
|
|
130
|
+
"blue-green": { speed: 3, safety: 4, downtime: 5, rollback: 5, cost: 2 },
|
|
131
|
+
canary: { speed: 2, safety: 5, downtime: 5, rollback: 4, cost: 3 },
|
|
132
|
+
ab: { speed: 2, safety: 4, downtime: 5, rollback: 3, cost: 3 },
|
|
133
|
+
shadow: { speed: 1, safety: 5, downtime: 5, rollback: 5, cost: 1 },
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// legend for scores
|
|
137
|
+
const TRADEOFF_HINT: Record<string, [string, string]> = {
|
|
138
|
+
speed: ["Speed", "How quickly can you release?"],
|
|
139
|
+
safety: ["Safety", "How much risk if the release has bugs?"],
|
|
140
|
+
downtime: ["No downtime", "Will users notice interruptions?"],
|
|
141
|
+
rollback: ["Rollback ease", "How easy to return to the old version?"],
|
|
142
|
+
cost: [
|
|
143
|
+
"Low cost",
|
|
144
|
+
"Servers, tools & processes required — inverted (5 = cheaper)",
|
|
145
|
+
],
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const INST_STYLE: Record<
|
|
149
|
+
InstState,
|
|
150
|
+
{ bg: string; border: string; text: string; label: string; pulse?: boolean }
|
|
151
|
+
> = {
|
|
152
|
+
old: {
|
|
153
|
+
bg: "bg-slate-700",
|
|
154
|
+
border: "border-slate-500",
|
|
155
|
+
text: "text-slate-300",
|
|
156
|
+
label: "v1",
|
|
157
|
+
},
|
|
158
|
+
new: {
|
|
159
|
+
bg: "bg-cyan-700",
|
|
160
|
+
border: "border-cyan-400",
|
|
161
|
+
text: "text-cyan-200",
|
|
162
|
+
label: "v2",
|
|
163
|
+
},
|
|
164
|
+
updating: {
|
|
165
|
+
bg: "bg-amber-700",
|
|
166
|
+
border: "border-amber-400",
|
|
167
|
+
text: "text-amber-200",
|
|
168
|
+
label: "↻",
|
|
169
|
+
pulse: true,
|
|
170
|
+
},
|
|
171
|
+
down: {
|
|
172
|
+
bg: "bg-red-900/80",
|
|
173
|
+
border: "border-red-700",
|
|
174
|
+
text: "text-red-400",
|
|
175
|
+
label: "✕",
|
|
176
|
+
},
|
|
177
|
+
shadow: {
|
|
178
|
+
bg: "bg-violet-900",
|
|
179
|
+
border: "border-violet-500",
|
|
180
|
+
text: "text-violet-300",
|
|
181
|
+
label: "v2",
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const DEFAULT_SVCS: Svc[] = [
|
|
186
|
+
{ id: "host", name: "Host", fromVer: "1.2.0", toVer: "2.0.0", instances: 3 },
|
|
187
|
+
{
|
|
188
|
+
id: "mfe-auth",
|
|
189
|
+
name: "MFE Auth",
|
|
190
|
+
fromVer: "1.0.4",
|
|
191
|
+
toVer: "1.1.0",
|
|
192
|
+
instances: 2,
|
|
193
|
+
},
|
|
194
|
+
];
|
|
195
|
+
|
|
196
|
+
const DEFAULT_CFG: Cfg = {
|
|
197
|
+
batchSize: 1,
|
|
198
|
+
canaryInitPct: 10,
|
|
199
|
+
canaryStepPct: 30,
|
|
200
|
+
abSplitPct: 50,
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
// ═══════════════════════════════════════════════════════════════════ Sim helpers
|
|
204
|
+
|
|
205
|
+
function buildInsts(svcs: Svc[]): Inst[] {
|
|
206
|
+
return svcs.flatMap((s) =>
|
|
207
|
+
Array.from({ length: s.instances }, (_, i) => ({
|
|
208
|
+
id: `${s.id}:${i}`,
|
|
209
|
+
svcId: s.id,
|
|
210
|
+
state: "old" as InstState,
|
|
211
|
+
env: "main" as const,
|
|
212
|
+
})),
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function baseMx(insts: Inst[]): Metrics {
|
|
217
|
+
return {
|
|
218
|
+
errorRate: 0.4,
|
|
219
|
+
p50: 42,
|
|
220
|
+
p99: 180,
|
|
221
|
+
rps: 1200,
|
|
222
|
+
healthyOld: insts.length,
|
|
223
|
+
healthyNew: 0,
|
|
224
|
+
total: insts.length,
|
|
225
|
+
trafficOldPct: 100,
|
|
226
|
+
trafficNewPct: 0,
|
|
227
|
+
rolloutPct: 0,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function patch(insts: Inst[], updates: Record<string, Partial<Inst>>): Inst[] {
|
|
232
|
+
return insts.map((i) => (updates[i.id] ? { ...i, ...updates[i.id] } : i));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ═══════════════════════════════════════════════════════════════════ Simulation builders
|
|
236
|
+
|
|
237
|
+
function simRecreate(svcs: Svc[]): Snapshot[] {
|
|
238
|
+
const base = buildInsts(svcs);
|
|
239
|
+
const n = base.length;
|
|
240
|
+
const mx = baseMx(base);
|
|
241
|
+
// ~10s to stop, ~40s to start per instance group
|
|
242
|
+
const stopDur = 10;
|
|
243
|
+
const startDur = Math.round(n * 15);
|
|
244
|
+
return [
|
|
245
|
+
{
|
|
246
|
+
label: "Live: v1 serving all traffic",
|
|
247
|
+
insts: base,
|
|
248
|
+
metrics: mx,
|
|
249
|
+
timestamp: 0,
|
|
250
|
+
eventType: "start",
|
|
251
|
+
infraCount: n,
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
label: "Shutting down all v1 instances...",
|
|
255
|
+
insts: base.map((i) => ({ ...i, state: "down" })),
|
|
256
|
+
metrics: {
|
|
257
|
+
...mx,
|
|
258
|
+
errorRate: 100,
|
|
259
|
+
rps: 0,
|
|
260
|
+
healthyOld: 0,
|
|
261
|
+
trafficOldPct: 0,
|
|
262
|
+
trafficNewPct: 0,
|
|
263
|
+
},
|
|
264
|
+
note: "⚠ DOWNTIME — App is unreachable during this phase",
|
|
265
|
+
timestamp: 5,
|
|
266
|
+
eventType: "downtime-start",
|
|
267
|
+
infraCount: 0,
|
|
268
|
+
isDowntime: true,
|
|
269
|
+
downtimeDeltaSecs: stopDur,
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
label: "Starting v2 instances...",
|
|
273
|
+
insts: base.map((i) => ({ ...i, state: "updating" })),
|
|
274
|
+
metrics: {
|
|
275
|
+
...mx,
|
|
276
|
+
errorRate: 100,
|
|
277
|
+
rps: 0,
|
|
278
|
+
healthyOld: 0,
|
|
279
|
+
trafficOldPct: 0,
|
|
280
|
+
trafficNewPct: 0,
|
|
281
|
+
},
|
|
282
|
+
note: "⚠ DOWNTIME — Instances initializing",
|
|
283
|
+
timestamp: 5 + stopDur,
|
|
284
|
+
eventType: "deploy-start",
|
|
285
|
+
infraCount: n,
|
|
286
|
+
isDowntime: true,
|
|
287
|
+
downtimeDeltaSecs: startDur,
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
label: "✓ v2 deployed — all traffic restored",
|
|
291
|
+
insts: base.map((i) => ({ ...i, state: "new" })),
|
|
292
|
+
metrics: {
|
|
293
|
+
...mx,
|
|
294
|
+
healthyOld: 0,
|
|
295
|
+
healthyNew: n,
|
|
296
|
+
trafficOldPct: 0,
|
|
297
|
+
trafficNewPct: 100,
|
|
298
|
+
rolloutPct: 100,
|
|
299
|
+
},
|
|
300
|
+
timestamp: 5 + stopDur + startDur,
|
|
301
|
+
eventType: "complete",
|
|
302
|
+
infraCount: n,
|
|
303
|
+
},
|
|
304
|
+
];
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function simRolling(svcs: Svc[], batchSize: number): Snapshot[] {
|
|
308
|
+
let current = buildInsts(svcs);
|
|
309
|
+
const n = current.length;
|
|
310
|
+
const mx = baseMx(current);
|
|
311
|
+
const perBatchSecs = 20;
|
|
312
|
+
const snaps: Snapshot[] = [
|
|
313
|
+
{
|
|
314
|
+
label: "Live: v1 serving all traffic",
|
|
315
|
+
insts: [...current],
|
|
316
|
+
metrics: mx,
|
|
317
|
+
timestamp: 0,
|
|
318
|
+
eventType: "start",
|
|
319
|
+
infraCount: n,
|
|
320
|
+
},
|
|
321
|
+
];
|
|
322
|
+
|
|
323
|
+
const ids = current.map((i) => i.id);
|
|
324
|
+
const totalBatches = Math.ceil(n / batchSize);
|
|
325
|
+
let t = 5;
|
|
326
|
+
|
|
327
|
+
for (let start = 0; start < ids.length; start += batchSize) {
|
|
328
|
+
const batch = ids.slice(start, start + batchSize);
|
|
329
|
+
const batchNum = Math.floor(start / batchSize) + 1;
|
|
330
|
+
|
|
331
|
+
const upd1: Record<string, Partial<Inst>> = {};
|
|
332
|
+
batch.forEach((id) => {
|
|
333
|
+
upd1[id] = { state: "updating" };
|
|
334
|
+
});
|
|
335
|
+
const insts1 = patch(current, upd1);
|
|
336
|
+
const newSoFar = insts1.filter((i) => i.state === "new").length;
|
|
337
|
+
|
|
338
|
+
snaps.push({
|
|
339
|
+
label: `Batch ${batchNum}/${totalBatches}: replacing instance(s)...`,
|
|
340
|
+
insts: insts1,
|
|
341
|
+
metrics: {
|
|
342
|
+
...mx,
|
|
343
|
+
errorRate: 0.8,
|
|
344
|
+
p50: 58,
|
|
345
|
+
healthyOld: n - newSoFar - batch.length,
|
|
346
|
+
healthyNew: newSoFar,
|
|
347
|
+
trafficOldPct: Math.round(((n - newSoFar) / n) * 100),
|
|
348
|
+
trafficNewPct: Math.round((newSoFar / n) * 100),
|
|
349
|
+
rolloutPct: Math.round((newSoFar / n) * 100),
|
|
350
|
+
},
|
|
351
|
+
timestamp: t,
|
|
352
|
+
eventType: "deploy-start",
|
|
353
|
+
infraCount: n,
|
|
354
|
+
});
|
|
355
|
+
t += Math.round(perBatchSecs * 0.6);
|
|
356
|
+
|
|
357
|
+
const upd2: Record<string, Partial<Inst>> = {};
|
|
358
|
+
batch.forEach((id) => {
|
|
359
|
+
upd2[id] = { state: "new" };
|
|
360
|
+
});
|
|
361
|
+
current = patch(current, upd2);
|
|
362
|
+
const newCount = current.filter((i) => i.state === "new").length;
|
|
363
|
+
|
|
364
|
+
snaps.push({
|
|
365
|
+
label: `Batch ${batchNum}/${totalBatches}: v2 live on updated instances`,
|
|
366
|
+
insts: [...current],
|
|
367
|
+
metrics: {
|
|
368
|
+
...mx,
|
|
369
|
+
healthyOld: n - newCount,
|
|
370
|
+
healthyNew: newCount,
|
|
371
|
+
trafficOldPct: Math.round(((n - newCount) / n) * 100),
|
|
372
|
+
trafficNewPct: Math.round((newCount / n) * 100),
|
|
373
|
+
rolloutPct: Math.round((newCount / n) * 100),
|
|
374
|
+
},
|
|
375
|
+
timestamp: t,
|
|
376
|
+
eventType: newCount === n ? "complete" : "health-check",
|
|
377
|
+
infraCount: n,
|
|
378
|
+
});
|
|
379
|
+
t += Math.round(perBatchSecs * 0.4);
|
|
380
|
+
}
|
|
381
|
+
return snaps;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function simBlueGreen(svcs: Svc[]): Snapshot[] {
|
|
385
|
+
const base = buildInsts(svcs);
|
|
386
|
+
const n = base.length;
|
|
387
|
+
const mx = baseMx(base);
|
|
388
|
+
const blue = base.map((i) => ({
|
|
389
|
+
...i,
|
|
390
|
+
env: "blue" as const,
|
|
391
|
+
state: "old" as InstState,
|
|
392
|
+
}));
|
|
393
|
+
const green = base.map((i) => ({
|
|
394
|
+
...i,
|
|
395
|
+
id: i.id + ":g",
|
|
396
|
+
env: "green" as const,
|
|
397
|
+
state: "new" as InstState,
|
|
398
|
+
}));
|
|
399
|
+
const spinUpSecs = Math.round(n * 20);
|
|
400
|
+
|
|
401
|
+
return [
|
|
402
|
+
{
|
|
403
|
+
label: "Live: Blue env (v1) serving all traffic",
|
|
404
|
+
insts: blue,
|
|
405
|
+
metrics: mx,
|
|
406
|
+
note: "Blue = active environment",
|
|
407
|
+
timestamp: 0,
|
|
408
|
+
eventType: "start" as EventType,
|
|
409
|
+
infraCount: n,
|
|
410
|
+
},
|
|
411
|
+
{
|
|
412
|
+
label: "Deploying v2 to Green environment...",
|
|
413
|
+
insts: [
|
|
414
|
+
...blue,
|
|
415
|
+
...green.map((i) => ({ ...i, state: "updating" as InstState })),
|
|
416
|
+
],
|
|
417
|
+
metrics: { ...mx, total: n * 2 },
|
|
418
|
+
note: "Green receives no user traffic yet",
|
|
419
|
+
timestamp: 5,
|
|
420
|
+
eventType: "deploy-start" as EventType,
|
|
421
|
+
infraCount: n * 2,
|
|
422
|
+
},
|
|
423
|
+
{
|
|
424
|
+
label: "Green v2 ready — running health checks...",
|
|
425
|
+
insts: [...blue, ...green],
|
|
426
|
+
metrics: { ...mx, total: n * 2 },
|
|
427
|
+
note: "✓ Health checks passing. Ready to switch.",
|
|
428
|
+
timestamp: 5 + spinUpSecs,
|
|
429
|
+
eventType: "health-check" as EventType,
|
|
430
|
+
infraCount: n * 2,
|
|
431
|
+
},
|
|
432
|
+
{
|
|
433
|
+
label: "✓ Traffic switched: Blue → Green (v2 live)",
|
|
434
|
+
insts: [...blue, ...green],
|
|
435
|
+
metrics: {
|
|
436
|
+
...mx,
|
|
437
|
+
healthyOld: n,
|
|
438
|
+
healthyNew: n,
|
|
439
|
+
total: n * 2,
|
|
440
|
+
trafficOldPct: 0,
|
|
441
|
+
trafficNewPct: 100,
|
|
442
|
+
rolloutPct: 100,
|
|
443
|
+
},
|
|
444
|
+
note: "Blue kept as warm standby. Rollback: flip LB back to Blue (< 30s)",
|
|
445
|
+
timestamp: 5 + spinUpSecs + 5,
|
|
446
|
+
eventType: "traffic-switch" as EventType,
|
|
447
|
+
infraCount: n * 2,
|
|
448
|
+
},
|
|
449
|
+
{
|
|
450
|
+
label: "Blue env decommissioned (optional)",
|
|
451
|
+
insts: green,
|
|
452
|
+
metrics: {
|
|
453
|
+
...mx,
|
|
454
|
+
healthyOld: 0,
|
|
455
|
+
healthyNew: n,
|
|
456
|
+
total: n,
|
|
457
|
+
trafficOldPct: 0,
|
|
458
|
+
trafficNewPct: 100,
|
|
459
|
+
rolloutPct: 100,
|
|
460
|
+
},
|
|
461
|
+
note: "Blue instances terminated. System back to baseline infra cost.",
|
|
462
|
+
timestamp: 5 + spinUpSecs + 5 + 300,
|
|
463
|
+
eventType: "rollback-point" as EventType,
|
|
464
|
+
infraCount: n,
|
|
465
|
+
},
|
|
466
|
+
];
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function simCanary(svcs: Svc[], initPct: number, stepPct: number): Snapshot[] {
|
|
470
|
+
let current = buildInsts(svcs);
|
|
471
|
+
const n = current.length;
|
|
472
|
+
const mx = baseMx(current);
|
|
473
|
+
const observationPerStep = 120; // 2 min observation window per canary step
|
|
474
|
+
const snaps: Snapshot[] = [
|
|
475
|
+
{
|
|
476
|
+
label: "Live: v1 serving 100% traffic",
|
|
477
|
+
insts: [...current],
|
|
478
|
+
metrics: mx,
|
|
479
|
+
timestamp: 0,
|
|
480
|
+
eventType: "start",
|
|
481
|
+
infraCount: n,
|
|
482
|
+
},
|
|
483
|
+
];
|
|
484
|
+
|
|
485
|
+
const pcts: number[] = [];
|
|
486
|
+
let p = initPct;
|
|
487
|
+
while (p < 100) {
|
|
488
|
+
pcts.push(p);
|
|
489
|
+
p = Math.min(100, p + stepPct);
|
|
490
|
+
}
|
|
491
|
+
pcts.push(100);
|
|
492
|
+
|
|
493
|
+
let alreadyNew = 0;
|
|
494
|
+
let t = 5;
|
|
495
|
+
for (const targetPct of pcts) {
|
|
496
|
+
const targetCount = Math.max(1, Math.round((targetPct / 100) * n));
|
|
497
|
+
const toFlip = targetCount - alreadyNew;
|
|
498
|
+
if (toFlip <= 0) continue;
|
|
499
|
+
|
|
500
|
+
let flipped = 0;
|
|
501
|
+
current = current.map((i) => {
|
|
502
|
+
if (i.state !== "old" || flipped >= toFlip) return i;
|
|
503
|
+
flipped++;
|
|
504
|
+
return { ...i, state: "new" as InstState };
|
|
505
|
+
});
|
|
506
|
+
alreadyNew = current.filter((i) => i.state === "new").length;
|
|
507
|
+
const actualPct = Math.round((alreadyNew / n) * 100);
|
|
508
|
+
const isLast = actualPct >= 100;
|
|
509
|
+
|
|
510
|
+
snaps.push({
|
|
511
|
+
label: `Canary at ${actualPct}% — monitoring signals...`,
|
|
512
|
+
insts: [...current],
|
|
513
|
+
metrics: {
|
|
514
|
+
...mx,
|
|
515
|
+
errorRate: 0.3,
|
|
516
|
+
healthyOld: n - alreadyNew,
|
|
517
|
+
healthyNew: alreadyNew,
|
|
518
|
+
trafficOldPct: 100 - actualPct,
|
|
519
|
+
trafficNewPct: actualPct,
|
|
520
|
+
rolloutPct: actualPct,
|
|
521
|
+
},
|
|
522
|
+
note: isLast
|
|
523
|
+
? "✓ Rollout complete — all traffic on v2"
|
|
524
|
+
: `${actualPct}% of users routed to v2 via X-Canary header. Watching error rate & latency.`,
|
|
525
|
+
timestamp: t,
|
|
526
|
+
eventType: isLast ? "complete" : "observation",
|
|
527
|
+
infraCount: n,
|
|
528
|
+
});
|
|
529
|
+
t += isLast ? 10 : observationPerStep;
|
|
530
|
+
}
|
|
531
|
+
return snaps;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function simAB(svcs: Svc[], splitPct: number): Snapshot[] {
|
|
535
|
+
const base = buildInsts(svcs);
|
|
536
|
+
const n = base.length;
|
|
537
|
+
const mx = baseMx(base);
|
|
538
|
+
const splitN = Math.max(1, Math.round((splitPct / 100) * n));
|
|
539
|
+
const split = base.map((i, idx) => ({
|
|
540
|
+
...i,
|
|
541
|
+
state: (idx < splitN ? "new" : "old") as InstState,
|
|
542
|
+
}));
|
|
543
|
+
const statSigSecs = 3600 * 24; // typically 24h+ to reach statistical significance
|
|
544
|
+
return [
|
|
545
|
+
{
|
|
546
|
+
label: "Live: v1 serving 100% traffic",
|
|
547
|
+
insts: base,
|
|
548
|
+
metrics: mx,
|
|
549
|
+
timestamp: 0,
|
|
550
|
+
eventType: "start",
|
|
551
|
+
infraCount: n,
|
|
552
|
+
},
|
|
553
|
+
{
|
|
554
|
+
label: `A/B active — A: ${100 - splitPct}% B: ${splitPct}%`,
|
|
555
|
+
insts: split,
|
|
556
|
+
metrics: {
|
|
557
|
+
...mx,
|
|
558
|
+
healthyOld: n - splitN,
|
|
559
|
+
healthyNew: splitN,
|
|
560
|
+
trafficOldPct: 100 - splitPct,
|
|
561
|
+
trafficNewPct: splitPct,
|
|
562
|
+
rolloutPct: splitPct,
|
|
563
|
+
},
|
|
564
|
+
note: "Users assigned by stable hash on user_id. Each user consistently sees the same variant.",
|
|
565
|
+
timestamp: 30,
|
|
566
|
+
eventType: "traffic-switch",
|
|
567
|
+
infraCount: n,
|
|
568
|
+
},
|
|
569
|
+
{
|
|
570
|
+
label: "Collecting signal data...",
|
|
571
|
+
insts: split,
|
|
572
|
+
metrics: {
|
|
573
|
+
...mx,
|
|
574
|
+
errorRate: 0.2,
|
|
575
|
+
p50: 38,
|
|
576
|
+
healthyOld: n - splitN,
|
|
577
|
+
healthyNew: splitN,
|
|
578
|
+
trafficOldPct: 100 - splitPct,
|
|
579
|
+
trafficNewPct: splitPct,
|
|
580
|
+
rolloutPct: splitPct,
|
|
581
|
+
},
|
|
582
|
+
note: "Comparing: conversion rate, session duration, error rates per variant",
|
|
583
|
+
timestamp: 30 + statSigSecs,
|
|
584
|
+
eventType: "observation",
|
|
585
|
+
infraCount: n,
|
|
586
|
+
},
|
|
587
|
+
{
|
|
588
|
+
label: "✓ Winner declared — shifting 100% to B",
|
|
589
|
+
insts: base.map((i) => ({ ...i, state: "new" as InstState })),
|
|
590
|
+
metrics: {
|
|
591
|
+
...mx,
|
|
592
|
+
healthyOld: 0,
|
|
593
|
+
healthyNew: n,
|
|
594
|
+
trafficOldPct: 0,
|
|
595
|
+
trafficNewPct: 100,
|
|
596
|
+
rolloutPct: 100,
|
|
597
|
+
},
|
|
598
|
+
note: "Based on statistical significance (p < 0.05). Full rollout complete.",
|
|
599
|
+
timestamp: 30 + statSigSecs + 300,
|
|
600
|
+
eventType: "complete",
|
|
601
|
+
infraCount: n,
|
|
602
|
+
},
|
|
603
|
+
];
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function simShadow(svcs: Svc[]): Snapshot[] {
|
|
607
|
+
const base = buildInsts(svcs);
|
|
608
|
+
const n = base.length;
|
|
609
|
+
const mx = baseMx(base);
|
|
610
|
+
const shadows = base.map((i) => ({
|
|
611
|
+
...i,
|
|
612
|
+
id: i.id + ":s",
|
|
613
|
+
state: "shadow" as InstState,
|
|
614
|
+
}));
|
|
615
|
+
const observeSecs = 3600 * 2; // 2 hours typical shadow observation period
|
|
616
|
+
return [
|
|
617
|
+
{
|
|
618
|
+
label: "Live: v1 serving all traffic",
|
|
619
|
+
insts: base,
|
|
620
|
+
metrics: mx,
|
|
621
|
+
timestamp: 0,
|
|
622
|
+
eventType: "start",
|
|
623
|
+
infraCount: n,
|
|
624
|
+
},
|
|
625
|
+
{
|
|
626
|
+
label: "Shadow mode: v2 receiving mirrored traffic",
|
|
627
|
+
insts: [...base, ...shadows],
|
|
628
|
+
metrics: { ...mx, total: n * 2 },
|
|
629
|
+
note: "Users only see v1 responses. v2 processes each request silently — responses discarded.",
|
|
630
|
+
timestamp: 30,
|
|
631
|
+
eventType: "deploy-start",
|
|
632
|
+
infraCount: n * 2,
|
|
633
|
+
},
|
|
634
|
+
{
|
|
635
|
+
label: "Observation phase: comparing v1 vs v2 behaviour",
|
|
636
|
+
insts: [...base, ...shadows],
|
|
637
|
+
metrics: { ...mx, p50: 41, p99: 172, errorRate: 0.1, total: n * 2 },
|
|
638
|
+
note: "⚠ Shadow traffic must be read-only — no writes to prod DB, real emails, or payments",
|
|
639
|
+
timestamp: 30 + observeSecs,
|
|
640
|
+
eventType: "observation",
|
|
641
|
+
infraCount: n * 2,
|
|
642
|
+
},
|
|
643
|
+
{
|
|
644
|
+
label: "✓ Observation complete — v2 promoted to primary",
|
|
645
|
+
insts: base.map((i) => ({ ...i, state: "new" as InstState })),
|
|
646
|
+
metrics: {
|
|
647
|
+
...mx,
|
|
648
|
+
healthyOld: 0,
|
|
649
|
+
healthyNew: n,
|
|
650
|
+
trafficOldPct: 0,
|
|
651
|
+
trafficNewPct: 100,
|
|
652
|
+
rolloutPct: 100,
|
|
653
|
+
},
|
|
654
|
+
note: "Shadow instances decommissioned. System at baseline infra cost.",
|
|
655
|
+
timestamp: 30 + observeSecs + 300,
|
|
656
|
+
eventType: "complete",
|
|
657
|
+
infraCount: n,
|
|
658
|
+
},
|
|
659
|
+
];
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function buildSim(svcs: Svc[], strategy: Strategy, cfg: Cfg): Snapshot[] {
|
|
663
|
+
switch (strategy) {
|
|
664
|
+
case "recreate":
|
|
665
|
+
return simRecreate(svcs);
|
|
666
|
+
case "rolling":
|
|
667
|
+
return simRolling(svcs, cfg.batchSize);
|
|
668
|
+
case "blue-green":
|
|
669
|
+
return simBlueGreen(svcs);
|
|
670
|
+
case "canary":
|
|
671
|
+
return simCanary(svcs, cfg.canaryInitPct, cfg.canaryStepPct);
|
|
672
|
+
case "ab":
|
|
673
|
+
return simAB(svcs, cfg.abSplitPct);
|
|
674
|
+
case "shadow":
|
|
675
|
+
return simShadow(svcs);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// ═══════════════════════════════════════════════════════════════════ Sub-components
|
|
680
|
+
|
|
681
|
+
function CodeBlock({ children }: { children: string }) {
|
|
682
|
+
return (
|
|
683
|
+
<pre className="bg-slate-900/80 border border-slate-700 rounded p-3 text-[10px] font-mono text-slate-300 overflow-x-auto whitespace-pre leading-relaxed">
|
|
684
|
+
{children}
|
|
685
|
+
</pre>
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function MetricBar({
|
|
690
|
+
label,
|
|
691
|
+
value,
|
|
692
|
+
max,
|
|
693
|
+
format,
|
|
694
|
+
color = "bg-cyan-500",
|
|
695
|
+
}: {
|
|
696
|
+
label: string;
|
|
697
|
+
value: number;
|
|
698
|
+
max: number;
|
|
699
|
+
format: (v: number) => string;
|
|
700
|
+
color?: string;
|
|
701
|
+
}) {
|
|
702
|
+
const pct = Math.min(100, (value / max) * 100);
|
|
703
|
+
return (
|
|
704
|
+
<div className="mb-3">
|
|
705
|
+
<div className="flex justify-between text-[10px] mb-1">
|
|
706
|
+
<span className="text-slate-400">{label}</span>
|
|
707
|
+
<span className="text-slate-300 font-mono">{format(value)}</span>
|
|
708
|
+
</div>
|
|
709
|
+
<div className="h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
|
710
|
+
<div
|
|
711
|
+
className={`h-full rounded-full transition-all duration-700 ${color}`}
|
|
712
|
+
style={{ width: `${pct}%` }}
|
|
713
|
+
/>
|
|
714
|
+
</div>
|
|
715
|
+
</div>
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function InstDot({ inst, svcs }: { inst: Inst; svcs: Svc[] }) {
|
|
720
|
+
const svc = svcs.find((s) => s.id === inst.svcId);
|
|
721
|
+
const style = INST_STYLE[inst.state];
|
|
722
|
+
const versionLabel =
|
|
723
|
+
inst.state === "old"
|
|
724
|
+
? svc?.fromVer
|
|
725
|
+
: inst.state === "new" || inst.state === "shadow"
|
|
726
|
+
? svc?.toVer
|
|
727
|
+
: inst.state;
|
|
728
|
+
|
|
729
|
+
return (
|
|
730
|
+
<div
|
|
731
|
+
title={`${inst.id} [${inst.env}] — ${versionLabel}`}
|
|
732
|
+
className={[
|
|
733
|
+
"w-10 h-10 rounded-lg border-2 flex items-center justify-center",
|
|
734
|
+
"text-[9px] font-bold select-none transition-all duration-500",
|
|
735
|
+
style.bg,
|
|
736
|
+
style.border,
|
|
737
|
+
style.text,
|
|
738
|
+
style.pulse ? "animate-pulse" : "",
|
|
739
|
+
inst.state === "shadow" ? "opacity-60 ring-1 ring-violet-400/30" : "",
|
|
740
|
+
inst.env === "blue" ? "ring-1 ring-blue-400/30" : "",
|
|
741
|
+
inst.env === "green" ? "ring-1 ring-emerald-400/30" : "",
|
|
742
|
+
]
|
|
743
|
+
.filter(Boolean)
|
|
744
|
+
.join(" ")}
|
|
745
|
+
>
|
|
746
|
+
{style.label}
|
|
747
|
+
</div>
|
|
748
|
+
);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function MechanismView({
|
|
752
|
+
strategy,
|
|
753
|
+
cfg,
|
|
754
|
+
snap,
|
|
755
|
+
}: {
|
|
756
|
+
strategy: Strategy;
|
|
757
|
+
cfg: Cfg;
|
|
758
|
+
snap: Snapshot | null;
|
|
759
|
+
}) {
|
|
760
|
+
const trafficNew = snap?.metrics.trafficNewPct ?? 0;
|
|
761
|
+
const trafficOld = snap?.metrics.trafficOldPct ?? 100;
|
|
762
|
+
|
|
763
|
+
return (
|
|
764
|
+
<div className="p-4 space-y-4">
|
|
765
|
+
<h3 className="text-[10px] font-semibold text-slate-500 uppercase tracking-widest">
|
|
766
|
+
Mechanism — {STRAT_LABELS[strategy][0]}
|
|
767
|
+
</h3>
|
|
768
|
+
|
|
769
|
+
{strategy === "recreate" && (
|
|
770
|
+
<div className="space-y-3">
|
|
771
|
+
<p className="text-[11px] text-slate-400">
|
|
772
|
+
No special routing mechanism. All instances are replaced before
|
|
773
|
+
traffic resumes.
|
|
774
|
+
</p>
|
|
775
|
+
<CodeBlock>{`# Kubernetes deployment strategy config
|
|
776
|
+
strategy:
|
|
777
|
+
type: Recreate ← stops all old pods, then starts new ones
|
|
778
|
+
|
|
779
|
+
# Deployment sequence
|
|
780
|
+
1. kubectl rollout: stop all v1 instances
|
|
781
|
+
2. Pull new image: registry.io/app:2.0.0
|
|
782
|
+
3. Start all v2 instances
|
|
783
|
+
4. Traffic resumes once v2 is healthy`}</CodeBlock>
|
|
784
|
+
<div className="bg-red-500/10 border border-red-500/30 rounded p-2.5 text-[10px] text-red-300 leading-relaxed">
|
|
785
|
+
⚠ Downtime window: ~30–90 seconds
|
|
786
|
+
<br />
|
|
787
|
+
Rollback: re-deploy v1 image — same downtime cost applies
|
|
788
|
+
</div>
|
|
789
|
+
</div>
|
|
790
|
+
)}
|
|
791
|
+
|
|
792
|
+
{strategy === "rolling" && (
|
|
793
|
+
<div className="space-y-3">
|
|
794
|
+
<p className="text-[11px] text-slate-400">
|
|
795
|
+
Load balancer routes only to healthy instances. Old and new coexist
|
|
796
|
+
during rollout.
|
|
797
|
+
</p>
|
|
798
|
+
<CodeBlock>{`# Kubernetes RollingUpdate config (deployment.yaml)
|
|
799
|
+
strategy:
|
|
800
|
+
type: RollingUpdate
|
|
801
|
+
rollingUpdate:
|
|
802
|
+
maxUnavailable: ${cfg.batchSize} ← max instances down at once
|
|
803
|
+
maxSurge: ${cfg.batchSize} ← max extra instances created
|
|
804
|
+
|
|
805
|
+
# Traffic routing during rollout
|
|
806
|
+
Request → Load Balancer → any healthy instance
|
|
807
|
+
(v1 OR v2, whichever is ready)
|
|
808
|
+
|
|
809
|
+
# No special request headers needed —
|
|
810
|
+
# LB health checks gate which instances receive traffic`}</CodeBlock>
|
|
811
|
+
<div className="bg-amber-500/10 border border-amber-500/30 rounded p-2.5 text-[10px] text-amber-300 leading-relaxed">
|
|
812
|
+
⚠ Backward compatibility required
|
|
813
|
+
<br />
|
|
814
|
+
v1 and v2 run simultaneously. API changes and DB schema must be
|
|
815
|
+
compatible with both versions during rollout.
|
|
816
|
+
</div>
|
|
817
|
+
</div>
|
|
818
|
+
)}
|
|
819
|
+
|
|
820
|
+
{strategy === "blue-green" && (
|
|
821
|
+
<div className="space-y-3">
|
|
822
|
+
<p className="text-[11px] text-slate-400">
|
|
823
|
+
Two full environments. Traffic switches atomically via a load
|
|
824
|
+
balancer rule.
|
|
825
|
+
</p>
|
|
826
|
+
<CodeBlock>{`# Before switch
|
|
827
|
+
User → ALB listener → Blue target group (v1.2.0) ✓
|
|
828
|
+
|
|
829
|
+
# After switch
|
|
830
|
+
User → ALB listener → Green target group (v2.0.0) ✓
|
|
831
|
+
Blue target group (v1.2.0) → kept as warm standby
|
|
832
|
+
|
|
833
|
+
# AWS ALB listener rule (simplified)
|
|
834
|
+
Action:
|
|
835
|
+
type: forward
|
|
836
|
+
target_group: ${trafficNew > 0 ? "green-tg ← v2.0.0 (active)" : "blue-tg ← v1.2.0 (active)"}
|
|
837
|
+
|
|
838
|
+
# Rollback: update listener to point back at blue-tg
|
|
839
|
+
# Takes < 30 seconds — no container restart needed`}</CodeBlock>
|
|
840
|
+
<div className="bg-emerald-500/10 border border-emerald-500/30 rounded p-2.5 text-[10px] text-emerald-300 leading-relaxed">
|
|
841
|
+
⚠ Database migrations must be forward-compatible
|
|
842
|
+
<br />
|
|
843
|
+
The DB is shared by both environments. If v2 changes the schema in a
|
|
844
|
+
way v1 cannot handle, rollback becomes hard.
|
|
845
|
+
</div>
|
|
846
|
+
</div>
|
|
847
|
+
)}
|
|
848
|
+
|
|
849
|
+
{strategy === "canary" && (
|
|
850
|
+
<div className="space-y-3">
|
|
851
|
+
<p className="text-[11px] text-slate-400">
|
|
852
|
+
A small percentage of users are routed to v2, identified by request
|
|
853
|
+
headers or ID hashing.
|
|
854
|
+
</p>
|
|
855
|
+
<CodeBlock>{`# Current traffic split
|
|
856
|
+
${trafficOld}% → v1.2.0 stable instances
|
|
857
|
+
${trafficNew}% → v2.0.0 canary instances
|
|
858
|
+
|
|
859
|
+
# NGINX split_clients (address + UA hashing)
|
|
860
|
+
split_clients "$remote_addr$http_user_agent" $upstream {
|
|
861
|
+
${String(trafficNew).padEnd(3)} canary_v2;
|
|
862
|
+
* stable_v1;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
# Request arriving at a canary instance:
|
|
866
|
+
GET /api/checkout HTTP/1.1
|
|
867
|
+
Host: app.example.com
|
|
868
|
+
X-Canary-Group: true
|
|
869
|
+
X-User-ID: a7f3bc92
|
|
870
|
+
Cookie: canary_session=1
|
|
871
|
+
|
|
872
|
+
# Routing decision (server-side)
|
|
873
|
+
IF hash(X-User-ID) % 100 < ${trafficNew}:
|
|
874
|
+
→ route to v2.0.0 canary instance group
|
|
875
|
+
ELSE:
|
|
876
|
+
→ route to v1.2.0 stable instance group`}</CodeBlock>
|
|
877
|
+
<div className="bg-yellow-500/10 border border-yellow-500/30 rounded p-2.5 text-[10px] text-yellow-300 leading-relaxed">
|
|
878
|
+
Blast radius: only {trafficNew}% of users see any v2 bugs right now
|
|
879
|
+
<br />
|
|
880
|
+
Monitor: error rate, p99 latency, conversion — before increasing %
|
|
881
|
+
</div>
|
|
882
|
+
</div>
|
|
883
|
+
)}
|
|
884
|
+
|
|
885
|
+
{strategy === "ab" && (
|
|
886
|
+
<div className="space-y-3">
|
|
887
|
+
<p className="text-[11px] text-slate-400">
|
|
888
|
+
Users are deterministically bucketed — consistent variant across
|
|
889
|
+
sessions.
|
|
890
|
+
</p>
|
|
891
|
+
<CodeBlock>{`# Traffic split
|
|
892
|
+
A (v1): ${trafficOld}% | B (v2): ${trafficNew}%
|
|
893
|
+
|
|
894
|
+
# User assignment (stable — same user always gets same variant)
|
|
895
|
+
bucket = murmur_hash(user_id) % 100
|
|
896
|
+
variant = bucket < ${trafficNew} ? "B" : "A"
|
|
897
|
+
route_to = variant === "B" ? v2_instances : v1_instances
|
|
898
|
+
|
|
899
|
+
# Request headers for a Group B user
|
|
900
|
+
GET /checkout HTTP/1.1
|
|
901
|
+
Cookie: user_id=u-48f2ca; ab_group=B
|
|
902
|
+
X-AB-Variant: B
|
|
903
|
+
|
|
904
|
+
# Metrics tracked per group
|
|
905
|
+
- Conversion rate
|
|
906
|
+
- Session duration (minutes)
|
|
907
|
+
- Error rate (%)
|
|
908
|
+
- Revenue per session ($)`}</CodeBlock>
|
|
909
|
+
<div className="bg-violet-500/10 border border-violet-500/30 rounded p-2.5 text-[10px] text-violet-300 leading-relaxed">
|
|
910
|
+
Unlike canary, the A/B split stays fixed until statistical
|
|
911
|
+
significance is reached.
|
|
912
|
+
<br />
|
|
913
|
+
Changing the split mid-experiment invalidates results.
|
|
914
|
+
</div>
|
|
915
|
+
</div>
|
|
916
|
+
)}
|
|
917
|
+
|
|
918
|
+
{strategy === "shadow" && (
|
|
919
|
+
<div className="space-y-3">
|
|
920
|
+
<p className="text-[11px] text-slate-400">
|
|
921
|
+
Real traffic is mirrored to v2. Users only ever see v1 responses.
|
|
922
|
+
</p>
|
|
923
|
+
<CodeBlock>{`# Envoy proxy mirror configuration
|
|
924
|
+
routes:
|
|
925
|
+
- match: { prefix: "/" }
|
|
926
|
+
route:
|
|
927
|
+
cluster: v1-primary ← user sees this response ✓
|
|
928
|
+
request_mirror_policies:
|
|
929
|
+
- cluster: v2-shadow
|
|
930
|
+
runtime_fraction:
|
|
931
|
+
default_value: { numerator: 100 } ← 100% mirrored
|
|
932
|
+
|
|
933
|
+
# What happens to each request
|
|
934
|
+
User Request
|
|
935
|
+
│
|
|
936
|
+
├── Primary → v1.2.0 → Response returned to user ✓
|
|
937
|
+
└── Mirror → v2.0.0 → Response DISCARDED (metrics only)
|
|
938
|
+
|
|
939
|
+
# v2 shadow response recorded for
|
|
940
|
+
- Error rate comparison
|
|
941
|
+
- Latency comparison (p50, p99)
|
|
942
|
+
- Response body diff (optional)`}</CodeBlock>
|
|
943
|
+
<div className="bg-red-500/10 border border-red-500/30 rounded p-2.5 text-[10px] text-red-300 leading-relaxed">
|
|
944
|
+
⚠ Critical isolation rules for shadow traffic
|
|
945
|
+
<br />
|
|
946
|
+
→ Must NOT write to the production database
|
|
947
|
+
<br />
|
|
948
|
+
→ Must NOT send real emails, SMS, or push notifications
|
|
949
|
+
<br />
|
|
950
|
+
→ Must NOT process real payments
|
|
951
|
+
<br />→ Use read-only DB replicas or mocked services in v2
|
|
952
|
+
</div>
|
|
953
|
+
</div>
|
|
954
|
+
)}
|
|
955
|
+
</div>
|
|
956
|
+
);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// ═══════════════════════════════════════════════════════════════════ Timeline helpers
|
|
960
|
+
|
|
961
|
+
function fmtTime(secs: number): string {
|
|
962
|
+
if (secs === 0) return "0 sec";
|
|
963
|
+
if (secs < 60) return `${secs} sec`;
|
|
964
|
+
if (secs < 3600) return `${Math.round(secs / 60)} min`;
|
|
965
|
+
if (secs < 86400) return `${(secs / 3600).toFixed(1)} hr`;
|
|
966
|
+
return `${(secs / 86400).toFixed(1)} days`;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
function fmtOffset(secs: number): string {
|
|
970
|
+
if (secs === 0) return "start";
|
|
971
|
+
if (secs < 60) return `+${secs}s`;
|
|
972
|
+
if (secs < 3600) return `+${Math.round(secs / 60)} min`;
|
|
973
|
+
if (secs < 86400) return `+${(secs / 3600).toFixed(1)} hr`;
|
|
974
|
+
return `+${(secs / 86400).toFixed(1)} days`;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
const EVENT_STYLE: Record<
|
|
978
|
+
EventType,
|
|
979
|
+
{ dot: string; text: string; label: string }
|
|
980
|
+
> = {
|
|
981
|
+
start: { dot: "bg-slate-500", text: "text-slate-400", label: "Start" },
|
|
982
|
+
"deploy-start": {
|
|
983
|
+
dot: "bg-amber-500",
|
|
984
|
+
text: "text-amber-400",
|
|
985
|
+
label: "Deploy",
|
|
986
|
+
},
|
|
987
|
+
"downtime-start": {
|
|
988
|
+
dot: "bg-red-600",
|
|
989
|
+
text: "text-red-400",
|
|
990
|
+
label: "Downtime start",
|
|
991
|
+
},
|
|
992
|
+
"downtime-end": {
|
|
993
|
+
dot: "bg-emerald-600",
|
|
994
|
+
text: "text-emerald-400",
|
|
995
|
+
label: "Downtime end",
|
|
996
|
+
},
|
|
997
|
+
"health-check": {
|
|
998
|
+
dot: "bg-blue-500",
|
|
999
|
+
text: "text-blue-400",
|
|
1000
|
+
label: "Health check",
|
|
1001
|
+
},
|
|
1002
|
+
"traffic-switch": {
|
|
1003
|
+
dot: "bg-cyan-500",
|
|
1004
|
+
text: "text-cyan-400",
|
|
1005
|
+
label: "Traffic switch",
|
|
1006
|
+
},
|
|
1007
|
+
"rollback-point": {
|
|
1008
|
+
dot: "bg-violet-500",
|
|
1009
|
+
text: "text-violet-400",
|
|
1010
|
+
label: "Rollback point",
|
|
1011
|
+
},
|
|
1012
|
+
observation: {
|
|
1013
|
+
dot: "bg-yellow-500",
|
|
1014
|
+
text: "text-yellow-400",
|
|
1015
|
+
label: "Observation",
|
|
1016
|
+
},
|
|
1017
|
+
complete: {
|
|
1018
|
+
dot: "bg-emerald-500",
|
|
1019
|
+
text: "text-emerald-400",
|
|
1020
|
+
label: "Complete",
|
|
1021
|
+
},
|
|
1022
|
+
};
|
|
1023
|
+
|
|
1024
|
+
function TimelineView({
|
|
1025
|
+
snapshots,
|
|
1026
|
+
currentIdx,
|
|
1027
|
+
svcs,
|
|
1028
|
+
onJump,
|
|
1029
|
+
}: {
|
|
1030
|
+
snapshots: Snapshot[];
|
|
1031
|
+
currentIdx: number;
|
|
1032
|
+
svcs: Svc[];
|
|
1033
|
+
onJump: (idx: number) => void;
|
|
1034
|
+
}) {
|
|
1035
|
+
if (snapshots.length === 0) {
|
|
1036
|
+
return (
|
|
1037
|
+
<div className="mt-24 text-center text-slate-600 text-[12px] p-6">
|
|
1038
|
+
Run a deployment to see the event timeline.
|
|
1039
|
+
</div>
|
|
1040
|
+
);
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
const totalSecs = snapshots[snapshots.length - 1].timestamp;
|
|
1044
|
+
const totalDowntime = snapshots.reduce(
|
|
1045
|
+
(acc, s) => acc + (s.downtimeDeltaSecs ?? 0),
|
|
1046
|
+
0,
|
|
1047
|
+
);
|
|
1048
|
+
const peakInfra = Math.max(...snapshots.map((s) => s.infraCount));
|
|
1049
|
+
const baseInfra = snapshots[0]?.infraCount ?? 0;
|
|
1050
|
+
const extraInfra = peakInfra - baseInfra;
|
|
1051
|
+
|
|
1052
|
+
return (
|
|
1053
|
+
<div className="p-5 space-y-5">
|
|
1054
|
+
{/* Summary stat cards */}
|
|
1055
|
+
<div className="grid grid-cols-4 gap-2">
|
|
1056
|
+
<div className="bg-slate-900 border border-slate-700 rounded p-3">
|
|
1057
|
+
<div className="text-[9px] text-slate-500 uppercase tracking-widest mb-1">
|
|
1058
|
+
Total time
|
|
1059
|
+
</div>
|
|
1060
|
+
<div className="text-[15px] font-mono text-slate-200 font-semibold leading-none">
|
|
1061
|
+
{fmtTime(totalSecs)}
|
|
1062
|
+
</div>
|
|
1063
|
+
<div className="text-[9px] text-slate-600 mt-1">
|
|
1064
|
+
from first step to complete
|
|
1065
|
+
</div>
|
|
1066
|
+
</div>
|
|
1067
|
+
<div
|
|
1068
|
+
className={`border rounded p-3 ${totalDowntime > 0 ? "bg-red-900/20 border-red-700/40" : "bg-slate-900 border-slate-700"}`}
|
|
1069
|
+
>
|
|
1070
|
+
<div className="text-[9px] text-slate-500 uppercase tracking-widest mb-1">
|
|
1071
|
+
Downtime
|
|
1072
|
+
</div>
|
|
1073
|
+
<div
|
|
1074
|
+
className={`text-[15px] font-mono font-semibold leading-none ${totalDowntime > 0 ? "text-red-400" : "text-emerald-400"}`}
|
|
1075
|
+
>
|
|
1076
|
+
{totalDowntime > 0 ? fmtTime(totalDowntime) : "Zero"}
|
|
1077
|
+
</div>
|
|
1078
|
+
<div className="text-[9px] text-slate-600 mt-1">
|
|
1079
|
+
user-visible outage
|
|
1080
|
+
</div>
|
|
1081
|
+
</div>
|
|
1082
|
+
<div
|
|
1083
|
+
className={`border rounded p-3 ${extraInfra > 0 ? "bg-amber-900/20 border-amber-700/40" : "bg-slate-900 border-slate-700"}`}
|
|
1084
|
+
>
|
|
1085
|
+
<div className="text-[9px] text-slate-500 uppercase tracking-widest mb-1">
|
|
1086
|
+
Peak infra
|
|
1087
|
+
</div>
|
|
1088
|
+
<div
|
|
1089
|
+
className={`text-[15px] font-mono font-semibold leading-none ${extraInfra > 0 ? "text-amber-400" : "text-slate-200"}`}
|
|
1090
|
+
>
|
|
1091
|
+
{peakInfra} inst.
|
|
1092
|
+
</div>
|
|
1093
|
+
<div className="text-[9px] text-slate-600 mt-1">
|
|
1094
|
+
{extraInfra > 0
|
|
1095
|
+
? `+${extraInfra} extra vs. baseline`
|
|
1096
|
+
: "same as baseline"}
|
|
1097
|
+
</div>
|
|
1098
|
+
</div>
|
|
1099
|
+
<div className="bg-slate-900 border border-slate-700 rounded p-3">
|
|
1100
|
+
<div className="text-[9px] text-slate-500 uppercase tracking-widest mb-1">
|
|
1101
|
+
Events
|
|
1102
|
+
</div>
|
|
1103
|
+
<div className="text-[15px] font-mono text-slate-200 font-semibold leading-none">
|
|
1104
|
+
{snapshots.length}
|
|
1105
|
+
</div>
|
|
1106
|
+
<div className="text-[9px] text-slate-600 mt-1">
|
|
1107
|
+
steps in this deploy
|
|
1108
|
+
</div>
|
|
1109
|
+
</div>
|
|
1110
|
+
</div>
|
|
1111
|
+
|
|
1112
|
+
{/* Infra chart */}
|
|
1113
|
+
<div className="bg-slate-900/60 border border-slate-800 rounded p-3">
|
|
1114
|
+
<div className="text-[10px] text-slate-500 mb-2">
|
|
1115
|
+
Instance count over time
|
|
1116
|
+
<span className="text-slate-600 ml-2 italic">
|
|
1117
|
+
(baseline = {baseInfra})
|
|
1118
|
+
</span>
|
|
1119
|
+
</div>
|
|
1120
|
+
<div className="flex items-end gap-1 h-12">
|
|
1121
|
+
{snapshots.map((s, i) => {
|
|
1122
|
+
const pct = peakInfra > 0 ? (s.infraCount / peakInfra) * 100 : 0;
|
|
1123
|
+
const isActive = i === currentIdx;
|
|
1124
|
+
return (
|
|
1125
|
+
<button
|
|
1126
|
+
key={i}
|
|
1127
|
+
onClick={() => onJump(i)}
|
|
1128
|
+
title={`${s.label}\n${s.infraCount} instances`}
|
|
1129
|
+
className="flex-1 rounded-sm transition-all relative"
|
|
1130
|
+
style={{ height: `${Math.max(8, pct)}%` }}
|
|
1131
|
+
>
|
|
1132
|
+
<div
|
|
1133
|
+
className={`w-full h-full rounded-sm transition-colors ${
|
|
1134
|
+
s.isDowntime
|
|
1135
|
+
? "bg-red-700"
|
|
1136
|
+
: isActive
|
|
1137
|
+
? "bg-cyan-500"
|
|
1138
|
+
: "bg-slate-600 hover:bg-slate-500"
|
|
1139
|
+
}`}
|
|
1140
|
+
/>
|
|
1141
|
+
</button>
|
|
1142
|
+
);
|
|
1143
|
+
})}
|
|
1144
|
+
</div>
|
|
1145
|
+
<div className="flex justify-between text-[9px] text-slate-600 mt-1">
|
|
1146
|
+
<span>start</span>
|
|
1147
|
+
<span>{fmtTime(totalSecs)} total</span>
|
|
1148
|
+
</div>
|
|
1149
|
+
</div>
|
|
1150
|
+
|
|
1151
|
+
{/* Event log */}
|
|
1152
|
+
<div>
|
|
1153
|
+
<div className="text-[10px] font-semibold tracking-widest text-slate-500 mb-3">
|
|
1154
|
+
EVENT LOG
|
|
1155
|
+
</div>
|
|
1156
|
+
<div className="relative">
|
|
1157
|
+
{/* Vertical line */}
|
|
1158
|
+
<div className="absolute left-[7px] top-2 bottom-2 w-px bg-slate-700" />
|
|
1159
|
+
|
|
1160
|
+
<div className="space-y-0">
|
|
1161
|
+
{snapshots.map((s, i) => {
|
|
1162
|
+
const style = EVENT_STYLE[s.eventType];
|
|
1163
|
+
const isActive = i === currentIdx;
|
|
1164
|
+
const isFuture = i > currentIdx;
|
|
1165
|
+
|
|
1166
|
+
return (
|
|
1167
|
+
<button
|
|
1168
|
+
key={i}
|
|
1169
|
+
onClick={() => onJump(i)}
|
|
1170
|
+
className={`w-full text-left flex items-start gap-3 py-2 px-1 rounded transition-colors group ${
|
|
1171
|
+
isActive ? "bg-slate-800/70" : "hover:bg-slate-800/40"
|
|
1172
|
+
} ${isFuture ? "opacity-40" : ""}`}
|
|
1173
|
+
>
|
|
1174
|
+
{/* Dot */}
|
|
1175
|
+
<div
|
|
1176
|
+
className={`w-3.5 h-3.5 rounded-full shrink-0 mt-0.5 ring-2 ring-slate-950 ${
|
|
1177
|
+
isActive
|
|
1178
|
+
? style.dot
|
|
1179
|
+
: isFuture
|
|
1180
|
+
? "bg-slate-700"
|
|
1181
|
+
: style.dot
|
|
1182
|
+
} transition-colors`}
|
|
1183
|
+
/>
|
|
1184
|
+
|
|
1185
|
+
<div className="flex-1 min-w-0">
|
|
1186
|
+
<div className="flex items-center gap-2 flex-wrap">
|
|
1187
|
+
<span
|
|
1188
|
+
className={`text-[10px] font-mono shrink-0 ${isActive ? "text-slate-300" : "text-slate-600"}`}
|
|
1189
|
+
>
|
|
1190
|
+
{fmtOffset(s.timestamp)}
|
|
1191
|
+
</span>
|
|
1192
|
+
<span
|
|
1193
|
+
className={`text-[9px] font-semibold uppercase tracking-wide ${style.text}`}
|
|
1194
|
+
>
|
|
1195
|
+
{style.label}
|
|
1196
|
+
</span>
|
|
1197
|
+
{s.isDowntime && (
|
|
1198
|
+
<span className="text-[9px] bg-red-900/60 text-red-400 border border-red-700/50 rounded px-1">
|
|
1199
|
+
DOWNTIME
|
|
1200
|
+
</span>
|
|
1201
|
+
)}
|
|
1202
|
+
</div>
|
|
1203
|
+
<div
|
|
1204
|
+
className={`text-[11px] leading-tight mt-0.5 ${isActive ? "text-slate-200" : "text-slate-400"}`}
|
|
1205
|
+
>
|
|
1206
|
+
{s.label}
|
|
1207
|
+
</div>
|
|
1208
|
+
{s.note && (
|
|
1209
|
+
<div className="text-[10px] text-slate-500 leading-snug mt-0.5 italic">
|
|
1210
|
+
{s.note}
|
|
1211
|
+
</div>
|
|
1212
|
+
)}
|
|
1213
|
+
<div className="flex gap-3 text-[10px] text-slate-600 mt-1">
|
|
1214
|
+
<span>{s.infraCount} inst.</span>
|
|
1215
|
+
{s.downtimeDeltaSecs != null &&
|
|
1216
|
+
s.downtimeDeltaSecs > 0 && (
|
|
1217
|
+
<span className="text-red-500">
|
|
1218
|
+
+{fmtTime(s.downtimeDeltaSecs)} outage
|
|
1219
|
+
</span>
|
|
1220
|
+
)}
|
|
1221
|
+
</div>
|
|
1222
|
+
</div>
|
|
1223
|
+
|
|
1224
|
+
{isActive && (
|
|
1225
|
+
<div className="w-1.5 h-1.5 rounded-full bg-cyan-400 shrink-0 mt-1.5 animate-pulse" />
|
|
1226
|
+
)}
|
|
1227
|
+
</button>
|
|
1228
|
+
);
|
|
1229
|
+
})}
|
|
1230
|
+
</div>
|
|
1231
|
+
</div>
|
|
1232
|
+
</div>
|
|
1233
|
+
</div>
|
|
1234
|
+
);
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
// ═══════════════════════════════════════════════════════════════════ Main component
|
|
1238
|
+
|
|
1239
|
+
export default function DeploymentLabModal() {
|
|
1240
|
+
const { closeDeploymentLab } = useStore();
|
|
1241
|
+
|
|
1242
|
+
const [svcs, setSvcs] = useState<Svc[]>(DEFAULT_SVCS);
|
|
1243
|
+
const [strategy, setStrategy] = useState<Strategy>("rolling");
|
|
1244
|
+
const [cfg, setCfg] = useState<Cfg>(DEFAULT_CFG);
|
|
1245
|
+
const [tab, setTab] = useState<Tab>("viz");
|
|
1246
|
+
const [snapshots, setSnapshots] = useState<Snapshot[]>([]);
|
|
1247
|
+
const [stepIdx, setStepIdx] = useState(0);
|
|
1248
|
+
const [playing, setPlaying] = useState(false);
|
|
1249
|
+
|
|
1250
|
+
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
1251
|
+
const isDeployed = snapshots.length > 0;
|
|
1252
|
+
const snap = snapshots[stepIdx] ?? null;
|
|
1253
|
+
const mx = snap?.metrics;
|
|
1254
|
+
|
|
1255
|
+
// Auto-step
|
|
1256
|
+
useEffect(() => {
|
|
1257
|
+
if (playing) {
|
|
1258
|
+
intervalRef.current = setInterval(() => {
|
|
1259
|
+
setStepIdx((prev) => {
|
|
1260
|
+
if (prev >= snapshots.length - 1) {
|
|
1261
|
+
setPlaying(false);
|
|
1262
|
+
return prev;
|
|
1263
|
+
}
|
|
1264
|
+
return prev + 1;
|
|
1265
|
+
});
|
|
1266
|
+
}, 1800);
|
|
1267
|
+
} else {
|
|
1268
|
+
if (intervalRef.current) clearInterval(intervalRef.current);
|
|
1269
|
+
}
|
|
1270
|
+
return () => {
|
|
1271
|
+
if (intervalRef.current) clearInterval(intervalRef.current);
|
|
1272
|
+
};
|
|
1273
|
+
}, [playing, snapshots.length]);
|
|
1274
|
+
|
|
1275
|
+
function handleDeploy() {
|
|
1276
|
+
if (svcs.length === 0) return;
|
|
1277
|
+
const sim = buildSim(svcs, strategy, cfg);
|
|
1278
|
+
setSnapshots(sim);
|
|
1279
|
+
setStepIdx(0);
|
|
1280
|
+
setPlaying(true);
|
|
1281
|
+
setTab("viz");
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
function handleReset() {
|
|
1285
|
+
setPlaying(false);
|
|
1286
|
+
setSnapshots([]);
|
|
1287
|
+
setStepIdx(0);
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
function addSvc() {
|
|
1291
|
+
const id = `svc-${Date.now()}`;
|
|
1292
|
+
setSvcs((prev) => [
|
|
1293
|
+
...prev,
|
|
1294
|
+
{ id, name: "MFE", fromVer: "1.0.0", toVer: "2.0.0", instances: 2 },
|
|
1295
|
+
]);
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
function updateSvc(id: string, field: keyof Svc, value: string | number) {
|
|
1299
|
+
setSvcs((prev) =>
|
|
1300
|
+
prev.map((s) => (s.id === id ? { ...s, [field]: value } : s)),
|
|
1301
|
+
);
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
// Build instance list, group by service for rendering
|
|
1305
|
+
const insts = snap?.insts ?? [];
|
|
1306
|
+
|
|
1307
|
+
return (
|
|
1308
|
+
<div className="fixed inset-0 z-50 flex flex-col bg-slate-950 text-slate-200">
|
|
1309
|
+
{/* ── Header ───────────────────────────────────────────────── */}
|
|
1310
|
+
<div className="flex items-center gap-3 px-4 py-2.5 border-b border-slate-800 shrink-0">
|
|
1311
|
+
<Network className="w-4 h-4 text-violet-400" />
|
|
1312
|
+
<span className="text-sm font-semibold text-slate-200">
|
|
1313
|
+
Deployment Lab
|
|
1314
|
+
</span>
|
|
1315
|
+
<span className="text-[10px] text-slate-600 font-mono ml-1">
|
|
1316
|
+
— simulate deployment strategies
|
|
1317
|
+
</span>
|
|
1318
|
+
<div className="flex-1" />
|
|
1319
|
+
<button
|
|
1320
|
+
onClick={closeDeploymentLab}
|
|
1321
|
+
className="p-1 text-slate-500 hover:text-slate-300 transition-colors"
|
|
1322
|
+
>
|
|
1323
|
+
<X className="w-4 h-4" />
|
|
1324
|
+
</button>
|
|
1325
|
+
</div>
|
|
1326
|
+
|
|
1327
|
+
{/* ── Body ─────────────────────────────────────────────────── */}
|
|
1328
|
+
<div className="flex flex-1 min-h-0">
|
|
1329
|
+
{/* Left panel */}
|
|
1330
|
+
<div className="w-64 shrink-0 border-r border-slate-800 flex flex-col overflow-y-auto">
|
|
1331
|
+
{/* Services */}
|
|
1332
|
+
<div className="p-3 border-b border-slate-800">
|
|
1333
|
+
<div className="flex items-center justify-between mb-2">
|
|
1334
|
+
<span className="text-[10px] font-semibold tracking-widest text-slate-500">
|
|
1335
|
+
SERVICES
|
|
1336
|
+
</span>
|
|
1337
|
+
<button
|
|
1338
|
+
onClick={addSvc}
|
|
1339
|
+
className="text-slate-500 hover:text-slate-300 transition-colors"
|
|
1340
|
+
>
|
|
1341
|
+
<Plus className="w-3.5 h-3.5" />
|
|
1342
|
+
</button>
|
|
1343
|
+
</div>
|
|
1344
|
+
|
|
1345
|
+
<div className="space-y-2">
|
|
1346
|
+
{svcs.map((s) => (
|
|
1347
|
+
<div
|
|
1348
|
+
key={s.id}
|
|
1349
|
+
className="bg-slate-900/60 border border-slate-800 rounded p-2"
|
|
1350
|
+
>
|
|
1351
|
+
<div className="flex items-center gap-1 mb-1.5">
|
|
1352
|
+
<Server className="w-3 h-3 text-slate-500 shrink-0" />
|
|
1353
|
+
<input
|
|
1354
|
+
value={s.name}
|
|
1355
|
+
onChange={(e) => updateSvc(s.id, "name", e.target.value)}
|
|
1356
|
+
className="flex-1 bg-transparent text-[11px] text-slate-300 outline-none min-w-0"
|
|
1357
|
+
/>
|
|
1358
|
+
<button
|
|
1359
|
+
onClick={() =>
|
|
1360
|
+
setSvcs((prev) => prev.filter((x) => x.id !== s.id))
|
|
1361
|
+
}
|
|
1362
|
+
className="text-slate-600 hover:text-red-400 transition-colors shrink-0"
|
|
1363
|
+
>
|
|
1364
|
+
<Trash2 className="w-3 h-3" />
|
|
1365
|
+
</button>
|
|
1366
|
+
</div>
|
|
1367
|
+
|
|
1368
|
+
<div className="flex items-center gap-1 text-[10px]">
|
|
1369
|
+
<input
|
|
1370
|
+
value={s.fromVer}
|
|
1371
|
+
onChange={(e) =>
|
|
1372
|
+
updateSvc(s.id, "fromVer", e.target.value)
|
|
1373
|
+
}
|
|
1374
|
+
className="w-14 bg-slate-800 rounded px-1 py-0.5 text-slate-400 font-mono outline-none"
|
|
1375
|
+
placeholder="1.0.0"
|
|
1376
|
+
/>
|
|
1377
|
+
<ArrowRight className="w-3 h-3 text-slate-600 shrink-0" />
|
|
1378
|
+
<input
|
|
1379
|
+
value={s.toVer}
|
|
1380
|
+
onChange={(e) => updateSvc(s.id, "toVer", e.target.value)}
|
|
1381
|
+
className="w-14 bg-slate-800 rounded px-1 py-0.5 text-cyan-400 font-mono outline-none"
|
|
1382
|
+
placeholder="2.0.0"
|
|
1383
|
+
/>
|
|
1384
|
+
</div>
|
|
1385
|
+
|
|
1386
|
+
<div className="flex items-center gap-1.5 mt-1.5 text-[10px] text-slate-500">
|
|
1387
|
+
<span>Instances:</span>
|
|
1388
|
+
<input
|
|
1389
|
+
type="number"
|
|
1390
|
+
min={1}
|
|
1391
|
+
max={6}
|
|
1392
|
+
value={s.instances}
|
|
1393
|
+
onChange={(e) =>
|
|
1394
|
+
updateSvc(
|
|
1395
|
+
s.id,
|
|
1396
|
+
"instances",
|
|
1397
|
+
Math.max(1, parseInt(e.target.value) || 1),
|
|
1398
|
+
)
|
|
1399
|
+
}
|
|
1400
|
+
className="w-8 bg-slate-800 rounded px-1 text-slate-300 outline-none text-center"
|
|
1401
|
+
/>
|
|
1402
|
+
</div>
|
|
1403
|
+
</div>
|
|
1404
|
+
))}
|
|
1405
|
+
</div>
|
|
1406
|
+
</div>
|
|
1407
|
+
|
|
1408
|
+
{/* Strategy picker */}
|
|
1409
|
+
<div className="p-3 border-b border-slate-800">
|
|
1410
|
+
<span className="text-[10px] font-semibold tracking-widest text-slate-500 block mb-2">
|
|
1411
|
+
STRATEGY
|
|
1412
|
+
</span>
|
|
1413
|
+
<div className="grid grid-cols-2 gap-1">
|
|
1414
|
+
{STRATEGIES.map((s) => {
|
|
1415
|
+
const [label, tag] = STRAT_LABELS[s];
|
|
1416
|
+
const active = strategy === s;
|
|
1417
|
+
return (
|
|
1418
|
+
<button
|
|
1419
|
+
key={s}
|
|
1420
|
+
onClick={() => setStrategy(s)}
|
|
1421
|
+
className={`text-left rounded border p-1.5 transition-colors ${
|
|
1422
|
+
active
|
|
1423
|
+
? STRAT_ACTIVE_COLOR[s]
|
|
1424
|
+
: "border-slate-700/50 text-slate-500 hover:text-slate-300 hover:border-slate-600"
|
|
1425
|
+
}`}
|
|
1426
|
+
>
|
|
1427
|
+
<div className="text-[10px] font-semibold leading-tight">
|
|
1428
|
+
{label}
|
|
1429
|
+
</div>
|
|
1430
|
+
<div className="text-[9px] opacity-70 leading-tight mt-0.5">
|
|
1431
|
+
{tag}
|
|
1432
|
+
</div>
|
|
1433
|
+
</button>
|
|
1434
|
+
);
|
|
1435
|
+
})}
|
|
1436
|
+
</div>
|
|
1437
|
+
</div>
|
|
1438
|
+
|
|
1439
|
+
{/* Tradeoffs */}
|
|
1440
|
+
<div className="p-3 border-b border-slate-800">
|
|
1441
|
+
<span className="text-[10px] font-semibold tracking-widest text-slate-500 block mb-2">
|
|
1442
|
+
TRADEOFFS
|
|
1443
|
+
</span>
|
|
1444
|
+
<div className="space-y-1.5">
|
|
1445
|
+
{(
|
|
1446
|
+
Object.keys(TRADEOFF_HINT) as (keyof typeof TRADEOFF_HINT)[]
|
|
1447
|
+
).map((dim) => {
|
|
1448
|
+
const score =
|
|
1449
|
+
STRAT_TRADEOFFS[strategy][
|
|
1450
|
+
dim as keyof (typeof STRAT_TRADEOFFS)[Strategy]
|
|
1451
|
+
];
|
|
1452
|
+
const [label, hint] = TRADEOFF_HINT[dim];
|
|
1453
|
+
return (
|
|
1454
|
+
<div key={dim} title={hint}>
|
|
1455
|
+
<div className="flex items-center justify-between mb-0.5">
|
|
1456
|
+
<span className="text-[10px] text-slate-400">
|
|
1457
|
+
{label}
|
|
1458
|
+
</span>
|
|
1459
|
+
<div className="flex gap-0.5">
|
|
1460
|
+
{Array.from({ length: 5 }, (_, i) => (
|
|
1461
|
+
<div
|
|
1462
|
+
key={i}
|
|
1463
|
+
className={`w-2.5 h-2.5 rounded-sm transition-colors ${
|
|
1464
|
+
i < score ? "bg-cyan-500" : "bg-slate-700"
|
|
1465
|
+
}`}
|
|
1466
|
+
/>
|
|
1467
|
+
))}
|
|
1468
|
+
</div>
|
|
1469
|
+
</div>
|
|
1470
|
+
</div>
|
|
1471
|
+
);
|
|
1472
|
+
})}
|
|
1473
|
+
</div>
|
|
1474
|
+
<p className="text-[9px] text-slate-600 mt-2 italic leading-snug">
|
|
1475
|
+
Higher = better for that dimension. Cost is inverted (5 =
|
|
1476
|
+
cheapest).
|
|
1477
|
+
</p>
|
|
1478
|
+
</div>
|
|
1479
|
+
|
|
1480
|
+
{/* Config */}
|
|
1481
|
+
<div className="p-3 border-b border-slate-800">
|
|
1482
|
+
<span className="text-[10px] font-semibold tracking-widest text-slate-500 block mb-2">
|
|
1483
|
+
CONFIG
|
|
1484
|
+
</span>
|
|
1485
|
+
|
|
1486
|
+
{strategy === "rolling" && (
|
|
1487
|
+
<div className="flex items-center justify-between text-[10px]">
|
|
1488
|
+
<span className="text-slate-400">Batch size</span>
|
|
1489
|
+
<input
|
|
1490
|
+
type="number"
|
|
1491
|
+
min={1}
|
|
1492
|
+
max={5}
|
|
1493
|
+
value={cfg.batchSize}
|
|
1494
|
+
onChange={(e) =>
|
|
1495
|
+
setCfg((c) => ({
|
|
1496
|
+
...c,
|
|
1497
|
+
batchSize: Math.max(1, parseInt(e.target.value) || 1),
|
|
1498
|
+
}))
|
|
1499
|
+
}
|
|
1500
|
+
className="w-10 bg-slate-800 rounded px-1 py-0.5 text-slate-300 font-mono outline-none text-center"
|
|
1501
|
+
/>
|
|
1502
|
+
</div>
|
|
1503
|
+
)}
|
|
1504
|
+
|
|
1505
|
+
{strategy === "canary" && (
|
|
1506
|
+
<div className="space-y-1.5">
|
|
1507
|
+
<div className="flex items-center justify-between text-[10px]">
|
|
1508
|
+
<span className="text-slate-400">Initial %</span>
|
|
1509
|
+
<input
|
|
1510
|
+
type="number"
|
|
1511
|
+
min={1}
|
|
1512
|
+
max={50}
|
|
1513
|
+
value={cfg.canaryInitPct}
|
|
1514
|
+
onChange={(e) =>
|
|
1515
|
+
setCfg((c) => ({
|
|
1516
|
+
...c,
|
|
1517
|
+
canaryInitPct: Math.max(
|
|
1518
|
+
1,
|
|
1519
|
+
parseInt(e.target.value) || 10,
|
|
1520
|
+
),
|
|
1521
|
+
}))
|
|
1522
|
+
}
|
|
1523
|
+
className="w-12 bg-slate-800 rounded px-1 py-0.5 text-slate-300 font-mono outline-none text-center"
|
|
1524
|
+
/>
|
|
1525
|
+
</div>
|
|
1526
|
+
<div className="flex items-center justify-between text-[10px]">
|
|
1527
|
+
<span className="text-slate-400">Step %</span>
|
|
1528
|
+
<input
|
|
1529
|
+
type="number"
|
|
1530
|
+
min={5}
|
|
1531
|
+
max={50}
|
|
1532
|
+
value={cfg.canaryStepPct}
|
|
1533
|
+
onChange={(e) =>
|
|
1534
|
+
setCfg((c) => ({
|
|
1535
|
+
...c,
|
|
1536
|
+
canaryStepPct: Math.max(
|
|
1537
|
+
5,
|
|
1538
|
+
parseInt(e.target.value) || 30,
|
|
1539
|
+
),
|
|
1540
|
+
}))
|
|
1541
|
+
}
|
|
1542
|
+
className="w-12 bg-slate-800 rounded px-1 py-0.5 text-slate-300 font-mono outline-none text-center"
|
|
1543
|
+
/>
|
|
1544
|
+
</div>
|
|
1545
|
+
</div>
|
|
1546
|
+
)}
|
|
1547
|
+
|
|
1548
|
+
{strategy === "ab" && (
|
|
1549
|
+
<div className="flex items-center justify-between text-[10px]">
|
|
1550
|
+
<span className="text-slate-400">B split %</span>
|
|
1551
|
+
<input
|
|
1552
|
+
type="number"
|
|
1553
|
+
min={1}
|
|
1554
|
+
max={99}
|
|
1555
|
+
value={cfg.abSplitPct}
|
|
1556
|
+
onChange={(e) =>
|
|
1557
|
+
setCfg((c) => ({
|
|
1558
|
+
...c,
|
|
1559
|
+
abSplitPct: Math.max(
|
|
1560
|
+
1,
|
|
1561
|
+
Math.min(99, parseInt(e.target.value) || 50),
|
|
1562
|
+
),
|
|
1563
|
+
}))
|
|
1564
|
+
}
|
|
1565
|
+
className="w-12 bg-slate-800 rounded px-1 py-0.5 text-slate-300 font-mono outline-none text-center"
|
|
1566
|
+
/>
|
|
1567
|
+
</div>
|
|
1568
|
+
)}
|
|
1569
|
+
|
|
1570
|
+
{(strategy === "recreate" ||
|
|
1571
|
+
strategy === "blue-green" ||
|
|
1572
|
+
strategy === "shadow") && (
|
|
1573
|
+
<span className="text-[10px] text-slate-600 italic">
|
|
1574
|
+
No extra config needed
|
|
1575
|
+
</span>
|
|
1576
|
+
)}
|
|
1577
|
+
</div>
|
|
1578
|
+
|
|
1579
|
+
{/* Actions */}
|
|
1580
|
+
<div className="p-3 space-y-2">
|
|
1581
|
+
<button
|
|
1582
|
+
onClick={handleDeploy}
|
|
1583
|
+
disabled={svcs.length === 0}
|
|
1584
|
+
className="w-full py-1.5 rounded text-[11px] font-semibold bg-cyan-600 hover:bg-cyan-500 text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center gap-1.5"
|
|
1585
|
+
>
|
|
1586
|
+
<Play className="w-3.5 h-3.5" />
|
|
1587
|
+
Deploy
|
|
1588
|
+
</button>
|
|
1589
|
+
{isDeployed && (
|
|
1590
|
+
<button
|
|
1591
|
+
onClick={handleReset}
|
|
1592
|
+
className="w-full py-1.5 rounded text-[11px] text-slate-400 border border-slate-700 hover:border-slate-600 hover:text-slate-300 transition-colors flex items-center justify-center gap-1.5"
|
|
1593
|
+
>
|
|
1594
|
+
<RotateCcw className="w-3 h-3" />
|
|
1595
|
+
Reset
|
|
1596
|
+
</button>
|
|
1597
|
+
)}
|
|
1598
|
+
</div>
|
|
1599
|
+
|
|
1600
|
+
{/* Legend */}
|
|
1601
|
+
<div className="p-3 mt-auto border-t border-slate-800">
|
|
1602
|
+
<span className="text-[9px] font-semibold tracking-widest text-slate-600 block mb-1.5">
|
|
1603
|
+
LEGEND
|
|
1604
|
+
</span>
|
|
1605
|
+
<div className="space-y-1">
|
|
1606
|
+
{(
|
|
1607
|
+
["old", "updating", "new", "down", "shadow"] as InstState[]
|
|
1608
|
+
).map((s) => (
|
|
1609
|
+
<div key={s} className="flex items-center gap-2">
|
|
1610
|
+
<div
|
|
1611
|
+
className={`w-5 h-5 rounded border-2 flex items-center justify-center text-[8px] font-bold ${INST_STYLE[s].bg} ${INST_STYLE[s].border} ${INST_STYLE[s].text} ${s === "shadow" ? "opacity-60" : ""}`}
|
|
1612
|
+
>
|
|
1613
|
+
{INST_STYLE[s].label}
|
|
1614
|
+
</div>
|
|
1615
|
+
<span className="text-[10px] text-slate-500 capitalize">
|
|
1616
|
+
{s}
|
|
1617
|
+
</span>
|
|
1618
|
+
</div>
|
|
1619
|
+
))}
|
|
1620
|
+
</div>
|
|
1621
|
+
</div>
|
|
1622
|
+
</div>
|
|
1623
|
+
|
|
1624
|
+
{/* Right panel */}
|
|
1625
|
+
<div className="flex-1 min-w-0 flex flex-col">
|
|
1626
|
+
{/* Tabs */}
|
|
1627
|
+
<div className="flex border-b border-slate-800 shrink-0">
|
|
1628
|
+
{(["viz", "timeline", "metrics", "mechanism"] as Tab[]).map((t) => (
|
|
1629
|
+
<button
|
|
1630
|
+
key={t}
|
|
1631
|
+
onClick={() => setTab(t)}
|
|
1632
|
+
className={`px-4 py-2 text-[11px] font-medium transition-colors border-b-2 ${
|
|
1633
|
+
tab === t
|
|
1634
|
+
? "border-cyan-500 text-cyan-400 bg-cyan-500/5"
|
|
1635
|
+
: "border-transparent text-slate-500 hover:text-slate-300"
|
|
1636
|
+
}`}
|
|
1637
|
+
>
|
|
1638
|
+
{
|
|
1639
|
+
{
|
|
1640
|
+
viz: "Visualization",
|
|
1641
|
+
timeline: "Timeline",
|
|
1642
|
+
metrics: "Metrics",
|
|
1643
|
+
mechanism: "Mechanism",
|
|
1644
|
+
}[t]
|
|
1645
|
+
}
|
|
1646
|
+
</button>
|
|
1647
|
+
))}
|
|
1648
|
+
</div>
|
|
1649
|
+
|
|
1650
|
+
{/* Tab content */}
|
|
1651
|
+
<div className="flex-1 min-h-0 overflow-y-auto">
|
|
1652
|
+
{/* ── Timeline ── */}
|
|
1653
|
+
{tab === "timeline" && (
|
|
1654
|
+
<TimelineView
|
|
1655
|
+
snapshots={snapshots}
|
|
1656
|
+
currentIdx={stepIdx}
|
|
1657
|
+
svcs={svcs}
|
|
1658
|
+
onJump={(i) => {
|
|
1659
|
+
setPlaying(false);
|
|
1660
|
+
setStepIdx(i);
|
|
1661
|
+
}}
|
|
1662
|
+
/>
|
|
1663
|
+
)}
|
|
1664
|
+
|
|
1665
|
+
{/* ── Visualization ── */}
|
|
1666
|
+
{tab === "viz" && (
|
|
1667
|
+
<div className="p-5">
|
|
1668
|
+
{!isDeployed ? (
|
|
1669
|
+
<div className="mt-24 text-center text-slate-600 text-[12px]">
|
|
1670
|
+
Configure services and strategy on the left, then click{" "}
|
|
1671
|
+
<strong className="text-slate-500">Deploy</strong> to
|
|
1672
|
+
simulate.
|
|
1673
|
+
</div>
|
|
1674
|
+
) : (
|
|
1675
|
+
<>
|
|
1676
|
+
{svcs.map((s) => {
|
|
1677
|
+
const svcInsts = insts.filter((i) => i.svcId === s.id);
|
|
1678
|
+
const blueInsts = svcInsts.filter(
|
|
1679
|
+
(i) => i.env === "blue",
|
|
1680
|
+
);
|
|
1681
|
+
const greenInsts = svcInsts.filter(
|
|
1682
|
+
(i) => i.env === "green",
|
|
1683
|
+
);
|
|
1684
|
+
const mainInsts = svcInsts.filter(
|
|
1685
|
+
(i) => i.env === "main",
|
|
1686
|
+
);
|
|
1687
|
+
const isBG =
|
|
1688
|
+
blueInsts.length > 0 || greenInsts.length > 0;
|
|
1689
|
+
const shadowInsts = mainInsts.filter(
|
|
1690
|
+
(i) => i.state === "shadow",
|
|
1691
|
+
);
|
|
1692
|
+
const primaryInsts = mainInsts.filter(
|
|
1693
|
+
(i) => i.state !== "shadow",
|
|
1694
|
+
);
|
|
1695
|
+
|
|
1696
|
+
return (
|
|
1697
|
+
<div key={s.id} className="mb-6">
|
|
1698
|
+
<div className="flex items-center gap-2 mb-3">
|
|
1699
|
+
<Server className="w-3.5 h-3.5 text-slate-500" />
|
|
1700
|
+
<span className="text-[12px] font-semibold text-slate-300">
|
|
1701
|
+
{s.name}
|
|
1702
|
+
</span>
|
|
1703
|
+
<span className="text-[10px] font-mono text-slate-600">
|
|
1704
|
+
{s.fromVer}
|
|
1705
|
+
</span>
|
|
1706
|
+
<ArrowRight className="w-3 h-3 text-slate-600" />
|
|
1707
|
+
<span className="text-[10px] font-mono text-cyan-500">
|
|
1708
|
+
{s.toVer}
|
|
1709
|
+
</span>
|
|
1710
|
+
</div>
|
|
1711
|
+
|
|
1712
|
+
{isBG ? (
|
|
1713
|
+
<div className="space-y-2.5">
|
|
1714
|
+
{blueInsts.length > 0 && (
|
|
1715
|
+
<div className="flex items-start gap-3">
|
|
1716
|
+
<span className="text-[9px] font-bold text-blue-400 w-12 pt-3 shrink-0">
|
|
1717
|
+
BLUE
|
|
1718
|
+
</span>
|
|
1719
|
+
<div className="flex flex-wrap gap-2">
|
|
1720
|
+
{blueInsts.map((i) => (
|
|
1721
|
+
<InstDot
|
|
1722
|
+
key={i.id}
|
|
1723
|
+
inst={i}
|
|
1724
|
+
svcs={svcs}
|
|
1725
|
+
/>
|
|
1726
|
+
))}
|
|
1727
|
+
</div>
|
|
1728
|
+
</div>
|
|
1729
|
+
)}
|
|
1730
|
+
{greenInsts.length > 0 && (
|
|
1731
|
+
<div className="flex items-start gap-3">
|
|
1732
|
+
<span className="text-[9px] font-bold text-emerald-400 w-12 pt-3 shrink-0">
|
|
1733
|
+
GREEN
|
|
1734
|
+
</span>
|
|
1735
|
+
<div className="flex flex-wrap gap-2">
|
|
1736
|
+
{greenInsts.map((i) => (
|
|
1737
|
+
<InstDot
|
|
1738
|
+
key={i.id}
|
|
1739
|
+
inst={i}
|
|
1740
|
+
svcs={svcs}
|
|
1741
|
+
/>
|
|
1742
|
+
))}
|
|
1743
|
+
</div>
|
|
1744
|
+
</div>
|
|
1745
|
+
)}
|
|
1746
|
+
</div>
|
|
1747
|
+
) : (
|
|
1748
|
+
<div>
|
|
1749
|
+
{primaryInsts.length > 0 && (
|
|
1750
|
+
<div className="flex flex-wrap gap-2 mb-2">
|
|
1751
|
+
{primaryInsts.map((i) => (
|
|
1752
|
+
<InstDot key={i.id} inst={i} svcs={svcs} />
|
|
1753
|
+
))}
|
|
1754
|
+
</div>
|
|
1755
|
+
)}
|
|
1756
|
+
{shadowInsts.length > 0 && (
|
|
1757
|
+
<div className="flex items-start gap-3 mt-1">
|
|
1758
|
+
<span className="text-[9px] font-bold text-violet-400 pt-3 shrink-0 w-12">
|
|
1759
|
+
SHADOW
|
|
1760
|
+
</span>
|
|
1761
|
+
<div className="flex flex-wrap gap-2">
|
|
1762
|
+
{shadowInsts.map((i) => (
|
|
1763
|
+
<InstDot
|
|
1764
|
+
key={i.id}
|
|
1765
|
+
inst={i}
|
|
1766
|
+
svcs={svcs}
|
|
1767
|
+
/>
|
|
1768
|
+
))}
|
|
1769
|
+
</div>
|
|
1770
|
+
</div>
|
|
1771
|
+
)}
|
|
1772
|
+
</div>
|
|
1773
|
+
)}
|
|
1774
|
+
</div>
|
|
1775
|
+
);
|
|
1776
|
+
})}
|
|
1777
|
+
|
|
1778
|
+
{/* Traffic bar */}
|
|
1779
|
+
{mx && (
|
|
1780
|
+
<div className="mt-2 bg-slate-900/60 border border-slate-800 rounded p-3">
|
|
1781
|
+
<div className="text-[10px] text-slate-500 mb-1.5">
|
|
1782
|
+
Traffic distribution
|
|
1783
|
+
</div>
|
|
1784
|
+
<div className="flex items-center gap-3">
|
|
1785
|
+
<div className="flex-1 h-3 bg-slate-800 rounded overflow-hidden flex">
|
|
1786
|
+
<div
|
|
1787
|
+
className="h-full bg-slate-500 transition-all duration-700"
|
|
1788
|
+
style={{ width: `${mx.trafficOldPct}%` }}
|
|
1789
|
+
/>
|
|
1790
|
+
<div
|
|
1791
|
+
className="h-full bg-cyan-500 transition-all duration-700"
|
|
1792
|
+
style={{ width: `${mx.trafficNewPct}%` }}
|
|
1793
|
+
/>
|
|
1794
|
+
</div>
|
|
1795
|
+
<span className="text-[10px] font-mono text-slate-400 shrink-0 w-28">
|
|
1796
|
+
v1: {mx.trafficOldPct}% / v2: {mx.trafficNewPct}%
|
|
1797
|
+
</span>
|
|
1798
|
+
</div>
|
|
1799
|
+
</div>
|
|
1800
|
+
)}
|
|
1801
|
+
</>
|
|
1802
|
+
)}
|
|
1803
|
+
</div>
|
|
1804
|
+
)}
|
|
1805
|
+
|
|
1806
|
+
{/* ── Metrics ── */}
|
|
1807
|
+
{tab === "metrics" && (
|
|
1808
|
+
<div className="p-5">
|
|
1809
|
+
{!mx ? (
|
|
1810
|
+
<div className="mt-24 text-center text-slate-600 text-[12px]">
|
|
1811
|
+
Run a deployment to see live metrics.
|
|
1812
|
+
</div>
|
|
1813
|
+
) : (
|
|
1814
|
+
<div className="max-w-sm">
|
|
1815
|
+
<MetricBar
|
|
1816
|
+
label="Error rate"
|
|
1817
|
+
value={mx.errorRate}
|
|
1818
|
+
max={100}
|
|
1819
|
+
format={(v) => `${v.toFixed(1)}%`}
|
|
1820
|
+
color={
|
|
1821
|
+
mx.errorRate > 5
|
|
1822
|
+
? "bg-red-500"
|
|
1823
|
+
: mx.errorRate > 1
|
|
1824
|
+
? "bg-amber-500"
|
|
1825
|
+
: "bg-emerald-500"
|
|
1826
|
+
}
|
|
1827
|
+
/>
|
|
1828
|
+
<MetricBar
|
|
1829
|
+
label="P50 response time"
|
|
1830
|
+
value={mx.p50}
|
|
1831
|
+
max={500}
|
|
1832
|
+
format={(v) => `${v} ms`}
|
|
1833
|
+
color="bg-cyan-500"
|
|
1834
|
+
/>
|
|
1835
|
+
<MetricBar
|
|
1836
|
+
label="P99 response time"
|
|
1837
|
+
value={mx.p99}
|
|
1838
|
+
max={2000}
|
|
1839
|
+
format={(v) => `${v} ms`}
|
|
1840
|
+
color="bg-blue-500"
|
|
1841
|
+
/>
|
|
1842
|
+
<MetricBar
|
|
1843
|
+
label="Requests / sec"
|
|
1844
|
+
value={mx.rps}
|
|
1845
|
+
max={2000}
|
|
1846
|
+
format={(v) => `${v}`}
|
|
1847
|
+
color="bg-violet-500"
|
|
1848
|
+
/>
|
|
1849
|
+
<MetricBar
|
|
1850
|
+
label="Rollout progress"
|
|
1851
|
+
value={mx.rolloutPct}
|
|
1852
|
+
max={100}
|
|
1853
|
+
format={(v) => `${v}%`}
|
|
1854
|
+
color="bg-emerald-500"
|
|
1855
|
+
/>
|
|
1856
|
+
|
|
1857
|
+
<div className="mt-5 grid grid-cols-2 gap-2 text-[10px]">
|
|
1858
|
+
<div className="bg-slate-900 border border-slate-700 rounded p-2.5">
|
|
1859
|
+
<div className="text-slate-500 mb-0.5">
|
|
1860
|
+
Healthy (v1)
|
|
1861
|
+
</div>
|
|
1862
|
+
<div className="text-slate-200 font-mono text-lg leading-none">
|
|
1863
|
+
{mx.healthyOld}
|
|
1864
|
+
</div>
|
|
1865
|
+
</div>
|
|
1866
|
+
<div className="bg-slate-900 border border-slate-700 rounded p-2.5">
|
|
1867
|
+
<div className="text-cyan-600 mb-0.5">Healthy (v2)</div>
|
|
1868
|
+
<div className="text-cyan-300 font-mono text-lg leading-none">
|
|
1869
|
+
{mx.healthyNew}
|
|
1870
|
+
</div>
|
|
1871
|
+
</div>
|
|
1872
|
+
</div>
|
|
1873
|
+
</div>
|
|
1874
|
+
)}
|
|
1875
|
+
</div>
|
|
1876
|
+
)}
|
|
1877
|
+
|
|
1878
|
+
{/* ── Mechanism ── */}
|
|
1879
|
+
{tab === "mechanism" && (
|
|
1880
|
+
<MechanismView strategy={strategy} cfg={cfg} snap={snap} />
|
|
1881
|
+
)}
|
|
1882
|
+
</div>
|
|
1883
|
+
|
|
1884
|
+
{/* Step nav bar */}
|
|
1885
|
+
{isDeployed && (
|
|
1886
|
+
<div className="shrink-0 border-t border-slate-800 px-4 py-2 flex items-center gap-2">
|
|
1887
|
+
<button
|
|
1888
|
+
onClick={() => {
|
|
1889
|
+
setPlaying(false);
|
|
1890
|
+
setStepIdx((p) => Math.max(0, p - 1));
|
|
1891
|
+
}}
|
|
1892
|
+
disabled={stepIdx === 0}
|
|
1893
|
+
className="p-1 text-slate-500 hover:text-slate-300 disabled:opacity-30 transition-colors"
|
|
1894
|
+
>
|
|
1895
|
+
<ChevronLeft className="w-4 h-4" />
|
|
1896
|
+
</button>
|
|
1897
|
+
|
|
1898
|
+
<button
|
|
1899
|
+
onClick={() => setPlaying((p) => !p)}
|
|
1900
|
+
disabled={stepIdx >= snapshots.length - 1 && !playing}
|
|
1901
|
+
className="p-1 text-slate-400 hover:text-slate-200 transition-colors disabled:opacity-30"
|
|
1902
|
+
>
|
|
1903
|
+
{playing ? (
|
|
1904
|
+
<Pause className="w-4 h-4" />
|
|
1905
|
+
) : (
|
|
1906
|
+
<Play className="w-4 h-4" />
|
|
1907
|
+
)}
|
|
1908
|
+
</button>
|
|
1909
|
+
|
|
1910
|
+
<button
|
|
1911
|
+
onClick={() => {
|
|
1912
|
+
setPlaying(false);
|
|
1913
|
+
setStepIdx((p) => Math.min(snapshots.length - 1, p + 1));
|
|
1914
|
+
}}
|
|
1915
|
+
disabled={stepIdx >= snapshots.length - 1}
|
|
1916
|
+
className="p-1 text-slate-500 hover:text-slate-300 disabled:opacity-30 transition-colors"
|
|
1917
|
+
>
|
|
1918
|
+
<ChevronRight className="w-4 h-4" />
|
|
1919
|
+
</button>
|
|
1920
|
+
|
|
1921
|
+
<div className="flex-1 flex flex-col min-w-0 ml-1">
|
|
1922
|
+
<span className="text-[11px] text-slate-300 truncate">
|
|
1923
|
+
{snap?.label}
|
|
1924
|
+
</span>
|
|
1925
|
+
{snap?.note && (
|
|
1926
|
+
<span className="text-[10px] text-amber-400/80 truncate">
|
|
1927
|
+
{snap.note}
|
|
1928
|
+
</span>
|
|
1929
|
+
)}
|
|
1930
|
+
</div>
|
|
1931
|
+
|
|
1932
|
+
<span className="text-[10px] text-slate-600 shrink-0 font-mono">
|
|
1933
|
+
{stepIdx + 1} / {snapshots.length}
|
|
1934
|
+
</span>
|
|
1935
|
+
</div>
|
|
1936
|
+
)}
|
|
1937
|
+
</div>
|
|
1938
|
+
</div>
|
|
1939
|
+
</div>
|
|
1940
|
+
);
|
|
1941
|
+
}
|