@beastmode-develeap/beastmode 0.1.110 → 0.1.112
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/web/board.html +586 -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__ = "20260418-
|
|
18
|
+
<script>window.__BUILD_STAMP__ = "20260418-125802-c140b02";</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">
|
|
@@ -1632,6 +1632,91 @@ input[type="range"]::-webkit-slider-thumb {
|
|
|
1632
1632
|
white-space: nowrap;
|
|
1633
1633
|
}
|
|
1634
1634
|
|
|
1635
|
+
/* Cost badge on card */
|
|
1636
|
+
.card-badge.badge-cost {
|
|
1637
|
+
background: var(--success-subtle);
|
|
1638
|
+
color: var(--success);
|
|
1639
|
+
font-family: var(--font-mono);
|
|
1640
|
+
font-size: 10px;
|
|
1641
|
+
font-weight: 600;
|
|
1642
|
+
letter-spacing: 0.01em;
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
/* Detail sidebar cost section */
|
|
1646
|
+
.detail-cost-section {
|
|
1647
|
+
padding: 12px 24px;
|
|
1648
|
+
border-bottom: 1px solid var(--border);
|
|
1649
|
+
}
|
|
1650
|
+
.detail-cost-title {
|
|
1651
|
+
margin: 0 0 10px 0;
|
|
1652
|
+
font-size: 13px;
|
|
1653
|
+
font-weight: 600;
|
|
1654
|
+
display: flex;
|
|
1655
|
+
align-items: center;
|
|
1656
|
+
gap: 6px;
|
|
1657
|
+
color: var(--text);
|
|
1658
|
+
}
|
|
1659
|
+
.cost-totals {
|
|
1660
|
+
display: grid;
|
|
1661
|
+
grid-template-columns: repeat(3, 1fr);
|
|
1662
|
+
gap: 8px;
|
|
1663
|
+
margin-bottom: 12px;
|
|
1664
|
+
}
|
|
1665
|
+
.cost-total-item {
|
|
1666
|
+
display: flex;
|
|
1667
|
+
flex-direction: column;
|
|
1668
|
+
gap: 2px;
|
|
1669
|
+
padding: 8px 10px;
|
|
1670
|
+
background: var(--bg-input);
|
|
1671
|
+
border-radius: var(--radius-xs);
|
|
1672
|
+
border: 1px solid var(--border-subtle);
|
|
1673
|
+
}
|
|
1674
|
+
.cost-total-label {
|
|
1675
|
+
font-size: 10px;
|
|
1676
|
+
font-weight: 500;
|
|
1677
|
+
color: var(--text-muted);
|
|
1678
|
+
text-transform: uppercase;
|
|
1679
|
+
letter-spacing: 0.04em;
|
|
1680
|
+
}
|
|
1681
|
+
.cost-total-value {
|
|
1682
|
+
font-size: 16px;
|
|
1683
|
+
font-weight: 600;
|
|
1684
|
+
font-family: var(--font-mono);
|
|
1685
|
+
color: var(--text);
|
|
1686
|
+
}
|
|
1687
|
+
.cost-value-usd {
|
|
1688
|
+
color: var(--success);
|
|
1689
|
+
}
|
|
1690
|
+
.cost-phase-table {
|
|
1691
|
+
width: 100%;
|
|
1692
|
+
border-collapse: collapse;
|
|
1693
|
+
font-size: 12px;
|
|
1694
|
+
}
|
|
1695
|
+
.cost-phase-table th {
|
|
1696
|
+
text-align: left;
|
|
1697
|
+
padding: 6px 8px;
|
|
1698
|
+
font-size: 10px;
|
|
1699
|
+
font-weight: 600;
|
|
1700
|
+
color: var(--text-muted);
|
|
1701
|
+
text-transform: uppercase;
|
|
1702
|
+
letter-spacing: 0.04em;
|
|
1703
|
+
border-bottom: 1px solid var(--border);
|
|
1704
|
+
}
|
|
1705
|
+
.cost-phase-table td {
|
|
1706
|
+
padding: 6px 8px;
|
|
1707
|
+
border-bottom: 1px solid var(--border-subtle);
|
|
1708
|
+
}
|
|
1709
|
+
.cost-phase-name {
|
|
1710
|
+
font-weight: 500;
|
|
1711
|
+
color: var(--text);
|
|
1712
|
+
text-transform: capitalize;
|
|
1713
|
+
}
|
|
1714
|
+
.cost-phase-value {
|
|
1715
|
+
font-family: var(--font-mono);
|
|
1716
|
+
color: var(--text-secondary);
|
|
1717
|
+
font-size: 11px;
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1635
1720
|
/* ================================================================
|
|
1636
1721
|
EXTENSIONS / ITEMS LIST
|
|
1637
1722
|
================================================================ */
|
|
@@ -2149,6 +2234,115 @@ input[type="range"]::-webkit-slider-thumb {
|
|
|
2149
2234
|
gap: 8px;
|
|
2150
2235
|
}
|
|
2151
2236
|
|
|
2237
|
+
/* ================================================================
|
|
2238
|
+
COSTS PAGE
|
|
2239
|
+
================================================================ */
|
|
2240
|
+
|
|
2241
|
+
.costs-table-wrapper {
|
|
2242
|
+
overflow-x: auto;
|
|
2243
|
+
-webkit-overflow-scrolling: touch;
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
.costs-table {
|
|
2247
|
+
width: 100%;
|
|
2248
|
+
border-collapse: collapse;
|
|
2249
|
+
font-family: var(--font-sans);
|
|
2250
|
+
font-size: 13px;
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2253
|
+
.costs-th {
|
|
2254
|
+
text-align: left;
|
|
2255
|
+
padding: 10px 12px;
|
|
2256
|
+
font-size: 11px;
|
|
2257
|
+
font-weight: 600;
|
|
2258
|
+
text-transform: uppercase;
|
|
2259
|
+
letter-spacing: 0.5px;
|
|
2260
|
+
color: var(--text-muted);
|
|
2261
|
+
border-bottom: 1px solid var(--border);
|
|
2262
|
+
cursor: pointer;
|
|
2263
|
+
user-select: none;
|
|
2264
|
+
white-space: nowrap;
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2267
|
+
.costs-th:hover {
|
|
2268
|
+
color: var(--accent);
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
.costs-tr {
|
|
2272
|
+
cursor: pointer;
|
|
2273
|
+
transition: background 0.15s ease;
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
.costs-tr:hover {
|
|
2277
|
+
background: var(--bg-card-hover);
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
.costs-td {
|
|
2281
|
+
padding: 10px 12px;
|
|
2282
|
+
border-bottom: 1px solid var(--border-subtle);
|
|
2283
|
+
color: var(--text);
|
|
2284
|
+
vertical-align: middle;
|
|
2285
|
+
}
|
|
2286
|
+
|
|
2287
|
+
.costs-td-mono {
|
|
2288
|
+
font-family: var(--font-mono);
|
|
2289
|
+
font-size: 13px;
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
.costs-td-name {
|
|
2293
|
+
font-weight: 500;
|
|
2294
|
+
max-width: 300px;
|
|
2295
|
+
overflow: hidden;
|
|
2296
|
+
text-overflow: ellipsis;
|
|
2297
|
+
white-space: nowrap;
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2300
|
+
.costs-phase-chart {
|
|
2301
|
+
padding: 16px 0;
|
|
2302
|
+
}
|
|
2303
|
+
|
|
2304
|
+
.costs-phase-row {
|
|
2305
|
+
display: flex;
|
|
2306
|
+
align-items: center;
|
|
2307
|
+
gap: 12px;
|
|
2308
|
+
padding: 6px 16px;
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
.costs-phase-label {
|
|
2312
|
+
width: 100px;
|
|
2313
|
+
font-size: 13px;
|
|
2314
|
+
font-weight: 500;
|
|
2315
|
+
color: var(--text-secondary);
|
|
2316
|
+
text-transform: capitalize;
|
|
2317
|
+
flex-shrink: 0;
|
|
2318
|
+
}
|
|
2319
|
+
|
|
2320
|
+
.costs-phase-bar-bg {
|
|
2321
|
+
flex: 1;
|
|
2322
|
+
height: 24px;
|
|
2323
|
+
background: var(--accent-subtle);
|
|
2324
|
+
border-radius: var(--radius-xs);
|
|
2325
|
+
overflow: hidden;
|
|
2326
|
+
}
|
|
2327
|
+
|
|
2328
|
+
.costs-phase-bar-fill {
|
|
2329
|
+
height: 100%;
|
|
2330
|
+
background: var(--accent);
|
|
2331
|
+
border-radius: var(--radius-xs);
|
|
2332
|
+
transition: width 0.4s ease;
|
|
2333
|
+
min-width: 2px;
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2336
|
+
.costs-phase-amount {
|
|
2337
|
+
width: 80px;
|
|
2338
|
+
text-align: right;
|
|
2339
|
+
font-family: var(--font-mono);
|
|
2340
|
+
font-size: 13px;
|
|
2341
|
+
font-weight: 500;
|
|
2342
|
+
color: var(--text);
|
|
2343
|
+
flex-shrink: 0;
|
|
2344
|
+
}
|
|
2345
|
+
|
|
2152
2346
|
/* ================================================================
|
|
2153
2347
|
RESPONSIVE
|
|
2154
2348
|
================================================================ */
|
|
@@ -2176,6 +2370,10 @@ input[type="range"]::-webkit-slider-thumb {
|
|
|
2176
2370
|
/* Detail sidebar: full-width on tablets */
|
|
2177
2371
|
.detail-sidebar { width: 100vw !important; max-width: 100vw; }
|
|
2178
2372
|
.detail-resize-handle { display: none; }
|
|
2373
|
+
/* Costs page */
|
|
2374
|
+
.costs-phase-label { width: 70px; font-size: 12px; }
|
|
2375
|
+
.costs-phase-amount { width: 60px; font-size: 12px; }
|
|
2376
|
+
.costs-td-name { max-width: 150px; }
|
|
2179
2377
|
}
|
|
2180
2378
|
|
|
2181
2379
|
@media (max-width: 600px) {
|
|
@@ -2192,6 +2390,9 @@ input[type="range"]::-webkit-slider-thumb {
|
|
|
2192
2390
|
.detail-sidebar { padding: 16px; }
|
|
2193
2391
|
.detail-header h3 { font-size: 14px; }
|
|
2194
2392
|
.update-body { font-size: 12px; }
|
|
2393
|
+
/* Costs page */
|
|
2394
|
+
.costs-table { font-size: 12px; }
|
|
2395
|
+
.costs-th, .costs-td { padding: 8px 6px; }
|
|
2195
2396
|
}
|
|
2196
2397
|
|
|
2197
2398
|
/* ================================================================
|
|
@@ -2446,6 +2647,36 @@ async function api(method, path, body) {
|
|
|
2446
2647
|
return data;
|
|
2447
2648
|
}
|
|
2448
2649
|
|
|
2650
|
+
// ================================================================
|
|
2651
|
+
// Cost / Token / Duration Formatters
|
|
2652
|
+
// ================================================================
|
|
2653
|
+
|
|
2654
|
+
function formatCost(usd) {
|
|
2655
|
+
if (usd === null || usd === undefined) return null;
|
|
2656
|
+
if (usd >= 100) return '$' + Math.round(usd);
|
|
2657
|
+
if (usd >= 10) return '$' + usd.toFixed(1);
|
|
2658
|
+
if (usd >= 0.01) return '$' + usd.toFixed(2);
|
|
2659
|
+
return '<$0.01';
|
|
2660
|
+
}
|
|
2661
|
+
|
|
2662
|
+
function formatTokens(n) {
|
|
2663
|
+
if (n === null || n === undefined || n === 0) return '0';
|
|
2664
|
+
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1).replace(/\.0$/, '') + 'M';
|
|
2665
|
+
if (n >= 1_000) return (n / 1_000).toFixed(1).replace(/\.0$/, '') + 'K';
|
|
2666
|
+
return String(n);
|
|
2667
|
+
}
|
|
2668
|
+
|
|
2669
|
+
function formatDuration(s) {
|
|
2670
|
+
if (!s || s <= 0) return '0s';
|
|
2671
|
+
if (s < 60) return Math.round(s) + 's';
|
|
2672
|
+
const m = Math.floor(s / 60);
|
|
2673
|
+
const sec = Math.round(s % 60);
|
|
2674
|
+
if (m < 60) return sec > 0 ? m + 'm ' + sec + 's' : m + 'm';
|
|
2675
|
+
const h = Math.floor(m / 60);
|
|
2676
|
+
const rm = m % 60;
|
|
2677
|
+
return rm > 0 ? h + 'h ' + rm + 'm' : h + 'h';
|
|
2678
|
+
}
|
|
2679
|
+
|
|
2449
2680
|
// ================================================================
|
|
2450
2681
|
// Router
|
|
2451
2682
|
// ================================================================
|
|
@@ -2516,6 +2747,7 @@ function Icon({ name, size = 18, className = '' }) {
|
|
|
2516
2747
|
lightbulb: html`<path d="M8 1a5 5 0 00-3 9c.5.6.8 1.2 1 1.8V13h4v-1.2c.2-.6.5-1.2 1-1.8A5 5 0 008 1z"/><line x1="6" y1="13.5" x2="10" y2="13.5"/><line x1="6.5" y1="15" x2="9.5" y2="15"/>`,
|
|
2517
2748
|
help: html`<circle cx="8" cy="8" r="6.5"/><path d="M6 6.5a2 2 0 013.7 1c0 1.5-1.7 1.5-1.7 3"/><circle cx="8" cy="12" r="0.5" fill="currentColor" stroke="none"/>`,
|
|
2518
2749
|
filter: html`<polygon points="1.5,2 14.5,2 9.5,8.5 9.5,13 6.5,14.5 6.5,8.5"/>`,
|
|
2750
|
+
costs: html`<circle cx="8" cy="8" r="6.5"/><path d="M8 3.5v1m0 7v1M6 10.5c0 .8.9 1.5 2 1.5s2-.7 2-1.5S9.1 9 8 9s-2-.7-2-1.5S6.9 6 8 6s2 .7 2 1.5"/>`,
|
|
2519
2751
|
};
|
|
2520
2752
|
|
|
2521
2753
|
return html`
|
|
@@ -2606,6 +2838,7 @@ function Sidebar({ currentHash, factoryName, theme, onToggleTheme, selectedProje
|
|
|
2606
2838
|
...(selectedProject !== 'all' ? [{ path: '#/board', icon: 'board', label: 'Board' }] : []),
|
|
2607
2839
|
...(selectedProject !== 'all' ? [{ path: '#/strategy', icon: 'strategy', label: 'Strategy' }] : []),
|
|
2608
2840
|
{ path: '#/runs', icon: 'runs', label: 'Runs' },
|
|
2841
|
+
{ path: '#/costs', icon: 'costs', label: 'Costs' },
|
|
2609
2842
|
{ path: '#/learnings', icon: 'lightbulb', label: 'Learnings' },
|
|
2610
2843
|
{ path: '#/projects', icon: 'projects', label: 'Projects' },
|
|
2611
2844
|
{ path: '#/extensions', icon: 'extensions', label: 'Extensions' },
|
|
@@ -3443,9 +3676,25 @@ function ItemDetailSidebar({ item, onClose, onStatusChange }) {
|
|
|
3443
3676
|
// that open in a new tab when clicked.
|
|
3444
3677
|
const [attachments, setAttachments] = useState([]);
|
|
3445
3678
|
const [loadingAttachments, setLoadingAttachments] = useState(true);
|
|
3679
|
+
const [costSummary, setCostSummary] = useState(null);
|
|
3680
|
+
const [loadingCost, setLoadingCost] = useState(true);
|
|
3446
3681
|
const sidebarRef = useRef(null);
|
|
3447
3682
|
const topCommentRef = useRef(null);
|
|
3448
3683
|
|
|
3684
|
+
// Cost summary fetch — keyed on item.id, refreshed alongside the
|
|
3685
|
+
// 10-second updates/attachments poll below. The api() helper only
|
|
3686
|
+
// auto-scopes /api/board/*, so append ?board=<proj> manually.
|
|
3687
|
+
// Treat record_count=0 as "no cost data" so the sidebar stays clean.
|
|
3688
|
+
const refreshCostSummary = useCallback(() => {
|
|
3689
|
+
if (!item) return;
|
|
3690
|
+
const proj = localStorage.getItem('beastmode-selected-project') || '';
|
|
3691
|
+
const boardParam = (proj && proj !== 'all') ? '?board=' + encodeURIComponent(proj) : '';
|
|
3692
|
+
fetch('/api/items/' + item.id + '/costs/summary' + boardParam)
|
|
3693
|
+
.then(r => r.ok ? r.json() : null)
|
|
3694
|
+
.then(data => setCostSummary(data && data.record_count > 0 ? data : null))
|
|
3695
|
+
.catch(() => setCostSummary(null));
|
|
3696
|
+
}, [item && item.id]);
|
|
3697
|
+
|
|
3449
3698
|
const refreshUpdates = useCallback(() => {
|
|
3450
3699
|
if (!item) return;
|
|
3451
3700
|
api('GET', '/api/board/items/' + item.id + '/updates')
|
|
@@ -3484,9 +3733,18 @@ function ItemDetailSidebar({ item, onClose, onStatusChange }) {
|
|
|
3484
3733
|
})
|
|
3485
3734
|
.catch(() => setAttachments([]))
|
|
3486
3735
|
.finally(() => setLoadingAttachments(false));
|
|
3736
|
+
setLoadingCost(true);
|
|
3737
|
+
const proj = localStorage.getItem('beastmode-selected-project') || '';
|
|
3738
|
+
const boardParam = (proj && proj !== 'all') ? '?board=' + encodeURIComponent(proj) : '';
|
|
3739
|
+
fetch('/api/items/' + item.id + '/costs/summary' + boardParam)
|
|
3740
|
+
.then(r => r.ok ? r.json() : null)
|
|
3741
|
+
.then(data => setCostSummary(data && data.record_count > 0 ? data : null))
|
|
3742
|
+
.catch(() => setCostSummary(null))
|
|
3743
|
+
.finally(() => setLoadingCost(false));
|
|
3487
3744
|
const interval = setInterval(() => {
|
|
3488
3745
|
refreshUpdates();
|
|
3489
3746
|
refreshAttachments();
|
|
3747
|
+
refreshCostSummary();
|
|
3490
3748
|
}, 10000);
|
|
3491
3749
|
return () => clearInterval(interval);
|
|
3492
3750
|
}, [item && item.id]);
|
|
@@ -3616,6 +3874,59 @@ function ItemDetailSidebar({ item, onClose, onStatusChange }) {
|
|
|
3616
3874
|
<label>Parent Epic</label>
|
|
3617
3875
|
<div class="detail-value">${item.parent_epic ? '#' + item.parent_epic : '\u2014'}</div>
|
|
3618
3876
|
</div>
|
|
3877
|
+
${loadingCost
|
|
3878
|
+
? html`
|
|
3879
|
+
<div class="detail-cost-section" data-testid="cost-section">
|
|
3880
|
+
<h4 class="detail-cost-title">Cost</h4>
|
|
3881
|
+
<div class="loading-text">Loading costs...</div>
|
|
3882
|
+
</div>`
|
|
3883
|
+
: (costSummary && html`
|
|
3884
|
+
<div class="detail-cost-section" data-testid="cost-section">
|
|
3885
|
+
<h4 class="detail-cost-title">
|
|
3886
|
+
<span style="font-family:var(--font-mono);color:var(--success);">$</span>
|
|
3887
|
+
Cost Summary
|
|
3888
|
+
</h4>
|
|
3889
|
+
<div class="cost-totals">
|
|
3890
|
+
<div class="cost-total-item">
|
|
3891
|
+
<span class="cost-total-label">Total Cost</span>
|
|
3892
|
+
<span class="cost-total-value cost-value-usd">${formatCost(costSummary.total_cost_usd) || '$0.00'}</span>
|
|
3893
|
+
</div>
|
|
3894
|
+
<div class="cost-total-item">
|
|
3895
|
+
<span class="cost-total-label">Tokens</span>
|
|
3896
|
+
<span class="cost-total-value">${formatTokens((costSummary.total_input_tokens || 0) + (costSummary.total_output_tokens || 0))}</span>
|
|
3897
|
+
</div>
|
|
3898
|
+
<div class="cost-total-item">
|
|
3899
|
+
<span class="cost-total-label">Duration</span>
|
|
3900
|
+
<span class="cost-total-value">${formatDuration(costSummary.total_duration_seconds)}</span>
|
|
3901
|
+
</div>
|
|
3902
|
+
</div>
|
|
3903
|
+
${costSummary.phases && Object.keys(costSummary.phases).length > 0 && html`
|
|
3904
|
+
<table class="cost-phase-table">
|
|
3905
|
+
<thead>
|
|
3906
|
+
<tr>
|
|
3907
|
+
<th>Phase</th>
|
|
3908
|
+
<th>Cost</th>
|
|
3909
|
+
<th>Tokens</th>
|
|
3910
|
+
<th>Duration</th>
|
|
3911
|
+
</tr>
|
|
3912
|
+
</thead>
|
|
3913
|
+
<tbody>
|
|
3914
|
+
${Object.values(costSummary.phases)
|
|
3915
|
+
.slice()
|
|
3916
|
+
.sort((a, b) => (b.cost_usd || 0) - (a.cost_usd || 0))
|
|
3917
|
+
.map((phase) => html`
|
|
3918
|
+
<tr key=${phase.phase}>
|
|
3919
|
+
<td class="cost-phase-name">${phase.phase}</td>
|
|
3920
|
+
<td class="cost-phase-value">${formatCost(phase.cost_usd) || '$0.00'}</td>
|
|
3921
|
+
<td class="cost-phase-value">${formatTokens((phase.input_tokens || 0) + (phase.output_tokens || 0))}</td>
|
|
3922
|
+
<td class="cost-phase-value">${formatDuration(phase.duration_seconds)}</td>
|
|
3923
|
+
</tr>
|
|
3924
|
+
`)}
|
|
3925
|
+
</tbody>
|
|
3926
|
+
</table>
|
|
3927
|
+
`}
|
|
3928
|
+
</div>
|
|
3929
|
+
`)}
|
|
3619
3930
|
${(loadingAttachments || attachments.length > 0) && html`
|
|
3620
3931
|
<div style="padding:12px 24px 0;">
|
|
3621
3932
|
<h4 style="margin:0 0 8px 0;font-size:13px;font-weight:600;">
|
|
@@ -3801,6 +4112,7 @@ function PipelineView({
|
|
|
3801
4112
|
columnSorts,
|
|
3802
4113
|
cycleSort,
|
|
3803
4114
|
sortColumnItems,
|
|
4115
|
+
costsByItem,
|
|
3804
4116
|
}) {
|
|
3805
4117
|
const [collapseKey, setCollapseKey] = useState(0);
|
|
3806
4118
|
const [, setEpicCollapseKey] = useState(0);
|
|
@@ -3825,6 +4137,11 @@ function PipelineView({
|
|
|
3825
4137
|
${item.priority && html`<span class=${'card-badge ' + priorityBadgeClass(item.priority)}>${item.priority}</span>`}
|
|
3826
4138
|
${item.task_type && html`<span class="card-badge badge-type">${item.task_type}</span>`}
|
|
3827
4139
|
${selectedProject === 'all' && item.project_id && html`<span class="card-badge badge-project">${item.project_id}</span>`}
|
|
4140
|
+
${(() => {
|
|
4141
|
+
const cost = costsByItem && costsByItem[String(item.id)];
|
|
4142
|
+
const label = cost ? formatCost(cost.total_cost_usd) : null;
|
|
4143
|
+
return label ? html`<span class="card-badge badge-cost" title=${'Total cost: ' + label}>${label}</span>` : null;
|
|
4144
|
+
})()}
|
|
3828
4145
|
<span class="card-time">${timeAgo(item.updated_at || item.created_at)}</span>
|
|
3829
4146
|
</div>
|
|
3830
4147
|
</div>
|
|
@@ -3939,6 +4256,7 @@ function BoardPage({ selectedProject }) {
|
|
|
3939
4256
|
const [filters, setFilters] = useState({ priority: '', taskType: '', project: '', dateRange: '', parentEpic: '' });
|
|
3940
4257
|
const [columnSorts, setColumnSorts] = useState({});
|
|
3941
4258
|
const [epicCollapseKey, setEpicCollapseKey] = useState(0);
|
|
4259
|
+
const [costsByItem, setCostsByItem] = useState({});
|
|
3942
4260
|
const viewMode = (() => {
|
|
3943
4261
|
try {
|
|
3944
4262
|
const params = new URLSearchParams(window.location.search);
|
|
@@ -3955,20 +4273,70 @@ function BoardPage({ selectedProject }) {
|
|
|
3955
4273
|
.finally(() => setLoading(false));
|
|
3956
4274
|
}, [selectedProject]);
|
|
3957
4275
|
|
|
4276
|
+
// Cost endpoints are /api/costs/* and /api/items/*/costs/summary —
|
|
4277
|
+
// the api() helper only auto-scopes /api/board/*, so we manually
|
|
4278
|
+
// append ?board=<proj> here. Errors are swallowed: if the cost
|
|
4279
|
+
// batch endpoint is unreachable, cards render without badges.
|
|
4280
|
+
const fetchCosts = useCallback(() => {
|
|
4281
|
+
const proj = localStorage.getItem('beastmode-selected-project') || '';
|
|
4282
|
+
const boardParam = (proj && proj !== 'all') ? '?board=' + encodeURIComponent(proj) : '';
|
|
4283
|
+
fetch('/api/costs/by-items' + boardParam)
|
|
4284
|
+
.then(r => r.ok ? r.json() : {})
|
|
4285
|
+
.then(data => setCostsByItem(data || {}))
|
|
4286
|
+
.catch(() => {});
|
|
4287
|
+
}, [selectedProject]);
|
|
4288
|
+
|
|
3958
4289
|
// Re-fetch whenever the selected project changes — each project
|
|
3959
4290
|
// has its own SQLite board on the server.
|
|
3960
4291
|
// Also poll every 10s for live updates from the daemon.
|
|
3961
4292
|
useEffect(() => {
|
|
3962
4293
|
fetchItems();
|
|
4294
|
+
fetchCosts();
|
|
3963
4295
|
const interval = setInterval(() => {
|
|
3964
4296
|
// Silent refresh — don't show loading spinner on polls
|
|
3965
4297
|
api('GET', '/api/board/items')
|
|
3966
4298
|
.then(data => setItems(data.items || []))
|
|
3967
4299
|
.catch(() => {});
|
|
4300
|
+
fetchCosts();
|
|
3968
4301
|
}, 10000);
|
|
3969
4302
|
return () => clearInterval(interval);
|
|
3970
4303
|
}, [selectedProject]);
|
|
3971
4304
|
|
|
4305
|
+
// WebSocket listener for real-time cost updates (T4).
|
|
4306
|
+
// On any `cost_recorded` or `costs_cleared` event, re-fetch the
|
|
4307
|
+
// full batch — the endpoint is a single GROUP BY query and is
|
|
4308
|
+
// cheap. Auto-reconnect after 5s on disconnect.
|
|
4309
|
+
useEffect(() => {
|
|
4310
|
+
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
4311
|
+
const wsUrl = proto + '//' + location.host + '/ws';
|
|
4312
|
+
let ws;
|
|
4313
|
+
let reconnectTimer;
|
|
4314
|
+
|
|
4315
|
+
function connect() {
|
|
4316
|
+
ws = new WebSocket(wsUrl);
|
|
4317
|
+
ws.onmessage = (event) => {
|
|
4318
|
+
try {
|
|
4319
|
+
const msg = JSON.parse(event.data);
|
|
4320
|
+
if (msg.type === 'cost_recorded' || msg.type === 'costs_cleared') {
|
|
4321
|
+
fetchCosts();
|
|
4322
|
+
}
|
|
4323
|
+
} catch {}
|
|
4324
|
+
};
|
|
4325
|
+
ws.onclose = () => {
|
|
4326
|
+
reconnectTimer = setTimeout(connect, 5000);
|
|
4327
|
+
};
|
|
4328
|
+
ws.onerror = () => {
|
|
4329
|
+
ws.close();
|
|
4330
|
+
};
|
|
4331
|
+
}
|
|
4332
|
+
connect();
|
|
4333
|
+
|
|
4334
|
+
return () => {
|
|
4335
|
+
clearTimeout(reconnectTimer);
|
|
4336
|
+
if (ws) ws.close();
|
|
4337
|
+
};
|
|
4338
|
+
}, []);
|
|
4339
|
+
|
|
3972
4340
|
// Sync scroll thumb position with kanban scroll + drag support
|
|
3973
4341
|
useEffect(() => {
|
|
3974
4342
|
const kanban = document.querySelector('.kanban');
|
|
@@ -4302,6 +4670,7 @@ function BoardPage({ selectedProject }) {
|
|
|
4302
4670
|
columnSorts=${columnSorts}
|
|
4303
4671
|
cycleSort=${cycleSort}
|
|
4304
4672
|
sortColumnItems=${sortColumnItems}
|
|
4673
|
+
costsByItem=${costsByItem}
|
|
4305
4674
|
/>`
|
|
4306
4675
|
: html`
|
|
4307
4676
|
<div class="kanban-wrapper">
|
|
@@ -4367,6 +4736,11 @@ function BoardPage({ selectedProject }) {
|
|
|
4367
4736
|
${item.priority && html`<span class=${'card-badge ' + priorityBadgeClass(item.priority)}>${item.priority}</span>`}
|
|
4368
4737
|
${item.task_type && html`<span class="card-badge badge-type">${item.task_type}</span>`}
|
|
4369
4738
|
${selectedProject === 'all' && item.project_id && html`<span class="card-badge badge-project">${item.project_id}</span>`}
|
|
4739
|
+
${(() => {
|
|
4740
|
+
const cost = costsByItem && costsByItem[String(item.id)];
|
|
4741
|
+
const label = cost ? formatCost(cost.total_cost_usd) : null;
|
|
4742
|
+
return label ? html`<span class="card-badge badge-cost" title=${'Total cost: ' + label}>${label}</span>` : null;
|
|
4743
|
+
})()}
|
|
4370
4744
|
<span class="card-time">${timeAgo(item.updated_at || item.created_at)}</span>
|
|
4371
4745
|
</div>
|
|
4372
4746
|
</div>
|
|
@@ -6895,6 +7269,216 @@ function StrategyPage({ selectedProject }) {
|
|
|
6895
7269
|
`;
|
|
6896
7270
|
}
|
|
6897
7271
|
|
|
7272
|
+
// ================================================================
|
|
7273
|
+
// Costs Page
|
|
7274
|
+
// ================================================================
|
|
7275
|
+
|
|
7276
|
+
function CostsPage({ selectedProject }) {
|
|
7277
|
+
const [costsByItems, setCostsByItems] = useState({});
|
|
7278
|
+
const [boardItems, setBoardItems] = useState([]);
|
|
7279
|
+
const [phaseData, setPhaseData] = useState([]);
|
|
7280
|
+
const [loading, setLoading] = useState(true);
|
|
7281
|
+
const [error, setError] = useState(null);
|
|
7282
|
+
const [sortCol, setSortCol] = useState('cost');
|
|
7283
|
+
const [sortDir, setSortDir] = useState('desc');
|
|
7284
|
+
|
|
7285
|
+
useEffect(() => {
|
|
7286
|
+
let cancelled = false;
|
|
7287
|
+
|
|
7288
|
+
async function fetchCosts() {
|
|
7289
|
+
try {
|
|
7290
|
+
const costQs = (selectedProject && selectedProject !== 'all')
|
|
7291
|
+
? '?board=' + encodeURIComponent(selectedProject)
|
|
7292
|
+
: '';
|
|
7293
|
+
|
|
7294
|
+
const [costsData, boardData] = await Promise.all([
|
|
7295
|
+
api('GET', '/api/costs/by-items' + costQs),
|
|
7296
|
+
api('GET', '/api/board/items'),
|
|
7297
|
+
]);
|
|
7298
|
+
|
|
7299
|
+
if (cancelled) return;
|
|
7300
|
+
setCostsByItems(costsData || {});
|
|
7301
|
+
setBoardItems((boardData && boardData.items) || []);
|
|
7302
|
+
|
|
7303
|
+
const itemIds = Object.keys(costsData || {});
|
|
7304
|
+
if (itemIds.length > 0) {
|
|
7305
|
+
const phaseMap = {};
|
|
7306
|
+
const summaryResults = await Promise.allSettled(
|
|
7307
|
+
itemIds.map(id => {
|
|
7308
|
+
const qs = (selectedProject && selectedProject !== 'all')
|
|
7309
|
+
? '?board=' + encodeURIComponent(selectedProject)
|
|
7310
|
+
: '';
|
|
7311
|
+
return api('GET', '/api/items/' + id + '/costs/summary' + qs);
|
|
7312
|
+
})
|
|
7313
|
+
);
|
|
7314
|
+
summaryResults.forEach(result => {
|
|
7315
|
+
if (result.status === 'fulfilled' && result.value && result.value.phases) {
|
|
7316
|
+
for (const [phaseName, phaseInfo] of Object.entries(result.value.phases)) {
|
|
7317
|
+
phaseMap[phaseName] = (phaseMap[phaseName] || 0) + (phaseInfo.cost_usd || 0);
|
|
7318
|
+
}
|
|
7319
|
+
}
|
|
7320
|
+
});
|
|
7321
|
+
const sorted = Object.entries(phaseMap)
|
|
7322
|
+
.map(([phase, cost]) => ({ phase, cost }))
|
|
7323
|
+
.sort((a, b) => b.cost - a.cost);
|
|
7324
|
+
if (!cancelled) setPhaseData(sorted);
|
|
7325
|
+
}
|
|
7326
|
+
} catch (e) {
|
|
7327
|
+
if (!cancelled) setError(e.message || 'Failed to load cost data');
|
|
7328
|
+
} finally {
|
|
7329
|
+
if (!cancelled) setLoading(false);
|
|
7330
|
+
}
|
|
7331
|
+
}
|
|
7332
|
+
|
|
7333
|
+
setLoading(true);
|
|
7334
|
+
setError(null);
|
|
7335
|
+
fetchCosts();
|
|
7336
|
+
const interval = setInterval(fetchCosts, 30000);
|
|
7337
|
+
return () => { cancelled = true; clearInterval(interval); };
|
|
7338
|
+
}, [selectedProject]);
|
|
7339
|
+
|
|
7340
|
+
function formatTokens(n) {
|
|
7341
|
+
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
|
|
7342
|
+
if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
|
|
7343
|
+
return String(n);
|
|
7344
|
+
}
|
|
7345
|
+
|
|
7346
|
+
function handleSort(col) {
|
|
7347
|
+
if (sortCol === col) {
|
|
7348
|
+
setSortDir(sortDir === 'asc' ? 'desc' : 'asc');
|
|
7349
|
+
} else {
|
|
7350
|
+
setSortCol(col);
|
|
7351
|
+
setSortDir(col === 'name' || col === 'status' ? 'asc' : 'desc');
|
|
7352
|
+
}
|
|
7353
|
+
}
|
|
7354
|
+
|
|
7355
|
+
if (loading) return html`<${SkeletonLoader} type="stats" />`;
|
|
7356
|
+
if (error) return html`<div class="page-content"><div class="error-msg">${error}</div></div>`;
|
|
7357
|
+
|
|
7358
|
+
const mergedRows = Object.entries(costsByItems).map(([itemId, costs]) => {
|
|
7359
|
+
const item = boardItems.find(i => String(i.id) === String(itemId));
|
|
7360
|
+
return {
|
|
7361
|
+
id: itemId,
|
|
7362
|
+
name: item ? item.name : 'Item #' + itemId,
|
|
7363
|
+
status: item ? item.status : 'Unknown',
|
|
7364
|
+
totalTokens: (costs.total_input_tokens || 0) + (costs.total_output_tokens || 0),
|
|
7365
|
+
costUsd: costs.total_cost_usd || 0,
|
|
7366
|
+
records: costs.record_count || 0,
|
|
7367
|
+
};
|
|
7368
|
+
});
|
|
7369
|
+
|
|
7370
|
+
if (mergedRows.length === 0) {
|
|
7371
|
+
return html`<div class="page-content">
|
|
7372
|
+
<${EmptyState} icon="costs" title="No Cost Data" description="Cost data will appear here as tasks are processed by the pipeline." />
|
|
7373
|
+
</div>`;
|
|
7374
|
+
}
|
|
7375
|
+
|
|
7376
|
+
const totalSpend = mergedRows.reduce((sum, r) => sum + r.costUsd, 0);
|
|
7377
|
+
const totalTokens = mergedRows.reduce((sum, r) => sum + r.totalTokens, 0);
|
|
7378
|
+
const tasksTracked = mergedRows.length;
|
|
7379
|
+
const avgCostPerTask = tasksTracked > 0 ? totalSpend / tasksTracked : 0;
|
|
7380
|
+
const maxPhaseCost = phaseData.length > 0 ? Math.max(...phaseData.map(p => p.cost)) : 0;
|
|
7381
|
+
|
|
7382
|
+
const sortedRows = [...mergedRows].sort((a, b) => {
|
|
7383
|
+
let cmp = 0;
|
|
7384
|
+
switch (sortCol) {
|
|
7385
|
+
case 'id': cmp = Number(a.id) - Number(b.id); break;
|
|
7386
|
+
case 'name': cmp = a.name.localeCompare(b.name); break;
|
|
7387
|
+
case 'status': cmp = a.status.localeCompare(b.status); break;
|
|
7388
|
+
case 'tokens': cmp = a.totalTokens - b.totalTokens; break;
|
|
7389
|
+
case 'cost': cmp = a.costUsd - b.costUsd; break;
|
|
7390
|
+
case 'records': cmp = a.records - b.records; break;
|
|
7391
|
+
}
|
|
7392
|
+
return sortDir === 'asc' ? cmp : -cmp;
|
|
7393
|
+
});
|
|
7394
|
+
|
|
7395
|
+
const sortIndicator = (col) => sortCol === col ? (sortDir === 'asc' ? ' \u25B2' : ' \u25BC') : '';
|
|
7396
|
+
|
|
7397
|
+
return html`
|
|
7398
|
+
<div class="page-content">
|
|
7399
|
+
<div class="page-header">
|
|
7400
|
+
<h2>Factory Costs</h2>
|
|
7401
|
+
<p>Token usage and cost analytics across all tasks</p>
|
|
7402
|
+
</div>
|
|
7403
|
+
|
|
7404
|
+
<div class="stat-grid mb-24">
|
|
7405
|
+
<div class="stat-card">
|
|
7406
|
+
<div class="stat-value">$${totalSpend.toFixed(2)}</div>
|
|
7407
|
+
<div class="stat-label">Total Spend</div>
|
|
7408
|
+
<div class="stat-dot"></div>
|
|
7409
|
+
</div>
|
|
7410
|
+
<div class="stat-card">
|
|
7411
|
+
<div class="stat-value">${formatTokens(totalTokens)}</div>
|
|
7412
|
+
<div class="stat-label">Total Tokens</div>
|
|
7413
|
+
<div class="stat-dot"></div>
|
|
7414
|
+
</div>
|
|
7415
|
+
<div class="stat-card">
|
|
7416
|
+
<div class="stat-value">${tasksTracked}</div>
|
|
7417
|
+
<div class="stat-label">Tasks Tracked</div>
|
|
7418
|
+
<div class="stat-dot"></div>
|
|
7419
|
+
</div>
|
|
7420
|
+
<div class="stat-card">
|
|
7421
|
+
<div class="stat-value">$${avgCostPerTask.toFixed(2)}</div>
|
|
7422
|
+
<div class="stat-label">Avg Cost / Task</div>
|
|
7423
|
+
<div class="stat-dot"></div>
|
|
7424
|
+
</div>
|
|
7425
|
+
</div>
|
|
7426
|
+
|
|
7427
|
+
<div class="card">
|
|
7428
|
+
<div class="card-header">
|
|
7429
|
+
<h3>Cost by Task</h3>
|
|
7430
|
+
<span class="badge badge-accent">${sortedRows.length} tasks</span>
|
|
7431
|
+
</div>
|
|
7432
|
+
<div class="costs-table-wrapper">
|
|
7433
|
+
<table class="costs-table">
|
|
7434
|
+
<thead>
|
|
7435
|
+
<tr>
|
|
7436
|
+
<th class="costs-th" onClick=${() => handleSort('id')}>ID${sortIndicator('id')}</th>
|
|
7437
|
+
<th class="costs-th" onClick=${() => handleSort('name')}>Task${sortIndicator('name')}</th>
|
|
7438
|
+
<th class="costs-th" onClick=${() => handleSort('status')}>Status${sortIndicator('status')}</th>
|
|
7439
|
+
<th class="costs-th" onClick=${() => handleSort('tokens')}>Tokens${sortIndicator('tokens')}</th>
|
|
7440
|
+
<th class="costs-th" onClick=${() => handleSort('cost')}>Cost (USD)${sortIndicator('cost')}</th>
|
|
7441
|
+
<th class="costs-th" onClick=${() => handleSort('records')}>Records${sortIndicator('records')}</th>
|
|
7442
|
+
</tr>
|
|
7443
|
+
</thead>
|
|
7444
|
+
<tbody>
|
|
7445
|
+
${sortedRows.map(row => html`
|
|
7446
|
+
<tr key=${row.id} class="costs-tr" onClick=${() => navigate('#/board?item=' + row.id)}>
|
|
7447
|
+
<td class="costs-td costs-td-mono">#${row.id}</td>
|
|
7448
|
+
<td class="costs-td costs-td-name">${row.name}</td>
|
|
7449
|
+
<td class="costs-td"><span class=${'badge ' + statusBadgeClass(row.status)}>${row.status}</span></td>
|
|
7450
|
+
<td class="costs-td costs-td-mono">${formatTokens(row.totalTokens)}</td>
|
|
7451
|
+
<td class="costs-td costs-td-mono">$${row.costUsd.toFixed(2)}</td>
|
|
7452
|
+
<td class="costs-td costs-td-mono">${row.records}</td>
|
|
7453
|
+
</tr>
|
|
7454
|
+
`)}
|
|
7455
|
+
</tbody>
|
|
7456
|
+
</table>
|
|
7457
|
+
</div>
|
|
7458
|
+
</div>
|
|
7459
|
+
|
|
7460
|
+
${phaseData.length > 0 && html`
|
|
7461
|
+
<div class="card" style="margin-top:24px;">
|
|
7462
|
+
<div class="card-header">
|
|
7463
|
+
<h3>Cost by Phase</h3>
|
|
7464
|
+
</div>
|
|
7465
|
+
<div class="costs-phase-chart">
|
|
7466
|
+
${phaseData.map(p => html`
|
|
7467
|
+
<div key=${p.phase} class="costs-phase-row">
|
|
7468
|
+
<div class="costs-phase-label">${p.phase}</div>
|
|
7469
|
+
<div class="costs-phase-bar-bg">
|
|
7470
|
+
<div class="costs-phase-bar-fill" style=${'width:' + (maxPhaseCost > 0 ? (p.cost / maxPhaseCost * 100) : 0) + '%'}></div>
|
|
7471
|
+
</div>
|
|
7472
|
+
<div class="costs-phase-amount">$${p.cost.toFixed(2)}</div>
|
|
7473
|
+
</div>
|
|
7474
|
+
`)}
|
|
7475
|
+
</div>
|
|
7476
|
+
</div>
|
|
7477
|
+
`}
|
|
7478
|
+
</div>
|
|
7479
|
+
`;
|
|
7480
|
+
}
|
|
7481
|
+
|
|
6898
7482
|
// ================================================================
|
|
6899
7483
|
// Learnings Page
|
|
6900
7484
|
// ================================================================
|
|
@@ -7152,6 +7736,7 @@ function App() {
|
|
|
7152
7736
|
case '#/board': page = html`<${BoardPage} selectedProject=${selectedProject} />`; break;
|
|
7153
7737
|
case '#/strategy': page = html`<${StrategyPage} selectedProject=${selectedProject} />`; break;
|
|
7154
7738
|
case '#/runs': page = html`<${RunsPage} selectedProject=${selectedProject} />`; break;
|
|
7739
|
+
case '#/costs': page = html`<${CostsPage} selectedProject=${selectedProject} />`; break;
|
|
7155
7740
|
case '#/learnings': page = html`<${LearningsPage} selectedProject=${selectedProject} />`; break;
|
|
7156
7741
|
case '#/projects': page = html`<${ProjectsPage} selectedProject=${selectedProject} onProjectChange=${setSelectedProject} />`; break;
|
|
7157
7742
|
case '#/extensions': page = html`<${ExtensionsPage} selectedProject=${selectedProject} />`; break;
|
package/dist/web/build-stamp.txt
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
20260418-
|
|
1
|
+
20260418-125802-c140b02
|