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.
@@ -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
+ }