@chrysb/alphaclaw 0.7.2-beta.0 → 0.7.2-beta.2
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/public/css/theme.css +12 -1
- package/lib/public/js/app.js +10 -2
- package/lib/public/js/components/cron-tab/cron-job-detail.js +18 -2
- package/lib/public/js/components/cron-tab/cron-job-list.js +43 -0
- package/lib/public/js/components/cron-tab/cron-job-trends-panel.js +319 -0
- package/lib/public/js/components/cron-tab/cron-job-usage.js +22 -8
- package/lib/public/js/components/cron-tab/cron-overview.js +17 -13
- package/lib/public/js/components/cron-tab/cron-prompt-editor.js +1 -1
- package/lib/public/js/components/cron-tab/cron-run-history-panel.js +66 -30
- package/lib/public/js/components/cron-tab/cron-runs-trend-card.js +109 -53
- package/lib/public/js/components/cron-tab/index.js +6 -0
- package/lib/public/js/components/cron-tab/use-cron-tab.js +51 -0
- package/lib/public/js/components/nodes-tab/connected-nodes/index.js +85 -0
- package/lib/public/js/components/nodes-tab/connected-nodes/user-connected-nodes.js +25 -0
- package/lib/public/js/components/nodes-tab/exec-allowlist/index.js +89 -0
- package/lib/public/js/components/nodes-tab/exec-allowlist/use-exec-allowlist.js +78 -0
- package/lib/public/js/components/nodes-tab/exec-config/index.js +118 -0
- package/lib/public/js/components/nodes-tab/exec-config/use-exec-config.js +79 -0
- package/lib/public/js/components/nodes-tab/index.js +46 -0
- package/lib/public/js/components/nodes-tab/setup-wizard/index.js +243 -0
- package/lib/public/js/components/nodes-tab/setup-wizard/use-setup-wizard.js +159 -0
- package/lib/public/js/components/nodes-tab/use-nodes-tab.js +22 -0
- package/lib/public/js/components/routes/index.js +1 -0
- package/lib/public/js/components/routes/nodes-route.js +11 -0
- package/lib/public/js/components/usage-tab/constants.js +8 -0
- package/lib/public/js/components/usage-tab/formatters.js +13 -0
- package/lib/public/js/components/usage-tab/index.js +2 -0
- package/lib/public/js/components/usage-tab/overview-section.js +22 -4
- package/lib/public/js/components/usage-tab/use-usage-tab.js +61 -16
- package/lib/public/js/lib/api.js +61 -0
- package/lib/public/js/lib/app-navigation.js +2 -0
- package/lib/public/js/lib/format.js +50 -0
- package/lib/server/constants.js +1 -0
- package/lib/server/cron-service.js +230 -1
- package/lib/server/db/usage/summary.js +101 -1
- package/lib/server/init/register-server-routes.js +8 -0
- package/lib/server/routes/cron.js +11 -0
- package/lib/server/routes/nodes.js +286 -0
- package/package.json +2 -2
package/lib/public/css/theme.css
CHANGED
|
@@ -73,12 +73,23 @@ body::before {
|
|
|
73
73
|
gap: 8px;
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
.ac-history-list.ac-history-list-tight {
|
|
77
|
+
gap: 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
76
80
|
.ac-history-item {
|
|
77
81
|
border: 1px solid var(--panel-border-contrast);
|
|
78
82
|
border-radius: 10px;
|
|
79
83
|
background: rgba(0, 0, 0, 0.12);
|
|
80
84
|
}
|
|
81
85
|
|
|
86
|
+
.ac-history-item.ac-history-item-flat {
|
|
87
|
+
border: 0;
|
|
88
|
+
border-bottom: 1px solid var(--panel-border-contrast);
|
|
89
|
+
border-radius: 0;
|
|
90
|
+
background: transparent;
|
|
91
|
+
}
|
|
92
|
+
|
|
82
93
|
.snippet-collapse-fade {
|
|
83
94
|
background: linear-gradient(to bottom, transparent 0%, rgba(0, 0, 0, 0.75) 70%);
|
|
84
95
|
}
|
|
@@ -113,7 +124,7 @@ body::before {
|
|
|
113
124
|
transition: transform 0.15s ease, color 0.15s ease;
|
|
114
125
|
}
|
|
115
126
|
|
|
116
|
-
.ac-history-item[open] .ac-history-toggle {
|
|
127
|
+
.ac-history-item[open] > .ac-history-summary .ac-history-toggle {
|
|
117
128
|
transform: rotate(90deg);
|
|
118
129
|
color: #d1d5db;
|
|
119
130
|
}
|
package/lib/public/js/app.js
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
EnvarsRoute,
|
|
22
22
|
GeneralRoute,
|
|
23
23
|
ModelsRoute,
|
|
24
|
+
NodesRoute,
|
|
24
25
|
RouteRedirect,
|
|
25
26
|
TelegramRoute,
|
|
26
27
|
UsageRoute,
|
|
@@ -79,6 +80,7 @@ const App = () => {
|
|
|
79
80
|
const isCronRoute = location.startsWith("/cron");
|
|
80
81
|
const isEnvarsRoute = location.startsWith("/envars");
|
|
81
82
|
const isModelsRoute = location.startsWith("/models");
|
|
83
|
+
const isNodesRoute = location.startsWith("/nodes");
|
|
82
84
|
const selectedAgentId = (() => {
|
|
83
85
|
const match = location.match(/^\/agents\/([^/]+)/);
|
|
84
86
|
return match ? decodeURIComponent(match[1]) : "";
|
|
@@ -304,13 +306,19 @@ const App = () => {
|
|
|
304
306
|
>
|
|
305
307
|
<${ModelsRoute} onRestartRequired=${controllerActions.setRestartRequired} />
|
|
306
308
|
</div>
|
|
309
|
+
<div
|
|
310
|
+
class="app-content-pane ac-fixed-header-pane"
|
|
311
|
+
style=${{ display: isNodesRoute ? "block" : "none" }}
|
|
312
|
+
>
|
|
313
|
+
<${NodesRoute} onRestartRequired=${controllerActions.setRestartRequired} />
|
|
314
|
+
</div>
|
|
307
315
|
<div
|
|
308
316
|
class="app-content-pane"
|
|
309
317
|
onscroll=${shellActions.handlePaneScroll}
|
|
310
|
-
style=${{ display: browseState.isBrowseRoute || isAgentsRoute || isCronRoute || isEnvarsRoute || isModelsRoute ? "none" : "block" }}
|
|
318
|
+
style=${{ display: browseState.isBrowseRoute || isAgentsRoute || isCronRoute || isEnvarsRoute || isModelsRoute || isNodesRoute ? "none" : "block" }}
|
|
311
319
|
>
|
|
312
320
|
<div class="max-w-2xl w-full mx-auto">
|
|
313
|
-
${!browseState.isBrowseRoute && !isAgentsRoute && !isCronRoute && !isEnvarsRoute && !isModelsRoute
|
|
321
|
+
${!browseState.isBrowseRoute && !isAgentsRoute && !isCronRoute && !isEnvarsRoute && !isModelsRoute && !isNodesRoute
|
|
314
322
|
? html`
|
|
315
323
|
<${Switch}>
|
|
316
324
|
<${Route} path="/general">
|
|
@@ -4,6 +4,7 @@ import htm from "https://esm.sh/htm";
|
|
|
4
4
|
import { ActionButton } from "../action-button.js";
|
|
5
5
|
import { formatTokenCount } from "./cron-helpers.js";
|
|
6
6
|
import { CronJobUsage } from "./cron-job-usage.js";
|
|
7
|
+
import { CronJobTrendsPanel } from "./cron-job-trends-panel.js";
|
|
7
8
|
import { CronRunHistoryPanel } from "./cron-run-history-panel.js";
|
|
8
9
|
import { CronPromptEditor } from "./cron-prompt-editor.js";
|
|
9
10
|
import { CronJobSettingsCard } from "./cron-job-settings-card.js";
|
|
@@ -19,6 +20,7 @@ const kRunStatusFilterOptions = [
|
|
|
19
20
|
export const CronJobDetail = ({
|
|
20
21
|
job = null,
|
|
21
22
|
runEntries = [],
|
|
23
|
+
filteredRunEntries = [],
|
|
22
24
|
runTotal = 0,
|
|
23
25
|
runHasMore = false,
|
|
24
26
|
loadingMoreRuns = false,
|
|
@@ -30,8 +32,13 @@ export const CronJobDetail = ({
|
|
|
30
32
|
onToggleEnabled = () => {},
|
|
31
33
|
togglingJobEnabled = false,
|
|
32
34
|
usage = null,
|
|
35
|
+
jobTrends = null,
|
|
36
|
+
jobTrendRange = "7d",
|
|
37
|
+
selectedJobTrendBucketFilter = null,
|
|
33
38
|
usageDays = 30,
|
|
34
39
|
onSetUsageDays = () => {},
|
|
40
|
+
onSetJobTrendRange = () => {},
|
|
41
|
+
onSetSelectedJobTrendBucketFilter = () => {},
|
|
35
42
|
promptValue = "",
|
|
36
43
|
savedPromptValue = "",
|
|
37
44
|
onChangePrompt = () => {},
|
|
@@ -105,13 +112,22 @@ export const CronJobDetail = ({
|
|
|
105
112
|
usageDays=${usageDays}
|
|
106
113
|
onSetUsageDays=${onSetUsageDays}
|
|
107
114
|
/>
|
|
115
|
+
<${CronJobTrendsPanel}
|
|
116
|
+
trends=${jobTrends}
|
|
117
|
+
range=${jobTrendRange}
|
|
118
|
+
onChangeRange=${onSetJobTrendRange}
|
|
119
|
+
selectedBucketFilter=${selectedJobTrendBucketFilter}
|
|
120
|
+
onChangeSelectedBucketFilter=${onSetSelectedJobTrendBucketFilter}
|
|
121
|
+
/>
|
|
108
122
|
|
|
109
123
|
<${CronRunHistoryPanel}
|
|
110
|
-
entryCountLabel=${`${formatTokenCount(runTotal)} entries`}
|
|
124
|
+
entryCountLabel=${`${formatTokenCount(selectedJobTrendBucketFilter ? filteredRunEntries.length : runTotal)} entries`}
|
|
111
125
|
primaryFilterOptions=${kRunStatusFilterOptions}
|
|
112
126
|
primaryFilterValue=${runStatusFilter}
|
|
113
127
|
onChangePrimaryFilter=${onSetRunStatusFilter}
|
|
114
|
-
|
|
128
|
+
activeFilterLabel=${selectedJobTrendBucketFilter?.label || ""}
|
|
129
|
+
onClearActiveFilter=${() => onSetSelectedJobTrendBucketFilter(null)}
|
|
130
|
+
rows=${selectedJobTrendBucketFilter ? filteredRunEntries : runEntries}
|
|
115
131
|
variant="detail"
|
|
116
132
|
footer=${runHasMore
|
|
117
133
|
? html`
|
|
@@ -55,6 +55,35 @@ const parseCronWeekdayField = (field = "") => {
|
|
|
55
55
|
if (weekdays.length === 0) return null;
|
|
56
56
|
return Math.min(...weekdays);
|
|
57
57
|
};
|
|
58
|
+
const parseCronWeekdayValues = (field = "") => {
|
|
59
|
+
const raw = String(field || "").trim().toLowerCase();
|
|
60
|
+
if (!raw || raw === "*") return [];
|
|
61
|
+
const segments = raw.split(",").map((segment) => segment.trim()).filter(Boolean);
|
|
62
|
+
const weekdays = new Set();
|
|
63
|
+
segments.forEach((segment) => {
|
|
64
|
+
const rangeMatch = segment.match(/^(\d{1,2})-(\d{1,2})$/);
|
|
65
|
+
if (rangeMatch) {
|
|
66
|
+
const start = normalizeCronWeekday(parseCronNumeric(rangeMatch[1]));
|
|
67
|
+
const end = normalizeCronWeekday(parseCronNumeric(rangeMatch[2]));
|
|
68
|
+
if (start == null || end == null) return;
|
|
69
|
+
if (start <= end) {
|
|
70
|
+
for (let value = start; value <= end; value += 1) weekdays.add(value);
|
|
71
|
+
} else {
|
|
72
|
+
for (let value = start; value <= 6; value += 1) weekdays.add(value);
|
|
73
|
+
for (let value = 0; value <= end; value += 1) weekdays.add(value);
|
|
74
|
+
}
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const single = normalizeCronWeekday(parseCronNumeric(segment));
|
|
78
|
+
if (single != null) weekdays.add(single);
|
|
79
|
+
});
|
|
80
|
+
return [...weekdays].sort((left, right) => left - right);
|
|
81
|
+
};
|
|
82
|
+
const isWeekdaysOnlyField = (field = "") => {
|
|
83
|
+
const weekdayValues = parseCronWeekdayValues(field);
|
|
84
|
+
if (weekdayValues.length !== 5) return false;
|
|
85
|
+
return weekdayValues.join(",") === "1,2,3,4,5";
|
|
86
|
+
};
|
|
58
87
|
|
|
59
88
|
const parseCronMinuteOfDay = ({ minuteField = "", hourField = "" }) => {
|
|
60
89
|
const minute = parseCronNumeric(minuteField);
|
|
@@ -113,6 +142,19 @@ const getInternalSortMeta = (job = {}, groupKey = "other") => {
|
|
|
113
142
|
secondary: nameKey,
|
|
114
143
|
};
|
|
115
144
|
}
|
|
145
|
+
if (
|
|
146
|
+
cronFields &&
|
|
147
|
+
cronFields.dayOfMonthField === "*" &&
|
|
148
|
+
cronFields.monthField === "*" &&
|
|
149
|
+
isWeekdaysOnlyField(cronFields.dayOfWeekField) &&
|
|
150
|
+
minuteOfDay != null
|
|
151
|
+
) {
|
|
152
|
+
return {
|
|
153
|
+
groupRank: 2,
|
|
154
|
+
primary: minuteOfDay,
|
|
155
|
+
secondary: nameKey,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
116
158
|
}
|
|
117
159
|
if (groupKey === "weekly" && cronFields) {
|
|
118
160
|
const weekday = parseCronWeekdayField(cronFields.dayOfWeekField);
|
|
@@ -187,6 +229,7 @@ const getScheduleGroupKey = (schedule = {}) => {
|
|
|
187
229
|
monthField === "*" &&
|
|
188
230
|
dayOfWeekField !== "*"
|
|
189
231
|
) {
|
|
232
|
+
if (isWeekdaysOnlyField(dayOfWeekField)) return "daily";
|
|
190
233
|
return "weekly";
|
|
191
234
|
}
|
|
192
235
|
if (dayOfMonthField !== "*" && monthField === "*") {
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { h } from "https://esm.sh/preact";
|
|
2
|
+
import { useEffect, useMemo, useRef, useState } from "https://esm.sh/preact/hooks";
|
|
3
|
+
import htm from "https://esm.sh/htm";
|
|
4
|
+
import { formatCost, formatTokenCount } from "./cron-helpers.js";
|
|
5
|
+
import { formatChartBucketLabel, formatDurationCompactMs } from "../../lib/format.js";
|
|
6
|
+
import { SegmentedControl } from "../segmented-control.js";
|
|
7
|
+
|
|
8
|
+
const html = htm.bind(h);
|
|
9
|
+
const kMetricOutcomes = "outcomes";
|
|
10
|
+
const kMetricTokens = "tokens";
|
|
11
|
+
const kMetricDuration = "duration";
|
|
12
|
+
const kMetricCost = "cost";
|
|
13
|
+
const kRange24h = "24h";
|
|
14
|
+
const kRange7d = "7d";
|
|
15
|
+
const kRange30d = "30d";
|
|
16
|
+
const kRangeOptions = [
|
|
17
|
+
{ label: "24h", value: kRange24h },
|
|
18
|
+
{ label: "7d", value: kRange7d },
|
|
19
|
+
{ label: "30d", value: kRange30d },
|
|
20
|
+
];
|
|
21
|
+
const kMetricOptions = [
|
|
22
|
+
{ label: "outcomes", value: kMetricOutcomes },
|
|
23
|
+
{ label: "tokens", value: kMetricTokens },
|
|
24
|
+
{ label: "duration", value: kMetricDuration },
|
|
25
|
+
{ label: "cost", value: kMetricCost },
|
|
26
|
+
];
|
|
27
|
+
const buildChartData = ({
|
|
28
|
+
trends = null,
|
|
29
|
+
metric = kMetricOutcomes,
|
|
30
|
+
selectedBucketKey = "",
|
|
31
|
+
} = {}) => {
|
|
32
|
+
const points = Array.isArray(trends?.points) ? trends.points : [];
|
|
33
|
+
const range = String(trends?.range || kRange7d);
|
|
34
|
+
const labels = points.map((point) =>
|
|
35
|
+
formatChartBucketLabel(point.startMs, {
|
|
36
|
+
range,
|
|
37
|
+
valueType: "epoch-ms",
|
|
38
|
+
}));
|
|
39
|
+
const dimAlpha = "0.22";
|
|
40
|
+
const fullAlpha = "0.86";
|
|
41
|
+
const isDimmed = (index) =>
|
|
42
|
+
selectedBucketKey && String(points[index]?.key || "") !== selectedBucketKey;
|
|
43
|
+
if (metric === kMetricOutcomes) {
|
|
44
|
+
return {
|
|
45
|
+
labels,
|
|
46
|
+
datasets: [
|
|
47
|
+
{
|
|
48
|
+
label: "ok",
|
|
49
|
+
data: points.map((point) => Number(point?.ok || 0)),
|
|
50
|
+
stack: "outcomes",
|
|
51
|
+
backgroundColor: points.map((_, index) =>
|
|
52
|
+
`rgba(34,255,170,${isDimmed(index) ? dimAlpha : fullAlpha})`),
|
|
53
|
+
borderColor: points.map((_, index) =>
|
|
54
|
+
`rgba(34,255,170,${isDimmed(index) ? "0.35" : "1"})`),
|
|
55
|
+
borderWidth: 1,
|
|
56
|
+
borderRadius: 0,
|
|
57
|
+
borderSkipped: false,
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
label: "error",
|
|
61
|
+
data: points.map((point) => Number(point?.error || 0)),
|
|
62
|
+
stack: "outcomes",
|
|
63
|
+
backgroundColor: points.map((_, index) =>
|
|
64
|
+
`rgba(255,74,138,${isDimmed(index) ? dimAlpha : fullAlpha})`),
|
|
65
|
+
borderColor: points.map((_, index) =>
|
|
66
|
+
`rgba(255,74,138,${isDimmed(index) ? "0.35" : "1"})`),
|
|
67
|
+
borderWidth: 1,
|
|
68
|
+
borderRadius: 0,
|
|
69
|
+
borderSkipped: false,
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
label: "skipped",
|
|
73
|
+
data: points.map((point) => Number(point?.skipped || 0)),
|
|
74
|
+
stack: "outcomes",
|
|
75
|
+
backgroundColor: points.map((_, index) =>
|
|
76
|
+
`rgba(255,214,64,${isDimmed(index) ? dimAlpha : fullAlpha})`),
|
|
77
|
+
borderColor: points.map((_, index) =>
|
|
78
|
+
`rgba(255,214,64,${isDimmed(index) ? "0.35" : "1"})`),
|
|
79
|
+
borderWidth: 1,
|
|
80
|
+
borderRadius: 0,
|
|
81
|
+
borderSkipped: false,
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
const valueByPoint = points.map((point) => {
|
|
87
|
+
if (metric === kMetricTokens) return Number(point?.totalTokens || 0);
|
|
88
|
+
if (metric === kMetricCost) return Number(point?.totalCost || 0);
|
|
89
|
+
return Number(point?.avgDurationMs || 0);
|
|
90
|
+
});
|
|
91
|
+
return {
|
|
92
|
+
labels,
|
|
93
|
+
datasets: [
|
|
94
|
+
{
|
|
95
|
+
label:
|
|
96
|
+
metric === kMetricTokens
|
|
97
|
+
? "tokens"
|
|
98
|
+
: metric === kMetricCost
|
|
99
|
+
? "cost"
|
|
100
|
+
: "avg duration",
|
|
101
|
+
data: valueByPoint,
|
|
102
|
+
backgroundColor: points.map((_, index) =>
|
|
103
|
+
metric === kMetricTokens
|
|
104
|
+
? `rgba(34,211,238,${isDimmed(index) ? dimAlpha : "0.72"})`
|
|
105
|
+
: metric === kMetricCost
|
|
106
|
+
? `rgba(167,139,250,${isDimmed(index) ? dimAlpha : "0.72"})`
|
|
107
|
+
: `rgba(148,163,184,${isDimmed(index) ? dimAlpha : "0.72"})`),
|
|
108
|
+
borderColor: points.map((_, index) =>
|
|
109
|
+
metric === kMetricTokens
|
|
110
|
+
? `rgba(34,211,238,${isDimmed(index) ? "0.35" : "1"})`
|
|
111
|
+
: metric === kMetricCost
|
|
112
|
+
? `rgba(167,139,250,${isDimmed(index) ? "0.35" : "1"})`
|
|
113
|
+
: `rgba(148,163,184,${isDimmed(index) ? "0.35" : "1"})`),
|
|
114
|
+
borderWidth: 1,
|
|
115
|
+
borderRadius: 0,
|
|
116
|
+
borderSkipped: false,
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
};
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export const CronJobTrendsPanel = ({
|
|
123
|
+
trends = null,
|
|
124
|
+
range = kRange7d,
|
|
125
|
+
onChangeRange = () => {},
|
|
126
|
+
selectedBucketFilter = null,
|
|
127
|
+
onChangeSelectedBucketFilter = () => {},
|
|
128
|
+
}) => {
|
|
129
|
+
const chartCanvasRef = useRef(null);
|
|
130
|
+
const chartInstanceRef = useRef(null);
|
|
131
|
+
const [metric, setMetric] = useState(kMetricOutcomes);
|
|
132
|
+
const points = useMemo(
|
|
133
|
+
() =>
|
|
134
|
+
Array.isArray(trends?.points)
|
|
135
|
+
? trends.points.map((point, index) => ({
|
|
136
|
+
...point,
|
|
137
|
+
key: String(point?.key || `point:${index}:${point?.startMs || 0}`),
|
|
138
|
+
}))
|
|
139
|
+
: [],
|
|
140
|
+
[trends?.points],
|
|
141
|
+
);
|
|
142
|
+
const selectedBucketKey = useMemo(() => {
|
|
143
|
+
if (!selectedBucketFilter) return "";
|
|
144
|
+
const matchingPoint = points.find(
|
|
145
|
+
(point) =>
|
|
146
|
+
Number(point.startMs) === Number(selectedBucketFilter.startMs) &&
|
|
147
|
+
Number(point.endMs) === Number(selectedBucketFilter.endMs),
|
|
148
|
+
);
|
|
149
|
+
return matchingPoint?.key || "";
|
|
150
|
+
}, [points, selectedBucketFilter]);
|
|
151
|
+
const hasData = useMemo(
|
|
152
|
+
() =>
|
|
153
|
+
points.some(
|
|
154
|
+
(point) =>
|
|
155
|
+
Number(point?.totalRuns || 0) > 0 ||
|
|
156
|
+
Number(point?.totalTokens || 0) > 0 ||
|
|
157
|
+
Number(point?.totalCost || 0) > 0 ||
|
|
158
|
+
Number(point?.avgDurationMs || 0) > 0,
|
|
159
|
+
),
|
|
160
|
+
[points],
|
|
161
|
+
);
|
|
162
|
+
const chartData = useMemo(
|
|
163
|
+
() => buildChartData({ trends: { ...trends, points }, metric, selectedBucketKey }),
|
|
164
|
+
[metric, points, selectedBucketKey, trends],
|
|
165
|
+
);
|
|
166
|
+
useEffect(() => {
|
|
167
|
+
const canvas = chartCanvasRef.current;
|
|
168
|
+
const Chart = window.Chart;
|
|
169
|
+
if (!canvas || !Chart) return;
|
|
170
|
+
if (chartInstanceRef.current) {
|
|
171
|
+
chartInstanceRef.current.destroy();
|
|
172
|
+
chartInstanceRef.current = null;
|
|
173
|
+
}
|
|
174
|
+
const getBucketFilter = (index) => {
|
|
175
|
+
const selectedPoint = points[index];
|
|
176
|
+
if (!selectedPoint) return null;
|
|
177
|
+
return {
|
|
178
|
+
key: selectedPoint.key,
|
|
179
|
+
label: formatChartBucketLabel(selectedPoint.startMs, {
|
|
180
|
+
range,
|
|
181
|
+
valueType: "epoch-ms",
|
|
182
|
+
}),
|
|
183
|
+
startMs: Number(selectedPoint.startMs || 0),
|
|
184
|
+
endMs: Number(selectedPoint.endMs || 0),
|
|
185
|
+
range,
|
|
186
|
+
};
|
|
187
|
+
};
|
|
188
|
+
chartInstanceRef.current = new Chart(canvas, {
|
|
189
|
+
type: "bar",
|
|
190
|
+
data: chartData,
|
|
191
|
+
options: {
|
|
192
|
+
responsive: true,
|
|
193
|
+
maintainAspectRatio: false,
|
|
194
|
+
interaction: { mode: "index", intersect: false },
|
|
195
|
+
animation: false,
|
|
196
|
+
onHover: (event, elements) => {
|
|
197
|
+
const target = event?.native?.target;
|
|
198
|
+
if (!target || !target.style) return;
|
|
199
|
+
target.style.cursor = Array.isArray(elements) && elements.length > 0
|
|
200
|
+
? "pointer"
|
|
201
|
+
: "default";
|
|
202
|
+
},
|
|
203
|
+
onClick: (_event, elements) => {
|
|
204
|
+
const index = Number(elements?.[0]?.index);
|
|
205
|
+
if (!Number.isFinite(index)) return;
|
|
206
|
+
const nextFilter = getBucketFilter(index);
|
|
207
|
+
if (!nextFilter) return;
|
|
208
|
+
if (nextFilter.key === selectedBucketKey) {
|
|
209
|
+
onChangeSelectedBucketFilter(null);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
onChangeSelectedBucketFilter(nextFilter);
|
|
213
|
+
},
|
|
214
|
+
scales: {
|
|
215
|
+
x: {
|
|
216
|
+
stacked: metric === kMetricOutcomes,
|
|
217
|
+
grid: { color: "rgba(148,163,184,0.08)" },
|
|
218
|
+
ticks: {
|
|
219
|
+
color: "rgba(156,163,175,1)",
|
|
220
|
+
maxRotation: 0,
|
|
221
|
+
autoSkip: true,
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
y: {
|
|
225
|
+
stacked: metric === kMetricOutcomes,
|
|
226
|
+
beginAtZero: true,
|
|
227
|
+
grid: { color: "rgba(148,163,184,0.12)" },
|
|
228
|
+
ticks: {
|
|
229
|
+
precision: metric === kMetricCost ? undefined : 0,
|
|
230
|
+
color: "rgba(156,163,175,1)",
|
|
231
|
+
callback: (value) => {
|
|
232
|
+
const numericValue = Number(value || 0);
|
|
233
|
+
if (metric === kMetricCost) return formatCost(numericValue);
|
|
234
|
+
if (metric === kMetricDuration) {
|
|
235
|
+
return numericValue > 0 ? formatDurationCompactMs(numericValue) : "0";
|
|
236
|
+
}
|
|
237
|
+
return formatTokenCount(numericValue);
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
plugins: {
|
|
243
|
+
legend: {
|
|
244
|
+
position: "bottom",
|
|
245
|
+
labels: {
|
|
246
|
+
color: "rgba(209,213,219,1)",
|
|
247
|
+
boxWidth: 10,
|
|
248
|
+
boxHeight: 10,
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
tooltip: {
|
|
252
|
+
callbacks: {
|
|
253
|
+
title: (items) => String(items?.[0]?.label || ""),
|
|
254
|
+
label: (context) => {
|
|
255
|
+
const value = Number(context.parsed.y || 0);
|
|
256
|
+
if (metric === kMetricCost) {
|
|
257
|
+
return `${context.dataset.label}: ${formatCost(value)}`;
|
|
258
|
+
}
|
|
259
|
+
if (metric === kMetricDuration) {
|
|
260
|
+
return `${context.dataset.label}: ${value > 0 ? formatDurationCompactMs(value) : "—"}`;
|
|
261
|
+
}
|
|
262
|
+
return `${context.dataset.label}: ${formatTokenCount(value)}`;
|
|
263
|
+
},
|
|
264
|
+
footer: (items) => {
|
|
265
|
+
const index = Number(items?.[0]?.dataIndex);
|
|
266
|
+
const point = points[index];
|
|
267
|
+
if (!point) return "";
|
|
268
|
+
const runsLabel = `runs: ${formatTokenCount(point.totalRuns || 0)}`;
|
|
269
|
+
const tokensLabel = `tokens: ${formatTokenCount(point.totalTokens || 0)}`;
|
|
270
|
+
const costLabel = `cost: ${formatCost(point.totalCost || 0)}`;
|
|
271
|
+
return `${runsLabel}\n${tokensLabel}\n${costLabel}`;
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
return () => {
|
|
279
|
+
if (chartInstanceRef.current) {
|
|
280
|
+
chartInstanceRef.current.destroy();
|
|
281
|
+
chartInstanceRef.current = null;
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
}, [
|
|
285
|
+
chartData,
|
|
286
|
+
metric,
|
|
287
|
+
onChangeSelectedBucketFilter,
|
|
288
|
+
points,
|
|
289
|
+
range,
|
|
290
|
+
selectedBucketKey,
|
|
291
|
+
trends?.bucket,
|
|
292
|
+
]);
|
|
293
|
+
return html`
|
|
294
|
+
<section class="bg-surface border border-border rounded-xl p-4 space-y-3">
|
|
295
|
+
<div class="flex items-center justify-between gap-2">
|
|
296
|
+
<h3 class="card-label card-label-bright">Trends</h3>
|
|
297
|
+
<div class="flex items-center gap-2">
|
|
298
|
+
<${SegmentedControl}
|
|
299
|
+
options=${kMetricOptions}
|
|
300
|
+
value=${metric}
|
|
301
|
+
onChange=${setMetric}
|
|
302
|
+
/>
|
|
303
|
+
<${SegmentedControl}
|
|
304
|
+
options=${kRangeOptions}
|
|
305
|
+
value=${range}
|
|
306
|
+
onChange=${onChangeRange}
|
|
307
|
+
/>
|
|
308
|
+
</div>
|
|
309
|
+
</div>
|
|
310
|
+
${hasData
|
|
311
|
+
? html`
|
|
312
|
+
<div class="h-44">
|
|
313
|
+
<canvas ref=${chartCanvasRef}></canvas>
|
|
314
|
+
</div>
|
|
315
|
+
`
|
|
316
|
+
: html`<div class="text-xs text-gray-500">No run data in this window yet.</div>`}
|
|
317
|
+
</section>
|
|
318
|
+
`;
|
|
319
|
+
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { h } from "https://esm.sh/preact";
|
|
2
2
|
import htm from "https://esm.sh/htm";
|
|
3
3
|
import { formatCost, formatTokenCount } from "./cron-helpers.js";
|
|
4
|
+
import { formatDurationCompactMs } from "../../lib/format.js";
|
|
4
5
|
import { SegmentedControl } from "../segmented-control.js";
|
|
5
6
|
|
|
6
7
|
const html = htm.bind(h);
|
|
@@ -25,37 +26,50 @@ export const CronJobUsage = ({ usage = null, usageDays = 30, onSetUsageDays = ()
|
|
|
25
26
|
const totals = usage?.totals || {};
|
|
26
27
|
const totalRuns = Number(totals?.runCount || 0);
|
|
27
28
|
const totalTokens = Number(totals?.totalTokens || 0);
|
|
29
|
+
const totalCost = Number(totals?.totalCost || 0);
|
|
30
|
+
const averageDurationMs = Number(totals?.avgDurationMs || 0);
|
|
28
31
|
const averageTokensPerRun = totalRuns > 0 ? Math.round(totalTokens / totalRuns) : 0;
|
|
32
|
+
const averageCostPerRun = totalRuns > 0 ? totalCost / totalRuns : 0;
|
|
29
33
|
return html`
|
|
30
34
|
<section class="bg-surface border border-border rounded-xl p-4 space-y-3">
|
|
31
35
|
<div class="flex items-center justify-between gap-2">
|
|
32
|
-
<h3 class="card-label">Usage</h3>
|
|
36
|
+
<h3 class="card-label card-label-bright">Usage</h3>
|
|
33
37
|
<${SegmentedControl}
|
|
34
38
|
options=${kUsageRangeOptions}
|
|
35
39
|
value=${usageDays}
|
|
36
40
|
onChange=${onSetUsageDays}
|
|
37
41
|
/>
|
|
38
42
|
</div>
|
|
39
|
-
<div class="grid grid-cols-
|
|
43
|
+
<div class="grid grid-cols-3 gap-2 text-xs">
|
|
44
|
+
<div class="ac-surface-inset rounded-lg p-2">
|
|
45
|
+
<div class="text-gray-500">Total runs</div>
|
|
46
|
+
<div class="text-gray-200 font-mono">${formatTokenCount(totalRuns)}</div>
|
|
47
|
+
</div>
|
|
40
48
|
<div class="ac-surface-inset rounded-lg p-2">
|
|
41
49
|
<div class="text-gray-500">Total tokens</div>
|
|
42
|
-
<div class="text-gray-200 font-mono">${formatTokenCount(
|
|
50
|
+
<div class="text-gray-200 font-mono">${formatTokenCount(totalTokens)}</div>
|
|
43
51
|
</div>
|
|
44
52
|
<div class="ac-surface-inset rounded-lg p-2">
|
|
45
|
-
<div class="text-gray-500">
|
|
46
|
-
<div class="text-gray-200 font-mono">${formatCost(
|
|
53
|
+
<div class="text-gray-500">Total cost</div>
|
|
54
|
+
<div class="text-gray-200 font-mono">${formatCost(totalCost)}</div>
|
|
47
55
|
</div>
|
|
48
56
|
<div class="ac-surface-inset rounded-lg p-2">
|
|
49
|
-
<div class="text-gray-500">
|
|
50
|
-
<div class="text-gray-200 font-mono"
|
|
57
|
+
<div class="text-gray-500">Avg run time</div>
|
|
58
|
+
<div class="text-gray-200 font-mono">
|
|
59
|
+
${averageDurationMs > 0 ? formatDurationCompactMs(averageDurationMs) : "—"}
|
|
60
|
+
</div>
|
|
51
61
|
</div>
|
|
52
62
|
<div class="ac-surface-inset rounded-lg p-2">
|
|
53
63
|
<div class="text-gray-500">Avg tokens/run</div>
|
|
54
64
|
<div class="text-gray-200 font-mono">${formatTokenCount(averageTokensPerRun)}</div>
|
|
55
65
|
</div>
|
|
66
|
+
<div class="ac-surface-inset rounded-lg p-2">
|
|
67
|
+
<div class="text-gray-500">Avg cost/run</div>
|
|
68
|
+
<div class="text-gray-200 font-mono">${formatCost(averageCostPerRun)}</div>
|
|
69
|
+
</div>
|
|
56
70
|
</div>
|
|
57
71
|
<div class="text-xs text-gray-500">
|
|
58
|
-
Dominant model
|
|
72
|
+
Dominant model:${" "}
|
|
59
73
|
<span class="text-gray-300 font-mono">${resolveDominantModel(usage)}</span>
|
|
60
74
|
</div>
|
|
61
75
|
</section>
|
|
@@ -56,7 +56,7 @@ const formatWarningsAttentionText = (warnings = []) => {
|
|
|
56
56
|
return `${parts.join(" and ")} may need your attention`;
|
|
57
57
|
};
|
|
58
58
|
|
|
59
|
-
const flattenRecentRuns = ({ bulkRunsByJobId = {}, jobs = [] } = {}) => {
|
|
59
|
+
const flattenRecentRuns = ({ bulkRunsByJobId = {}, jobs = [], limit = 0 } = {}) => {
|
|
60
60
|
const jobNameById = jobs.reduce((accumulator, job) => {
|
|
61
61
|
const jobId = String(job?.id || "");
|
|
62
62
|
if (!jobId) return accumulator;
|
|
@@ -76,7 +76,7 @@ const flattenRecentRuns = ({ bulkRunsByJobId = {}, jobs = [] } = {}) => {
|
|
|
76
76
|
})
|
|
77
77
|
.filter((entry) => Number(entry?.ts || 0) > 0)
|
|
78
78
|
.sort((left, right) => Number(right?.ts || 0) - Number(left?.ts || 0))
|
|
79
|
-
.slice(0,
|
|
79
|
+
.slice(0, Number(limit || 0) > 0 ? Number(limit || 0) : undefined);
|
|
80
80
|
};
|
|
81
81
|
|
|
82
82
|
const buildCollapsedRunRows = (recentRuns = []) => {
|
|
@@ -197,12 +197,16 @@ export const CronOverview = ({
|
|
|
197
197
|
const disabledCount = jobs.length - enabledCount;
|
|
198
198
|
const nextRunMs = getNextScheduledRunAcrossJobs(jobs);
|
|
199
199
|
const warnings = buildCronOptimizationWarnings(jobs, bulkRunsByJobId);
|
|
200
|
-
const
|
|
200
|
+
const allRecentRuns = useMemo(
|
|
201
201
|
() => flattenRecentRuns({ bulkRunsByJobId, jobs }),
|
|
202
202
|
[bulkRunsByJobId, jobs],
|
|
203
203
|
);
|
|
204
|
+
const recentRunsForDisplay = useMemo(
|
|
205
|
+
() => allRecentRuns.slice(0, kRecentRunFetchLimit),
|
|
206
|
+
[allRecentRuns],
|
|
207
|
+
);
|
|
204
208
|
const timeFilteredRecentRuns = useMemo(() => {
|
|
205
|
-
if (!selectedTrendBucketFilter) return
|
|
209
|
+
if (!selectedTrendBucketFilter) return recentRunsForDisplay;
|
|
206
210
|
const startMs = Number(selectedTrendBucketFilter?.startMs || 0);
|
|
207
211
|
const endMs = Number(selectedTrendBucketFilter?.endMs || 0);
|
|
208
212
|
if (
|
|
@@ -210,9 +214,9 @@ export const CronOverview = ({
|
|
|
210
214
|
!Number.isFinite(endMs) ||
|
|
211
215
|
endMs <= startMs
|
|
212
216
|
) {
|
|
213
|
-
return
|
|
217
|
+
return recentRunsForDisplay;
|
|
214
218
|
}
|
|
215
|
-
return
|
|
219
|
+
return allRecentRuns.filter((entry) => {
|
|
216
220
|
const timestampMs = Number(entry?.ts || 0);
|
|
217
221
|
return (
|
|
218
222
|
Number.isFinite(timestampMs) &&
|
|
@@ -220,7 +224,7 @@ export const CronOverview = ({
|
|
|
220
224
|
timestampMs < endMs
|
|
221
225
|
);
|
|
222
226
|
});
|
|
223
|
-
}, [
|
|
227
|
+
}, [allRecentRuns, recentRunsForDisplay, selectedTrendBucketFilter]);
|
|
224
228
|
const filteredRecentRuns = useMemo(
|
|
225
229
|
() =>
|
|
226
230
|
timeFilteredRecentRuns.filter((entry) =>
|
|
@@ -329,6 +333,12 @@ export const CronOverview = ({
|
|
|
329
333
|
onSelectJob=${onSelectJob}
|
|
330
334
|
/>
|
|
331
335
|
|
|
336
|
+
<${CronInsightsPanel}
|
|
337
|
+
jobs=${jobs}
|
|
338
|
+
bulkRunsByJobId=${bulkRunsByJobId}
|
|
339
|
+
onSelectJob=${onSelectJob}
|
|
340
|
+
/>
|
|
341
|
+
|
|
332
342
|
<${CronRunsTrendCard}
|
|
333
343
|
bulkRunsByJobId=${bulkRunsByJobId}
|
|
334
344
|
initialRange=${initialTrendRange}
|
|
@@ -336,12 +346,6 @@ export const CronOverview = ({
|
|
|
336
346
|
onBucketFilterChange=${setSelectedTrendBucketFilter}
|
|
337
347
|
/>
|
|
338
348
|
|
|
339
|
-
<${CronInsightsPanel}
|
|
340
|
-
jobs=${jobs}
|
|
341
|
-
bulkRunsByJobId=${bulkRunsByJobId}
|
|
342
|
-
onSelectJob=${onSelectJob}
|
|
343
|
-
/>
|
|
344
|
-
|
|
345
349
|
<${CronRunHistoryPanel}
|
|
346
350
|
entryCountLabel=${`${formatTokenCount(filteredRecentRuns.length)} entries`}
|
|
347
351
|
primaryFilterOptions=${kRunStatusFilterOptions}
|
|
@@ -139,7 +139,7 @@ export const CronPromptEditor = ({
|
|
|
139
139
|
return html`
|
|
140
140
|
<section class="bg-surface border border-border rounded-xl p-4 space-y-3">
|
|
141
141
|
<div class="flex items-center justify-between gap-2">
|
|
142
|
-
<h3 class="card-label inline-flex items-center gap-1.5">
|
|
142
|
+
<h3 class="card-label card-label-bright inline-flex items-center gap-1.5">
|
|
143
143
|
Prompt
|
|
144
144
|
${isDirty ? html`<span class="file-viewer-dirty-dot"></span>` : null}
|
|
145
145
|
</h3>
|