@beastmode-develeap/beastmode 0.1.144 → 0.1.146

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.
@@ -15,7 +15,7 @@
15
15
  }
16
16
  </script>
17
17
  <!--BOARD_DATA-->
18
- <script>window.__BUILD_STAMP__ = "20260425-101733-1ae8ca9";</script>
18
+ <script>window.__BUILD_STAMP__ = "20260501-084015-3ceb514";</script>
19
19
  <link rel="preconnect" href="https://fonts.googleapis.com">
20
20
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
21
21
  <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
@@ -526,8 +526,9 @@ body {
526
526
 
527
527
  .stat-grid {
528
528
  display: grid;
529
- grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
529
+ grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
530
530
  gap: 16px;
531
+ margin-bottom: 24px;
531
532
  }
532
533
 
533
534
  .stat-card {
@@ -1244,6 +1245,268 @@ input[type="range"]::-webkit-slider-thumb {
1244
1245
  .badge-priority-low { background: rgba(59, 130, 246, 0.15); color: #60a5fa; }
1245
1246
  .badge-type { background: rgba(100, 116, 139, 0.15); color: var(--text-secondary); }
1246
1247
 
1248
+ /* Environment badges — color-coded by env tier */
1249
+ .badge-env {
1250
+ display: inline-block;
1251
+ padding: 2px 8px;
1252
+ border-radius: 4px;
1253
+ font-size: 11px;
1254
+ font-weight: 500;
1255
+ text-transform: lowercase;
1256
+ }
1257
+ .badge-env-local {
1258
+ background: rgba(100, 116, 139, 0.15);
1259
+ color: #94a3b8;
1260
+ }
1261
+ .badge-env-staging {
1262
+ background: rgba(245, 166, 35, 0.15);
1263
+ color: #F5A623;
1264
+ }
1265
+ .badge-env-production {
1266
+ background: rgba(34, 197, 94, 0.15);
1267
+ color: #22c55e;
1268
+ }
1269
+ .badge-env:not(.badge-env-local):not(.badge-env-staging):not(.badge-env-production) {
1270
+ background: rgba(56, 189, 248, 0.15);
1271
+ color: #38bdf8;
1272
+ }
1273
+
1274
+ /* Environment Management Panel (Story 9) */
1275
+ .env-panel {
1276
+ margin-top: 12px;
1277
+ padding: 16px;
1278
+ border-top: 1px solid var(--border-subtle);
1279
+ width: 100%;
1280
+ flex-basis: 100%;
1281
+ }
1282
+ .env-panel-header {
1283
+ display: flex;
1284
+ align-items: center;
1285
+ justify-content: space-between;
1286
+ cursor: pointer;
1287
+ user-select: none;
1288
+ padding: 8px 0;
1289
+ }
1290
+ .env-panel-header h4 {
1291
+ font-size: 14px;
1292
+ font-weight: 600;
1293
+ color: var(--text);
1294
+ margin: 0;
1295
+ }
1296
+ .env-panel-toggle {
1297
+ font-size: 12px;
1298
+ color: var(--text-muted);
1299
+ transition: transform 0.15s ease;
1300
+ }
1301
+ .env-panel-toggle.collapsed {
1302
+ transform: rotate(-90deg);
1303
+ }
1304
+ .env-table {
1305
+ width: 100%;
1306
+ border-collapse: collapse;
1307
+ font-size: 13px;
1308
+ margin-top: 12px;
1309
+ }
1310
+ .env-table th {
1311
+ text-align: left;
1312
+ font-size: 11px;
1313
+ font-weight: 600;
1314
+ text-transform: uppercase;
1315
+ letter-spacing: 0.5px;
1316
+ color: var(--text-muted);
1317
+ padding: 8px 12px;
1318
+ border-bottom: 1px solid var(--border);
1319
+ }
1320
+ .env-table td {
1321
+ padding: 10px 12px;
1322
+ border-bottom: 1px solid var(--border-subtle);
1323
+ color: var(--text);
1324
+ vertical-align: middle;
1325
+ }
1326
+ .env-table tr:last-child td {
1327
+ border-bottom: none;
1328
+ }
1329
+ .env-table tr:hover td {
1330
+ background: var(--bg-card-hover);
1331
+ }
1332
+ .env-edit-dialog {
1333
+ max-width: 560px;
1334
+ width: 90%;
1335
+ }
1336
+ .promotion-chain {
1337
+ display: flex;
1338
+ align-items: center;
1339
+ gap: 8px;
1340
+ padding: 12px 0;
1341
+ margin-bottom: 8px;
1342
+ overflow-x: auto;
1343
+ }
1344
+ .promotion-chain-arrow {
1345
+ color: var(--text-muted);
1346
+ font-size: 14px;
1347
+ flex-shrink: 0;
1348
+ }
1349
+ /* Generic dialog styles (used by env edit / delete confirm dialogs) */
1350
+ .dialog-overlay {
1351
+ position: fixed;
1352
+ top: 0;
1353
+ left: 0;
1354
+ right: 0;
1355
+ bottom: 0;
1356
+ background: rgba(0, 0, 0, 0.6);
1357
+ display: flex;
1358
+ align-items: center;
1359
+ justify-content: center;
1360
+ z-index: 200;
1361
+ animation: fadeIn 0.15s ease;
1362
+ }
1363
+ .dialog {
1364
+ background: var(--bg-card);
1365
+ border: 1px solid var(--border);
1366
+ border-radius: var(--radius-lg);
1367
+ width: 480px;
1368
+ max-width: 90vw;
1369
+ max-height: 90vh;
1370
+ overflow-y: auto;
1371
+ box-shadow: var(--shadow-lg);
1372
+ animation: slideUp 0.2s ease;
1373
+ }
1374
+ .dialog-header {
1375
+ display: flex;
1376
+ justify-content: space-between;
1377
+ align-items: center;
1378
+ padding: 16px 20px;
1379
+ border-bottom: 1px solid var(--border);
1380
+ }
1381
+ .dialog-header h3 {
1382
+ font-size: 16px;
1383
+ font-weight: 600;
1384
+ margin: 0;
1385
+ }
1386
+ .dialog-body {
1387
+ padding: 16px 20px;
1388
+ }
1389
+
1390
+ /* Environment Timeline — vertical step ladder in detail panel */
1391
+ .env-timeline {
1392
+ display: flex;
1393
+ flex-direction: column;
1394
+ gap: 0;
1395
+ padding: 8px 0;
1396
+ }
1397
+ .env-timeline-step {
1398
+ display: flex;
1399
+ align-items: flex-start;
1400
+ gap: 12px;
1401
+ padding: 8px 0;
1402
+ position: relative;
1403
+ }
1404
+ .env-timeline-step:not(:last-child)::after {
1405
+ content: '';
1406
+ position: absolute;
1407
+ left: 7px;
1408
+ top: 24px;
1409
+ bottom: -8px;
1410
+ width: 2px;
1411
+ background: var(--border);
1412
+ }
1413
+ .env-step-dot {
1414
+ width: 16px;
1415
+ height: 16px;
1416
+ border-radius: 50%;
1417
+ border: 2px solid var(--border);
1418
+ background: var(--bg);
1419
+ flex-shrink: 0;
1420
+ margin-top: 2px;
1421
+ }
1422
+ .env-step-body {
1423
+ display: flex;
1424
+ flex-direction: column;
1425
+ gap: 2px;
1426
+ }
1427
+ .env-step-name {
1428
+ font-size: 13px;
1429
+ font-weight: 600;
1430
+ color: var(--text);
1431
+ text-transform: capitalize;
1432
+ }
1433
+ .env-step-score {
1434
+ font-size: 12px;
1435
+ font-weight: 500;
1436
+ color: var(--accent);
1437
+ }
1438
+ .env-step-time {
1439
+ font-size: 11px;
1440
+ color: var(--text-muted);
1441
+ }
1442
+ .env-step-passed .env-step-dot {
1443
+ background: #22c55e;
1444
+ border-color: #22c55e;
1445
+ }
1446
+ .env-step-verifying .env-step-dot {
1447
+ background: #F5A623;
1448
+ border-color: #F5A623;
1449
+ animation: pulse-dot 2s infinite;
1450
+ }
1451
+ .env-step-deploying .env-step-dot {
1452
+ background: #38bdf8;
1453
+ border-color: #38bdf8;
1454
+ animation: pulse-dot 2s infinite;
1455
+ }
1456
+ .env-step-failed .env-step-dot {
1457
+ background: #f87171;
1458
+ border-color: #f87171;
1459
+ }
1460
+ .env-step-blocked .env-step-dot {
1461
+ background: #f97316;
1462
+ border-color: #f97316;
1463
+ }
1464
+ .env-step-pending .env-step-dot {
1465
+ background: transparent;
1466
+ border-color: var(--border);
1467
+ }
1468
+ .env-step-current {
1469
+ background: rgba(245, 166, 35, 0.05);
1470
+ border-radius: 6px;
1471
+ margin: 0 -8px;
1472
+ padding: 8px;
1473
+ }
1474
+ .btn-env-deploy {
1475
+ display: inline-flex;
1476
+ align-items: center;
1477
+ justify-content: center;
1478
+ width: 24px;
1479
+ height: 24px;
1480
+ min-width: 24px;
1481
+ border: 1px solid var(--border);
1482
+ border-radius: 4px;
1483
+ background: transparent;
1484
+ cursor: pointer;
1485
+ font-size: 12px;
1486
+ line-height: 1;
1487
+ padding: 0;
1488
+ margin-left: 8px;
1489
+ color: var(--text-muted);
1490
+ transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
1491
+ flex-shrink: 0;
1492
+ }
1493
+ .btn-env-deploy:hover {
1494
+ background: var(--surface-elevated);
1495
+ color: var(--text);
1496
+ border-color: var(--accent);
1497
+ }
1498
+ .btn-env-deploy:active {
1499
+ transform: scale(0.95);
1500
+ }
1501
+ .btn-env-deploy:disabled {
1502
+ opacity: 0.4;
1503
+ cursor: not-allowed;
1504
+ }
1505
+ @keyframes pulse-dot {
1506
+ 0%, 100% { opacity: 1; }
1507
+ 50% { opacity: 0.5; }
1508
+ }
1509
+
1247
1510
  /* Overlay status badges — more prominent than standard card-badges */
1248
1511
  .badge-overlay {
1249
1512
  font-weight: 600;
@@ -2030,6 +2293,42 @@ input[type="range"]::-webkit-slider-thumb {
2030
2293
  color: var(--text-secondary);
2031
2294
  font-size: 11px;
2032
2295
  }
2296
+ /* Expandable phase rows for per-iteration drill-down (FR-6) */
2297
+ .cost-phase-row-expandable {
2298
+ cursor: pointer;
2299
+ }
2300
+ .cost-phase-row-expandable:hover {
2301
+ background: var(--bg-hover);
2302
+ }
2303
+ .cost-phase-expand-icon {
2304
+ display: inline-block;
2305
+ width: 16px;
2306
+ font-size: 10px;
2307
+ color: var(--text-secondary);
2308
+ transition: transform 0.15s ease;
2309
+ }
2310
+ .cost-phase-expand-icon.expanded {
2311
+ transform: rotate(90deg);
2312
+ }
2313
+ .cost-iteration-row {
2314
+ border-left: 3px solid var(--accent);
2315
+ padding: 4px 12px;
2316
+ font-size: 12px;
2317
+ font-family: var(--font-mono);
2318
+ color: var(--text-secondary);
2319
+ }
2320
+ .cost-iteration-row td {
2321
+ padding: 2px 8px;
2322
+ font-size: 11px;
2323
+ }
2324
+ .cost-model-badge {
2325
+ display: inline-block;
2326
+ padding: 1px 6px;
2327
+ border-radius: 3px;
2328
+ font-size: 10px;
2329
+ background: var(--bg-secondary);
2330
+ color: var(--text-secondary);
2331
+ }
2033
2332
 
2034
2333
  /* ================================================================
2035
2334
  EXTENSIONS / ITEMS LIST
@@ -3127,8 +3426,15 @@ async function api(method, path, body) {
3127
3426
  }
3128
3427
  }
3129
3428
  const res = await fetch(path, opts);
3130
- const data = await res.json();
3131
- if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
3429
+ // 204 No Content has no body — skip JSON parse entirely
3430
+ if (res.status === 204) return null;
3431
+ const ct = res.headers.get('content-type') || '';
3432
+ let data = null;
3433
+ if (ct.includes('application/json')) {
3434
+ try { data = await res.json(); } catch (_) {}
3435
+ }
3436
+ // FastAPI error bodies use `detail`, not `error`
3437
+ if (!res.ok) throw new Error((data && (data.detail || data.error)) || `HTTP ${res.status}`);
3132
3438
  return data;
3133
3439
  }
3134
3440
 
@@ -3691,10 +3997,18 @@ const KANBAN_COLUMNS = [
3691
3997
  { id: 'Stuck', label: 'Stuck', status: 'stuck', color: '#f87171', tooltip: 'Task failed after max retries. Needs human help or comment "reset" to restart.' },
3692
3998
  ];
3693
3999
 
4000
+ function normalizeDt(s) {
4001
+ if (!s) return s;
4002
+ if (s.length >= 19 && s[10] === ' ') s = s.slice(0, 10) + 'T' + s.slice(11);
4003
+ if (s.endsWith('+00:00')) s = s.slice(0, -6) + 'Z';
4004
+ if (s.length >= 19 && !s.endsWith('Z') && !s.includes('+')) s += 'Z';
4005
+ return s;
4006
+ }
4007
+
3694
4008
  function timeAgo(dateString) {
3695
4009
  if (!dateString) return '';
3696
4010
  const now = new Date();
3697
- const date = new Date(dateString);
4011
+ const date = new Date(normalizeDt(dateString));
3698
4012
  const seconds = Math.floor((now - date) / 1000);
3699
4013
  if (seconds < 60) return 'just now';
3700
4014
  const minutes = Math.floor(seconds / 60);
@@ -3716,7 +4030,7 @@ function timeAgo(dateString) {
3716
4030
  function statusAge(dateString) {
3717
4031
  if (!dateString) return { text: '', severity: 'ok' };
3718
4032
  const now = new Date();
3719
- const date = new Date(dateString);
4033
+ const date = new Date(normalizeDt(dateString));
3720
4034
  const totalMin = Math.max(0, Math.floor((now - date) / 60000));
3721
4035
  let text;
3722
4036
  if (totalMin < 1) text = 'just now';
@@ -4264,9 +4578,91 @@ function TopCommentBox({ itemId, onPosted }) {
4264
4578
  `;
4265
4579
  }
4266
4580
 
4581
+ // ── Per-Phase Cost Table with expandable iteration rows (FR-6) ──
4582
+
4583
+ function CostPhaseTable({ itemId, boardParam }) {
4584
+ const [phaseData, setPhaseData] = useState([]);
4585
+ const [expandedPhases, setExpandedPhases] = useState({});
4586
+
4587
+ function loadPhaseData() {
4588
+ if (!itemId) return;
4589
+ fetch('/api/items/' + itemId + '/costs/by-phase' + (boardParam || ''))
4590
+ .then(r => r.ok ? r.json() : [])
4591
+ .then(data => setPhaseData(Array.isArray(data) ? data : []))
4592
+ .catch(() => setPhaseData([]));
4593
+ }
4594
+
4595
+ useEffect(() => {
4596
+ loadPhaseData();
4597
+ }, [itemId]);
4598
+
4599
+ // Expose refresh so the parent can call it on cost_recorded WS events
4600
+ CostPhaseTable._instances = CostPhaseTable._instances || {};
4601
+ CostPhaseTable._instances[itemId] = loadPhaseData;
4602
+
4603
+ function togglePhase(phaseName) {
4604
+ setExpandedPhases(prev => ({
4605
+ ...prev,
4606
+ [phaseName]: !prev[phaseName],
4607
+ }));
4608
+ }
4609
+
4610
+ if (!phaseData || phaseData.length === 0) return null;
4611
+
4612
+ return html`
4613
+ <table class="cost-phase-table">
4614
+ <thead>
4615
+ <tr>
4616
+ <th>Phase</th>
4617
+ <th>Cost</th>
4618
+ <th>Tokens</th>
4619
+ <th>Duration</th>
4620
+ </tr>
4621
+ </thead>
4622
+ <tbody>
4623
+ ${phaseData
4624
+ .slice()
4625
+ .sort((a, b) => (b.total_cost_usd || 0) - (a.total_cost_usd || 0))
4626
+ .map(phase => {
4627
+ const hasIterations = phase.iterations && phase.iterations.length > 1;
4628
+ const isExpanded = expandedPhases[phase.phase];
4629
+ return html`
4630
+ <tr
4631
+ class=${hasIterations ? 'cost-phase-row-expandable' : ''}
4632
+ onClick=${hasIterations ? () => togglePhase(phase.phase) : undefined}
4633
+ key=${phase.phase}
4634
+ >
4635
+ <td class="cost-phase-name">
4636
+ ${hasIterations ? html`<span class=${'cost-phase-expand-icon' + (isExpanded ? ' expanded' : '')}>&#9654;</span>` : null}
4637
+ ${phase.phase}
4638
+ </td>
4639
+ <td class="cost-phase-value">${formatCost(phase.total_cost_usd) || '$0.00'}</td>
4640
+ <td class="cost-phase-value">${formatTokens((phase.total_input_tokens || 0) + (phase.total_output_tokens || 0))}</td>
4641
+ <td class="cost-phase-value">${formatDuration(phase.total_duration_seconds)}</td>
4642
+ </tr>
4643
+ ${isExpanded && phase.iterations && phase.iterations.map(iter => html`
4644
+ <tr class="cost-iteration-row" key=${phase.phase + '-' + iter.iteration}>
4645
+ <td style="padding-left:28px">
4646
+ Iter ${iter.iteration}
4647
+ ${iter.model && iter.model !== 'mixed' ? html`
4648
+ <span class="cost-model-badge">${iter.model.replace('claude-', '').split('-20')[0]}</span>
4649
+ ` : null}
4650
+ </td>
4651
+ <td class="cost-phase-value">${formatCost(iter.cost_usd) || '$0.00'}</td>
4652
+ <td class="cost-phase-value">${formatTokens((iter.input_tokens || 0) + (iter.output_tokens || 0))}</td>
4653
+ <td class="cost-phase-value">${formatDuration(iter.duration_seconds)}</td>
4654
+ </tr>
4655
+ `)}
4656
+ `;
4657
+ })}
4658
+ </tbody>
4659
+ </table>
4660
+ `;
4661
+ }
4662
+
4267
4663
  // ── Item Detail Sidebar ──
4268
4664
 
4269
- function ItemDetailSidebar({ item, onClose, onStatusChange }) {
4665
+ function ItemDetailSidebar({ item, onClose, onStatusChange, selectedProject }) {
4270
4666
  const [updates, setUpdates] = useState([]);
4271
4667
  const [loadingUpdates, setLoadingUpdates] = useState(true);
4272
4668
  const [sortNewest, setSortNewest] = useState(true);
@@ -4277,9 +4673,29 @@ function ItemDetailSidebar({ item, onClose, onStatusChange }) {
4277
4673
  const [loadingAttachments, setLoadingAttachments] = useState(true);
4278
4674
  const [costSummary, setCostSummary] = useState(null);
4279
4675
  const [loadingCost, setLoadingCost] = useState(true);
4676
+ const [phaseData, setPhaseData] = useState([]);
4677
+ const [envTimeline, setEnvTimeline] = useState(null);
4280
4678
  const sidebarRef = useRef(null);
4281
4679
  const topCommentRef = useRef(null);
4282
4680
 
4681
+ useEffect(() => {
4682
+ if (!item || !item.extra || !item.extra.current_env) {
4683
+ setEnvTimeline(null);
4684
+ return;
4685
+ }
4686
+ // Use selectedProject (prop) to determine DB routing so we query the
4687
+ // same DB that served the items list. 'all' or falsy = default DB
4688
+ // (no board param). A named project = that project's specific DB.
4689
+ const proj = selectedProject && selectedProject !== 'all' ? selectedProject : null;
4690
+ const projParam = proj || item.project_id || 'beastmode';
4691
+ const qs = '?project=' + encodeURIComponent(projParam) +
4692
+ (proj ? '&board=' + encodeURIComponent(proj) : '');
4693
+ fetch('/api/items/' + item.id + '/env-timeline' + qs)
4694
+ .then(r => r.ok ? r.json() : null)
4695
+ .then(data => setEnvTimeline(data))
4696
+ .catch(() => setEnvTimeline(null));
4697
+ }, [item && item.id, item && item.extra && item.extra.current_env]);
4698
+
4283
4699
  // Cost summary fetch — keyed on item.id, refreshed alongside the
4284
4700
  // 10-second updates/attachments poll below. The api() helper only
4285
4701
  // auto-scopes /api/board/*, so append ?board=<proj> manually.
@@ -4294,6 +4710,16 @@ function ItemDetailSidebar({ item, onClose, onStatusChange }) {
4294
4710
  .catch(() => setCostSummary(null));
4295
4711
  }, [item && item.id]);
4296
4712
 
4713
+ const refreshPhaseData = useCallback(() => {
4714
+ if (!item) return;
4715
+ const proj = localStorage.getItem('beastmode-selected-project') || '';
4716
+ const boardParam = (proj && proj !== 'all') ? '?board=' + encodeURIComponent(proj) : '';
4717
+ fetch('/api/items/' + item.id + '/costs/by-phase' + boardParam)
4718
+ .then(r => r.ok ? r.json() : [])
4719
+ .then(data => setPhaseData(Array.isArray(data) ? data : []))
4720
+ .catch(() => setPhaseData([]));
4721
+ }, [item && item.id]);
4722
+
4297
4723
  const refreshUpdates = useCallback(() => {
4298
4724
  if (!item) return;
4299
4725
  api('GET', '/api/board/items/' + item.id + '/updates')
@@ -4340,10 +4766,16 @@ function ItemDetailSidebar({ item, onClose, onStatusChange }) {
4340
4766
  .then(data => setCostSummary(data && data.record_count > 0 ? data : null))
4341
4767
  .catch(() => setCostSummary(null))
4342
4768
  .finally(() => setLoadingCost(false));
4769
+ // Also fetch the per-phase breakdown for the expandable table
4770
+ fetch('/api/items/' + item.id + '/costs/by-phase' + boardParam)
4771
+ .then(r => r.ok ? r.json() : [])
4772
+ .then(data => setPhaseData(Array.isArray(data) ? data : []))
4773
+ .catch(() => setPhaseData([]));
4343
4774
  const interval = setInterval(() => {
4344
4775
  refreshUpdates();
4345
4776
  refreshAttachments();
4346
4777
  refreshCostSummary();
4778
+ refreshPhaseData();
4347
4779
  }, 10000);
4348
4780
  return () => clearInterval(interval);
4349
4781
  }, [item && item.id]);
@@ -4499,33 +4931,112 @@ function ItemDetailSidebar({ item, onClose, onStatusChange }) {
4499
4931
  <span class="cost-total-value">${formatDuration(costSummary.total_duration_seconds)}</span>
4500
4932
  </div>
4501
4933
  </div>
4502
- ${costSummary.phases && Object.keys(costSummary.phases).length > 0 && html`
4503
- <table class="cost-phase-table">
4504
- <thead>
4505
- <tr>
4506
- <th>Phase</th>
4507
- <th>Cost</th>
4508
- <th>Tokens</th>
4509
- <th>Duration</th>
4510
- </tr>
4511
- </thead>
4512
- <tbody>
4513
- ${Object.values(costSummary.phases)
4514
- .slice()
4515
- .sort((a, b) => (b.cost_usd || 0) - (a.cost_usd || 0))
4516
- .map((phase) => html`
4517
- <tr key=${phase.phase}>
4518
- <td class="cost-phase-name">${phase.phase}</td>
4519
- <td class="cost-phase-value">${formatCost(phase.cost_usd) || '$0.00'}</td>
4520
- <td class="cost-phase-value">${formatTokens((phase.input_tokens || 0) + (phase.output_tokens || 0))}</td>
4521
- <td class="cost-phase-value">${formatDuration(phase.duration_seconds)}</td>
4522
- </tr>
4523
- `)}
4524
- </tbody>
4525
- </table>
4526
- `}
4934
+ ${phaseData && phaseData.length > 0
4935
+ ? html`<${CostPhaseTable} itemId=${item.id} boardParam=${(() => { const p = localStorage.getItem('beastmode-selected-project') || ''; return (p && p !== 'all') ? '?board=' + encodeURIComponent(p) : ''; })()} />`
4936
+ : (costSummary.phases && Object.keys(costSummary.phases).length > 0 && html`
4937
+ <table class="cost-phase-table">
4938
+ <thead>
4939
+ <tr>
4940
+ <th>Phase</th>
4941
+ <th>Cost</th>
4942
+ <th>Tokens</th>
4943
+ <th>Duration</th>
4944
+ </tr>
4945
+ </thead>
4946
+ <tbody>
4947
+ ${Object.values(costSummary.phases)
4948
+ .slice()
4949
+ .sort((a, b) => (b.cost_usd || 0) - (a.cost_usd || 0))
4950
+ .map((phase) => html`
4951
+ <tr key=${phase.phase}>
4952
+ <td class="cost-phase-name">${phase.phase}</td>
4953
+ <td class="cost-phase-value">${formatCost(phase.cost_usd) || '$0.00'}</td>
4954
+ <td class="cost-phase-value">${formatTokens((phase.input_tokens || 0) + (phase.output_tokens || 0))}</td>
4955
+ <td class="cost-phase-value">${formatDuration(phase.duration_seconds)}</td>
4956
+ </tr>
4957
+ `)}
4958
+ </tbody>
4959
+ </table>
4960
+ `)
4961
+ }
4527
4962
  </div>
4528
4963
  `)}
4964
+ ${envTimeline && (envTimeline.entries.length > 0 || envTimeline.promotion_chain.length > 0) && html`
4965
+ <div class="detail-section" style="padding:12px 24px 0;" data-testid="env-timeline-section">
4966
+ <h4 class="detail-section-title" style="margin:0 0 8px 0;font-size:13px;font-weight:600;">Environment Timeline</h4>
4967
+ <div class="env-timeline" data-testid="env-timeline">
4968
+ ${(envTimeline.promotion_chain.length > 0
4969
+ ? envTimeline.promotion_chain
4970
+ : envTimeline.entries.map(e => e.env)
4971
+ ).map(envName => {
4972
+ const entry = envTimeline.entries.find(e => e.env === envName);
4973
+ const isCurrent = envName === envTimeline.current_env;
4974
+ const stepClass = 'env-timeline-step'
4975
+ + (entry ? ' env-step-' + entry.status : ' env-step-pending')
4976
+ + (isCurrent ? ' env-step-current' : '');
4977
+ const isDeploying = entry && (entry.status === 'deploying' || entry.status === 'verifying');
4978
+ return html`
4979
+ <div class=${stepClass} key=${envName}>
4980
+ <div class="env-step-dot"></div>
4981
+ <div class="env-step-body">
4982
+ <div class="env-step-name">${envName}</div>
4983
+ ${entry && entry.status === 'passed' && entry.satisfaction != null && html`
4984
+ <div class="env-step-score">${(entry.satisfaction * 100).toFixed(0)}%</div>
4985
+ `}
4986
+ ${entry && entry.verified_at && html`
4987
+ <div class="env-step-time">${timeAgo(entry.verified_at)}</div>
4988
+ `}
4989
+ ${!entry && html`
4990
+ <div class="env-step-time">pending</div>
4991
+ `}
4992
+ </div>
4993
+ <button
4994
+ class="btn-env-deploy"
4995
+ title=${'Deploy to ' + envName}
4996
+ data-testid=${'btn-deploy-' + envName}
4997
+ aria-label=${'Deploy to ' + envName}
4998
+ disabled=${isDeploying}
4999
+ onClick=${(e) => {
5000
+ e.stopPropagation();
5001
+ if (isDeploying) return;
5002
+ if (!confirm('Deploy current code to ' + envName + '?')) return;
5003
+ const proj = (selectedProject && selectedProject !== 'all')
5004
+ ? selectedProject
5005
+ : (item && item.project_id) || 'beastmode';
5006
+ const boardParam = proj ? '?board=' + encodeURIComponent(proj) : '';
5007
+ const body = {
5008
+ environment: envName,
5009
+ project: proj,
5010
+ };
5011
+ if (item && item.id) body.source_item_id = String(item.id);
5012
+ fetch('/api/deploy/trigger' + boardParam, {
5013
+ method: 'POST',
5014
+ headers: { 'Content-Type': 'application/json' },
5015
+ body: JSON.stringify(body),
5016
+ })
5017
+ .then(r => r.json().then(data => ({ ok: r.ok, status: r.status, data })))
5018
+ .then(({ ok, status, data }) => {
5019
+ if (status === 409) {
5020
+ alert(data.detail || 'A deploy to ' + envName + ' is already in progress.');
5021
+ return;
5022
+ }
5023
+ if (!ok) {
5024
+ alert('Deploy failed: ' + (data.detail || ('HTTP ' + status)));
5025
+ return;
5026
+ }
5027
+ if (data && data.id) {
5028
+ alert('Deploy task created: #' + data.id);
5029
+ }
5030
+ })
5031
+ .catch(err => alert('Deploy error: ' + err.message));
5032
+ }}
5033
+ >🚀</button>
5034
+ </div>
5035
+ `;
5036
+ })}
5037
+ </div>
5038
+ </div>
5039
+ `}
4529
5040
  ${(loadingAttachments || attachments.length > 0) && html`
4530
5041
  <div style="padding:12px 24px 0;">
4531
5042
  <h4 style="margin:0 0 8px 0;font-size:13px;font-weight:600;">
@@ -4742,9 +5253,12 @@ function PipelineView({
4742
5253
  return label ? html`<span class="card-badge badge-cost" title=${'Total cost: ' + label}>${label}</span>` : null;
4743
5254
  })()}
4744
5255
  ${(() => {
4745
- // Time-in-status badge (2026-04-20): answers "how long has
4746
- // this been waiting?" without the user having to click into
4747
- // the task. Falls back to updated_at for pre-v5 rows.
5256
+ const env = item.extra && item.extra.current_env;
5257
+ if (!env) return null;
5258
+ return html`<span class=${'card-badge badge-env badge-env-' + env}
5259
+ title=${'Environment: ' + env}>${env}</span>`;
5260
+ })()}
5261
+ ${(() => {
4748
5262
  const src = item.status_changed_at || item.updated_at || item.created_at;
4749
5263
  const age = statusAge(src);
4750
5264
  if (!age.text) return null;
@@ -5027,7 +5541,7 @@ function BoardPage({ selectedProject }) {
5027
5541
  const [showCreateDialog, setShowCreateDialog] = useState(false);
5028
5542
  const [searchTerm, setSearchTerm] = useState('');
5029
5543
  const [filtersOpen, setFiltersOpen] = useState(false);
5030
- const [filters, setFilters] = useState({ priority: '', taskType: '', project: '', dateRange: '', parentEpic: '' });
5544
+ const [filters, setFilters] = useState({ priority: '', taskType: '', project: '', dateRange: '', parentEpic: '', environment: '' });
5031
5545
  const [activeSwimlanesSet, setActiveSwimlanesSet] = useState(() => {
5032
5546
  try {
5033
5547
  const saved = JSON.parse(localStorage.getItem('beastmode-swimlane-filter') || 'null');
@@ -5134,6 +5648,36 @@ function BoardPage({ selectedProject }) {
5134
5648
  return () => clearInterval(interval);
5135
5649
  }, [selectedProject]);
5136
5650
 
5651
+ // Layout test surface: expose column metrics on window for scenario
5652
+ // verification. Guards on !loading and items.length > 0 so metrics are
5653
+ // only exposed once the board has actually rendered content.
5654
+ // requestAnimationFrame defers measurement until after the browser has
5655
+ // painted, ensuring scrollHeight/clientHeight reflect the real layout
5656
+ // (not stale values from a partially-completed render cycle).
5657
+ useEffect(() => {
5658
+ if (loading || items.length === 0) return;
5659
+ const raf = requestAnimationFrame(() => {
5660
+ const columns = document.querySelectorAll('.kanban-items');
5661
+ if (columns.length === 0) return;
5662
+ const metrics = Array.from(columns).map(col => {
5663
+ const cs = getComputedStyle(col);
5664
+ return {
5665
+ scrollHeight: col.scrollHeight,
5666
+ clientHeight: col.clientHeight,
5667
+ isScrollable: col.scrollHeight > col.clientHeight,
5668
+ hasOverflowAuto: cs.overflowY === 'auto',
5669
+ maxHeight: cs.maxHeight,
5670
+ };
5671
+ });
5672
+ window.__BEASTMODE_BOARD_LAYOUT__ = {
5673
+ columnCount: columns.length,
5674
+ columns: metrics,
5675
+ timestamp: Date.now(),
5676
+ };
5677
+ });
5678
+ return () => cancelAnimationFrame(raf);
5679
+ }, [items, loading]);
5680
+
5137
5681
  // WebSocket listener for real-time cost updates (T4).
5138
5682
  // On any `cost_recorded` or `costs_cleared` event, re-fetch the
5139
5683
  // full batch — the endpoint is a single GROUP BY query and is
@@ -5356,6 +5900,11 @@ function BoardPage({ selectedProject }) {
5356
5900
  if (filters.project && (item.project_id || '') !== filters.project) return false;
5357
5901
  // Parent epic filter
5358
5902
  if (filters.parentEpic && String(item.parent_epic || '') !== filters.parentEpic) return false;
5903
+ // Environment filter
5904
+ if (filters.environment) {
5905
+ const env = item.extra && item.extra.current_env;
5906
+ if (env !== filters.environment) return false;
5907
+ }
5359
5908
  // Date range filter
5360
5909
  if (filters.dateRange) {
5361
5910
  const d = new Date(item.created_at || item.updated_at);
@@ -5456,7 +6005,7 @@ function BoardPage({ selectedProject }) {
5456
6005
  });
5457
6006
 
5458
6007
  // Active filter count
5459
- const _baseFilterCount = [filters.priority, filters.taskType, filters.project, filters.dateRange, filters.parentEpic].filter(Boolean).length;
6008
+ const _baseFilterCount = [filters.priority, filters.taskType, filters.project, filters.dateRange, filters.parentEpic, filters.environment].filter(Boolean).length;
5460
6009
  const _swimlaneFilterActive = activeSwimlanesSet.size < (((window.PIPELINE_CONFIG && window.PIPELINE_CONFIG.swimlanes) || []).length || 0);
5461
6010
  const activeFilterCount = _baseFilterCount + (_swimlaneFilterActive ? 1 : 0);
5462
6011
 
@@ -5486,6 +6035,16 @@ function BoardPage({ selectedProject }) {
5486
6035
  // Unique project IDs and parent epics for filter dropdowns
5487
6036
  const uniqueProjects = [...new Set(items.map(i => i.project_id).filter(Boolean))];
5488
6037
  const uniqueEpics = [...new Set(items.map(i => i.parent_epic).filter(Boolean))].map(String);
6038
+ // Unique environments (NLSpec §5.3) — backward-compat: empty list when no item has env data,
6039
+ // which hides the Environment filter dropdown for single-env projects.
6040
+ const uniqueEnvs = useMemo(() => {
6041
+ const envSet = new Set();
6042
+ items.forEach(item => {
6043
+ const env = item.extra && item.extra.current_env;
6044
+ if (env) envSet.add(env);
6045
+ });
6046
+ return [...envSet].sort();
6047
+ }, [items]);
5489
6048
 
5490
6049
  if (loading) return html`<${SkeletonLoader} type="cards" />`;
5491
6050
 
@@ -5614,8 +6173,17 @@ function BoardPage({ selectedProject }) {
5614
6173
  </select>
5615
6174
  </div>
5616
6175
  `}
6176
+ ${uniqueEnvs.length > 0 && html`
6177
+ <div class="filter-group">
6178
+ <label>Environment</label>
6179
+ <select value=${filters.environment} onChange=${(e) => setFilters(f => ({...f, environment: e.target.value}))}>
6180
+ <option value="">All</option>
6181
+ ${uniqueEnvs.map(env => html`<option key=${env} value=${env}>${env}</option>`)}
6182
+ </select>
6183
+ </div>
6184
+ `}
5617
6185
  ${activeFilterCount > 0 && html`
5618
- <button class="filter-clear-link" onClick=${() => setFilters({ priority: '', taskType: '', project: '', dateRange: '', parentEpic: '' })}>Clear all</button>
6186
+ <button class="filter-clear-link" onClick=${() => setFilters({ priority: '', taskType: '', project: '', dateRange: '', parentEpic: '', environment: '' })}>Clear all</button>
5619
6187
  `}
5620
6188
  </div>
5621
6189
  `}
@@ -5721,9 +6289,12 @@ function BoardPage({ selectedProject }) {
5721
6289
  return label ? html`<span class="card-badge badge-cost" title=${'Total cost: ' + label}>${label}</span>` : null;
5722
6290
  })()}
5723
6291
  ${(() => {
5724
- // Time-in-status badge (2026-04-20): answers "how long has
5725
- // this been waiting?" without the user having to click into
5726
- // the task. Falls back to updated_at for pre-v5 rows.
6292
+ const env = item.extra && item.extra.current_env;
6293
+ if (!env) return null;
6294
+ return html`<span class=${'card-badge badge-env badge-env-' + env}
6295
+ title=${'Environment: ' + env}>${env}</span>`;
6296
+ })()}
6297
+ ${(() => {
5727
6298
  const src = item.status_changed_at || item.updated_at || item.created_at;
5728
6299
  const age = statusAge(src);
5729
6300
  if (!age.text) return null;
@@ -5767,7 +6338,7 @@ function BoardPage({ selectedProject }) {
5767
6338
  `}
5768
6339
 
5769
6340
  ${showCreateDialog && html`<${CreateTaskDialog} onClose=${() => setShowCreateDialog(false)} onCreated=${fetchItems} />`}
5770
- ${selectedItem && html`<${ItemDetailSidebar} item=${selectedItem} onClose=${() => setSelectedItem(null)} onStatusChange=${() => { fetchItems(); setSelectedItem(null); }} />`}
6341
+ ${selectedItem && html`<${ItemDetailSidebar} item=${selectedItem} selectedProject=${selectedProject} onClose=${() => setSelectedItem(null)} onStatusChange=${() => { fetchItems(); setSelectedItem(null); }} />`}
5771
6342
  </div>
5772
6343
  `;
5773
6344
  }
@@ -6034,6 +6605,530 @@ function RunsPage({ selectedProject }) {
6034
6605
  `;
6035
6606
  }
6036
6607
 
6608
+ // ================================================================
6609
+ // Environment Management Panel (Story 9)
6610
+ // ================================================================
6611
+
6612
+ function validateEnvForm(values, existingNames, isEdit) {
6613
+ const errors = {};
6614
+ if (!values.name) {
6615
+ errors.name = 'Name is required';
6616
+ } else if (!/^[a-z0-9][a-z0-9_-]*$/.test(values.name)) {
6617
+ errors.name = 'Lowercase alphanumeric, hyphens, underscores only. Must start with letter or digit.';
6618
+ } else if (!isEdit && existingNames.includes(values.name)) {
6619
+ errors.name = 'An environment with this name already exists';
6620
+ }
6621
+ if (values.health_url && !/^https?:\/\//.test(values.health_url)) {
6622
+ errors.health_url = 'Must start with http:// or https://';
6623
+ }
6624
+ return errors;
6625
+ }
6626
+
6627
+ function EnvironmentRow({ env, health, onEdit, onDelete }) {
6628
+ const healthDotColor = health === 'up' ? '#22c55e' : health === 'down' ? '#ef4444' : '#6b7280';
6629
+ const healthTitle = health === 'up' ? 'Healthy' : health === 'down' ? 'Unreachable' : 'Not configured';
6630
+ const classesDisplay = (env.scenario_classes && env.scenario_classes.length)
6631
+ ? env.scenario_classes.join(', ') : 'all';
6632
+ const truncatedUrl = env.health_url
6633
+ ? (env.health_url.length > 40 ? env.health_url.slice(0, 40) + '...' : env.health_url)
6634
+ : null;
6635
+
6636
+ return html`
6637
+ <tr>
6638
+ <td>
6639
+ <span class=${'badge-env badge-env-' + env.name}>${env.name}</span>
6640
+ ${env.is_virtual ? html` <span style="font-size:11px;color:var(--text-muted);">(virtual)</span>` : null}
6641
+ </td>
6642
+ <td>
6643
+ <span aria-label=${'Health: ' + healthTitle} title=${healthTitle}
6644
+ style=${'display:inline-block;width:8px;height:8px;border-radius:50%;background:' + healthDotColor + ';margin-right:6px;vertical-align:middle;'}></span>
6645
+ ${truncatedUrl
6646
+ ? html`<span style="font-size:12px;color:var(--text-secondary);" title=${env.health_url}>${truncatedUrl}</span>`
6647
+ : html`<span style="font-size:12px;color:var(--text-muted);">—</span>`}
6648
+ </td>
6649
+ <td style="font-size:12px;">${classesDisplay}</td>
6650
+ <td style="text-align:center;">${env.auto_promote ? '✓' : '—'}</td>
6651
+ <td style="text-align:center;">${env.approval_required ? '✓' : '—'}</td>
6652
+ <td>
6653
+ ${env.promote_to
6654
+ ? html`<span style="color:var(--text-muted);margin-right:4px;">→</span><span class=${'badge-env badge-env-' + env.promote_to}>${env.promote_to}</span>`
6655
+ : html`<span style="font-size:12px;color:var(--text-muted);">terminal</span>`}
6656
+ </td>
6657
+ <td>
6658
+ <div style="display:flex;gap:6px;">
6659
+ <button class="btn btn-sm btn-secondary" onClick=${onEdit}>Edit</button>
6660
+ <button class="btn btn-sm btn-danger" onClick=${onDelete} disabled=${env.is_virtual}>Delete</button>
6661
+ </div>
6662
+ </td>
6663
+ </tr>
6664
+ `;
6665
+ }
6666
+
6667
+ function EnvironmentsTable({ environments, healthStatuses, onEdit, onDelete }) {
6668
+ if (!environments.length) {
6669
+ return html`<div style="color:var(--text-muted);font-size:13px;padding:12px 0;">
6670
+ No environments configured. Add one to enable multi-environment verification.
6671
+ </div>`;
6672
+ }
6673
+ return html`
6674
+ <table class="env-table" role="table" aria-labelledby="env-table-label">
6675
+ <thead>
6676
+ <tr>
6677
+ <th id="env-table-label">Name</th>
6678
+ <th>Health</th>
6679
+ <th>Scenario Classes</th>
6680
+ <th style="width:60px;text-align:center;">Auto</th>
6681
+ <th style="width:60px;text-align:center;">Approval</th>
6682
+ <th>Promotes to</th>
6683
+ <th style="width:120px;">Actions</th>
6684
+ </tr>
6685
+ </thead>
6686
+ <tbody>
6687
+ ${environments.map(env => html`
6688
+ <${EnvironmentRow}
6689
+ key=${env.name}
6690
+ env=${env}
6691
+ health=${healthStatuses[env.name] || 'unknown'}
6692
+ onEdit=${() => onEdit(env.name)}
6693
+ onDelete=${() => onDelete(env.name)}
6694
+ />
6695
+ `)}
6696
+ </tbody>
6697
+ </table>
6698
+ `;
6699
+ }
6700
+
6701
+ // Compute the longest linear promotion chain from environments client-side.
6702
+ // The API's promotion_chain reflects only the default production terminal;
6703
+ // when multiple chains exist we pick the longest one from the actual data.
6704
+ function computePromotionChain(environments) {
6705
+ if (!environments || environments.length < 2) return (environments || []).map(e => e.name);
6706
+ const envMap = {};
6707
+ environments.forEach(e => { envMap[e.name] = e; });
6708
+ const isTarget = new Set(environments.map(e => e.promote_to).filter(Boolean));
6709
+ const sources = environments.filter(e => !isTarget.has(e.name));
6710
+ let longest = [];
6711
+ for (const src of sources) {
6712
+ const chain = [src.name];
6713
+ let cur = src;
6714
+ const seen = new Set([src.name]);
6715
+ while (cur.promote_to && envMap[cur.promote_to] && !seen.has(cur.promote_to)) {
6716
+ chain.push(cur.promote_to);
6717
+ seen.add(cur.promote_to);
6718
+ cur = envMap[cur.promote_to];
6719
+ }
6720
+ if (chain.length > longest.length) longest = chain;
6721
+ }
6722
+ return longest;
6723
+ }
6724
+
6725
+ function PromotionChainViz({ chain, environments }) {
6726
+ const envMap = {};
6727
+ for (const e of environments) envMap[e.name] = e;
6728
+ return html`
6729
+ <div class="promotion-chain" aria-label="Promotion chain">
6730
+ ${chain.map((name, i) => html`
6731
+ ${i > 0 ? html`<span class="promotion-chain-arrow">→</span>` : null}
6732
+ <span key=${name} class=${'badge-env badge-env-' + name} style="padding:4px 12px;font-size:12px;">
6733
+ ${name}
6734
+ ${envMap[name] && envMap[name].auto_promote ? html` <span style="font-size:10px;opacity:0.7;" title="auto-promote">(auto)</span>` : null}
6735
+ ${envMap[name] && envMap[name].approval_required ? html` <span style="font-size:10px;opacity:0.7;" title="approval required">(gate)</span>` : null}
6736
+ </span>
6737
+ `)}
6738
+ </div>
6739
+ `;
6740
+ }
6741
+
6742
+ function EnvFormFields({ values, setValues, errors, setErrors, environments, isEdit }) {
6743
+ return html`
6744
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
6745
+ <div class="form-group" style="margin-bottom:0;">
6746
+ <label style="font-size:12px;font-weight:500;color:var(--text-secondary);display:block;margin-bottom:4px;">Name *</label>
6747
+ <input class="form-input" placeholder="e.g. staging"
6748
+ disabled=${isEdit}
6749
+ value=${values.name}
6750
+ onInput=${e => { setValues({ ...values, name: e.target.value }); setErrors({ ...errors, name: null }); }} />
6751
+ ${errors.name ? html`<div style="color:#ef4444;font-size:11px;margin-top:2px;">${errors.name}</div>` : null}
6752
+ </div>
6753
+ <div class="form-group" style="margin-bottom:0;">
6754
+ <label style="font-size:12px;font-weight:500;color:var(--text-secondary);display:block;margin-bottom:4px;">Health URL</label>
6755
+ <input class="form-input" placeholder="https://staging.example.com/health"
6756
+ value=${values.health_url}
6757
+ onInput=${e => { setValues({ ...values, health_url: e.target.value }); setErrors({ ...errors, health_url: null }); }} />
6758
+ ${errors.health_url ? html`<div style="color:#ef4444;font-size:11px;margin-top:2px;">${errors.health_url}</div>` : null}
6759
+ </div>
6760
+ <div class="form-group" style="margin-bottom:0;">
6761
+ <label style="font-size:12px;font-weight:500;color:var(--text-secondary);display:block;margin-bottom:4px;">Deploy Strategy</label>
6762
+ <input class="form-input" placeholder="infra/staging-deploy.md"
6763
+ value=${values.deploy_strategy}
6764
+ onInput=${e => setValues({ ...values, deploy_strategy: e.target.value })} />
6765
+ </div>
6766
+ <div class="form-group" style="margin-bottom:0;">
6767
+ <label style="font-size:12px;font-weight:500;color:var(--text-secondary);display:block;margin-bottom:4px;">Scenario Classes</label>
6768
+ <input class="form-input" placeholder="integration, e2e (comma-separated)"
6769
+ value=${values.scenario_classes_text}
6770
+ onInput=${e => setValues({ ...values, scenario_classes_text: e.target.value })} />
6771
+ </div>
6772
+ <div class="form-group" style="margin-bottom:0;">
6773
+ <label style="font-size:12px;font-weight:500;color:var(--text-secondary);display:block;margin-bottom:4px;">Promotes to</label>
6774
+ <select class="form-input" value=${values.promote_to || ''}
6775
+ onChange=${e => setValues({ ...values, promote_to: e.target.value || null })}>
6776
+ <option value="">None (terminal)</option>
6777
+ ${environments.filter(env => !isEdit || env.name !== values.name).map(env => html`
6778
+ <option key=${env.name} value=${env.name}>${env.name}</option>
6779
+ `)}
6780
+ </select>
6781
+ </div>
6782
+ <div style="display:flex;gap:16px;align-items:center;padding-top:20px;">
6783
+ <label style="display:flex;align-items:center;gap:6px;font-size:13px;cursor:pointer;">
6784
+ <input type="checkbox" checked=${values.auto_promote}
6785
+ onChange=${e => setValues({ ...values, auto_promote: e.target.checked })} />
6786
+ Auto-promote
6787
+ </label>
6788
+ <label style="display:flex;align-items:center;gap:6px;font-size:13px;cursor:pointer;">
6789
+ <input type="checkbox" checked=${values.approval_required}
6790
+ onChange=${e => setValues({ ...values, approval_required: e.target.checked })} />
6791
+ Approval required
6792
+ </label>
6793
+ </div>
6794
+ </div>
6795
+ `;
6796
+ }
6797
+
6798
+ function AddEnvironmentForm({ projectName, existingNames, environments, onCreated, onCancel }) {
6799
+ const [values, setValues] = useState({
6800
+ name: '', health_url: '', deploy_strategy: '',
6801
+ scenario_classes_text: '', auto_promote: false,
6802
+ approval_required: false, promote_to: null,
6803
+ });
6804
+ const [errors, setErrors] = useState({});
6805
+ const [submitting, setSubmitting] = useState(false);
6806
+ const [apiError, setApiError] = useState(null);
6807
+
6808
+ const handleSubmit = async () => {
6809
+ const errs = validateEnvForm(values, existingNames, false);
6810
+ if (Object.keys(errs).length) { setErrors(errs); return; }
6811
+ setSubmitting(true);
6812
+ setApiError(null);
6813
+ try {
6814
+ const payload = {
6815
+ name: values.name,
6816
+ health_url: values.health_url || undefined,
6817
+ deploy_strategy: values.deploy_strategy || undefined,
6818
+ scenario_classes: values.scenario_classes_text
6819
+ ? values.scenario_classes_text.split(',').map(s => s.trim()).filter(Boolean)
6820
+ : [],
6821
+ auto_promote: values.auto_promote,
6822
+ approval_required: values.approval_required,
6823
+ promote_to: values.promote_to || null,
6824
+ };
6825
+ await api('POST', '/api/environments?project=' + encodeURIComponent(projectName), payload);
6826
+ onCreated();
6827
+ } catch (e) { setApiError(e.message); }
6828
+ finally { setSubmitting(false); }
6829
+ };
6830
+
6831
+ return html`
6832
+ <div style="margin-top:16px;padding:16px;border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--bg-input);">
6833
+ <h5 style="font-size:13px;font-weight:600;margin:0 0 12px;">Add Environment</h5>
6834
+ ${apiError ? html`<div class="error-msg" style="margin-bottom:8px;">${apiError}</div>` : null}
6835
+ <${EnvFormFields}
6836
+ values=${values}
6837
+ setValues=${setValues}
6838
+ errors=${errors}
6839
+ setErrors=${setErrors}
6840
+ environments=${environments}
6841
+ isEdit=${false}
6842
+ />
6843
+ <div style="display:flex;gap:8px;margin-top:14px;">
6844
+ <button class="btn btn-primary btn-sm" onClick=${handleSubmit} disabled=${submitting}>
6845
+ ${submitting ? 'Creating...' : 'Create Environment'}
6846
+ </button>
6847
+ <button class="btn btn-secondary btn-sm" onClick=${onCancel}>Cancel</button>
6848
+ </div>
6849
+ </div>
6850
+ `;
6851
+ }
6852
+
6853
+ function EditEnvironmentDialog({ projectName, env, existingNames, environments, onSaved, onClose }) {
6854
+ const [values, setValues] = useState({
6855
+ name: env.name,
6856
+ health_url: env.health_url || '',
6857
+ deploy_strategy: env.deploy_strategy || '',
6858
+ scenario_classes_text: (env.scenario_classes || []).join(', '),
6859
+ auto_promote: !!env.auto_promote,
6860
+ approval_required: !!env.approval_required,
6861
+ promote_to: env.promote_to || null,
6862
+ });
6863
+ const [errors, setErrors] = useState({});
6864
+ const [submitting, setSubmitting] = useState(false);
6865
+ const [apiError, setApiError] = useState(null);
6866
+ const dialogRef = useRef(null);
6867
+
6868
+ const handleSave = async () => {
6869
+ const errs = validateEnvForm(values, existingNames, true);
6870
+ if (Object.keys(errs).length) { setErrors(errs); return; }
6871
+ setSubmitting(true);
6872
+ setApiError(null);
6873
+ try {
6874
+ const payload = {
6875
+ health_url: values.health_url || null,
6876
+ deploy_strategy: values.deploy_strategy || null,
6877
+ scenario_classes: values.scenario_classes_text
6878
+ ? values.scenario_classes_text.split(',').map(s => s.trim()).filter(Boolean)
6879
+ : [],
6880
+ auto_promote: values.auto_promote,
6881
+ approval_required: values.approval_required,
6882
+ promote_to: values.promote_to || null,
6883
+ clear_promote_to: !values.promote_to,
6884
+ };
6885
+ await api('PATCH', '/api/environments/' + encodeURIComponent(env.name) + '?project=' + encodeURIComponent(projectName), payload);
6886
+ onSaved();
6887
+ } catch (e) { setApiError(e.message); }
6888
+ finally { setSubmitting(false); }
6889
+ };
6890
+
6891
+ useEffect(() => {
6892
+ const firstInput = dialogRef.current && dialogRef.current.querySelector('input:not([disabled])');
6893
+ if (firstInput) firstInput.focus();
6894
+ }, []);
6895
+
6896
+ useEffect(() => {
6897
+ const handler = (e) => { if (e.key === 'Escape') onClose(); };
6898
+ document.addEventListener('keydown', handler);
6899
+ return () => document.removeEventListener('keydown', handler);
6900
+ }, []);
6901
+
6902
+ return html`
6903
+ <div class="dialog-overlay" onClick=${onClose}>
6904
+ <div class="dialog env-edit-dialog" ref=${dialogRef}
6905
+ onClick=${e => e.stopPropagation()}
6906
+ role="dialog" aria-label="Edit environment" aria-modal="true">
6907
+ <div class="dialog-header">
6908
+ <h3>Edit Environment: ${env.name}</h3>
6909
+ <button class="btn btn-ghost btn-sm" onClick=${onClose}>✕</button>
6910
+ </div>
6911
+ <div class="dialog-body">
6912
+ ${apiError ? html`<div class="error-msg" style="margin-bottom:8px;">${apiError}</div>` : null}
6913
+ <${EnvFormFields}
6914
+ values=${values}
6915
+ setValues=${setValues}
6916
+ errors=${errors}
6917
+ setErrors=${setErrors}
6918
+ environments=${environments}
6919
+ isEdit=${true}
6920
+ />
6921
+ <div style="display:flex;gap:8px;margin-top:14px;justify-content:flex-end;">
6922
+ <button class="btn btn-secondary btn-sm" onClick=${onClose}>Cancel</button>
6923
+ <button class="btn btn-primary btn-sm" onClick=${handleSave} disabled=${submitting}>
6924
+ ${submitting ? 'Saving...' : 'Save Changes'}
6925
+ </button>
6926
+ </div>
6927
+ </div>
6928
+ </div>
6929
+ </div>
6930
+ `;
6931
+ }
6932
+
6933
+ function DeleteConfirmDialog({ projectName, envName, onDeleted, onClose }) {
6934
+ const [deleting, setDeleting] = useState(false);
6935
+ const [apiError, setApiError] = useState(null);
6936
+
6937
+ useEffect(() => {
6938
+ const handler = (e) => { if (e.key === 'Escape') onClose(); };
6939
+ document.addEventListener('keydown', handler);
6940
+ return () => document.removeEventListener('keydown', handler);
6941
+ }, []);
6942
+
6943
+ const handleDelete = async () => {
6944
+ setDeleting(true);
6945
+ setApiError(null);
6946
+ try {
6947
+ await api('DELETE', '/api/environments/' + encodeURIComponent(envName) + '?project=' + encodeURIComponent(projectName));
6948
+ onDeleted();
6949
+ } catch (e) {
6950
+ setApiError(e.message);
6951
+ }
6952
+ finally { setDeleting(false); }
6953
+ };
6954
+
6955
+ return html`
6956
+ <div class="dialog-overlay" onClick=${onClose}>
6957
+ <div class="dialog" style="max-width:400px;"
6958
+ onClick=${e => e.stopPropagation()}
6959
+ role="alertdialog" aria-label="Confirm delete environment" aria-modal="true">
6960
+ <div class="dialog-header">
6961
+ <h3>Delete Environment</h3>
6962
+ <button class="btn btn-ghost btn-sm" onClick=${onClose}>✕</button>
6963
+ </div>
6964
+ <div class="dialog-body">
6965
+ <p style="font-size:14px;color:var(--text);">
6966
+ Are you sure you want to delete <strong>${envName}</strong>?
6967
+ This cannot be undone.
6968
+ </p>
6969
+ ${apiError ? html`<div class="error-msg" style="margin-top:8px;">${apiError}</div>` : null}
6970
+ <div style="display:flex;gap:8px;margin-top:16px;justify-content:flex-end;">
6971
+ <button class="btn btn-secondary btn-sm" onClick=${onClose}>Cancel</button>
6972
+ <button class="btn btn-danger btn-sm" onClick=${handleDelete} disabled=${deleting}>
6973
+ ${deleting ? 'Deleting...' : 'Delete'}
6974
+ </button>
6975
+ </div>
6976
+ </div>
6977
+ </div>
6978
+ </div>
6979
+ `;
6980
+ }
6981
+
6982
+ function EnvironmentPanel({ projectName }) {
6983
+ const [environments, setEnvironments] = useState([]);
6984
+ const [promotionChain, setPromotionChain] = useState([]);
6985
+ const [loading, setLoading] = useState(true);
6986
+ const [error, setError] = useState(null);
6987
+ const [collapsed, setCollapsed] = useState(false);
6988
+ const [showAddForm, setShowAddForm] = useState(false);
6989
+ const [editingEnv, setEditingEnv] = useState(null);
6990
+ const [deletingEnv, setDeletingEnv] = useState(null);
6991
+ const [healthStatuses, setHealthStatuses] = useState({});
6992
+
6993
+ const fetchEnvs = useCallback(async () => {
6994
+ try {
6995
+ const data = await api('GET', '/api/environments?project=' + encodeURIComponent(projectName));
6996
+ const envs = data.environments || [];
6997
+ setEnvironments(envs);
6998
+ setPromotionChain(computePromotionChain(envs));
6999
+ setError(null);
7000
+ } catch (e) {
7001
+ setError(e.message);
7002
+ } finally {
7003
+ setLoading(false);
7004
+ }
7005
+ }, [projectName]);
7006
+
7007
+ useEffect(() => { fetchEnvs(); }, [projectName]);
7008
+
7009
+ // Health polling — every 30s, check each env's health_url
7010
+ useEffect(() => {
7011
+ if (!environments.length) return;
7012
+ let cancelled = false;
7013
+ const pollHealth = async () => {
7014
+ const statuses = {};
7015
+ for (const env of environments) {
7016
+ if (!env.health_url) {
7017
+ statuses[env.name] = 'unknown';
7018
+ continue;
7019
+ }
7020
+ try {
7021
+ const ctrl = new AbortController();
7022
+ const timeoutId = setTimeout(() => ctrl.abort(), 5000);
7023
+ await fetch(env.health_url, { mode: 'no-cors', signal: ctrl.signal });
7024
+ clearTimeout(timeoutId);
7025
+ statuses[env.name] = 'up';
7026
+ } catch {
7027
+ statuses[env.name] = 'down';
7028
+ }
7029
+ }
7030
+ if (!cancelled) setHealthStatuses(statuses);
7031
+ };
7032
+ pollHealth();
7033
+ const interval = setInterval(pollHealth, 30000);
7034
+ return () => { cancelled = true; clearInterval(interval); };
7035
+ }, [environments]);
7036
+
7037
+ // WebSocket listener for env CRUD events
7038
+ useEffect(() => {
7039
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
7040
+ const wsUrl = proto + '//' + location.host + '/ws';
7041
+ let ws;
7042
+ let reconnectTimer;
7043
+ let closed = false;
7044
+
7045
+ const interestingTypes = new Set([
7046
+ 'environment_created',
7047
+ 'environment_updated',
7048
+ 'environment_deleted',
7049
+ 'environments_reordered',
7050
+ ]);
7051
+
7052
+ const connect = () => {
7053
+ ws = new WebSocket(wsUrl);
7054
+ ws.onmessage = (event) => {
7055
+ try {
7056
+ const msg = JSON.parse(event.data);
7057
+ if (interestingTypes.has(msg.type) && msg.board === projectName) {
7058
+ fetchEnvs();
7059
+ }
7060
+ } catch {}
7061
+ };
7062
+ ws.onclose = () => {
7063
+ if (!closed) reconnectTimer = setTimeout(connect, 5000);
7064
+ };
7065
+ ws.onerror = () => { if (ws) ws.close(); };
7066
+ };
7067
+ connect();
7068
+
7069
+ return () => {
7070
+ closed = true;
7071
+ clearTimeout(reconnectTimer);
7072
+ if (ws) ws.close();
7073
+ };
7074
+ }, [projectName, fetchEnvs]);
7075
+
7076
+ return html`
7077
+ <div class="env-panel">
7078
+ <div class="env-panel-header" onClick=${() => setCollapsed(c => !c)}
7079
+ role="button" aria-expanded=${!collapsed}>
7080
+ <h4>Environments</h4>
7081
+ <span class=${'env-panel-toggle' + (collapsed ? ' collapsed' : '')}>▼</span>
7082
+ </div>
7083
+ ${!collapsed ? html`
7084
+ <div class="env-panel-body">
7085
+ ${error ? html`<div class="error-msg" style="margin-bottom:12px;">${error}</div>` : null}
7086
+ ${loading ? html`<div style="color:var(--text-muted);font-size:13px;">Loading environments...</div>` : html`
7087
+ ${promotionChain.length > 1 ? html`<${PromotionChainViz} chain=${promotionChain} environments=${environments} />` : null}
7088
+ <${EnvironmentsTable}
7089
+ environments=${environments}
7090
+ healthStatuses=${healthStatuses}
7091
+ onEdit=${(name) => setEditingEnv(name)}
7092
+ onDelete=${(name) => setDeletingEnv(name)}
7093
+ />
7094
+ ${!showAddForm ? html`
7095
+ <button class="btn btn-sm btn-secondary" style="margin-top:12px;" onClick=${() => setShowAddForm(true)}>
7096
+ + Add Environment
7097
+ </button>
7098
+ ` : html`
7099
+ <${AddEnvironmentForm}
7100
+ projectName=${projectName}
7101
+ existingNames=${environments.map(e => e.name)}
7102
+ environments=${environments}
7103
+ onCreated=${() => { setShowAddForm(false); fetchEnvs(); }}
7104
+ onCancel=${() => setShowAddForm(false)}
7105
+ />
7106
+ `}
7107
+ `}
7108
+ ${editingEnv ? html`
7109
+ <${EditEnvironmentDialog}
7110
+ projectName=${projectName}
7111
+ env=${environments.find(e => e.name === editingEnv)}
7112
+ existingNames=${environments.map(e => e.name)}
7113
+ environments=${environments}
7114
+ onSaved=${() => { setEditingEnv(null); fetchEnvs(); }}
7115
+ onClose=${() => setEditingEnv(null)}
7116
+ />
7117
+ ` : null}
7118
+ ${deletingEnv ? html`
7119
+ <${DeleteConfirmDialog}
7120
+ projectName=${projectName}
7121
+ envName=${deletingEnv}
7122
+ onDeleted=${() => { setDeletingEnv(null); fetchEnvs(); }}
7123
+ onClose=${() => setDeletingEnv(null)}
7124
+ />
7125
+ ` : null}
7126
+ </div>
7127
+ ` : null}
7128
+ </div>
7129
+ `;
7130
+ }
7131
+
6037
7132
  // ================================================================
6038
7133
  // Projects Page
6039
7134
  // ================================================================
@@ -6186,7 +7281,7 @@ function ProjectsPage({ selectedProject, onProjectChange }) {
6186
7281
  title="No projects yet"
6187
7282
  description="Add a project path to start building." />`
6188
7283
  : projects.map(proj => html`
6189
- <div class="ext-item" key=${proj.name}>
7284
+ <div class="ext-item" key=${proj.name} style="flex-wrap:wrap;">
6190
7285
  <div class="ext-info">
6191
7286
  <div class="ext-name">${proj.name}</div>
6192
7287
  <div class="ext-meta">
@@ -6198,6 +7293,7 @@ function ProjectsPage({ selectedProject, onProjectChange }) {
6198
7293
  <div class="ext-actions">
6199
7294
  <button class="btn btn-sm btn-danger" onClick=${() => removeProject(proj.name)}>Remove</button>
6200
7295
  </div>
7296
+ <${EnvironmentPanel} projectName=${proj.name} />
6201
7297
  </div>
6202
7298
  `)}
6203
7299
  </div>