@chrysb/alphaclaw 0.7.0-beta.1 → 0.7.1

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 (31) hide show
  1. package/lib/public/css/cron.css +26 -17
  2. package/lib/public/css/theme.css +14 -0
  3. package/lib/public/js/components/cron-tab/cron-calendar.js +17 -12
  4. package/lib/public/js/components/cron-tab/cron-job-list.js +11 -1
  5. package/lib/public/js/components/cron-tab/index.js +16 -2
  6. package/lib/public/js/components/icons.js +11 -0
  7. package/lib/public/js/components/routes/watchdog-route.js +1 -1
  8. package/lib/public/js/components/watchdog-tab/console/index.js +115 -0
  9. package/lib/public/js/components/watchdog-tab/console/use-console.js +137 -0
  10. package/lib/public/js/components/watchdog-tab/helpers.js +106 -0
  11. package/lib/public/js/components/watchdog-tab/incidents/index.js +56 -0
  12. package/lib/public/js/components/watchdog-tab/incidents/use-incidents.js +33 -0
  13. package/lib/public/js/components/watchdog-tab/index.js +84 -0
  14. package/lib/public/js/components/watchdog-tab/resource-bar.js +76 -0
  15. package/lib/public/js/components/watchdog-tab/resources/index.js +85 -0
  16. package/lib/public/js/components/watchdog-tab/resources/use-resources.js +13 -0
  17. package/lib/public/js/components/watchdog-tab/settings/index.js +44 -0
  18. package/lib/public/js/components/watchdog-tab/settings/use-settings.js +117 -0
  19. package/lib/public/js/components/watchdog-tab/terminal/index.js +20 -0
  20. package/lib/public/js/components/watchdog-tab/terminal/use-terminal.js +263 -0
  21. package/lib/public/js/components/watchdog-tab/use-watchdog-tab.js +55 -0
  22. package/lib/public/js/lib/api.js +39 -0
  23. package/lib/server/init/register-server-routes.js +239 -0
  24. package/lib/server/init/runtime-init.js +44 -0
  25. package/lib/server/init/server-lifecycle.js +55 -0
  26. package/lib/server/routes/watchdog.js +62 -0
  27. package/lib/server/watchdog-terminal-ws.js +114 -0
  28. package/lib/server/watchdog-terminal.js +262 -0
  29. package/lib/server.js +89 -215
  30. package/package.json +3 -2
  31. package/lib/public/js/components/watchdog-tab.js +0 -535
@@ -400,23 +400,6 @@
400
400
  justify-content: center;
401
401
  }
402
402
 
403
- .cron-calendar-expand-btn {
404
- width: 22px;
405
- height: 22px;
406
- border-radius: 6px;
407
- border: 1px solid var(--border);
408
- background: rgba(255, 255, 255, 0.03);
409
- color: var(--text-dim);
410
- display: inline-flex;
411
- align-items: center;
412
- justify-content: center;
413
- }
414
-
415
- .cron-calendar-expand-btn:hover {
416
- color: var(--text);
417
- border-color: rgba(148, 163, 184, 0.5);
418
- }
419
-
420
403
  .cron-calendar-grid-wrap {
421
404
  position: relative;
422
405
  }
@@ -464,6 +447,7 @@
464
447
  border-left: 1px solid var(--border);
465
448
  border-bottom: 1px solid var(--border);
466
449
  padding: 5px;
450
+ position: relative;
467
451
  display: flex;
468
452
  flex-direction: column;
469
453
  gap: 4px;
@@ -473,6 +457,29 @@
473
457
  background: rgba(99, 235, 255, 0.04);
474
458
  }
475
459
 
460
+ .cron-calendar-now-indicator {
461
+ position: absolute;
462
+ left: 5px;
463
+ right: 5px;
464
+ height: 2px;
465
+ border-radius: 999px;
466
+ background: rgba(248, 113, 113, 0.95);
467
+ box-shadow: 0 0 0 1px rgba(248, 113, 113, 0.18), 0 0 8px rgba(239, 68, 68, 0.55);
468
+ pointer-events: none;
469
+ z-index: 1;
470
+ }
471
+
472
+ .cron-calendar-now-indicator-dot {
473
+ position: absolute;
474
+ left: -3px;
475
+ top: 50%;
476
+ width: 6px;
477
+ height: 6px;
478
+ border-radius: 999px;
479
+ background: rgba(248, 113, 113, 0.98);
480
+ transform: translateY(-50%);
481
+ }
482
+
476
483
  .cron-calendar-slot-chip {
477
484
  font-size: 11px;
478
485
  line-height: 1.2;
@@ -485,6 +492,8 @@
485
492
  width: 100%;
486
493
  max-width: 100%;
487
494
  overflow: hidden;
495
+ position: relative;
496
+ z-index: 2;
488
497
  }
489
498
 
490
499
  .cron-calendar-slot-overflow {
@@ -685,3 +685,17 @@ textarea:focus {
685
685
  resize: vertical;
686
686
  }
687
687
 
688
+ .watchdog-terminal-host {
689
+ position: relative;
690
+ }
691
+
692
+ .watchdog-terminal-host .xterm {
693
+ height: 100%;
694
+ letter-spacing: 0;
695
+ font-kerning: none;
696
+ }
697
+
698
+ .watchdog-terminal-host .xterm-viewport {
699
+ overflow-y: auto !important;
700
+ }
701
+
@@ -8,7 +8,7 @@ import {
8
8
  import htm from "https://esm.sh/htm";
9
9
  import { Tooltip } from "../tooltip.js";
10
10
  import { ModalShell } from "../modal-shell.js";
11
- import { CloseIcon, FullscreenLineIcon } from "../icons.js";
11
+ import { CloseIcon } from "../icons.js";
12
12
  import {
13
13
  formatCost,
14
14
  formatCronScheduleLabel,
@@ -306,6 +306,9 @@ export const CronCalendar = ({
306
306
  };
307
307
  }, []);
308
308
  const todayDayKey = toLocalDayKey(nowMs);
309
+ const nowDateValue = useMemo(() => new Date(nowMs), [nowMs]);
310
+ const currentHourOfDay = nowDateValue.getHours();
311
+ const currentMinuteProgress = nowDateValue.getMinutes() / 60;
309
312
  const { repeatingJobs, scheduledJobs } = useMemo(
310
313
  () => classifyRepeatingJobs(jobs),
311
314
  [jobs],
@@ -569,17 +572,7 @@ export const CronCalendar = ({
569
572
  : html`
570
573
  <div class="cron-calendar-grid-wrap">
571
574
  <div class="cron-calendar-grid-header">
572
- <div class="cron-calendar-hour-cell cron-calendar-grid-corner">
573
- <button
574
- type="button"
575
- class="cron-calendar-expand-btn"
576
- title="Expand calendar"
577
- aria-label="Expand calendar"
578
- onClick=${() => setCalendarLightboxOpen(true)}
579
- >
580
- <${FullscreenLineIcon} className="w-3.5 h-3.5" />
581
- </button>
582
- </div>
575
+ <div class="cron-calendar-hour-cell cron-calendar-grid-corner"></div>
583
576
  ${timeline.days.map(
584
577
  (day) => html`
585
578
  <div
@@ -611,6 +604,18 @@ export const CronCalendar = ({
611
604
  key=${cellKey}
612
605
  class=${`cron-calendar-grid-cell ${day.dayKey === todayDayKey ? "is-today" : ""}`}
613
606
  >
607
+ ${day.dayKey === todayDayKey &&
608
+ hourOfDay === currentHourOfDay
609
+ ? html`
610
+ <div
611
+ class="cron-calendar-now-indicator"
612
+ style=${`top: ${Math.max(0, Math.min(100, currentMinuteProgress * 100))}%;`}
613
+ aria-hidden="true"
614
+ >
615
+ <span class="cron-calendar-now-indicator-dot"></span>
616
+ </div>
617
+ `
618
+ : null}
614
619
  ${visibleSlots.map((slot) => {
615
620
  const status = statusBySlotKey[slot.key] || "";
616
621
  const isPast = slot.scheduledAtMs <= nowMs;
@@ -1,5 +1,5 @@
1
1
  import { h } from "https://esm.sh/preact";
2
- import { useMemo, useState } from "https://esm.sh/preact/hooks";
2
+ import { useEffect, useMemo, useRef, useState } from "https://esm.sh/preact/hooks";
3
3
  import htm from "https://esm.sh/htm";
4
4
  import {
5
5
  formatCronScheduleLabel,
@@ -202,7 +202,16 @@ export const CronJobList = ({
202
202
  onSelectAllJobs = () => {},
203
203
  onSelectJob = () => {},
204
204
  }) => {
205
+ const searchInputRef = useRef(null);
205
206
  const [searchQuery, setSearchQuery] = useState("");
207
+ useEffect(() => {
208
+ const frameId = window.requestAnimationFrame(() => {
209
+ searchInputRef.current?.focus();
210
+ });
211
+ return () => {
212
+ window.cancelAnimationFrame(frameId);
213
+ };
214
+ }, []);
206
215
  const normalizedQuery = String(searchQuery || "").trim().toLowerCase();
207
216
  const filteredJobs = useMemo(() => {
208
217
  if (!normalizedQuery) return jobs;
@@ -236,6 +245,7 @@ export const CronJobList = ({
236
245
  <div class="cron-list-panel-inner">
237
246
  <div class="cron-list-sticky-search">
238
247
  <input
248
+ ref=${searchInputRef}
239
249
  type="text"
240
250
  value=${searchQuery}
241
251
  placeholder="Search cron jobs..."
@@ -2,6 +2,7 @@ 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
4
  import { ActionButton } from "../action-button.js";
5
+ import { AlarmLineIcon } from "../icons.js";
5
6
  import { PageHeader } from "../page-header.js";
6
7
  import { CronJobList } from "./cron-job-list.js";
7
8
  import { CronJobDetail } from "./cron-job-detail.js";
@@ -144,8 +145,21 @@ export const CronTab = ({ jobId = "", onSetLocation = () => {} }) => {
144
145
  <main class="cron-detail-panel">
145
146
  ${noJobs
146
147
  ? html`
147
- <div class="h-full flex items-center justify-center text-sm text-gray-500">
148
- No cron jobs configured. Cron jobs are managed via the OpenClaw CLI.
148
+ <div
149
+ class="bg-surface border border-border rounded-xl px-6 py-10 min-h-[26rem] flex flex-col items-center justify-center text-center"
150
+ >
151
+ <div class="max-w-md w-full flex flex-col items-center gap-4">
152
+ <${AlarmLineIcon} className="h-12 w-12 text-cyan-400" />
153
+ <div class="space-y-2">
154
+ <h2 class="font-semibold text-lg text-gray-100">
155
+ No cron jobs yet
156
+ </h2>
157
+ <p class="text-xs text-gray-400 leading-5">
158
+ Cron jobs are managed via the OpenClaw CLI. Once jobs are
159
+ configured, schedules and run history will appear here.
160
+ </p>
161
+ </div>
162
+ </div>
149
163
  </div>
150
164
  `
151
165
  : isAllJobsSelected
@@ -429,6 +429,17 @@ export const ErrorWarningLineIcon = ({ className = "" }) => html`
429
429
  </svg>
430
430
  `;
431
431
 
432
+ export const AlarmLineIcon = ({ className = "" }) => html`
433
+ <svg
434
+ class=${className}
435
+ viewBox="0 0 24 24"
436
+ fill="currentColor"
437
+ aria-hidden="true"
438
+ >
439
+ <path d="M12.0001 22.0001C7.02956 22.0001 3.00012 17.9707 3.00012 13.0001C3.00012 8.02956 7.02956 4.00012 12.0001 4.00012C16.9707 4.00012 21.0001 8.02956 21.0001 13.0001C21.0001 17.9707 16.9707 22.0001 12.0001 22.0001ZM12.0001 20.0001C15.8661 20.0001 19.0001 16.8661 19.0001 13.0001C19.0001 9.13412 15.8661 6.00012 12.0001 6.00012C8.13412 6.00012 5.00012 9.13412 5.00012 13.0001C5.00012 16.8661 8.13412 20.0001 12.0001 20.0001ZM13.0001 13.0001H16.0001V15.0001H11.0001V8.00012H13.0001V13.0001ZM1.74707 6.2826L5.2826 2.74707L6.69682 4.16128L3.16128 7.69682L1.74707 6.2826ZM18.7176 2.74707L22.2532 6.2826L20.839 7.69682L17.3034 4.16128L18.7176 2.74707Z" />
440
+ </svg>
441
+ `;
442
+
432
443
  export const FullscreenLineIcon = ({ className = "" }) => html`
433
444
  <svg
434
445
  class=${className}
@@ -1,6 +1,6 @@
1
1
  import { h } from "https://esm.sh/preact";
2
2
  import htm from "https://esm.sh/htm";
3
- import { WatchdogTab } from "../watchdog-tab.js";
3
+ import { WatchdogTab } from "../watchdog-tab/index.js";
4
4
 
5
5
  const html = htm.bind(h);
6
6
 
@@ -0,0 +1,115 @@
1
+ import { h } from "https://esm.sh/preact";
2
+ import htm from "https://esm.sh/htm";
3
+ import {
4
+ kWatchdogConsoleTabLogs,
5
+ kWatchdogConsoleTabTerminal,
6
+ } from "../helpers.js";
7
+ import { WatchdogTerminal } from "../terminal/index.js";
8
+
9
+ const html = htm.bind(h);
10
+
11
+ export const WatchdogConsoleCard = ({
12
+ activeConsoleTab = kWatchdogConsoleTabLogs,
13
+ stickToBottom = true,
14
+ onSetStickToBottom = () => {},
15
+ onSelectConsoleTab = () => {},
16
+ connectingTerminal = false,
17
+ terminalConnected = false,
18
+ terminalEnded = false,
19
+ terminalStatusText = "",
20
+ terminalUiSettling = false,
21
+ onRestartTerminalSession = () => {},
22
+ logsRef = null,
23
+ logs = "",
24
+ loadingLogs = true,
25
+ terminalPanelRef = null,
26
+ terminalHostRef = null,
27
+ terminalInstanceRef = null,
28
+ logsPanelHeightPx = 320,
29
+ }) => html`
30
+ <div class="bg-surface border border-border rounded-xl p-4">
31
+ <div class="flex items-center justify-between gap-2 mb-3">
32
+ <div
33
+ class="inline-flex items-center rounded-lg border border-border bg-black/20 p-0.5"
34
+ >
35
+ <button
36
+ type="button"
37
+ class=${`px-2.5 py-1 text-xs rounded-md ${activeConsoleTab === kWatchdogConsoleTabLogs ? "bg-surface text-gray-100" : "text-gray-400 hover:text-gray-200"}`}
38
+ onClick=${() => onSelectConsoleTab(kWatchdogConsoleTabLogs)}
39
+ >
40
+ Logs
41
+ </button>
42
+ <button
43
+ type="button"
44
+ class=${`px-2.5 py-1 text-xs rounded-md ${activeConsoleTab === kWatchdogConsoleTabTerminal ? "bg-surface text-gray-100" : "text-gray-400 hover:text-gray-200"}`}
45
+ onClick=${() => onSelectConsoleTab(kWatchdogConsoleTabTerminal)}
46
+ >
47
+ Terminal
48
+ </button>
49
+ </div>
50
+ <div class="flex items-center gap-2">
51
+ ${activeConsoleTab === kWatchdogConsoleTabLogs
52
+ ? html`
53
+ <label class="inline-flex items-center gap-2 text-xs text-gray-400">
54
+ <input
55
+ type="checkbox"
56
+ checked=${stickToBottom}
57
+ onchange=${(event) =>
58
+ onSetStickToBottom(!!event.currentTarget?.checked)}
59
+ />
60
+ Stick to bottom
61
+ </label>
62
+ `
63
+ : html`
64
+ <div class="flex items-center gap-2 pr-1">
65
+ ${terminalUiSettling
66
+ ? null
67
+ : html`
68
+ <span class="text-xs text-gray-500">
69
+ ${connectingTerminal
70
+ ? "Connecting..."
71
+ : terminalEnded
72
+ ? "Session ended"
73
+ : terminalConnected
74
+ ? "Connected"
75
+ : terminalStatusText || "Disconnected"}
76
+ </span>
77
+ ${connectingTerminal || terminalConnected
78
+ ? null
79
+ : html`
80
+ <button
81
+ type="button"
82
+ class="ac-btn-secondary text-xs px-2.5 py-1 rounded-lg"
83
+ onClick=${onRestartTerminalSession}
84
+ >
85
+ New session
86
+ </button>
87
+ `}
88
+ `}
89
+ </div>
90
+ `}
91
+ </div>
92
+ </div>
93
+ <div class=${activeConsoleTab === kWatchdogConsoleTabLogs ? "" : "hidden"}>
94
+ <pre
95
+ ref=${logsRef}
96
+ class="watchdog-logs-panel bg-black/40 border border-border rounded-lg p-3 overflow-auto text-xs text-gray-300 whitespace-pre-wrap break-words"
97
+ style=${{ height: `${logsPanelHeightPx}px` }}
98
+ >
99
+ ${loadingLogs ? "Loading logs..." : logs || "No logs yet."}</pre
100
+ >
101
+ </div>
102
+ <div
103
+ class=${activeConsoleTab === kWatchdogConsoleTabTerminal
104
+ ? "space-y-2"
105
+ : "hidden"}
106
+ >
107
+ <${WatchdogTerminal}
108
+ panelRef=${terminalPanelRef}
109
+ hostRef=${terminalHostRef}
110
+ terminalInstanceRef=${terminalInstanceRef}
111
+ panelHeightPx=${logsPanelHeightPx}
112
+ />
113
+ </div>
114
+ </div>
115
+ `;
@@ -0,0 +1,137 @@
1
+ import { useEffect, useRef, useState } from "https://esm.sh/preact/hooks";
2
+ import { fetchWatchdogLogs } from "../../../lib/api.js";
3
+ import { readUiSettings, writeUiSettings } from "../../../lib/ui-settings.js";
4
+ import {
5
+ clampWatchdogLogsPanelHeight,
6
+ kWatchdogConsoleTabLogs,
7
+ kWatchdogConsoleTabTerminal,
8
+ kWatchdogConsoleTabUiSettingKey,
9
+ kWatchdogLogsPanelHeightUiSettingKey,
10
+ normalizeWatchdogConsoleTab,
11
+ readCssHeightPx,
12
+ } from "../helpers.js";
13
+ import { useWatchdogTerminal } from "../terminal/use-terminal.js";
14
+
15
+ export const useWatchdogConsole = () => {
16
+ const [logs, setLogs] = useState("");
17
+ const [loadingLogs, setLoadingLogs] = useState(true);
18
+ const [stickToBottom, setStickToBottom] = useState(true);
19
+ const [activeConsoleTab, setActiveConsoleTab] = useState(() => {
20
+ const settings = readUiSettings();
21
+ return normalizeWatchdogConsoleTab(settings?.[kWatchdogConsoleTabUiSettingKey]);
22
+ });
23
+ const [logsPanelHeightPx, setLogsPanelHeightPx] = useState(() => {
24
+ const settings = readUiSettings();
25
+ return clampWatchdogLogsPanelHeight(
26
+ settings?.[kWatchdogLogsPanelHeightUiSettingKey],
27
+ );
28
+ });
29
+ const logsRef = useRef(null);
30
+ const terminalPanelRef = useRef(null);
31
+ const terminalHostRef = useRef(null);
32
+ const terminal = useWatchdogTerminal({
33
+ active: activeConsoleTab === kWatchdogConsoleTabTerminal,
34
+ panelRef: terminalPanelRef,
35
+ hostRef: terminalHostRef,
36
+ });
37
+
38
+ useEffect(() => {
39
+ const settings = readUiSettings();
40
+ settings[kWatchdogConsoleTabUiSettingKey] =
41
+ normalizeWatchdogConsoleTab(activeConsoleTab);
42
+ writeUiSettings(settings);
43
+ }, [activeConsoleTab]);
44
+
45
+ useEffect(() => {
46
+ let active = true;
47
+ let timer = null;
48
+ const pollLogs = async () => {
49
+ try {
50
+ const text = await fetchWatchdogLogs(65536);
51
+ if (!active) return;
52
+ setLogs(text || "");
53
+ setLoadingLogs(false);
54
+ } catch {
55
+ if (!active) return;
56
+ setLoadingLogs(false);
57
+ }
58
+ if (!active) return;
59
+ timer = setTimeout(pollLogs, 3000);
60
+ };
61
+ pollLogs();
62
+ return () => {
63
+ active = false;
64
+ if (timer) clearTimeout(timer);
65
+ };
66
+ }, []);
67
+
68
+ useEffect(() => {
69
+ const logsElement = logsRef.current;
70
+ if (!logsElement || !stickToBottom) return;
71
+ logsElement.scrollTop = logsElement.scrollHeight;
72
+ }, [logs, stickToBottom]);
73
+
74
+ useEffect(() => {
75
+ const panelElement =
76
+ activeConsoleTab === kWatchdogConsoleTabLogs
77
+ ? logsRef.current
78
+ : terminalPanelRef.current;
79
+ if (!panelElement || typeof ResizeObserver === "undefined") return () => {};
80
+ let saveTimer = null;
81
+ const observer = new ResizeObserver((entries) => {
82
+ const entry = entries?.[0];
83
+ const nextHeight = clampWatchdogLogsPanelHeight(
84
+ readCssHeightPx(entry?.target),
85
+ );
86
+ setLogsPanelHeightPx((currentValue) =>
87
+ Math.abs(currentValue - nextHeight) >= 1 ? nextHeight : currentValue,
88
+ );
89
+ if (saveTimer) window.clearTimeout(saveTimer);
90
+ saveTimer = window.setTimeout(() => {
91
+ const settings = readUiSettings();
92
+ settings[kWatchdogLogsPanelHeightUiSettingKey] = nextHeight;
93
+ writeUiSettings(settings);
94
+ }, 120);
95
+ if (activeConsoleTab === kWatchdogConsoleTabTerminal) {
96
+ window.requestAnimationFrame(() => {
97
+ terminal.fitNow();
98
+ });
99
+ }
100
+ });
101
+ observer.observe(panelElement);
102
+ return () => {
103
+ observer.disconnect();
104
+ if (saveTimer) window.clearTimeout(saveTimer);
105
+ };
106
+ }, [activeConsoleTab]);
107
+
108
+ const handleSelectConsoleTab = (nextTab = kWatchdogConsoleTabLogs) => {
109
+ const normalizedTab = normalizeWatchdogConsoleTab(nextTab);
110
+ if (normalizedTab === kWatchdogConsoleTabTerminal) {
111
+ terminal.prepareForActivate();
112
+ } else {
113
+ terminal.clearSettling();
114
+ }
115
+ setActiveConsoleTab(normalizedTab);
116
+ };
117
+
118
+ const onRestartTerminalSession = () => {
119
+ terminal.restartSession();
120
+ setActiveConsoleTab(kWatchdogConsoleTabTerminal);
121
+ };
122
+
123
+ return {
124
+ logs,
125
+ loadingLogs,
126
+ stickToBottom,
127
+ setStickToBottom,
128
+ activeConsoleTab,
129
+ handleSelectConsoleTab,
130
+ logsPanelHeightPx,
131
+ logsRef,
132
+ terminalPanelRef,
133
+ terminalHostRef,
134
+ onRestartTerminalSession,
135
+ ...terminal,
136
+ };
137
+ };
@@ -0,0 +1,106 @@
1
+ export const kWatchdogConsoleTabLogs = "logs";
2
+ export const kWatchdogConsoleTabTerminal = "terminal";
3
+ export const kWatchdogConsoleTabUiSettingKey = "watchdogConsoleTab";
4
+ export const kWatchdogLogsPanelHeightUiSettingKey = "watchdogLogsPanelHeightPx";
5
+ export const kWatchdogLogsPanelDefaultHeightPx = 320;
6
+ export const kWatchdogLogsPanelMinHeightPx = 160;
7
+ export const kXtermCssUrl =
8
+ "https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.css";
9
+ export const kWatchdogTerminalWsPath = "/api/watchdog/terminal/ws";
10
+
11
+ let xtermModulesPromise = null;
12
+
13
+ export const loadXtermModules = () => {
14
+ if (!xtermModulesPromise) {
15
+ xtermModulesPromise = Promise.all([
16
+ import("https://esm.sh/@xterm/xterm@5.5.0"),
17
+ import("https://esm.sh/@xterm/addon-fit@0.10.0"),
18
+ ]);
19
+ }
20
+ return xtermModulesPromise;
21
+ };
22
+
23
+ export const ensureXtermStylesheet = () => {
24
+ if (typeof document === "undefined") return;
25
+ if (document.getElementById("ac-xterm-css")) return;
26
+ const link = document.createElement("link");
27
+ link.id = "ac-xterm-css";
28
+ link.rel = "stylesheet";
29
+ link.href = kXtermCssUrl;
30
+ document.head.appendChild(link);
31
+ };
32
+
33
+ export const fitTerminalWhenVisible = ({
34
+ panel = null,
35
+ fitAddon = null,
36
+ minWidthPx = 120,
37
+ minHeightPx = 80,
38
+ } = {}) => {
39
+ if (!panel || !fitAddon) return false;
40
+ const panelWidth = Number(panel.clientWidth || 0);
41
+ const panelHeight = Number(panel.clientHeight || 0);
42
+ if (panelWidth < minWidthPx || panelHeight < minHeightPx) return false;
43
+ fitAddon.fit();
44
+ return true;
45
+ };
46
+
47
+ export const normalizeWatchdogConsoleTab = (value) =>
48
+ value === kWatchdogConsoleTabTerminal
49
+ ? kWatchdogConsoleTabTerminal
50
+ : kWatchdogConsoleTabLogs;
51
+
52
+ export const clampWatchdogLogsPanelHeight = (value) => {
53
+ const parsed = Number(value);
54
+ const normalized = Number.isFinite(parsed)
55
+ ? Math.round(parsed)
56
+ : kWatchdogLogsPanelDefaultHeightPx;
57
+ return Math.max(kWatchdogLogsPanelMinHeightPx, normalized);
58
+ };
59
+
60
+ export const readCssHeightPx = (element) => {
61
+ if (!element) return 0;
62
+ const computedHeight = Number.parseFloat(
63
+ window.getComputedStyle(element).height || "0",
64
+ );
65
+ return Number.isFinite(computedHeight) ? computedHeight : 0;
66
+ };
67
+
68
+ export const formatBytes = (bytes) => {
69
+ if (bytes == null) return "—";
70
+ if (bytes < 1024) return `${bytes} B`;
71
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
72
+ if (bytes < 1024 * 1024 * 1024)
73
+ return `${(bytes / (1024 * 1024)).toFixed(0)} MB`;
74
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
75
+ };
76
+
77
+ export const getIncidentStatusTone = (event) => {
78
+ const eventType = String(event?.eventType || "")
79
+ .trim()
80
+ .toLowerCase();
81
+ const status = String(event?.status || "")
82
+ .trim()
83
+ .toLowerCase();
84
+ if (status === "failed") {
85
+ return {
86
+ dotClass: "bg-red-500/90",
87
+ label: "Failed",
88
+ };
89
+ }
90
+ if (status === "ok" && eventType === "health_check") {
91
+ return {
92
+ dotClass: "bg-green-500/90",
93
+ label: "Healthy",
94
+ };
95
+ }
96
+ if (status === "warn" || status === "warning") {
97
+ return {
98
+ dotClass: "bg-yellow-400/90",
99
+ label: "Warning",
100
+ };
101
+ }
102
+ return {
103
+ dotClass: "bg-gray-500/70",
104
+ label: "Unknown",
105
+ };
106
+ };
@@ -0,0 +1,56 @@
1
+ import { h } from "https://esm.sh/preact";
2
+ import htm from "https://esm.sh/htm";
3
+ import { getIncidentStatusTone } from "../helpers.js";
4
+
5
+ const html = htm.bind(h);
6
+
7
+ export const WatchdogIncidentsCard = ({
8
+ events = [],
9
+ onRefresh = () => {},
10
+ }) => html`
11
+ <div class="bg-surface border border-border rounded-xl p-4">
12
+ <div class="flex items-center justify-between gap-2 mb-3">
13
+ <h2 class="card-label">Recent incidents</h2>
14
+ <button class="text-xs text-gray-400 hover:text-gray-200" onclick=${onRefresh}>
15
+ Refresh
16
+ </button>
17
+ </div>
18
+ <div class="ac-history-list">
19
+ ${events.length === 0 &&
20
+ html`<p class="text-xs text-gray-500">No incidents recorded.</p>`}
21
+ ${events.map((event) => {
22
+ const tone = getIncidentStatusTone(event);
23
+ return html`
24
+ <details class="ac-history-item">
25
+ <summary class="ac-history-summary">
26
+ <div class="ac-history-summary-row">
27
+ <span class="inline-flex items-center gap-2 min-w-0">
28
+ <span class="ac-history-toggle shrink-0" aria-hidden="true"
29
+ >▸</span
30
+ >
31
+ <span class="truncate">
32
+ ${event.createdAt || ""} · ${event.eventType || "event"} ·
33
+ ${event.status || "unknown"}
34
+ </span>
35
+ </span>
36
+ <span
37
+ class=${`h-2.5 w-2.5 shrink-0 rounded-full ${tone.dotClass}`}
38
+ title=${tone.label}
39
+ aria-label=${tone.label}
40
+ ></span>
41
+ </div>
42
+ </summary>
43
+ <div class="ac-history-body text-xs text-gray-400">
44
+ <div>Source: ${event.source || "unknown"}</div>
45
+ <pre class="mt-2 bg-black/30 rounded p-2 whitespace-pre-wrap break-words">
46
+ ${typeof event.details === "string"
47
+ ? event.details
48
+ : JSON.stringify(event.details || {}, null, 2)}</pre
49
+ >
50
+ </div>
51
+ </details>
52
+ `;
53
+ })}
54
+ </div>
55
+ </div>
56
+ `;
@@ -0,0 +1,33 @@
1
+ import { useEffect } from "https://esm.sh/preact/hooks";
2
+ import { usePolling } from "../../../hooks/usePolling.js";
3
+ import { fetchWatchdogEvents } from "../../../lib/api.js";
4
+
5
+ export const useWatchdogIncidents = ({
6
+ restartSignal = 0,
7
+ onRefreshStatuses = () => {},
8
+ } = {}) => {
9
+ const eventsPoll = usePolling(() => fetchWatchdogEvents(20), 15000);
10
+
11
+ useEffect(() => {
12
+ if (!restartSignal) return;
13
+ onRefreshStatuses();
14
+ eventsPoll.refresh();
15
+ const t1 = setTimeout(() => {
16
+ onRefreshStatuses();
17
+ eventsPoll.refresh();
18
+ }, 1200);
19
+ const t2 = setTimeout(() => {
20
+ onRefreshStatuses();
21
+ eventsPoll.refresh();
22
+ }, 3500);
23
+ return () => {
24
+ clearTimeout(t1);
25
+ clearTimeout(t2);
26
+ };
27
+ }, [restartSignal, onRefreshStatuses, eventsPoll.refresh]);
28
+
29
+ return {
30
+ events: eventsPoll.data?.events || [],
31
+ refreshEvents: eventsPoll.refresh,
32
+ };
33
+ };