@beastmode-develeap/beastmode 0.1.145 → 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.
- package/dist/index.js +1219 -464
- package/dist/index.js.map +1 -1
- package/dist/web/board.html +1139 -43
- package/dist/web/build-commit.txt +1 -1
- package/dist/web/build-stamp.txt +1 -1
- package/package.json +1 -1
package/dist/web/board.html
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
}
|
|
16
16
|
</script>
|
|
17
17
|
<!--BOARD_DATA-->
|
|
18
|
-
<script>window.__BUILD_STAMP__ = "
|
|
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-
|
|
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
|
-
|
|
3131
|
-
if (
|
|
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' : '')}>▶</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
|
-
${
|
|
4503
|
-
|
|
4504
|
-
|
|
4505
|
-
|
|
4506
|
-
|
|
4507
|
-
<
|
|
4508
|
-
|
|
4509
|
-
|
|
4510
|
-
|
|
4511
|
-
|
|
4512
|
-
|
|
4513
|
-
|
|
4514
|
-
|
|
4515
|
-
.
|
|
4516
|
-
|
|
4517
|
-
|
|
4518
|
-
|
|
4519
|
-
<
|
|
4520
|
-
|
|
4521
|
-
|
|
4522
|
-
|
|
4523
|
-
|
|
4524
|
-
|
|
4525
|
-
|
|
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
|
-
|
|
4746
|
-
|
|
4747
|
-
|
|
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
|
-
|
|
5725
|
-
|
|
5726
|
-
|
|
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>
|