@chrysb/alphaclaw 0.7.2-beta.1 → 0.7.2-beta.3

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.
Files changed (40) hide show
  1. package/lib/public/css/theme.css +12 -1
  2. package/lib/public/js/app.js +10 -2
  3. package/lib/public/js/components/cron-tab/cron-job-detail.js +18 -2
  4. package/lib/public/js/components/cron-tab/cron-job-list.js +43 -0
  5. package/lib/public/js/components/cron-tab/cron-job-trends-panel.js +319 -0
  6. package/lib/public/js/components/cron-tab/cron-job-usage.js +22 -8
  7. package/lib/public/js/components/cron-tab/cron-overview.js +17 -13
  8. package/lib/public/js/components/cron-tab/cron-prompt-editor.js +1 -1
  9. package/lib/public/js/components/cron-tab/cron-run-history-panel.js +29 -12
  10. package/lib/public/js/components/cron-tab/cron-runs-trend-card.js +109 -53
  11. package/lib/public/js/components/cron-tab/index.js +6 -0
  12. package/lib/public/js/components/cron-tab/use-cron-tab.js +51 -0
  13. package/lib/public/js/components/icons.js +11 -0
  14. package/lib/public/js/components/nodes-tab/browser-attach/index.js +85 -0
  15. package/lib/public/js/components/nodes-tab/connected-nodes/index.js +324 -0
  16. package/lib/public/js/components/nodes-tab/connected-nodes/user-connected-nodes.js +25 -0
  17. package/lib/public/js/components/nodes-tab/exec-allowlist/index.js +89 -0
  18. package/lib/public/js/components/nodes-tab/exec-allowlist/use-exec-allowlist.js +78 -0
  19. package/lib/public/js/components/nodes-tab/exec-config/index.js +118 -0
  20. package/lib/public/js/components/nodes-tab/exec-config/use-exec-config.js +79 -0
  21. package/lib/public/js/components/nodes-tab/index.js +55 -0
  22. package/lib/public/js/components/nodes-tab/setup-wizard/index.js +243 -0
  23. package/lib/public/js/components/nodes-tab/setup-wizard/use-setup-wizard.js +161 -0
  24. package/lib/public/js/components/nodes-tab/use-nodes-tab.js +36 -0
  25. package/lib/public/js/components/onboarding/welcome-import-step.js +4 -3
  26. package/lib/public/js/components/routes/index.js +1 -0
  27. package/lib/public/js/components/routes/nodes-route.js +11 -0
  28. package/lib/public/js/components/usage-tab/use-usage-tab.js +11 -3
  29. package/lib/public/js/lib/api.js +70 -0
  30. package/lib/public/js/lib/app-navigation.js +2 -0
  31. package/lib/public/js/lib/format.js +50 -0
  32. package/lib/server/constants.js +1 -0
  33. package/lib/server/cron-service.js +230 -1
  34. package/lib/server/init/register-server-routes.js +8 -0
  35. package/lib/server/openclaw-version.js +5 -1
  36. package/lib/server/routes/cron.js +11 -0
  37. package/lib/server/routes/nodes.js +338 -0
  38. package/lib/server/routes/pairings.js +2 -2
  39. package/lib/server/webhook-middleware.js +92 -3
  40. package/package.json +2 -2
@@ -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
  }
@@ -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"
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
- rows=${runEntries}
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-2 gap-2 text-xs">
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(totals.totalTokens)}</div>
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">Estimated cost</div>
46
- <div class="text-gray-200 font-mono">${formatCost(totals.totalCost)}</div>
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">Runs</div>
50
- <div class="text-gray-200 font-mono">${formatTokenCount(totals.runCount)}</div>
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, kRecentRunFetchLimit);
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 recentRuns = useMemo(
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 recentRuns;
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 recentRuns;
217
+ return recentRunsForDisplay;
214
218
  }
215
- return recentRuns.filter((entry) => {
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
- }, [recentRuns, selectedTrendBucketFilter]);
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>