@chrysb/alphaclaw 0.2.2 → 0.3.0

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 (65) hide show
  1. package/bin/alphaclaw.js +79 -0
  2. package/lib/public/css/shell.css +57 -2
  3. package/lib/public/css/theme.css +184 -0
  4. package/lib/public/js/app.js +330 -89
  5. package/lib/public/js/components/action-button.js +92 -0
  6. package/lib/public/js/components/channels.js +16 -7
  7. package/lib/public/js/components/confirm-dialog.js +25 -19
  8. package/lib/public/js/components/credentials-modal.js +32 -23
  9. package/lib/public/js/components/device-pairings.js +15 -2
  10. package/lib/public/js/components/envars.js +22 -65
  11. package/lib/public/js/components/features.js +1 -1
  12. package/lib/public/js/components/gateway.js +139 -32
  13. package/lib/public/js/components/global-restart-banner.js +31 -0
  14. package/lib/public/js/components/google.js +9 -9
  15. package/lib/public/js/components/icons.js +19 -0
  16. package/lib/public/js/components/info-tooltip.js +18 -0
  17. package/lib/public/js/components/loading-spinner.js +32 -0
  18. package/lib/public/js/components/modal-shell.js +42 -0
  19. package/lib/public/js/components/models.js +34 -29
  20. package/lib/public/js/components/onboarding/welcome-form-step.js +45 -32
  21. package/lib/public/js/components/onboarding/welcome-pairing-step.js +2 -2
  22. package/lib/public/js/components/onboarding/welcome-setup-step.js +7 -24
  23. package/lib/public/js/components/page-header.js +13 -0
  24. package/lib/public/js/components/pairings.js +15 -2
  25. package/lib/public/js/components/providers.js +216 -142
  26. package/lib/public/js/components/scope-picker.js +1 -1
  27. package/lib/public/js/components/secret-input.js +1 -0
  28. package/lib/public/js/components/telegram-workspace.js +37 -49
  29. package/lib/public/js/components/toast.js +34 -5
  30. package/lib/public/js/components/toggle-switch.js +25 -0
  31. package/lib/public/js/components/update-action-button.js +13 -53
  32. package/lib/public/js/components/watchdog-tab.js +312 -0
  33. package/lib/public/js/components/webhooks.js +981 -0
  34. package/lib/public/js/components/welcome.js +2 -1
  35. package/lib/public/js/lib/api.js +102 -1
  36. package/lib/public/js/lib/model-config.js +0 -5
  37. package/lib/public/login.html +1 -0
  38. package/lib/public/setup.html +1 -0
  39. package/lib/server/alphaclaw-version.js +5 -3
  40. package/lib/server/constants.js +33 -0
  41. package/lib/server/discord-api.js +48 -0
  42. package/lib/server/gateway.js +64 -4
  43. package/lib/server/log-writer.js +102 -0
  44. package/lib/server/onboarding/github.js +21 -1
  45. package/lib/server/openclaw-version.js +2 -6
  46. package/lib/server/restart-required-state.js +86 -0
  47. package/lib/server/routes/auth.js +9 -4
  48. package/lib/server/routes/proxy.js +12 -14
  49. package/lib/server/routes/system.js +61 -15
  50. package/lib/server/routes/telegram.js +17 -48
  51. package/lib/server/routes/watchdog.js +68 -0
  52. package/lib/server/routes/webhooks.js +214 -0
  53. package/lib/server/telegram-api.js +11 -0
  54. package/lib/server/watchdog-db.js +148 -0
  55. package/lib/server/watchdog-notify.js +93 -0
  56. package/lib/server/watchdog.js +585 -0
  57. package/lib/server/webhook-middleware.js +195 -0
  58. package/lib/server/webhooks-db.js +265 -0
  59. package/lib/server/webhooks.js +238 -0
  60. package/lib/server.js +119 -4
  61. package/lib/setup/core-prompts/AGENTS.md +84 -0
  62. package/lib/setup/core-prompts/TOOLS.md +13 -0
  63. package/lib/setup/core-prompts/UI-DRY-OPPORTUNITIES.md +50 -0
  64. package/lib/setup/gitignore +2 -0
  65. package/package.json +2 -1
@@ -3,6 +3,7 @@ import { useState, useEffect } from "https://esm.sh/preact/hooks";
3
3
  import htm from "https://esm.sh/htm";
4
4
  import { Badge } from "./badge.js";
5
5
  import { showToast } from "./toast.js";
6
+ import { ActionButton } from "./action-button.js";
6
7
 
7
8
  const html = htm.bind(h);
8
9
 
@@ -14,11 +15,6 @@ const authFetch = async (url, opts = {}) => {
14
15
  }
15
16
  return res;
16
17
  };
17
- const encodePayloadQuery = (payload) =>
18
- encodeURIComponent(
19
- JSON.stringify(payload && typeof payload === "object" ? payload : {}),
20
- );
21
-
22
18
  const api = {
23
19
  verifyBot: async () => {
24
20
  const res = await authFetch("/api/telegram/bot");
@@ -35,15 +31,11 @@ const api = {
35
31
  return res.json();
36
32
  },
37
33
  verifyGroup: async (groupId) => {
38
- const groupIdParam = encodeURIComponent(String(groupId || "").trim());
39
- const res = await authFetch(
40
- `/api/telegram/groups/verify?groupId=${groupIdParam}`,
41
- {
42
- method: "POST",
43
- headers: { "Content-Type": "application/json" },
44
- body: JSON.stringify({ groupId }),
45
- },
46
- );
34
+ const res = await authFetch("/api/telegram/groups/verify", {
35
+ method: "POST",
36
+ headers: { "Content-Type": "application/json" },
37
+ body: JSON.stringify({ groupId }),
38
+ });
47
39
  return res.json();
48
40
  },
49
41
  listTopics: async (groupId) => {
@@ -53,9 +45,8 @@ const api = {
53
45
  return res.json();
54
46
  },
55
47
  createTopicsBulk: async (groupId, topics) => {
56
- const queryPayload = encodePayloadQuery({ topics });
57
48
  const res = await authFetch(
58
- `/api/telegram/groups/${encodeURIComponent(groupId)}/topics/bulk?payload=${queryPayload}`,
49
+ `/api/telegram/groups/${encodeURIComponent(groupId)}/topics/bulk`,
59
50
  {
60
51
  method: "POST",
61
52
  headers: { "Content-Type": "application/json" },
@@ -72,9 +63,8 @@ const api = {
72
63
  return res.json();
73
64
  },
74
65
  updateTopic: async (groupId, topicId, payload) => {
75
- const queryPayload = encodePayloadQuery(payload);
76
66
  const res = await authFetch(
77
- `/api/telegram/groups/${encodeURIComponent(groupId)}/topics/${encodeURIComponent(topicId)}?payload=${queryPayload}`,
67
+ `/api/telegram/groups/${encodeURIComponent(groupId)}/topics/${encodeURIComponent(topicId)}`,
78
68
  {
79
69
  method: "PUT",
80
70
  headers: { "Content-Type": "application/json" },
@@ -84,9 +74,8 @@ const api = {
84
74
  return res.json();
85
75
  },
86
76
  configureGroup: async (groupId, payload) => {
87
- const queryPayload = encodePayloadQuery(payload);
88
77
  const res = await authFetch(
89
- `/api/telegram/groups/${encodeURIComponent(groupId)}/configure?payload=${queryPayload}`,
78
+ `/api/telegram/groups/${encodeURIComponent(groupId)}/configure`,
90
79
  {
91
80
  method: "POST",
92
81
  headers: { "Content-Type": "application/json" },
@@ -427,16 +416,16 @@ const AddBotStep = ({
427
416
  placeholder="-100XXXXXXXXXX"
428
417
  class="flex-1 bg-black/30 border border-border rounded-lg px-3 py-2 text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-gray-500"
429
418
  />
430
- <button
431
- onclick=${verify}
432
- disabled=${loading || !input.trim()}
433
- class="text-sm px-4 py-2 rounded-lg border border-border transition-colors ${loading ||
434
- !input.trim()
435
- ? "text-gray-600 cursor-not-allowed"
436
- : "text-gray-300 hover:text-gray-100 hover:border-gray-500"}"
437
- >
438
- ${loading ? "Verifying..." : "Verify"}
439
- </button>
419
+ <${ActionButton}
420
+ onClick=${verify}
421
+ disabled=${!input.trim() || loading}
422
+ loading=${loading}
423
+ tone="secondary"
424
+ size="md"
425
+ idleLabel="Verify"
426
+ loadingMode="inline"
427
+ className="rounded-lg"
428
+ />
440
429
  </div>
441
430
 
442
431
  ${verifyGroupError &&
@@ -652,16 +641,16 @@ const TopicsStep = ({ groupId, topics, setTopics, onNext, onBack }) => {
652
641
  class="w-full bg-black/30 border border-border rounded-lg px-3 py-2 text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-gray-500 resize-y"
653
642
  />
654
643
  <div class="flex justify-end">
655
- <button
656
- onclick=${createSingle}
644
+ <${ActionButton}
645
+ onClick=${createSingle}
657
646
  disabled=${creating || !newTopicName.trim()}
658
- class="text-sm min-w-[88px] px-5 py-2 rounded-lg border border-border transition-colors ${creating ||
659
- !newTopicName.trim()
660
- ? "text-gray-600 cursor-not-allowed"
661
- : "text-gray-300 hover:text-gray-100 hover:border-gray-500"}"
662
- >
663
- Add
664
- </button>
647
+ loading=${creating}
648
+ tone="secondary"
649
+ size="lg"
650
+ idleLabel="Add"
651
+ loadingMode="inline"
652
+ className="min-w-[88px]"
653
+ />
665
654
  </div>
666
655
  </div>
667
656
  </div>
@@ -932,7 +921,7 @@ const ManageTelegramWorkspace = ({
932
921
  String(id)}
933
922
  class="text-xs px-2 py-1 rounded transition-all ac-btn-cyan ${renamingTopicId ===
934
923
  String(id)
935
- ? "opacity-60 cursor-not-allowed"
924
+ ? "opacity-50 cursor-not-allowed"
936
925
  : ""}"
937
926
  >
938
927
  Save
@@ -1045,16 +1034,15 @@ const ManageTelegramWorkspace = ({
1045
1034
  class="w-full bg-black/30 border border-border rounded-lg px-3 py-2 text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-gray-500 resize-y"
1046
1035
  />
1047
1036
  <div class="flex justify-end">
1048
- <button
1049
- onclick=${createSingle}
1037
+ <${ActionButton}
1038
+ onClick=${createSingle}
1050
1039
  disabled=${creating || !newTopicName.trim()}
1051
- class="text-sm px-3 py-2 rounded-lg border border-border transition-colors ${creating ||
1052
- !newTopicName.trim()
1053
- ? "text-gray-600 cursor-not-allowed"
1054
- : "text-gray-300 hover:text-gray-100 hover:border-gray-500"}"
1055
- >
1056
- ${creating ? "Creating..." : "Add topic"}
1057
- </button>
1040
+ loading=${creating}
1041
+ tone="secondary"
1042
+ size="lg"
1043
+ idleLabel="Add topic"
1044
+ loadingLabel="Creating..."
1045
+ />
1058
1046
  </div>
1059
1047
  </div>
1060
1048
  </div>
@@ -6,11 +6,38 @@ const html = htm.bind(h);
6
6
  let toastId = 0;
7
7
  let addToastFn = null;
8
8
 
9
- export function showToast(text, color) {
10
- if (addToastFn) addToastFn({ id: ++toastId, text, color });
9
+ const kToastTypeByAlias = {
10
+ success: "success",
11
+ error: "error",
12
+ warning: "warning",
13
+ info: "info",
14
+ green: "success",
15
+ red: "error",
16
+ yellow: "warning",
17
+ blue: "info",
18
+ };
19
+
20
+ const kToastClassByType = {
21
+ success: "bg-green-950/95 border border-green-700/80 text-green-200",
22
+ error: "bg-red-950/95 border border-red-700/80 text-red-200",
23
+ warning: "bg-yellow-950/95 border border-yellow-700/80 text-yellow-100",
24
+ info: "bg-cyan-950/95 border border-cyan-700/80 text-cyan-100",
25
+ };
26
+
27
+ const normalizeToastType = (type) => {
28
+ const normalized = String(type || "")
29
+ .trim()
30
+ .toLowerCase();
31
+ return kToastTypeByAlias[normalized] || "info";
32
+ };
33
+
34
+ export function showToast(text, type = "info") {
35
+ if (addToastFn) addToastFn({ id: ++toastId, text, type: normalizeToastType(type) });
11
36
  }
12
37
 
13
- export function ToastContainer() {
38
+ export function ToastContainer({
39
+ className = "fixed bottom-4 right-4 z-50 space-y-2",
40
+ }) {
14
41
  const [toasts, setToasts] = useState([]);
15
42
 
16
43
  useEffect(() => {
@@ -21,9 +48,11 @@ export function ToastContainer() {
21
48
  return () => { addToastFn = null; };
22
49
  }, []);
23
50
 
24
- return html`<div class="fixed top-4 right-4 z-50 space-y-2">
51
+ if (toasts.length === 0) return null;
52
+
53
+ return html`<div class=${className}>
25
54
  ${toasts.map(t => html`
26
- <div key=${t.id} class="bg-${t.color}-500/20 border border-${t.color}-500/30 text-${t.color}-400 px-4 py-2 rounded-lg text-sm">
55
+ <div key=${t.id} class="${kToastClassByType[normalizeToastType(t.type)]} px-4 py-2 rounded-lg text-sm">
27
56
  ${t.text}
28
57
  </div>
29
58
  `)}
@@ -0,0 +1,25 @@
1
+ import { h } from "https://esm.sh/preact";
2
+ import htm from "https://esm.sh/htm";
3
+
4
+ const html = htm.bind(h);
5
+
6
+ export const ToggleSwitch = ({
7
+ checked = false,
8
+ disabled = false,
9
+ onChange = () => {},
10
+ label = "Enabled",
11
+ }) => html`
12
+ <label class="ac-toggle">
13
+ <input
14
+ class="ac-toggle-input"
15
+ type="checkbox"
16
+ checked=${!!checked}
17
+ disabled=${!!disabled}
18
+ onchange=${(e) => onChange(!!e.target.checked)}
19
+ />
20
+ <span class="ac-toggle-track" aria-hidden="true">
21
+ <span class="ac-toggle-thumb"></span>
22
+ </span>
23
+ ${label ? html`<span class="ac-toggle-label">${label}</span>` : null}
24
+ </label>
25
+ `;
@@ -1,5 +1,6 @@
1
1
  import { h } from "https://esm.sh/preact";
2
2
  import htm from "https://esm.sh/htm";
3
+ import { ActionButton } from "./action-button.js";
3
4
 
4
5
  const html = htm.bind(h);
5
6
 
@@ -11,56 +12,15 @@ export const UpdateActionButton = ({
11
12
  idleLabel = "Check updates",
12
13
  loadingLabel = "Checking...",
13
14
  className = "",
14
- }) => {
15
- const isInteractive = !loading && !disabled;
16
- const toneClass = warning
17
- ? isInteractive
18
- ? "border-yellow-500/35 text-yellow-400 bg-yellow-500/10 hover:border-yellow-400/60 hover:text-yellow-300 hover:bg-yellow-500/15"
19
- : "border-yellow-500/35 text-yellow-400 bg-yellow-500/10"
20
- : isInteractive
21
- ? "border-border text-gray-500 hover:text-gray-300 hover:border-gray-500"
22
- : "border-border text-gray-500";
23
- const loadingClass = loading
24
- ? `cursor-not-allowed ${warning
25
- ? "opacity-90 animate-pulse shadow-[0_0_0_1px_rgba(234,179,8,0.22),0_0_18px_rgba(234,179,8,0.12)]"
26
- : "opacity-80"}`
27
- : "";
28
-
29
- return html`
30
- <button
31
- onclick=${onClick}
32
- disabled=${disabled || loading}
33
- class="inline-flex items-center justify-center h-7 text-xs leading-none px-2.5 py-1 rounded-lg border transition-colors whitespace-nowrap ${toneClass} ${loadingClass} ${className}"
34
- >
35
- ${loading
36
- ? html`
37
- <span class="inline-flex items-center gap-1.5 leading-none">
38
- <svg
39
- class="animate-spin"
40
- width="12"
41
- height="12"
42
- viewBox="0 0 24 24"
43
- fill="none"
44
- aria-hidden="true"
45
- >
46
- <circle
47
- class="opacity-30"
48
- cx="12"
49
- cy="12"
50
- r="9"
51
- stroke="currentColor"
52
- stroke-width="3"
53
- />
54
- <path
55
- class="opacity-90"
56
- fill="currentColor"
57
- d="M12 3a9 9 0 0 1 9 9h-3a6 6 0 0 0-6-6V3z"
58
- />
59
- </svg>
60
- ${loadingLabel}
61
- </span>
62
- `
63
- : idleLabel}
64
- </button>
65
- `;
66
- };
15
+ }) => html`
16
+ <${ActionButton}
17
+ onClick=${onClick}
18
+ disabled=${disabled}
19
+ loading=${loading}
20
+ tone=${warning ? "warning" : "neutral"}
21
+ size="sm"
22
+ idleLabel=${idleLabel}
23
+ loadingLabel=${loadingLabel}
24
+ className=${className}
25
+ />
26
+ `;
@@ -0,0 +1,312 @@
1
+ import { h } from "https://esm.sh/preact";
2
+ import { useEffect, useRef, useState } from "https://esm.sh/preact/hooks";
3
+ import htm from "https://esm.sh/htm";
4
+ import {
5
+ fetchWatchdogEvents,
6
+ fetchWatchdogLogs,
7
+ fetchWatchdogSettings,
8
+ updateWatchdogSettings,
9
+ triggerWatchdogRepair,
10
+ } from "../lib/api.js";
11
+ import { usePolling } from "../hooks/usePolling.js";
12
+ import { Gateway } from "./gateway.js";
13
+ import { InfoTooltip } from "./info-tooltip.js";
14
+ import { ToggleSwitch } from "./toggle-switch.js";
15
+ import { showToast } from "./toast.js";
16
+
17
+ const html = htm.bind(h);
18
+
19
+ const getIncidentStatusTone = (event) => {
20
+ const eventType = String(event?.eventType || "").trim().toLowerCase();
21
+ const status = String(event?.status || "").trim().toLowerCase();
22
+ if (status === "failed") {
23
+ return {
24
+ dotClass: "bg-red-500/90",
25
+ label: "Failed",
26
+ };
27
+ }
28
+ if (status === "ok" && eventType === "health_check") {
29
+ return {
30
+ dotClass: "bg-green-500/90",
31
+ label: "Healthy",
32
+ };
33
+ }
34
+ if (status === "warn" || status === "warning") {
35
+ return {
36
+ dotClass: "bg-yellow-400/90",
37
+ label: "Warning",
38
+ };
39
+ }
40
+ return {
41
+ dotClass: "bg-gray-500/70",
42
+ label: "Unknown",
43
+ };
44
+ };
45
+
46
+ export const WatchdogTab = ({
47
+ gatewayStatus = null,
48
+ openclawVersion = null,
49
+ watchdogStatus = null,
50
+ onRefreshStatuses = () => {},
51
+ restartingGateway = false,
52
+ onRestartGateway,
53
+ restartSignal = 0,
54
+ }) => {
55
+ const eventsPoll = usePolling(() => fetchWatchdogEvents(20), 15000);
56
+ const [settings, setSettings] = useState({
57
+ autoRepair: false,
58
+ notificationsEnabled: true,
59
+ });
60
+ const [savingSettings, setSavingSettings] = useState(false);
61
+ const [repairing, setRepairing] = useState(false);
62
+ const [logs, setLogs] = useState("");
63
+ const [loadingLogs, setLoadingLogs] = useState(true);
64
+ const [stickToBottom, setStickToBottom] = useState(true);
65
+ const logsRef = useRef(null);
66
+
67
+ const currentWatchdogStatus = watchdogStatus || {};
68
+ const events = eventsPoll.data?.events || [];
69
+ const isRepairInProgress = repairing || !!currentWatchdogStatus?.operationInProgress;
70
+
71
+ useEffect(() => {
72
+ let active = true;
73
+ const loadSettings = async () => {
74
+ try {
75
+ const data = await fetchWatchdogSettings();
76
+ if (!active) return;
77
+ setSettings(
78
+ data.settings || {
79
+ autoRepair: false,
80
+ notificationsEnabled: true,
81
+ },
82
+ );
83
+ } catch (err) {
84
+ if (!active) return;
85
+ showToast(err.message || "Could not load watchdog settings", "error");
86
+ }
87
+ };
88
+ loadSettings();
89
+ return () => {
90
+ active = false;
91
+ };
92
+ }, []);
93
+
94
+ useEffect(() => {
95
+ let active = true;
96
+ let timer = null;
97
+ const pollLogs = async () => {
98
+ try {
99
+ const text = await fetchWatchdogLogs(65536);
100
+ if (!active) return;
101
+ setLogs(text || "");
102
+ setLoadingLogs(false);
103
+ } catch (err) {
104
+ if (!active) return;
105
+ setLoadingLogs(false);
106
+ }
107
+ if (!active) return;
108
+ timer = setTimeout(pollLogs, 3000);
109
+ };
110
+ pollLogs();
111
+ return () => {
112
+ active = false;
113
+ if (timer) clearTimeout(timer);
114
+ };
115
+ }, []);
116
+
117
+ useEffect(() => {
118
+ const el = logsRef.current;
119
+ if (!el || !stickToBottom) return;
120
+ el.scrollTop = el.scrollHeight;
121
+ }, [logs, stickToBottom]);
122
+
123
+ useEffect(() => {
124
+ if (!restartSignal) return;
125
+ onRefreshStatuses();
126
+ eventsPoll.refresh();
127
+ const t1 = setTimeout(() => {
128
+ onRefreshStatuses();
129
+ eventsPoll.refresh();
130
+ }, 1200);
131
+ const t2 = setTimeout(() => {
132
+ onRefreshStatuses();
133
+ eventsPoll.refresh();
134
+ }, 3500);
135
+ return () => {
136
+ clearTimeout(t1);
137
+ clearTimeout(t2);
138
+ };
139
+ }, [restartSignal, onRefreshStatuses, eventsPoll.refresh]);
140
+
141
+ const onToggleAutoRepair = async (nextValue) => {
142
+ if (savingSettings) return;
143
+ setSavingSettings(true);
144
+ try {
145
+ const data = await updateWatchdogSettings({ autoRepair: !!nextValue });
146
+ setSettings(
147
+ data.settings || {
148
+ ...settings,
149
+ autoRepair: !!nextValue,
150
+ },
151
+ );
152
+ onRefreshStatuses();
153
+ showToast(`Auto-repair ${nextValue ? "enabled" : "disabled"}`, "success");
154
+ } catch (err) {
155
+ showToast(err.message || "Could not update auto-repair", "error");
156
+ } finally {
157
+ setSavingSettings(false);
158
+ }
159
+ };
160
+
161
+ const onToggleNotifications = async (nextValue) => {
162
+ if (savingSettings) return;
163
+ setSavingSettings(true);
164
+ try {
165
+ const data = await updateWatchdogSettings({
166
+ notificationsEnabled: !!nextValue,
167
+ });
168
+ setSettings(
169
+ data.settings || {
170
+ ...settings,
171
+ notificationsEnabled: !!nextValue,
172
+ },
173
+ );
174
+ onRefreshStatuses();
175
+ showToast(`Notifications ${nextValue ? "enabled" : "disabled"}`, "success");
176
+ } catch (err) {
177
+ showToast(err.message || "Could not update notifications", "error");
178
+ } finally {
179
+ setSavingSettings(false);
180
+ }
181
+ };
182
+
183
+ const onRepair = async () => {
184
+ if (isRepairInProgress) return;
185
+ setRepairing(true);
186
+ try {
187
+ const data = await triggerWatchdogRepair();
188
+ if (!data.ok) throw new Error(data.error || "Repair failed");
189
+ showToast("Repair triggered", "success");
190
+ setTimeout(() => {
191
+ onRefreshStatuses();
192
+ eventsPoll.refresh();
193
+ }, 800);
194
+ } catch (err) {
195
+ showToast(err.message || "Could not run repair", "error");
196
+ } finally {
197
+ setRepairing(false);
198
+ }
199
+ };
200
+
201
+ return html`
202
+ <div class="space-y-4">
203
+ <${Gateway}
204
+ status=${gatewayStatus}
205
+ openclawVersion=${openclawVersion}
206
+ restarting=${restartingGateway}
207
+ onRestart=${onRestartGateway}
208
+ watchdogStatus=${currentWatchdogStatus}
209
+ onRepair=${onRepair}
210
+ repairing=${isRepairInProgress}
211
+ />
212
+
213
+ <div class="bg-surface border border-border rounded-xl p-4">
214
+ <div class="flex items-center justify-between gap-3">
215
+ <div class="inline-flex items-center gap-2 text-xs text-gray-400">
216
+ <span>Auto-repair</span>
217
+ <${InfoTooltip}
218
+ text="Automatically runs OpenClaw doctor repair when watchdog detects gateway health failures or crash loops."
219
+ />
220
+ </div>
221
+ <${ToggleSwitch}
222
+ checked=${!!settings.autoRepair}
223
+ disabled=${savingSettings}
224
+ onChange=${onToggleAutoRepair}
225
+ label=""
226
+ />
227
+ </div>
228
+ <div class="flex items-center justify-between gap-3 mt-3">
229
+ <div class="inline-flex items-center gap-2 text-xs text-gray-400">
230
+ <span>Notifications</span>
231
+ <${InfoTooltip}
232
+ text="Sends Telegram notices for watchdog alerts and auto-repair outcomes."
233
+ />
234
+ </div>
235
+ <${ToggleSwitch}
236
+ checked=${!!settings.notificationsEnabled}
237
+ disabled=${savingSettings}
238
+ onChange=${onToggleNotifications}
239
+ label=""
240
+ />
241
+ </div>
242
+ </div>
243
+
244
+ <div class="bg-surface border border-border rounded-xl p-4">
245
+ <div class="flex items-center justify-between gap-2 mb-3">
246
+ <h2 class="font-semibold text-sm">Logs</h2>
247
+ <label class="inline-flex items-center gap-2 text-xs text-gray-400">
248
+ <input
249
+ type="checkbox"
250
+ checked=${stickToBottom}
251
+ onchange=${(e) => setStickToBottom(!!e.target.checked)}
252
+ />
253
+ Stick to bottom
254
+ </label>
255
+ </div>
256
+ <pre
257
+ ref=${logsRef}
258
+ class="bg-black/40 border border-border rounded-lg p-3 h-72 overflow-auto text-xs text-gray-300 whitespace-pre-wrap break-words"
259
+ >${loadingLogs ? "Loading logs..." : logs || "No logs yet."}</pre>
260
+ </div>
261
+
262
+ <div class="bg-surface border border-border rounded-xl p-4">
263
+ <div class="flex items-center justify-between gap-2 mb-3">
264
+ <h2 class="font-semibold text-sm">Recent incidents</h2>
265
+ <button
266
+ class="text-xs text-gray-400 hover:text-gray-200"
267
+ onclick=${() => eventsPoll.refresh()}
268
+ >
269
+ Refresh
270
+ </button>
271
+ </div>
272
+ <div class="space-y-2">
273
+ ${events.length === 0 &&
274
+ html`<p class="text-xs text-gray-500">No incidents recorded.</p>`}
275
+ ${events.map(
276
+ (event) => {
277
+ const tone = getIncidentStatusTone(event);
278
+ return html`
279
+ <details class="border border-border rounded-lg p-2">
280
+ <summary class="cursor-pointer text-xs text-gray-300 list-none [&::-webkit-details-marker]:hidden">
281
+ <div class="flex items-center justify-between gap-2">
282
+ <span class="inline-flex items-center gap-2 min-w-0">
283
+ <span class="text-gray-500 shrink-0" aria-hidden="true">▸</span>
284
+ <span class="truncate">
285
+ ${event.createdAt || ""} · ${event.eventType || "event"} · ${event.status ||
286
+ "unknown"}
287
+ </span>
288
+ </span>
289
+ <span
290
+ class=${`h-2.5 w-2.5 shrink-0 rounded-full ${tone.dotClass}`}
291
+ title=${tone.label}
292
+ aria-label=${tone.label}
293
+ ></span>
294
+ </div>
295
+ </summary>
296
+ <div class="mt-2 text-xs text-gray-400">
297
+ <div>Source: ${event.source || "unknown"}</div>
298
+ <pre class="mt-2 bg-black/30 rounded p-2 whitespace-pre-wrap break-words"
299
+ >${typeof event.details === "string"
300
+ ? event.details
301
+ : JSON.stringify(event.details || {}, null, 2)}</pre
302
+ >
303
+ </div>
304
+ </details>
305
+ `;
306
+ },
307
+ )}
308
+ </div>
309
+ </div>
310
+ </div>
311
+ `;
312
+ };