@goodandready/dsh-cron 0.3.4 → 0.3.5
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/lib/api.js +5 -0
- package/lib/client.js +207 -0
- package/lib/routes.js +21 -0
- package/lib/timeline.js +230 -0
- package/package.json +1 -1
package/lib/api.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { calculate24hTimeline } from './timeline.js';
|
|
1
2
|
import { rollbackPromptVersion, sanitizePromptHistory } from './prompt-evolver.js';
|
|
2
3
|
import { sanitizeAdaptiveConfig } from './adaptive.js';
|
|
3
4
|
import { logStreamer } from './log-streamer.js';
|
|
@@ -91,6 +92,10 @@ export function createCronApiHandler(store, scheduler, collection) {
|
|
|
91
92
|
await handleSchedulePreview({ req, res, url, store });
|
|
92
93
|
return;
|
|
93
94
|
}
|
|
95
|
+
if (scope === 'schedule' && id === 'timeline-24h') {
|
|
96
|
+
await handleScheduleTimeline({ req, res, url, store, scheduler });
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
94
99
|
|
|
95
100
|
if (!id) {
|
|
96
101
|
await handleTaskCollection({ store, scheduler, collection, req, res, url, scope, apiToken });
|
package/lib/client.js
CHANGED
|
@@ -285,6 +285,12 @@ en: {
|
|
|
285
285
|
'form.adaptiveEnabled': 'Adaptive Polling', 'form.adaptiveHint': 'Exponential backoff when no changes are detected',
|
|
286
286
|
'liveLog.modalTitle': 'Real-Time Streaming Execution Logs', 'liveLog.viewLog': 'Live Stream',
|
|
287
287
|
'liveLog.autoScroll': 'Auto-scroll', 'liveLog.connecting': 'Connecting to log stream...', 'liveLog.waiting': 'Waiting for runner output...', 'liveLog.completed': 'Execution stream completed', 'liveLog.close': 'Close Stream',
|
|
288
|
+
'timeline.modalTitle': '24-Hour Schedule Timeline', 'timeline.buttonLabel': 'Timeline',
|
|
289
|
+
'timeline.buttonTitle': 'View 24-hour projected schedule and concurrency timeline',
|
|
290
|
+
'timeline.tabTimeline': 'Schedule Timeline', 'timeline.legendDesc': 'Projected run distribution across 24 hourly buckets:',
|
|
291
|
+
'timeline.peakWarning': 'Peak concurrency warning',
|
|
292
|
+
'heatmap.modalTitle': 'Cost & Token Burn Heatmap', 'heatmap.buttonLabel': 'Heatmap',
|
|
293
|
+
'heatmap.tabHeatmap': 'Cost Heatmap', 'heatmap.matrixDesc': 'Weekly hour-of-day cost concentration (7 days × 24 hours):',
|
|
288
294
|
'digest.modalTitle': 'Automation & Cost Digest', 'digest.buttonLabel': 'Digest',
|
|
289
295
|
'digest.buttonTitle': 'View 7-day or 30-day automation analytics and cost digest',
|
|
290
296
|
'digest.period7': 'Last 7 Days', 'digest.period30': 'Last 30 Days',
|
|
@@ -461,6 +467,12 @@ zh: {
|
|
|
461
467
|
'form.adaptiveEnabled': '自适应轮询 (Adaptive Polling)', 'form.adaptiveHint': '数据无变化时指数级延长执行间隔',
|
|
462
468
|
'liveLog.modalTitle': '实时任务执行日志流', 'liveLog.viewLog': '实时日志',
|
|
463
469
|
'liveLog.autoScroll': '自动滚动', 'liveLog.connecting': '正在连接日志流...', 'liveLog.waiting': '等待运行器输出...', 'liveLog.completed': '日志流已结束', 'liveLog.close': '关闭日志流',
|
|
470
|
+
'timeline.modalTitle': '24小时调度时间线', 'timeline.buttonLabel': '时间线',
|
|
471
|
+
'timeline.buttonTitle': '查看24小时预测任务调度与并发时间线',
|
|
472
|
+
'timeline.tabTimeline': '调度时间线', 'timeline.legendDesc': '未来24小时各时段任务预计执行分布:',
|
|
473
|
+
'timeline.peakWarning': '并发峰值预警',
|
|
474
|
+
'heatmap.modalTitle': '成本与Token消耗热力图', 'heatmap.buttonLabel': '热力图',
|
|
475
|
+
'heatmap.tabHeatmap': '成本热力图', 'heatmap.matrixDesc': '每周各时段成本分布矩阵(7天 × 24小时):',
|
|
464
476
|
'digest.modalTitle': '自动化与成本分析摘要', 'digest.buttonLabel': '分析摘要',
|
|
465
477
|
'digest.buttonTitle': '查看 7 天或 30 天运行统计与花费摘要',
|
|
466
478
|
'digest.period7': '最近 7 天', 'digest.period30': '最近 30 天',
|
|
@@ -1481,6 +1493,191 @@ style: { padding: '6px 14px', fontSize: '12px' }
|
|
|
1481
1493
|
)
|
|
1482
1494
|
);
|
|
1483
1495
|
}
|
|
1496
|
+
function renderTimelineHeatmapModal({
|
|
1497
|
+
isOpen,
|
|
1498
|
+
onClose,
|
|
1499
|
+
activeTab,
|
|
1500
|
+
onSelectTab,
|
|
1501
|
+
timelineData,
|
|
1502
|
+
heatmapData,
|
|
1503
|
+
isLoading,
|
|
1504
|
+
t
|
|
1505
|
+
}) {
|
|
1506
|
+
if (!isOpen) return null;
|
|
1507
|
+
const tab = activeTab || 'timeline';
|
|
1508
|
+
const timeline = timelineData || { buckets: [], totalRuns: 0, peakHoursCount: 0, maxConcurrent: 3 };
|
|
1509
|
+
const heatmap = heatmapData || { matrix: [], dayLabels: [], hourLabels: [], totalCostUsd: 0, totalTokens: 0, maxCostHour: 0 };
|
|
1510
|
+
return React.createElement('div', {
|
|
1511
|
+
className: 'dsh-cron-modal-overlay',
|
|
1512
|
+
onClick: onClose
|
|
1513
|
+
},
|
|
1514
|
+
React.createElement('div', {
|
|
1515
|
+
className: 'dsh-cron-modal',
|
|
1516
|
+
style: { maxWidth: '960px', width: '94vw', maxHeight: '88vh', display: 'flex', flexDirection: 'column', background: 'var(--dsw-alias-bg-base, #111)', color: 'var(--dsw-alias-label-primary, #ddd)', border: '1px solid var(--dsw-alias-border-l2, #333)', borderRadius: '8px' },
|
|
1517
|
+
role: 'dialog',
|
|
1518
|
+
'aria-modal': 'true',
|
|
1519
|
+
onClick: (e) => e.stopPropagation()
|
|
1520
|
+
},
|
|
1521
|
+
React.createElement('div', {
|
|
1522
|
+
style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '14px 18px', borderBottom: '1px solid var(--dsw-alias-border-l2, #333)', background: 'var(--dsw-alias-bg-layer-2, #111)' }
|
|
1523
|
+
},
|
|
1524
|
+
React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '10px' } },
|
|
1525
|
+
React.createElement('span', { style: { fontSize: '20px' } }, tab === 'timeline' ? '⏱' : '🔥'),
|
|
1526
|
+
React.createElement('div', null,
|
|
1527
|
+
React.createElement('div', { style: { fontWeight: '600', fontSize: '15px', color: 'var(--dsw-alias-label-primary, #fff)' } },
|
|
1528
|
+
tab === 'timeline' ? (T_KEY(t, 'timeline.modalTitle') || '24-Hour Schedule Timeline') : (T_KEY(t, 'heatmap.modalTitle') || 'Cost & Token Burn Heatmap')
|
|
1529
|
+
),
|
|
1530
|
+
React.createElement('div', { style: { fontSize: '11.5px', color: 'var(--dsw-alias-label-secondary, #888)', marginTop: '2px' } },
|
|
1531
|
+
tab === 'timeline'
|
|
1532
|
+
? (timeline.totalRuns + ' runs projected · ' + (timeline.peakHoursCount ? '⚠️ ' + timeline.peakHoursCount + ' peak hours' : '✓ balanced concurrency'))
|
|
1533
|
+
: ('$' + Number(heatmap.totalCostUsd || 0).toFixed(4) + ' USD · ' + Number(heatmap.totalTokens || 0).toLocaleString() + ' tokens')
|
|
1534
|
+
)
|
|
1535
|
+
)
|
|
1536
|
+
),
|
|
1537
|
+
React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '8px' } },
|
|
1538
|
+
React.createElement('button', {
|
|
1539
|
+
type: 'button',
|
|
1540
|
+
className: 'dsh-cron-btn-secondary',
|
|
1541
|
+
style: { padding: '4px 12px', fontSize: '12px', fontWeight: tab === 'timeline' ? '600' : '400', background: tab === 'timeline' ? 'var(--dsw-alias-bg-layer-3, #222)' : 'transparent' },
|
|
1542
|
+
onClick: () => onSelectTab && onSelectTab('timeline')
|
|
1543
|
+
}, '⏱ ' + (T_KEY(t, 'timeline.tabTimeline') || 'Timeline')),
|
|
1544
|
+
React.createElement('button', {
|
|
1545
|
+
type: 'button',
|
|
1546
|
+
className: 'dsh-cron-btn-secondary',
|
|
1547
|
+
style: { padding: '4px 12px', fontSize: '12px', fontWeight: tab === 'heatmap' ? '600' : '400', background: tab === 'heatmap' ? 'var(--dsw-alias-bg-layer-3, #222)' : 'transparent' },
|
|
1548
|
+
onClick: () => onSelectTab && onSelectTab('heatmap')
|
|
1549
|
+
}, '🔥 ' + (T_KEY(t, 'heatmap.tabHeatmap') || 'Heatmap')),
|
|
1550
|
+
React.createElement('button', {
|
|
1551
|
+
type: 'button',
|
|
1552
|
+
className: 'dsh-cron-icon-btn',
|
|
1553
|
+
onClick: onClose,
|
|
1554
|
+
style: { marginLeft: '8px' }
|
|
1555
|
+
}, React.createElement('span', null, '✕'))
|
|
1556
|
+
)
|
|
1557
|
+
),
|
|
1558
|
+
React.createElement('div', {
|
|
1559
|
+
style: { padding: '16px 18px', overflowY: 'auto', flex: 1, display: 'flex', flexDirection: 'column', gap: '14px' }
|
|
1560
|
+
},
|
|
1561
|
+
isLoading ? React.createElement('div', { style: { padding: '40px', textAlign: 'center', color: 'var(--dsw-alias-label-secondary, #888)' } }, 'Loading visualization data...') : (
|
|
1562
|
+
tab === 'timeline' ? (
|
|
1563
|
+
React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: '8px' } },
|
|
1564
|
+
React.createElement('div', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-secondary, #888)', display: 'flex', justifyContent: 'space-between' } },
|
|
1565
|
+
React.createElement('span', null, T_KEY(t, 'timeline.legendDesc') || 'Projected run distribution across 24 hourly buckets:'),
|
|
1566
|
+
React.createElement('span', { style: { color: 'var(--dsh-cron-warning, #fa0)' } }, '⚠️ ' + (T_KEY(t, 'timeline.peakWarning') || 'Peak hours exceed max concurrent limit') + ' (' + timeline.maxConcurrent + ')')
|
|
1567
|
+
),
|
|
1568
|
+
React.createElement('div', {
|
|
1569
|
+
style: { display: 'grid', gridTemplateColumns: 'repeat(12, 1fr)', gap: '8px', marginTop: '6px' }
|
|
1570
|
+
},
|
|
1571
|
+
(timeline.buckets || []).map((b) => {
|
|
1572
|
+
const isPeak = b.isPeak;
|
|
1573
|
+
const hasRuns = b.count > 0;
|
|
1574
|
+
return React.createElement('div', {
|
|
1575
|
+
key: b.index,
|
|
1576
|
+
style: {
|
|
1577
|
+
background: isPeak
|
|
1578
|
+
? 'color-mix(in srgb, var(--dsh-cron-danger, #e33) 12%, var(--dsw-alias-bg-layer-2, #111))'
|
|
1579
|
+
: (hasRuns ? 'var(--dsw-alias-bg-layer-2, #111)' : 'var(--dsw-alias-bg-base, #111)'),
|
|
1580
|
+
border: isPeak
|
|
1581
|
+
? '1px solid var(--dsh-cron-danger, #e33)'
|
|
1582
|
+
: (hasRuns ? '1px solid var(--dsw-alias-border-l2, #333)' : '1px dashed var(--dsw-alias-border-l2, #222)'),
|
|
1583
|
+
borderRadius: '6px',
|
|
1584
|
+
padding: '8px',
|
|
1585
|
+
minHeight: '75px',
|
|
1586
|
+
display: 'flex',
|
|
1587
|
+
flexDirection: 'column',
|
|
1588
|
+
justifyContent: 'space-between'
|
|
1589
|
+
}
|
|
1590
|
+
},
|
|
1591
|
+
React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: '11px' } },
|
|
1592
|
+
React.createElement('span', { style: { fontWeight: '600', color: isPeak ? 'var(--dsh-cron-danger, #e33)' : 'var(--dsw-alias-label-primary, #fff)' } }, b.label),
|
|
1593
|
+
hasRuns ? React.createElement('span', {
|
|
1594
|
+
style: { fontSize: '10px', padding: '1px 5px', borderRadius: '8px', background: isPeak ? 'var(--dsh-cron-danger, #e33)' : 'var(--dsw-alias-bg-layer-3, #222)', color: '#fff' }
|
|
1595
|
+
}, b.count) : null
|
|
1596
|
+
),
|
|
1597
|
+
React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: '3px', marginTop: '6px' } },
|
|
1598
|
+
(b.runs || []).slice(0, 3).map((r, rIdx) => React.createElement('div', {
|
|
1599
|
+
key: rIdx,
|
|
1600
|
+
title: r.title + ' (at ' + new Date(r.runAt).toLocaleTimeString() + ')',
|
|
1601
|
+
style: {
|
|
1602
|
+
fontSize: '10px',
|
|
1603
|
+
padding: '2px 4px',
|
|
1604
|
+
borderRadius: '3px',
|
|
1605
|
+
background: 'var(--dsw-alias-bg-layer-3, #222)',
|
|
1606
|
+
color: 'var(--dsw-alias-label-primary, #ddd)',
|
|
1607
|
+
overflow: 'hidden',
|
|
1608
|
+
textOverflow: 'ellipsis',
|
|
1609
|
+
whiteSpace: 'nowrap'
|
|
1610
|
+
}
|
|
1611
|
+
}, r.title)),
|
|
1612
|
+
b.count > 3 ? React.createElement('div', { style: { fontSize: '9px', color: 'var(--dsw-alias-label-secondary, #888)' } }, '+' + (b.count - 3) + ' more') : null
|
|
1613
|
+
)
|
|
1614
|
+
);
|
|
1615
|
+
})
|
|
1616
|
+
)
|
|
1617
|
+
)
|
|
1618
|
+
) : (
|
|
1619
|
+
React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: '10px' } },
|
|
1620
|
+
React.createElement('div', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-secondary, #888)', display: 'flex', justifyContent: 'space-between' } },
|
|
1621
|
+
React.createElement('span', null, T_KEY(t, 'heatmap.matrixDesc') || 'Weekly hour-of-day cost concentration (7 days × 24 hours):'),
|
|
1622
|
+
React.createElement('span', null, 'Max hour spend: $' + Number(heatmap.maxCostHour || 0).toFixed(4))
|
|
1623
|
+
),
|
|
1624
|
+
React.createElement('div', {
|
|
1625
|
+
style: { overflowX: 'auto', paddingBottom: '8px' }
|
|
1626
|
+
},
|
|
1627
|
+
React.createElement('table', {
|
|
1628
|
+
style: { borderCollapse: 'collapse', width: '100%', minWidth: '700px', fontSize: '10.5px' }
|
|
1629
|
+
},
|
|
1630
|
+
React.createElement('thead', null,
|
|
1631
|
+
React.createElement('tr', null,
|
|
1632
|
+
React.createElement('th', { style: { padding: '4px 6px', textAlign: 'left', color: 'var(--dsw-alias-label-secondary, #888)' } }, 'Day'),
|
|
1633
|
+
Array.from({ length: 24 }, (_, h) => React.createElement('th', {
|
|
1634
|
+
key: h,
|
|
1635
|
+
style: { padding: '4px 2px', textAlign: 'center', color: 'var(--dsw-alias-label-secondary, #888)', fontWeight: '400' }
|
|
1636
|
+
}, String(h).padStart(2, '0')))
|
|
1637
|
+
)
|
|
1638
|
+
),
|
|
1639
|
+
React.createElement('tbody', null,
|
|
1640
|
+
(heatmap.matrix || []).map((row, dIdx) => React.createElement('tr', { key: dIdx },
|
|
1641
|
+
React.createElement('td', { style: { padding: '4px 6px', fontWeight: '600', color: 'var(--dsw-alias-label-primary, #ddd)' } }, (heatmap.dayLabels && heatmap.dayLabels[dIdx]) || 'Day'),
|
|
1642
|
+
row.map((cell, hIdx) => {
|
|
1643
|
+
const intensity = cell.costIntensity || 0;
|
|
1644
|
+
const hasSpend = cell.costUsd > 0;
|
|
1645
|
+
const bg = hasSpend
|
|
1646
|
+
? 'color-mix(in srgb, var(--dsh-cron-primary, #4af) ' + Math.max(15, Math.round(intensity * 100)) + '%, transparent)'
|
|
1647
|
+
: 'transparent';
|
|
1648
|
+
return React.createElement('td', {
|
|
1649
|
+
key: hIdx,
|
|
1650
|
+
title: cell.dayLabel + ' ' + cell.hourLabel + ': $' + cell.costUsd + ' (' + cell.tokens.toLocaleString() + ' tokens, ' + cell.runs + ' runs)',
|
|
1651
|
+
style: {
|
|
1652
|
+
padding: '3px',
|
|
1653
|
+
textAlign: 'center',
|
|
1654
|
+
background: bg,
|
|
1655
|
+
border: '1px solid var(--dsw-alias-border-l2, #222)',
|
|
1656
|
+
cursor: hasSpend ? 'pointer' : 'default'
|
|
1657
|
+
}
|
|
1658
|
+
}, hasSpend ? React.createElement('span', { style: { fontSize: '9px', color: intensity > 0.5 ? '#fff' : 'var(--dsw-alias-label-primary, #ddd)' } }, '$' + (cell.costUsd >= 0.01 ? cell.costUsd.toFixed(2) : cell.costUsd.toFixed(3).slice(1))) : '·');
|
|
1659
|
+
})
|
|
1660
|
+
))
|
|
1661
|
+
)
|
|
1662
|
+
)
|
|
1663
|
+
)
|
|
1664
|
+
)
|
|
1665
|
+
)
|
|
1666
|
+
)
|
|
1667
|
+
),
|
|
1668
|
+
React.createElement('div', {
|
|
1669
|
+
style: { display: 'flex', justifyContent: 'flex-end', padding: '12px 18px', borderTop: '1px solid var(--dsw-alias-border-l2, #333)', background: 'var(--dsw-alias-bg-layer-2, #111)' }
|
|
1670
|
+
},
|
|
1671
|
+
React.createElement('button', {
|
|
1672
|
+
type: 'button',
|
|
1673
|
+
className: 'dsh-cron-btn-secondary',
|
|
1674
|
+
onClick: onClose,
|
|
1675
|
+
style: { padding: '6px 16px', fontSize: '12px' }
|
|
1676
|
+
}, T_KEY(t, 'chainGraph.close') || 'Close')
|
|
1677
|
+
)
|
|
1678
|
+
)
|
|
1679
|
+
);
|
|
1680
|
+
}
|
|
1484
1681
|
function CronScreen(props) {
|
|
1485
1682
|
const { ctx, toggle, onClose } = props;
|
|
1486
1683
|
const t = props.t || translate;
|
|
@@ -2711,6 +2908,16 @@ React.createElement('button', {
|
|
|
2711
2908
|
type: 'button',
|
|
2712
2909
|
className: 'dsh-cron-btn-secondary',
|
|
2713
2910
|
style: { display: 'flex', alignItems: 'center', gap: '6px', height: '36px', padding: '0 12px' },
|
|
2911
|
+
title: T_KEY(t, 'timeline.buttonTitle'),
|
|
2912
|
+
onClick: () => openTimelineModal('timeline')
|
|
2913
|
+
},
|
|
2914
|
+
React.createElement('span', { style: { fontSize: '14px' } }, '⏱'),
|
|
2915
|
+
T_KEY(t, 'timeline.buttonLabel')
|
|
2916
|
+
),
|
|
2917
|
+
React.createElement('button', {
|
|
2918
|
+
type: 'button',
|
|
2919
|
+
className: 'dsh-cron-btn-secondary',
|
|
2920
|
+
style: { display: 'flex', alignItems: 'center', gap: '6px', height: '36px', padding: '0 12px' },
|
|
2714
2921
|
title: T_KEY(t, 'digest.buttonTitle'),
|
|
2715
2922
|
onClick: openAnalyticsDigest
|
|
2716
2923
|
},
|
package/lib/routes.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { calculateCostHeatmap } from './timeline.js';
|
|
1
2
|
import { buildAnalyticsDigest, formatDigestMarkdown, sendAnalyticsDigest } from './analytics-digest.js';
|
|
2
3
|
import { listVerifiedRecipes } from './recipes-catalog.js';
|
|
3
4
|
import { randomUUID } from 'node:crypto';
|
|
@@ -111,6 +112,26 @@ export function registerRoutes(ctx, { store, scheduler, recommendations }) {
|
|
|
111
112
|
const path = url.pathname;
|
|
112
113
|
|
|
113
114
|
if (req.method === 'GET') {
|
|
115
|
+
if (path === '/dsh-cron/analytics/heatmap') {
|
|
116
|
+
const modelFilter = url.searchParams.get('model') || null;
|
|
117
|
+
const providerFilter = url.searchParams.get('provider') || null;
|
|
118
|
+
const costLedger = store.costLedger || [];
|
|
119
|
+
let history = [];
|
|
120
|
+
if (store.history) {
|
|
121
|
+
if (typeof store.history.values === 'function') {
|
|
122
|
+
for (const runs of store.history.values()) {
|
|
123
|
+
if (Array.isArray(runs)) history.push(...runs);
|
|
124
|
+
}
|
|
125
|
+
} else if (typeof store.history === 'object') {
|
|
126
|
+
for (const runs of Object.values(store.history)) {
|
|
127
|
+
if (Array.isArray(runs)) history.push(...runs);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const heatmap = calculateCostHeatmap({ costLedger, history, modelFilter, providerFilter });
|
|
132
|
+
sendJson(res, 200, { ok: true, heatmap });
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
114
135
|
const days = parseInt(url.searchParams.get('days') || '7', 10);
|
|
115
136
|
const tasks = store.list ? store.list() : Object.values(store.tasks || {});
|
|
116
137
|
let history = [];
|
package/lib/timeline.js
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Visual Schedule Timeline & Cost Heatmap Engine (#332, #333)
|
|
3
|
+
*
|
|
4
|
+
* 1. 24-hour schedule projection with collision and peak concurrency detection.
|
|
5
|
+
* 2. 7x24 weekly hour-of-day cost and token burn heatmap matrix.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Cron } from 'croner';
|
|
9
|
+
|
|
10
|
+
const DAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
|
11
|
+
|
|
12
|
+
export function calculate24hTimeline({
|
|
13
|
+
tasks = [],
|
|
14
|
+
maxConcurrent = 3,
|
|
15
|
+
fromTimestamp = Date.now(),
|
|
16
|
+
hoursCount = 24,
|
|
17
|
+
}) {
|
|
18
|
+
const limit = Math.max(1, Number(maxConcurrent) || 3);
|
|
19
|
+
const activeTasks = (Array.isArray(tasks) ? tasks : Object.values(tasks || {})).filter(
|
|
20
|
+
(t) => t && t.status === 'active'
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
const startMs = typeof fromTimestamp === 'number' ? fromTimestamp : Date.now();
|
|
24
|
+
const endMs = startMs + hoursCount * 3600000;
|
|
25
|
+
|
|
26
|
+
const buckets = Array.from({ length: hoursCount }, (_, i) => {
|
|
27
|
+
const bucketStart = startMs + i * 3600000;
|
|
28
|
+
const d = new Date(bucketStart);
|
|
29
|
+
const hourPad = String(d.getHours()).padStart(2, '0');
|
|
30
|
+
return {
|
|
31
|
+
index: i,
|
|
32
|
+
label: `${hourPad}:00`,
|
|
33
|
+
startMs: bucketStart,
|
|
34
|
+
endMs: bucketStart + 3600000,
|
|
35
|
+
runs: [],
|
|
36
|
+
count: 0,
|
|
37
|
+
isPeak: false,
|
|
38
|
+
};
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
let totalRuns = 0;
|
|
42
|
+
|
|
43
|
+
for (const task of activeTasks) {
|
|
44
|
+
if (task.oneShot) {
|
|
45
|
+
const runAt = Number(task.nextRunAt || task.targetTimestamp);
|
|
46
|
+
if (runAt >= startMs && runAt < endMs) {
|
|
47
|
+
const bIdx = Math.floor((runAt - startMs) / 3600000);
|
|
48
|
+
if (bIdx >= 0 && bIdx < hoursCount) {
|
|
49
|
+
buckets[bIdx].runs.push({
|
|
50
|
+
taskId: task.id,
|
|
51
|
+
title: task.title || task.id,
|
|
52
|
+
runAt,
|
|
53
|
+
durationEstMs: task.avgDuration || 2000,
|
|
54
|
+
type: task.type || 'llm',
|
|
55
|
+
});
|
|
56
|
+
totalRuns += 1;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (task.schedule) {
|
|
63
|
+
try {
|
|
64
|
+
const c = new Cron(task.schedule, { timezone: task.timezone });
|
|
65
|
+
let cursor = new Date(startMs - 1);
|
|
66
|
+
// Bound to max 96 occurrences per task in 24h window
|
|
67
|
+
for (let step = 0; step < 96; step++) {
|
|
68
|
+
const nextDate = c.nextRun(cursor);
|
|
69
|
+
if (!nextDate) break;
|
|
70
|
+
const runMs = nextDate.getTime();
|
|
71
|
+
if (runMs >= endMs) break;
|
|
72
|
+
|
|
73
|
+
const bIdx = Math.floor((runMs - startMs) / 3600000);
|
|
74
|
+
if (bIdx >= 0 && bIdx < hoursCount) {
|
|
75
|
+
buckets[bIdx].runs.push({
|
|
76
|
+
taskId: task.id,
|
|
77
|
+
title: task.title || task.id,
|
|
78
|
+
runAt: runMs,
|
|
79
|
+
durationEstMs: task.avgDuration || 2000,
|
|
80
|
+
type: task.type || 'llm',
|
|
81
|
+
});
|
|
82
|
+
totalRuns += 1;
|
|
83
|
+
}
|
|
84
|
+
cursor = new Date(runMs + 1000);
|
|
85
|
+
}
|
|
86
|
+
} catch (err) {
|
|
87
|
+
// Fallback for custom or invalid cron schedules
|
|
88
|
+
const runAt = Number(task.nextRunAt);
|
|
89
|
+
if (runAt >= startMs && runAt < endMs) {
|
|
90
|
+
const bIdx = Math.floor((runAt - startMs) / 3600000);
|
|
91
|
+
if (bIdx >= 0 && bIdx < hoursCount) {
|
|
92
|
+
buckets[bIdx].runs.push({
|
|
93
|
+
taskId: task.id,
|
|
94
|
+
title: task.title || task.id,
|
|
95
|
+
runAt,
|
|
96
|
+
durationEstMs: task.avgDuration || 2000,
|
|
97
|
+
type: task.type || 'llm',
|
|
98
|
+
});
|
|
99
|
+
totalRuns += 1;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const peakHours = [];
|
|
107
|
+
|
|
108
|
+
for (const b of buckets) {
|
|
109
|
+
b.count = b.runs.length;
|
|
110
|
+
b.isPeak = b.count > limit;
|
|
111
|
+
if (b.isPeak) {
|
|
112
|
+
peakHours.push(b.label);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
fromTimestamp: startMs,
|
|
118
|
+
toTimestamp: endMs,
|
|
119
|
+
maxConcurrent: limit,
|
|
120
|
+
totalRuns,
|
|
121
|
+
peakHoursCount: peakHours.length,
|
|
122
|
+
peakHours,
|
|
123
|
+
buckets,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function calculateCostHeatmap({
|
|
128
|
+
costLedger = [],
|
|
129
|
+
history = [],
|
|
130
|
+
modelFilter = null,
|
|
131
|
+
providerFilter = null,
|
|
132
|
+
}) {
|
|
133
|
+
const ledger = Array.isArray(costLedger) ? costLedger : [];
|
|
134
|
+
const historyList = Array.isArray(history) ? history : [];
|
|
135
|
+
|
|
136
|
+
// Matrix: 7 rows (Mon..Sun) x 24 columns (00..23h)
|
|
137
|
+
const matrix = Array.from({ length: 7 }, (_, dIdx) =>
|
|
138
|
+
Array.from({ length: 24 }, (_, hIdx) => ({
|
|
139
|
+
day: dIdx,
|
|
140
|
+
dayLabel: DAY_LABELS[dIdx],
|
|
141
|
+
hour: hIdx,
|
|
142
|
+
hourLabel: `${String(hIdx).padStart(2, '0')}:00`,
|
|
143
|
+
tokens: 0,
|
|
144
|
+
costUsd: 0,
|
|
145
|
+
runs: 0,
|
|
146
|
+
costIntensity: 0,
|
|
147
|
+
tokenIntensity: 0,
|
|
148
|
+
}))
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
let totalCostUsd = 0;
|
|
152
|
+
let totalTokens = 0;
|
|
153
|
+
let totalRuns = 0;
|
|
154
|
+
|
|
155
|
+
// Aggregate costLedger
|
|
156
|
+
for (const entry of ledger) {
|
|
157
|
+
if (!entry || typeof entry.timestamp !== 'number') continue;
|
|
158
|
+
if (modelFilter && entry.model && entry.model !== modelFilter) continue;
|
|
159
|
+
if (providerFilter && entry.provider && entry.provider !== providerFilter) continue;
|
|
160
|
+
|
|
161
|
+
const d = new Date(entry.timestamp);
|
|
162
|
+
const dayOfWeek = (d.getUTCDay() + 6) % 7; // Convert Sun(0) to 6, Mon(1) to 0
|
|
163
|
+
const hour = d.getUTCHours();
|
|
164
|
+
|
|
165
|
+
if (dayOfWeek >= 0 && dayOfWeek < 7 && hour >= 0 && hour < 24) {
|
|
166
|
+
const cell = matrix[dayOfWeek][hour];
|
|
167
|
+
const cost = typeof entry.costUsd === 'number' ? entry.costUsd : 0;
|
|
168
|
+
const tokens = typeof entry.tokens === 'number' ? entry.tokens : 0;
|
|
169
|
+
|
|
170
|
+
cell.costUsd += cost;
|
|
171
|
+
cell.tokens += tokens;
|
|
172
|
+
totalCostUsd += cost;
|
|
173
|
+
totalTokens += tokens;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Aggregate run counts from history
|
|
178
|
+
for (const r of historyList) {
|
|
179
|
+
if (!r) continue;
|
|
180
|
+
const t = r.startedAt || r.completedAt || r.timestamp;
|
|
181
|
+
if (typeof t !== 'number') continue;
|
|
182
|
+
if (modelFilter && r.model && r.model !== modelFilter) continue;
|
|
183
|
+
if (providerFilter && r.provider && r.provider !== providerFilter) continue;
|
|
184
|
+
|
|
185
|
+
const d = new Date(t);
|
|
186
|
+
const dayOfWeek = (d.getUTCDay() + 6) % 7;
|
|
187
|
+
const hour = d.getUTCHours();
|
|
188
|
+
|
|
189
|
+
if (dayOfWeek >= 0 && dayOfWeek < 7 && hour >= 0 && hour < 24) {
|
|
190
|
+
matrix[dayOfWeek][hour].runs += 1;
|
|
191
|
+
totalRuns += 1;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Calculate maximums for intensity normalisation
|
|
196
|
+
let maxCostHour = 0;
|
|
197
|
+
let maxTokensHour = 0;
|
|
198
|
+
|
|
199
|
+
for (let d = 0; d < 7; d++) {
|
|
200
|
+
for (let h = 0; h < 24; h++) {
|
|
201
|
+
const cell = matrix[d][h];
|
|
202
|
+
cell.costUsd = Number(cell.costUsd.toFixed(4));
|
|
203
|
+
if (cell.costUsd > maxCostHour) maxCostHour = cell.costUsd;
|
|
204
|
+
if (cell.tokens > maxTokensHour) maxTokensHour = cell.tokens;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Normalise intensity (0.0 to 1.0)
|
|
209
|
+
const costDenom = maxCostHour > 0 ? maxCostHour : 1;
|
|
210
|
+
const tokenDenom = maxTokensHour > 0 ? maxTokensHour : 1;
|
|
211
|
+
|
|
212
|
+
for (let d = 0; d < 7; d++) {
|
|
213
|
+
for (let h = 0; h < 24; h++) {
|
|
214
|
+
const cell = matrix[d][h];
|
|
215
|
+
cell.costIntensity = Number((cell.costUsd / costDenom).toFixed(3));
|
|
216
|
+
cell.tokenIntensity = Number((cell.tokens / tokenDenom).toFixed(3));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
dayLabels: DAY_LABELS,
|
|
222
|
+
hourLabels: Array.from({ length: 24 }, (_, i) => `${String(i).padStart(2, '0')}:00`),
|
|
223
|
+
matrix,
|
|
224
|
+
maxCostHour,
|
|
225
|
+
maxTokensHour,
|
|
226
|
+
totalCostUsd: Number(totalCostUsd.toFixed(4)),
|
|
227
|
+
totalTokens,
|
|
228
|
+
totalRuns,
|
|
229
|
+
};
|
|
230
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-cron",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5",
|
|
4
4
|
"description": "Background automation runner for DSH: isolated agent runs, script/HTTP/SSH/Docker runtimes, cost guard, notifications, heartbeats.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|