@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
@@ -60,7 +60,9 @@ const renderEntrySummaryRow = ({ runEntry = {}, variant = "overview" }) => {
60
60
  : hasRunTitle
61
61
  ? html`
62
62
  <span class="inline-flex items-center gap-2 min-w-0">
63
- <span class="truncate text-xs text-gray-300">${runTitle}</span>
63
+ <span class="truncate text-xs text-gray-300"
64
+ >${runTitle}</span
65
+ >
64
66
  <span class="text-xs text-gray-500 shrink-0">
65
67
  ${formatRowTimestamp(runEntry.ts, variant)}
66
68
  </span>
@@ -68,19 +70,26 @@ const renderEntrySummaryRow = ({ runEntry = {}, variant = "overview" }) => {
68
70
  `
69
71
  : html`
70
72
  <span class="truncate text-xs text-gray-300">
71
- ${runEntry.jobId} - ${formatRowTimestamp(runEntry.ts, variant)}
73
+ ${runEntry.jobId} -
74
+ ${formatRowTimestamp(runEntry.ts, variant)}
72
75
  </span>
73
76
  `}
74
77
  </span>
75
78
  <span class="inline-flex items-center gap-3 shrink-0 text-xs">
76
79
  <span class=${runStatusClassName(runStatus)}>${runStatus}</span>
77
- <span class="text-gray-400">${formatDurationCompactMs(runEntry.durationMs)}</span>
80
+ <span class="text-gray-400"
81
+ >${formatDurationCompactMs(runEntry.durationMs)}</span
82
+ >
78
83
  <span class="text-gray-400">${formatTokenCount(runTokens)} tk</span>
79
84
  ${isDetail
80
- ? html`<span class="text-gray-500">${runDeliveryLabel(runEntry)}</span>`
85
+ ? html`<span class="text-gray-500"
86
+ >${runDeliveryLabel(runEntry)}</span
87
+ >`
81
88
  : html`
82
89
  <span class="text-gray-500">
83
- ${runEstimatedCost == null ? "—" : `~${formatCost(runEstimatedCost)}`}
90
+ ${runEstimatedCost == null
91
+ ? "—"
92
+ : `~${formatCost(runEstimatedCost)}`}
84
93
  </span>
85
94
  `}
86
95
  </span>
@@ -102,7 +111,8 @@ const getCollapsedGroupAggregates = (entries = []) =>
102
111
  );
103
112
  const renderCollapsedGroupRow = ({ row, rowIndex, onSelectJob = () => {} }) => {
104
113
  const entries = Array.isArray(row?.entries) ? row.entries : [];
105
- const { totalTokens, totalCost, hasAnyCost } = getCollapsedGroupAggregates(entries);
114
+ const { totalTokens, totalCost, hasAnyCost } =
115
+ getCollapsedGroupAggregates(entries);
106
116
  const timeRangeLabel = `${formatOverviewTimestamp(row.oldestTs)} - ${formatOverviewTimestamp(row.newestTs)}`;
107
117
  return html`
108
118
  <details
@@ -117,21 +127,25 @@ const renderCollapsedGroupRow = ({ row, rowIndex, onSelectJob = () => {} }) => {
117
127
  <span class="truncate text-xs text-gray-300">
118
128
  ${row.jobName} - ${formatTokenCount(row.count)} runs
119
129
  </span>
120
- <span class="text-xs text-gray-500 shrink-0">${timeRangeLabel}</span>
130
+ <span class="text-xs text-gray-500 shrink-0"
131
+ >${timeRangeLabel}</span
132
+ >
121
133
  </span>
122
134
  </span>
123
135
  <span class="inline-flex items-center gap-3 shrink-0 text-xs">
124
- <span class="text-gray-400">${formatTokenCount(totalTokens)} tk</span>
136
+ <span class="text-gray-400"
137
+ >${formatTokenCount(totalTokens)} tk</span
138
+ >
125
139
  <span class="text-gray-500">
126
140
  ${hasAnyCost ? `~${formatCost(totalCost)}` : "—"}
127
141
  </span>
128
142
  </span>
129
143
  </div>
130
144
  </summary>
131
- <div class="ac-history-body space-y-2 text-xs">
145
+ <div class="border-t border-border pb-2 text-xs">
132
146
  ${entries.length > 0
133
147
  ? html`
134
- <div class="ac-history-list">
148
+ <div class="ac-history-list ac-history-list-tight">
135
149
  ${entries.map((runEntry, entryIndex) =>
136
150
  renderEntryRow({
137
151
  row: {
@@ -142,6 +156,8 @@ const renderCollapsedGroupRow = ({ row, rowIndex, onSelectJob = () => {} }) => {
142
156
  variant: "overview",
143
157
  onSelectJob,
144
158
  showOpenJobButton: false,
159
+ itemClassName:
160
+ "ac-history-item ac-history-item-flat border-b border-border rounded-none",
145
161
  }),
146
162
  )}
147
163
  </div>
@@ -149,7 +165,7 @@ const renderCollapsedGroupRow = ({ row, rowIndex, onSelectJob = () => {} }) => {
149
165
  : null}
150
166
  ${row?.jobId
151
167
  ? html`
152
- <div>
168
+ <div class="px-2.5 pt-2 pb-0.5">
153
169
  <button
154
170
  type="button"
155
171
  class="text-xs px-2 py-1 rounded border border-border text-gray-400 hover:text-gray-200"
@@ -170,6 +186,7 @@ const renderEntryRow = ({
170
186
  variant = "overview",
171
187
  onSelectJob = () => {},
172
188
  showOpenJobButton = false,
189
+ itemClassName = "ac-history-item",
173
190
  }) => {
174
191
  const runEntry = row?.entry || row || {};
175
192
  const runUsage = runEntry?.usage || {};
@@ -184,7 +201,7 @@ const renderEntryRow = ({
184
201
  return html`
185
202
  <details
186
203
  key=${`entry:${rowIndex}:${runEntry.ts}:${runEntry.jobId || ""}`}
187
- class="ac-history-item"
204
+ class=${itemClassName}
188
205
  >
189
206
  <summary class="ac-history-summary">
190
207
  <div class="inline-flex items-center gap-2 min-w-0 w-full">
@@ -1,8 +1,14 @@
1
1
  import { h } from "https://esm.sh/preact";
2
2
  import { useEffect, useMemo, useRef, useState } from "https://esm.sh/preact/hooks";
3
3
  import htm from "https://esm.sh/htm";
4
+ import { formatChartBucketLabel } from "../../lib/format.js";
4
5
  import { SegmentedControl } from "../segmented-control.js";
5
- import { formatCost, getCronRunEstimatedCost } from "./cron-helpers.js";
6
+ import {
7
+ formatCost,
8
+ formatTokenCount,
9
+ getCronRunEstimatedCost,
10
+ getCronRunTotalTokens,
11
+ } from "./cron-helpers.js";
6
12
 
7
13
  const html = htm.bind(h);
8
14
 
@@ -15,6 +21,14 @@ const kRanges = [
15
21
  { label: "7d", value: kRange7d },
16
22
  { label: "30d", value: kRange30d },
17
23
  ];
24
+ const kMetricOutcomes = "outcomes";
25
+ const kMetricTokens = "tokens";
26
+ const kMetricCost = "cost";
27
+ const kMetricOptions = [
28
+ { label: "outcomes", value: kMetricOutcomes },
29
+ { label: "tokens", value: kMetricTokens },
30
+ { label: "cost", value: kMetricCost },
31
+ ];
18
32
 
19
33
  const startOfLocalDayMs = (valueMs) => {
20
34
  const dateValue = new Date(valueMs);
@@ -34,9 +48,7 @@ const getBucketConfig = (range = kRange7d) => {
34
48
  bucketCount: 24,
35
49
  bucketMs: 60 * 60 * 1000,
36
50
  formatLabel: (valueMs) =>
37
- new Date(valueMs).toLocaleTimeString([], {
38
- hour: "numeric",
39
- }),
51
+ formatChartBucketLabel(valueMs, { range: kRange24h, valueType: "epoch-ms" }),
40
52
  showLabel: (_, index, total) => index % 3 === 0 || index === total - 1,
41
53
  alignToLocalDay: false,
42
54
  };
@@ -45,7 +57,8 @@ const getBucketConfig = (range = kRange7d) => {
45
57
  return {
46
58
  bucketCount: 30,
47
59
  bucketMs: 24 * 60 * 60 * 1000,
48
- formatLabel: (valueMs) => new Date(valueMs).toLocaleDateString([], { month: "numeric", day: "numeric" }),
60
+ formatLabel: (valueMs) =>
61
+ formatChartBucketLabel(valueMs, { range: kRange30d, valueType: "epoch-ms" }),
49
62
  showLabel: (_, index, total) => index % 5 === 0 || index === total - 1,
50
63
  alignToLocalDay: true,
51
64
  };
@@ -53,12 +66,8 @@ const getBucketConfig = (range = kRange7d) => {
53
66
  return {
54
67
  bucketCount: 7,
55
68
  bucketMs: 24 * 60 * 60 * 1000,
56
- formatLabel: (valueMs) =>
57
- new Date(valueMs).toLocaleDateString([], {
58
- weekday: "short",
59
- month: "numeric",
60
- day: "numeric",
61
- }),
69
+ formatLabel: (valueMs) =>
70
+ formatChartBucketLabel(valueMs, { range: kRange7d, valueType: "epoch-ms" }),
62
71
  showLabel: () => true,
63
72
  alignToLocalDay: true,
64
73
  };
@@ -86,6 +95,7 @@ const buildTrendData = ({ bulkRunsByJobId = {}, nowMs = Date.now(), range = kRan
86
95
  ok: 0,
87
96
  error: 0,
88
97
  skipped: 0,
98
+ totalTokens: 0,
89
99
  totalCost: 0,
90
100
  costCount: 0,
91
101
  };
@@ -110,6 +120,7 @@ const buildTrendData = ({ bulkRunsByJobId = {}, nowMs = Date.now(), range = kRan
110
120
  if (!Number.isFinite(Number(bucketIndex))) return;
111
121
  if (bucketIndex < 0 || bucketIndex >= config.bucketCount) return;
112
122
  points[bucketIndex][status] += 1;
123
+ points[bucketIndex].totalTokens += getCronRunTotalTokens(entry);
113
124
  const estimatedCost = getCronRunEstimatedCost(entry);
114
125
  if (estimatedCost != null) {
115
126
  points[bucketIndex].totalCost += estimatedCost;
@@ -142,6 +153,7 @@ export const CronRunsTrendCard = ({
142
153
  }) => {
143
154
  const chartCanvasRef = useRef(null);
144
155
  const chartInstanceRef = useRef(null);
156
+ const [metric, setMetric] = useState(kMetricOutcomes);
145
157
  const [range, setRange] = useState(
146
158
  initialRange === kRange30d
147
159
  ? kRange30d
@@ -176,48 +188,74 @@ export const CronRunsTrendCard = ({
176
188
  const fullAlpha = "0.86";
177
189
  const isDimmed = (index) => selectedPointIndex >= 0 && selectedPointIndex !== index;
178
190
  const labels = trend.points.map((point) => (point.showLabel ? point.label : ""));
191
+ if (metric === kMetricOutcomes) {
192
+ return {
193
+ labels,
194
+ datasets: [
195
+ {
196
+ label: "ok",
197
+ data: trend.points.map((point) => Number(point.ok || 0)),
198
+ stack: "outcomes",
199
+ backgroundColor: trend.points.map((_, index) =>
200
+ `rgba(34,255,170,${isDimmed(index) ? dimAlpha : fullAlpha})`),
201
+ borderColor: trend.points.map((_, index) =>
202
+ `rgba(34,255,170,${isDimmed(index) ? "0.35" : "1"})`),
203
+ borderWidth: 1,
204
+ borderRadius: 0,
205
+ borderSkipped: false,
206
+ },
207
+ {
208
+ label: "error",
209
+ data: trend.points.map((point) => Number(point.error || 0)),
210
+ stack: "outcomes",
211
+ backgroundColor: trend.points.map((_, index) =>
212
+ `rgba(255,74,138,${isDimmed(index) ? dimAlpha : fullAlpha})`),
213
+ borderColor: trend.points.map((_, index) =>
214
+ `rgba(255,74,138,${isDimmed(index) ? "0.35" : "1"})`),
215
+ borderWidth: 1,
216
+ borderRadius: 0,
217
+ borderSkipped: false,
218
+ },
219
+ {
220
+ label: "skipped",
221
+ data: trend.points.map((point) => Number(point.skipped || 0)),
222
+ stack: "outcomes",
223
+ backgroundColor: trend.points.map((_, index) =>
224
+ `rgba(255,214,64,${isDimmed(index) ? dimAlpha : fullAlpha})`),
225
+ borderColor: trend.points.map((_, index) =>
226
+ `rgba(255,214,64,${isDimmed(index) ? "0.35" : "1"})`),
227
+ borderWidth: 1,
228
+ borderRadius: 0,
229
+ borderSkipped: false,
230
+ },
231
+ ],
232
+ };
233
+ }
234
+ const datasetLabel = metric === kMetricTokens ? "tokens" : "cost";
179
235
  return {
180
236
  labels,
181
237
  datasets: [
182
238
  {
183
- label: "ok",
184
- data: trend.points.map((point) => Number(point.ok || 0)),
185
- stack: "outcomes",
186
- backgroundColor: trend.points.map((_, index) =>
187
- `rgba(34,255,170,${isDimmed(index) ? dimAlpha : fullAlpha})`),
188
- borderColor: trend.points.map((_, index) =>
189
- `rgba(34,255,170,${isDimmed(index) ? "0.35" : "1"})`),
190
- borderWidth: 1,
191
- borderRadius: 0,
192
- borderSkipped: false,
193
- },
194
- {
195
- label: "error",
196
- data: trend.points.map((point) => Number(point.error || 0)),
197
- stack: "outcomes",
198
- backgroundColor: trend.points.map((_, index) =>
199
- `rgba(255,74,138,${isDimmed(index) ? dimAlpha : fullAlpha})`),
200
- borderColor: trend.points.map((_, index) =>
201
- `rgba(255,74,138,${isDimmed(index) ? "0.35" : "1"})`),
202
- borderWidth: 1,
203
- borderRadius: 0,
204
- borderSkipped: false,
205
- },
206
- {
207
- label: "skipped",
208
- data: trend.points.map((point) => Number(point.skipped || 0)),
209
- stack: "outcomes",
239
+ label: datasetLabel,
240
+ data: trend.points.map((point) =>
241
+ metric === kMetricTokens
242
+ ? Number(point.totalTokens || 0)
243
+ : Number(point.totalCost || 0)),
210
244
  backgroundColor: trend.points.map((_, index) =>
211
- `rgba(255,214,64,${isDimmed(index) ? dimAlpha : fullAlpha})`),
245
+ metric === kMetricTokens
246
+ ? `rgba(34,211,238,${isDimmed(index) ? dimAlpha : "0.72"})`
247
+ : `rgba(167,139,250,${isDimmed(index) ? dimAlpha : "0.72"})`),
212
248
  borderColor: trend.points.map((_, index) =>
213
- `rgba(255,214,64,${isDimmed(index) ? "0.35" : "1"})`),
249
+ metric === kMetricTokens
250
+ ? `rgba(34,211,238,${isDimmed(index) ? "0.35" : "1"})`
251
+ : `rgba(167,139,250,${isDimmed(index) ? "0.35" : "1"})`),
214
252
  borderWidth: 1,
215
253
  borderRadius: 0,
216
254
  borderSkipped: false,
217
255
  },
218
256
  ],
219
257
  };
220
- }, [selectedPointIndex, trend.points]);
258
+ }, [metric, selectedPointIndex, trend.points]);
221
259
 
222
260
  useEffect(() => {
223
261
  const canvas = chartCanvasRef.current;
@@ -266,7 +304,7 @@ export const CronRunsTrendCard = ({
266
304
  },
267
305
  scales: {
268
306
  x: {
269
- stacked: true,
307
+ stacked: metric === kMetricOutcomes,
270
308
  grid: { color: "rgba(148,163,184,0.08)" },
271
309
  ticks: {
272
310
  color: "rgba(156,163,175,1)",
@@ -275,12 +313,16 @@ export const CronRunsTrendCard = ({
275
313
  },
276
314
  },
277
315
  y: {
278
- stacked: true,
316
+ stacked: metric === kMetricOutcomes,
279
317
  beginAtZero: true,
280
318
  grid: { color: "rgba(148,163,184,0.12)" },
281
319
  ticks: {
282
- precision: 0,
320
+ precision: metric === kMetricCost ? undefined : 0,
283
321
  color: "rgba(156,163,175,1)",
322
+ callback: (value) =>
323
+ metric === kMetricCost
324
+ ? formatCost(Number(value || 0))
325
+ : formatTokenCount(Number(value || 0)),
284
326
  },
285
327
  },
286
328
  },
@@ -296,14 +338,21 @@ export const CronRunsTrendCard = ({
296
338
  tooltip: {
297
339
  callbacks: {
298
340
  title: (items) => String(items?.[0]?.label || ""),
299
- label: (context) => `${context.dataset.label}: ${Number(context.parsed.y || 0)}`,
341
+ label: (context) => {
342
+ const value = Number(context.parsed.y || 0);
343
+ if (metric === kMetricCost) {
344
+ return `${context.dataset.label}: ${formatCost(value)}`;
345
+ }
346
+ return `${context.dataset.label}: ${formatTokenCount(value)}`;
347
+ },
300
348
  footer: (items) => {
301
349
  const index = Number(items?.[0]?.dataIndex);
302
350
  const point = trend.points[index];
303
351
  if (!point) return "";
304
352
  const costLabel =
305
353
  point.costCount > 0 ? `~${formatCost(point.totalCost)}` : "—";
306
- return `total: ${point.total}\ncost: ${costLabel}`;
354
+ const tokensLabel = formatTokenCount(point.totalTokens || 0);
355
+ return `runs: ${point.total}\ntokens: ${tokensLabel}\ncost: ${costLabel}`;
307
356
  },
308
357
  },
309
358
  },
@@ -316,17 +365,24 @@ export const CronRunsTrendCard = ({
316
365
  chartInstanceRef.current = null;
317
366
  }
318
367
  };
319
- }, [chartData, onBucketFilterChange, range, selectedBucketKey, trend.points]);
368
+ }, [chartData, metric, onBucketFilterChange, range, selectedBucketKey, trend.points]);
320
369
 
321
370
  return html`
322
371
  <section class="bg-surface border border-border rounded-xl p-4 space-y-3">
323
372
  <div class="flex items-center justify-between gap-2">
324
- <h3 class="card-label cron-calendar-title">Run Outcomes</h3>
325
- <${SegmentedControl}
326
- options=${kRanges}
327
- value=${range}
328
- onChange=${setRange}
329
- />
373
+ <h3 class="card-label cron-calendar-title">Trends</h3>
374
+ <div class="flex items-center gap-2">
375
+ <${SegmentedControl}
376
+ options=${kMetricOptions}
377
+ value=${metric}
378
+ onChange=${setMetric}
379
+ />
380
+ <${SegmentedControl}
381
+ options=${kRanges}
382
+ value=${range}
383
+ onChange=${setRange}
384
+ />
385
+ </div>
330
386
  </div>
331
387
  <div class="h-40">
332
388
  <canvas ref=${chartCanvasRef}></canvas>
@@ -176,6 +176,7 @@ export const CronTab = ({ jobId = "", onSetLocation = () => {} }) => {
176
176
  <${CronJobDetail}
177
177
  job=${state.selectedJob}
178
178
  runEntries=${state.runEntries}
179
+ filteredRunEntries=${state.filteredRunEntries}
179
180
  runTotal=${state.runTotal}
180
181
  runHasMore=${state.runHasMore}
181
182
  loadingMoreRuns=${state.loadingMoreRuns}
@@ -187,8 +188,13 @@ export const CronTab = ({ jobId = "", onSetLocation = () => {} }) => {
187
188
  onToggleEnabled=${actions.setSelectedJobEnabled}
188
189
  togglingJobEnabled=${state.togglingJobEnabled}
189
190
  usage=${state.usage}
191
+ jobTrends=${state.jobTrends}
192
+ jobTrendRange=${state.jobTrendRange}
193
+ selectedJobTrendBucketFilter=${state.selectedJobTrendBucketFilter}
190
194
  usageDays=${state.usageDays}
191
195
  onSetUsageDays=${actions.setUsageDays}
196
+ onSetJobTrendRange=${actions.setJobTrendRange}
197
+ onSetSelectedJobTrendBucketFilter=${actions.setSelectedJobTrendBucketFilter}
192
198
  promptValue=${state.promptValue}
193
199
  savedPromptValue=${state.savedPromptValue}
194
200
  onChangePrompt=${actions.setPromptValue}
@@ -11,6 +11,7 @@ import {
11
11
  fetchCronBulkRuns,
12
12
  fetchCronBulkUsage,
13
13
  fetchCronJobRuns,
14
+ fetchCronJobTrends,
14
15
  fetchCronJobs,
15
16
  fetchCronJobUsage,
16
17
  fetchCronStatus,
@@ -30,6 +31,9 @@ const kListPanelWidthUiSettingKey = "cronListPanelWidthPx";
30
31
  const kRunsPageSize = 25;
31
32
  const kCalendarUsageDays = 30;
32
33
  const kCalendarPastDays = 30;
34
+ const kTrendRange24h = "24h";
35
+ const kTrendRange7d = "7d";
36
+ const kTrendRange30d = "30d";
33
37
  const kRoutingDefaults = {
34
38
  sessionTarget: "main",
35
39
  wakeMode: "now",
@@ -79,6 +83,8 @@ export const useCronTab = ({ jobId = "", onSetLocation = () => {} } = {}) => {
79
83
  const [togglingJobEnabled, setTogglingJobEnabled] = useState(false);
80
84
  const [routingDraft, setRoutingDraft] = useState(kRoutingDefaults);
81
85
  const [usageDays, setUsageDays] = useState(30);
86
+ const [jobTrendRange, setJobTrendRange] = useState(kTrendRange7d);
87
+ const [selectedJobTrendBucketFilter, setSelectedJobTrendBucketFilter] = useState(null);
82
88
  const {
83
89
  sessions: deliverySessions,
84
90
  loading: loadingDeliverySessions,
@@ -122,6 +128,14 @@ export const useCronTab = ({ jobId = "", onSetLocation = () => {} } = {}) => {
122
128
  60000,
123
129
  { enabled: !!selectedJobId },
124
130
  );
131
+ const trendsPoll = usePolling(
132
+ () => {
133
+ if (!selectedJobId) return Promise.resolve({ ok: true, trends: null });
134
+ return fetchCronJobTrends(selectedJobId, { range: jobTrendRange });
135
+ },
136
+ 60000,
137
+ { enabled: !!selectedJobId },
138
+ );
125
139
  const bulkUsagePoll = usePolling(
126
140
  () => fetchCronBulkUsage({ days: kCalendarUsageDays }),
127
141
  60000,
@@ -201,6 +215,29 @@ export const useCronTab = ({ jobId = "", onSetLocation = () => {} } = {}) => {
201
215
  if (!selectedJobId) return;
202
216
  usagePoll.refresh();
203
217
  }, [selectedJobId, usageDays]);
218
+ useEffect(() => {
219
+ if (!selectedJobId) return;
220
+ setSelectedJobTrendBucketFilter(null);
221
+ trendsPoll.refresh();
222
+ }, [jobTrendRange, selectedJobId]);
223
+ const filteredRunEntries = useMemo(() => {
224
+ const entries = Array.isArray(runEntries) ? runEntries : [];
225
+ const filterValue = selectedJobTrendBucketFilter;
226
+ if (!filterValue) return entries;
227
+ const startMs = Number(filterValue?.startMs || 0);
228
+ const endMs = Number(filterValue?.endMs || 0);
229
+ if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs) {
230
+ return entries;
231
+ }
232
+ return entries.filter((entry) => {
233
+ const timestampMs = Number(entry?.ts || 0);
234
+ return (
235
+ Number.isFinite(timestampMs) &&
236
+ timestampMs >= startMs &&
237
+ timestampMs < endMs
238
+ );
239
+ });
240
+ }, [runEntries, selectedJobTrendBucketFilter]);
204
241
 
205
242
  const resizeListPanelWithClientX = useCallback((clientX) => {
206
243
  const listPanelElement = listPanelRef.current;
@@ -257,6 +294,7 @@ export const useCronTab = ({ jobId = "", onSetLocation = () => {} } = {}) => {
257
294
  statusPoll.refresh();
258
295
  runsPoll.refresh();
259
296
  usagePoll.refresh();
297
+ trendsPoll.refresh();
260
298
  bulkUsagePoll.refresh();
261
299
  bulkRunsPoll.refresh();
262
300
  }, [
@@ -265,6 +303,7 @@ export const useCronTab = ({ jobId = "", onSetLocation = () => {} } = {}) => {
265
303
  jobsPoll.refresh,
266
304
  runsPoll.refresh,
267
305
  statusPoll.refresh,
306
+ trendsPoll.refresh,
268
307
  usagePoll.refresh,
269
308
  ]);
270
309
 
@@ -417,6 +456,7 @@ export const useCronTab = ({ jobId = "", onSetLocation = () => {} } = {}) => {
417
456
  listPanelWidthPx,
418
457
  isResizingListPanel,
419
458
  runEntries,
459
+ filteredRunEntries,
420
460
  runHasMore,
421
461
  runNextOffset,
422
462
  runTotal,
@@ -424,8 +464,17 @@ export const useCronTab = ({ jobId = "", onSetLocation = () => {} } = {}) => {
424
464
  runsError: runsPoll.error,
425
465
  loadingMoreRuns,
426
466
  usage: usagePoll.data?.usage || null,
467
+ jobTrends: trendsPoll.data?.trends || null,
427
468
  usageError: usagePoll.error,
469
+ trendsError: trendsPoll.error,
428
470
  usageDays,
471
+ jobTrendRange:
472
+ jobTrendRange === kTrendRange30d
473
+ ? kTrendRange30d
474
+ : jobTrendRange === kTrendRange24h
475
+ ? kTrendRange24h
476
+ : kTrendRange7d,
477
+ selectedJobTrendBucketFilter,
429
478
  bulkUsageByJobId: bulkUsagePoll.data?.usage?.byJobId || {},
430
479
  bulkUsageError: bulkUsagePoll.error,
431
480
  bulkRunsByJobId: bulkRunsPoll.data?.runs?.byJobId || {},
@@ -444,6 +493,8 @@ export const useCronTab = ({ jobId = "", onSetLocation = () => {} } = {}) => {
444
493
  actions: {
445
494
  setRunStatusFilter,
446
495
  setUsageDays,
496
+ setJobTrendRange,
497
+ setSelectedJobTrendBucketFilter,
447
498
  setPromptValue,
448
499
  saveChanges,
449
500
  refreshAll,
@@ -450,3 +450,14 @@ export const FullscreenLineIcon = ({ className = "" }) => html`
450
450
  <path d="M8 3V5H4V9H2V3H8ZM2 21V15H4V19H8V21H2ZM22 21H16V19H20V15H22V21ZM22 9H20V5H16V3H22V9Z" />
451
451
  </svg>
452
452
  `;
453
+
454
+ export const ComputerLineIcon = ({ className = "" }) => html`
455
+ <svg
456
+ class=${className}
457
+ viewBox="0 0 24 24"
458
+ fill="currentColor"
459
+ aria-hidden="true"
460
+ >
461
+ <path d="M4 16H20V5H4V16ZM13 18V20H17V22H7V20H11V18H2.9918C2.44405 18 2 17.5511 2 16.9925V4.00748C2 3.45107 2.45531 3 2.9918 3H21.0082C21.556 3 22 3.44892 22 4.00748V16.9925C22 17.5489 21.5447 18 21.0082 18H13Z" />
462
+ </svg>
463
+ `;
@@ -0,0 +1,85 @@
1
+ import { h } from "https://esm.sh/preact";
2
+ import { useMemo } from "https://esm.sh/preact/hooks";
3
+ import htm from "https://esm.sh/htm";
4
+ import { marked } from "https://esm.sh/marked";
5
+
6
+ const html = htm.bind(h);
7
+
8
+ const kReleaseNotesUrl =
9
+ "https://github.com/openclaw/openclaw/releases/tag/v2026.3.13";
10
+ const kSetupInstructionsMarkdown = `Release reference: [OpenClaw 2026.3.13](${kReleaseNotesUrl})
11
+
12
+ ## Requirements
13
+
14
+ - OpenClaw 2026.3.13+
15
+ - Chrome 144+
16
+ - Node.js installed on the Mac node so \`npx\` is available
17
+
18
+ ## Setup
19
+
20
+ ### 1) Enable remote debugging in Chrome
21
+
22
+ Open \`chrome://inspect/#remote-debugging\` and turn it on. Do **not** launch Chrome with \`--remote-debugging-port\`.
23
+
24
+ ### 2) Configure the node
25
+
26
+ In \`~/.openclaw/openclaw.json\` on the Mac node:
27
+
28
+ \`\`\`json
29
+ {
30
+ "browser": {
31
+ "defaultProfile": "user"
32
+ }
33
+ }
34
+ \`\`\`
35
+
36
+ The built-in \`user\` profile uses live Chrome attach. You do not need a custom \`existing-session\` profile.
37
+
38
+ ### 3) Approve Chrome consent
39
+
40
+ On first connect, Chrome prompts for DevTools MCP access. Click **Allow**.
41
+
42
+ ## Troubleshooting
43
+
44
+ | Problem | Fix |
45
+ | --- | --- |
46
+ | Browser proxy times out (20s) | Restart Chrome cleanly and run the check again. |
47
+ | Config validation error on existing-session | Do not define a custom existing-session profile. Use \`defaultProfile: "user"\`. |
48
+ | EADDRINUSE on port 9222 | Quit Chrome launched with \`--remote-debugging-port\` and relaunch normally. |
49
+ | Consent dialog appears but attach hangs | Quit Chrome, relaunch, and approve the dialog again. |
50
+ | \`npx chrome-devtools-mcp\` not found | Install Node.js on the Mac node so \`npx\` exists in PATH. |`;
51
+
52
+ export const BrowserAttachCard = () => {
53
+ const setupInstructionsHtml = useMemo(
54
+ () =>
55
+ marked.parse(kSetupInstructionsMarkdown, {
56
+ gfm: true,
57
+ breaks: true,
58
+ }),
59
+ [],
60
+ );
61
+
62
+ return html`
63
+ <div class="bg-surface border border-border rounded-xl p-4 space-y-3">
64
+ <div class="space-y-1">
65
+ <h3 class="font-semibold text-sm">Live Chrome Attach (Mac Node)</h3>
66
+ <p class="text-xs text-gray-500">
67
+ Connect your agent to real Chrome sessions (logged-in tabs, cookies,
68
+ and all) using the built-in <code>user</code> profile.
69
+ </p>
70
+ </div>
71
+
72
+ <details class="rounded-lg border border-border bg-black/20 px-3 py-2.5">
73
+ <summary
74
+ class="cursor-pointer text-xs text-gray-300 hover:text-gray-200"
75
+ >
76
+ Setup instructions
77
+ </summary>
78
+ <div
79
+ class="pt-3 file-viewer-preview release-notes-preview text-xs leading-5"
80
+ dangerouslySetInnerHTML=${{ __html: setupInstructionsHtml }}
81
+ ></div>
82
+ </details>
83
+ </div>
84
+ `;
85
+ };