@chrysb/alphaclaw 0.2.3 → 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 (63) 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/server/alphaclaw-version.js +5 -3
  38. package/lib/server/constants.js +33 -0
  39. package/lib/server/discord-api.js +48 -0
  40. package/lib/server/gateway.js +64 -4
  41. package/lib/server/log-writer.js +102 -0
  42. package/lib/server/onboarding/github.js +21 -1
  43. package/lib/server/openclaw-version.js +2 -6
  44. package/lib/server/restart-required-state.js +86 -0
  45. package/lib/server/routes/auth.js +9 -4
  46. package/lib/server/routes/proxy.js +12 -14
  47. package/lib/server/routes/system.js +61 -15
  48. package/lib/server/routes/telegram.js +17 -48
  49. package/lib/server/routes/watchdog.js +68 -0
  50. package/lib/server/routes/webhooks.js +214 -0
  51. package/lib/server/telegram-api.js +11 -0
  52. package/lib/server/watchdog-db.js +148 -0
  53. package/lib/server/watchdog-notify.js +93 -0
  54. package/lib/server/watchdog.js +585 -0
  55. package/lib/server/webhook-middleware.js +195 -0
  56. package/lib/server/webhooks-db.js +265 -0
  57. package/lib/server/webhooks.js +238 -0
  58. package/lib/server.js +119 -4
  59. package/lib/setup/core-prompts/AGENTS.md +84 -0
  60. package/lib/setup/core-prompts/TOOLS.md +13 -0
  61. package/lib/setup/core-prompts/UI-DRY-OPPORTUNITIES.md +50 -0
  62. package/lib/setup/gitignore +2 -0
  63. package/package.json +2 -1
@@ -23,10 +23,13 @@ export function Channels({ channels, onSwitchTab, onNavigate }) {
23
23
  badge = html`<a
24
24
  href="#"
25
25
  onclick=${(e) => { e.preventDefault(); onSwitchTab?.('envars'); }}
26
- class="text-xs text-gray-500 hover:text-gray-300"
26
+ class="text-xs px-2 py-1 rounded-lg ac-btn-ghost"
27
27
  >Add token</a>`;
28
28
  } else if (info.status === 'paired') {
29
- badge = html`<${Badge} tone="success">Paired (${info.paired})</${Badge}>`;
29
+ const pairedCount = Number(info.paired || 0);
30
+ badge = html`<${Badge} tone="success"
31
+ >${ch === 'telegram' || pairedCount <= 1 ? 'Paired' : `Paired (${pairedCount})`}</${Badge
32
+ }>`;
30
33
  } else {
31
34
  badge = html`<${Badge} tone="warning">Awaiting pairing</${Badge}>`;
32
35
  }
@@ -39,15 +42,21 @@ export function Channels({ channels, onSwitchTab, onNavigate }) {
39
42
  ? html`<img src=${channelMeta.iconSrc} alt="" class="w-4 h-4 rounded-sm" aria-hidden="true" />`
40
43
  : null}
41
44
  ${channelMeta.label}
42
- </span>
43
- <span class="flex items-center gap-2">
44
- ${badge}
45
45
  ${isClickable && html`
46
- <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" class="text-gray-600">
47
- <path d="M5.646 3.354a.5.5 0 01.708 0l4.5 4.5a.5.5 0 010 .708l-4.5 4.5a.5.5 0 01-.708-.708L9.793 8 5.646 3.854a.5.5 0 010-.5z"/>
46
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none" class="text-gray-600">
47
+ <path
48
+ d="M6 3.5L10.5 8L6 12.5"
49
+ stroke="currentColor"
50
+ stroke-width="2"
51
+ stroke-linecap="round"
52
+ stroke-linejoin="round"
53
+ />
48
54
  </svg>
49
55
  `}
50
56
  </span>
57
+ <span class="flex items-center gap-2">
58
+ ${badge}
59
+ </span>
51
60
  </div>`;
52
61
  }) : html`<div class="text-gray-500 text-sm text-center py-2">Loading...</div>`}
53
62
  </div>
@@ -1,6 +1,7 @@
1
1
  import { h } from "https://esm.sh/preact";
2
2
  import { useEffect } from "https://esm.sh/preact/hooks";
3
3
  import htm from "https://esm.sh/htm";
4
+ import { ActionButton } from "./action-button.js";
4
5
 
5
6
  const html = htm.bind(h);
6
7
 
@@ -8,11 +9,15 @@ export const ConfirmDialog = ({
8
9
  visible = false,
9
10
  title = "Confirm action",
10
11
  message = "Are you sure you want to continue?",
12
+ details = null,
11
13
  confirmLabel = "Confirm",
14
+ confirmLoadingLabel = "Working...",
12
15
  cancelLabel = "Cancel",
13
16
  onConfirm,
14
17
  onCancel,
15
18
  confirmTone = "primary",
19
+ confirmLoading = false,
20
+ confirmDisabled = false,
16
21
  }) => {
17
22
  useEffect(() => {
18
23
  if (!visible) return;
@@ -28,11 +33,7 @@ export const ConfirmDialog = ({
28
33
  }, [visible, onCancel]);
29
34
 
30
35
  if (!visible) return null;
31
-
32
- const confirmClass =
33
- confirmTone === "warning"
34
- ? "border border-yellow-500/45 text-yellow-300 bg-[linear-gradient(180deg,rgba(234,179,8,0.22)_0%,rgba(234,179,8,0.12)_100%)] shadow-[inset_0_0_0_1px_rgba(234,179,8,0.18)] hover:border-yellow-300/75 hover:text-yellow-200 hover:bg-[linear-gradient(180deg,rgba(234,179,8,0.3)_0%,rgba(234,179,8,0.16)_100%)] hover:shadow-[inset_0_0_0_1px_rgba(234,179,8,0.26),0_0_12px_rgba(234,179,8,0.16)]"
35
- : "ac-btn-cyan";
36
+ const actionTone = confirmTone === "warning" ? "warning" : "primary";
36
37
 
37
38
  return html`
38
39
  <div
@@ -44,21 +45,26 @@ export const ConfirmDialog = ({
44
45
  <div class="bg-modal border border-border rounded-xl p-5 max-w-md w-full space-y-3">
45
46
  <h2 class="text-base font-semibold">${title}</h2>
46
47
  <p class="text-sm text-gray-400">${message}</p>
48
+ ${details}
47
49
  <div class="pt-1 flex items-center justify-end gap-2">
48
- <button
49
- type="button"
50
- onclick=${onCancel}
51
- class="px-4 py-2 rounded-lg text-sm ac-btn-secondary"
52
- >
53
- ${cancelLabel}
54
- </button>
55
- <button
56
- type="button"
57
- onclick=${onConfirm}
58
- class="px-4 py-2 rounded-lg text-sm transition-all ${confirmClass}"
59
- >
60
- ${confirmLabel}
61
- </button>
50
+ <${ActionButton}
51
+ onClick=${onCancel}
52
+ disabled=${confirmLoading}
53
+ tone="secondary"
54
+ size="md"
55
+ idleLabel=${cancelLabel}
56
+ className="px-4 py-2 rounded-lg text-sm"
57
+ />
58
+ <${ActionButton}
59
+ onClick=${onConfirm}
60
+ disabled=${confirmDisabled}
61
+ loading=${confirmLoading}
62
+ tone=${actionTone}
63
+ size="md"
64
+ idleLabel=${confirmLabel}
65
+ loadingLabel=${confirmLoadingLabel}
66
+ className="px-4 py-2 rounded-lg text-sm"
67
+ />
62
68
  </div>
63
69
  </div>
64
70
  </div>
@@ -3,6 +3,10 @@ import { useState, useRef } from "https://esm.sh/preact/hooks";
3
3
  import htm from "https://esm.sh/htm";
4
4
  import { saveGoogleCredentials } from "../lib/api.js";
5
5
  import { SecretInput } from "./secret-input.js";
6
+ import { ModalShell } from "./modal-shell.js";
7
+ import { PageHeader } from "./page-header.js";
8
+ import { ActionButton } from "./action-button.js";
9
+ import { CloseIcon } from "./icons.js";
6
10
  const html = htm.bind(h);
7
11
 
8
12
  export const CredentialsModal = ({ visible, onClose, onSaved }) => {
@@ -93,16 +97,25 @@ export const CredentialsModal = ({ visible, onClose, onSaved }) => {
93
97
  </div>
94
98
  `;
95
99
 
96
- return html` <div
97
- class="fixed inset-0 bg-black/70 flex items-center justify-center p-4 z-50"
98
- onclick=${(e) => {
99
- if (e.target === e.currentTarget) onClose();
100
- }}
100
+ return html` <${ModalShell}
101
+ visible=${visible}
102
+ onClose=${onClose}
103
+ closeOnOverlayClick=${false}
104
+ panelClassName="bg-modal border border-border rounded-xl p-6 max-w-lg w-full space-y-4"
101
105
  >
102
- <div
103
- class="bg-modal border border-border rounded-xl p-6 max-w-lg w-full space-y-4"
104
- >
105
- <h2 class="text-lg font-semibold">Connect Google Workspace</h2>
106
+ <${PageHeader}
107
+ title="Connect Google Workspace"
108
+ actions=${html`
109
+ <button
110
+ type="button"
111
+ onclick=${onClose}
112
+ class="h-8 w-8 inline-flex items-center justify-center rounded-lg ac-btn-secondary"
113
+ aria-label="Close modal"
114
+ >
115
+ <${CloseIcon} className="w-3.5 h-3.5 text-gray-300" />
116
+ </button>
117
+ `}
118
+ />
106
119
  <div class="space-y-3">
107
120
  <div>
108
121
  <p class="text-gray-400 text-sm mb-3">
@@ -319,21 +332,17 @@ export const CredentialsModal = ({ visible, onClose, onSaved }) => {
319
332
  </div>
320
333
  </div>
321
334
  <div class="flex gap-2 pt-2">
322
- <button
323
- onclick=${submit}
335
+ <${ActionButton}
336
+ onClick=${submit}
324
337
  disabled=${saving}
325
- class="flex-1 font-medium py-2 rounded-lg text-sm ac-btn-cyan"
326
- >
327
- ${saving ? "Saving..." : "Connect Google"}
328
- </button>
329
- <button
330
- onclick=${onClose}
331
- class="px-4 py-2 rounded-lg text-sm ac-btn-secondary"
332
- >
333
- Cancel
334
- </button>
338
+ loading=${saving}
339
+ tone="primary"
340
+ size="lg"
341
+ idleLabel="Connect Google"
342
+ loadingLabel="Saving..."
343
+ className="w-full px-4 py-2 rounded-lg text-sm"
344
+ />
335
345
  </div>
336
346
  ${error ? html`<div class="text-red-400 text-xs">${error}</div>` : null}
337
- </div>
338
- </div>`;
347
+ </${ModalShell}>`;
339
348
  };
@@ -1,6 +1,7 @@
1
1
  import { h } from 'https://esm.sh/preact';
2
2
  import { useState } from 'https://esm.sh/preact/hooks';
3
3
  import htm from 'https://esm.sh/htm';
4
+ import { ActionButton } from './action-button.js';
4
5
  const html = htm.bind(h);
5
6
 
6
7
  const kModeLabels = {
@@ -55,8 +56,20 @@ const DeviceRow = ({ d, onApprove, onReject }) => {
55
56
  ${subtitle && html`<span class="text-xs text-gray-500">${subtitle}</span>`}
56
57
  </div>
57
58
  <div class="flex gap-2">
58
- <button onclick=${() => handle('approve')} class="ac-btn-green text-xs font-medium px-3 py-1.5 rounded-lg">Approve</button>
59
- <button onclick=${() => handle('reject')} class="bg-gray-800 text-gray-300 text-xs px-3 py-1.5 rounded-lg hover:bg-gray-700">Reject</button>
59
+ <${ActionButton}
60
+ onClick=${() => handle('approve')}
61
+ tone="success"
62
+ size="sm"
63
+ idleLabel="Approve"
64
+ className="font-medium px-3 py-1.5"
65
+ />
66
+ <${ActionButton}
67
+ onClick=${() => handle('reject')}
68
+ tone="secondary"
69
+ size="sm"
70
+ idleLabel="Reject"
71
+ className="font-medium px-3 py-1.5"
72
+ />
60
73
  </div>
61
74
  </div>`;
62
75
  };
@@ -1,9 +1,11 @@
1
1
  import { h } from "https://esm.sh/preact";
2
2
  import { useState, useEffect, useCallback, useRef } from "https://esm.sh/preact/hooks";
3
3
  import htm from "https://esm.sh/htm";
4
- import { fetchEnvVars, saveEnvVars, restartGateway } from "../lib/api.js";
4
+ import { fetchEnvVars, saveEnvVars } from "../lib/api.js";
5
5
  import { showToast } from "./toast.js";
6
6
  import { SecretInput } from "./secret-input.js";
7
+ import { PageHeader } from "./page-header.js";
8
+ import { ActionButton } from "./action-button.js";
7
9
  const html = htm.bind(h);
8
10
 
9
11
  const kGroupLabels = {
@@ -80,13 +82,11 @@ const EnvRow = ({ envVar, onChange, onDelete, disabled }) => {
80
82
  `;
81
83
  };
82
84
 
83
- export const Envars = () => {
85
+ export const Envars = ({ onRestartRequired = () => {} }) => {
84
86
  const [vars, setVars] = useState([]);
85
87
  const [reservedKeys, setReservedKeys] = useState(() => new Set());
86
88
  const [dirty, setDirty] = useState(false);
87
89
  const [saving, setSaving] = useState(false);
88
- const [restartingGateway, setRestartingGateway] = useState(false);
89
- const [restartRequired, setRestartRequired] = useState(false);
90
90
  const [newKey, setNewKey] = useState("");
91
91
  const baselineSignatureRef = useRef("[]");
92
92
 
@@ -97,7 +97,7 @@ export const Envars = () => {
97
97
  baselineSignatureRef.current = getVarsSignature(nextVars);
98
98
  setVars(nextVars);
99
99
  setReservedKeys(new Set(data.reservedKeys || []));
100
- setRestartRequired(!!data.restartRequired);
100
+ onRestartRequired(!!data.restartRequired);
101
101
  } catch (err) {
102
102
  console.error("Failed to load env vars:", err);
103
103
  }
@@ -128,7 +128,7 @@ export const Envars = () => {
128
128
  .map((v) => ({ key: v.key, value: v.value }));
129
129
  const result = await saveEnvVars(toSave);
130
130
  const needsRestart = !!result?.restartRequired;
131
- setRestartRequired(needsRestart);
131
+ if (needsRestart) onRestartRequired(true);
132
132
  showToast(
133
133
  needsRestart
134
134
  ? "Environment variables saved. Restart gateway to apply."
@@ -144,20 +144,6 @@ export const Envars = () => {
144
144
  }
145
145
  };
146
146
 
147
- const handleRestartGateway = async () => {
148
- if (restartingGateway) return;
149
- setRestartingGateway(true);
150
- try {
151
- await restartGateway();
152
- setRestartRequired(false);
153
- showToast("Gateway restarted", "success");
154
- } catch (err) {
155
- showToast("Restart failed: " + err.message, "error");
156
- } finally {
157
- setRestartingGateway(false);
158
- }
159
- };
160
-
161
147
  const [newVal, setNewVal] = useState("");
162
148
 
163
149
  const parsePaste = (input) => {
@@ -281,6 +267,22 @@ export const Envars = () => {
281
267
 
282
268
  return html`
283
269
  <div class="space-y-4">
270
+ <${PageHeader}
271
+ title="Envars"
272
+ actions=${html`
273
+ <${ActionButton}
274
+ onClick=${handleSave}
275
+ disabled=${!dirty || saving}
276
+ loading=${saving}
277
+ tone="primary"
278
+ size="sm"
279
+ idleLabel="Save changes"
280
+ loadingLabel="Saving..."
281
+ className="transition-all"
282
+ />
283
+ `}
284
+ />
285
+
284
286
  ${kGroupOrder
285
287
  .filter((g) => grouped[g]?.length)
286
288
  .map(
@@ -341,51 +343,6 @@ export const Envars = () => {
341
343
  </div>
342
344
  </div>
343
345
 
344
- ${restartRequired
345
- ? html`<div
346
- class="bg-yellow-500/10 border border-yellow-500/30 rounded-xl p-4 flex items-center justify-between gap-3"
347
- >
348
- <p class="text-sm text-yellow-200">
349
- Gateway restart required to apply env changes.
350
- </p>
351
- <button
352
- onclick=${handleRestartGateway}
353
- disabled=${restartingGateway}
354
- class="text-xs px-2.5 py-1 rounded-lg border border-yellow-500/40 text-yellow-200 hover:border-yellow-400 hover:text-yellow-100 transition-colors shrink-0 ${restartingGateway
355
- ? "opacity-60 cursor-not-allowed"
356
- : ""}"
357
- >
358
- ${restartingGateway ? "Restarting..." : "Restart Gateway"}
359
- </button>
360
- </div>`
361
- : null}
362
-
363
- <button
364
- onclick=${handleSave}
365
- disabled=${!dirty || saving || restartingGateway}
366
- class="w-full text-sm font-medium px-4 py-2.5 rounded-xl transition-all ac-btn-cyan"
367
- >
368
- ${saving
369
- ? html`<span class="flex items-center justify-center gap-2">
370
- <svg class="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
371
- <circle
372
- class="opacity-25"
373
- cx="12"
374
- cy="12"
375
- r="10"
376
- stroke="currentColor"
377
- stroke-width="4"
378
- />
379
- <path
380
- class="opacity-75"
381
- fill="currentColor"
382
- d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
383
- />
384
- </svg>
385
- Saving...
386
- </span>`
387
- : "Save changes"}
388
- </button>
389
346
  </div>
390
347
  `;
391
348
  };
@@ -63,7 +63,7 @@ export const Features = ({ onSwitchTab }) => {
63
63
  e.preventDefault();
64
64
  onSwitchTab?.("providers");
65
65
  }}
66
- class="text-xs text-gray-500 hover:text-gray-300"
66
+ class="text-xs px-2 py-1 rounded-lg ac-btn-ghost"
67
67
  >Add provider</a>
68
68
  <${Badge} tone=${feature.hasDefault ? "neutral" : "danger"}>Disabled</${Badge}>
69
69
  </span>
@@ -1,16 +1,29 @@
1
1
  import { h } from "https://esm.sh/preact";
2
- import { useEffect, useState } from "https://esm.sh/preact/hooks";
2
+ import { useEffect, useMemo, useState } from "https://esm.sh/preact/hooks";
3
3
  import htm from "https://esm.sh/htm";
4
4
  import {
5
5
  fetchOpenclawVersion,
6
- restartGateway,
7
6
  updateOpenclaw,
8
7
  } from "../lib/api.js";
9
- import { showToast } from "./toast.js";
10
8
  import { UpdateActionButton } from "./update-action-button.js";
11
9
  import { ConfirmDialog } from "./confirm-dialog.js";
10
+ import { showToast } from "./toast.js";
12
11
  const html = htm.bind(h);
13
12
 
13
+ const formatDuration = (ms) => {
14
+ const safeMs = Number(ms || 0);
15
+ if (!Number.isFinite(safeMs) || safeMs <= 0) return "0s";
16
+ const totalSeconds = Math.floor(safeMs / 1000);
17
+ const days = Math.floor(totalSeconds / 86400);
18
+ const hours = Math.floor(totalSeconds / 3600);
19
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
20
+ const seconds = totalSeconds % 60;
21
+ if (days > 0) return `${days}d ${hours % 24}h ${minutes}m ${seconds}s`;
22
+ if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`;
23
+ if (minutes > 0) return `${minutes}m ${seconds}s`;
24
+ return `${seconds}s`;
25
+ };
26
+
14
27
  function VersionRow({ label, currentVersion, fetchVersion, applyUpdate }) {
15
28
  const [checking, setChecking] = useState(false);
16
29
  const [version, setVersion] = useState(currentVersion || null);
@@ -140,7 +153,12 @@ function VersionRow({ label, currentVersion, fetchVersion, applyUpdate }) {
140
153
  ? `${version}`
141
154
  : "..."}
142
155
  </p>
143
- ${error && html`<p class="text-xs text-yellow-500 mt-1">${error}</p>`}
156
+ ${error && effectiveHasUpdate &&
157
+ html`<div
158
+ class="mt-1 text-xs text-red-300 bg-red-900/30 border border-red-800 rounded-lg px-2 py-1"
159
+ >
160
+ ${error}
161
+ </div>`}
144
162
  </div>
145
163
  <div class="flex items-center gap-2 shrink-0">
146
164
  ${effectiveHasUpdate && effectiveLatestVersion && html`
@@ -212,44 +230,133 @@ function VersionRow({ label, currentVersion, fetchVersion, applyUpdate }) {
212
230
  `;
213
231
  }
214
232
 
215
- export function Gateway({ status, openclawVersion }) {
216
- const [restarting, setRestarting] = useState(false);
233
+ export function Gateway({
234
+ status,
235
+ openclawVersion,
236
+ restarting = false,
237
+ onRestart,
238
+ watchdogStatus = null,
239
+ onOpenWatchdog,
240
+ onRepair,
241
+ repairing = false,
242
+ }) {
243
+ const [nowMs, setNowMs] = useState(() => Date.now());
217
244
  const isRunning = status === "running" && !restarting;
218
245
  const dotClass = isRunning
219
- ? "w-2 h-2 rounded-full bg-green-500"
246
+ ? "ac-status-dot ac-status-dot--healthy"
220
247
  : "w-2 h-2 rounded-full bg-yellow-500 animate-pulse";
221
-
222
- const handleRestart = async () => {
223
- if (restarting) return;
224
- setRestarting(true);
225
- try {
226
- await restartGateway();
227
- showToast("Gateway restarted", "success");
228
- } catch (err) {
229
- showToast("Restart failed: " + err.message, "error");
248
+ const watchdogHealth = watchdogStatus?.lifecycle === "crash_loop"
249
+ ? "crash_loop"
250
+ : watchdogStatus?.health;
251
+ const watchdogDotClass = watchdogHealth === "healthy"
252
+ ? "ac-status-dot ac-status-dot--healthy ac-status-dot--healthy-offset"
253
+ : watchdogHealth === "degraded"
254
+ ? "bg-yellow-500"
255
+ : watchdogHealth === "unhealthy" || watchdogHealth === "crash_loop"
256
+ ? "bg-red-500"
257
+ : "bg-gray-500";
258
+ const watchdogLabel = watchdogHealth === "unknown"
259
+ ? "initializing"
260
+ : watchdogHealth || "unknown";
261
+ const isRepairInProgress = repairing || !!watchdogStatus?.operationInProgress;
262
+ const showRepairButton =
263
+ isRepairInProgress ||
264
+ watchdogStatus?.lifecycle === "crash_loop" ||
265
+ watchdogStatus?.health === "degraded" ||
266
+ watchdogStatus?.health === "unhealthy" ||
267
+ watchdogStatus?.health === "crashed";
268
+ const liveUptimeMs = useMemo(() => {
269
+ const startedAtMs = watchdogStatus?.uptimeStartedAt
270
+ ? Date.parse(watchdogStatus.uptimeStartedAt)
271
+ : null;
272
+ if (Number.isFinite(startedAtMs)) {
273
+ return Math.max(0, nowMs - startedAtMs);
230
274
  }
231
- setRestarting(false);
232
- };
275
+ return watchdogStatus?.uptimeMs || 0;
276
+ }, [watchdogStatus?.uptimeStartedAt, watchdogStatus?.uptimeMs, nowMs]);
277
+
278
+ useEffect(() => {
279
+ const id = setInterval(() => {
280
+ setNowMs(Date.now());
281
+ }, 1000);
282
+ return () => clearInterval(id);
283
+ }, []);
233
284
 
234
285
  return html` <div class="bg-surface border border-border rounded-xl p-4">
235
- <div class="flex items-start justify-between gap-3">
236
- <div class="min-w-0">
237
- <div class="flex items-center gap-2">
286
+ <div class="space-y-2">
287
+ <div class="flex items-center justify-between gap-3">
288
+ <div class="min-w-0 flex items-center gap-2 text-sm">
238
289
  <span class=${dotClass}></span>
239
290
  <span class="font-semibold">Gateway:</span>
240
- <span class="text-gray-400"
241
- >${restarting ? "restarting..." : status || "checking..."}</span
242
- >
291
+ <span class="text-gray-400">${restarting ? "restarting..." : status || "checking..."}</span>
292
+ ${!restarting && isRunning
293
+ ? html`
294
+ <span class="hidden md:inline text-gray-500">·</span>
295
+ <span class="hidden md:inline text-xs text-gray-500"
296
+ >Uptime: ${formatDuration(liveUptimeMs)}</span
297
+ >
298
+ `
299
+ : null}
243
300
  </div>
301
+ <${UpdateActionButton}
302
+ onClick=${onRestart}
303
+ disabled=${!status}
304
+ loading=${restarting}
305
+ warning=${false}
306
+ idleLabel="Restart"
307
+ loadingLabel="On it..."
308
+ />
309
+ </div>
310
+ ${!restarting && isRunning
311
+ ? html`
312
+ <div class="md:hidden pl-4 text-xs text-gray-500">
313
+ Uptime: ${formatDuration(liveUptimeMs)}
314
+ </div>
315
+ `
316
+ : null}
317
+ <div class="flex items-center justify-between gap-3">
318
+ ${onOpenWatchdog
319
+ ? html`
320
+ <button
321
+ class="inline-flex items-center gap-2 text-sm hover:opacity-90"
322
+ onclick=${onOpenWatchdog}
323
+ title="Open Watchdog tab"
324
+ >
325
+ <span class=${watchdogDotClass.startsWith("ac-status-dot")
326
+ ? watchdogDotClass
327
+ : `w-2 h-2 rounded-full ${watchdogDotClass}`}></span>
328
+ <span class="font-semibold">Watchdog:</span>
329
+ <span class="text-gray-400">${watchdogLabel}</span>
330
+ </button>
331
+ `
332
+ : html`
333
+ <div class="inline-flex items-center gap-2 text-sm">
334
+ <span class=${watchdogDotClass.startsWith("ac-status-dot")
335
+ ? watchdogDotClass
336
+ : `w-2 h-2 rounded-full ${watchdogDotClass}`}></span>
337
+ <span class="font-semibold">Watchdog:</span>
338
+ <span class="text-gray-400">${watchdogLabel}</span>
339
+ </div>
340
+ `}
341
+ ${onRepair
342
+ ? html`
343
+ <div class="shrink-0 w-32 flex justify-end">
344
+ ${showRepairButton
345
+ ? html`
346
+ <${UpdateActionButton}
347
+ onClick=${onRepair}
348
+ loading=${isRepairInProgress}
349
+ warning=${true}
350
+ idleLabel="Repair"
351
+ loadingLabel="Repairing..."
352
+ className="w-full justify-center"
353
+ />
354
+ `
355
+ : html`<span class="inline-flex h-7 w-full" aria-hidden="true"></span>`}
356
+ </div>
357
+ `
358
+ : null}
244
359
  </div>
245
- <${UpdateActionButton}
246
- onClick=${handleRestart}
247
- disabled=${!status}
248
- loading=${restarting}
249
- warning=${false}
250
- idleLabel="Restart"
251
- loadingLabel="On it..."
252
- />
253
360
  </div>
254
361
  <div class="mt-3 pt-3 border-t border-border">
255
362
  <${VersionRow}
@@ -0,0 +1,31 @@
1
+ import { h } from "https://esm.sh/preact";
2
+ import htm from "https://esm.sh/htm";
3
+ import { UpdateActionButton } from "./update-action-button.js";
4
+
5
+ const html = htm.bind(h);
6
+
7
+ export const GlobalRestartBanner = ({
8
+ visible = false,
9
+ restarting = false,
10
+ onRestart,
11
+ }) => {
12
+ if (!visible) return null;
13
+ return html`
14
+ <div class="global-restart-banner">
15
+ <div class="global-restart-banner__content">
16
+ <p class="global-restart-banner__text">
17
+ Gateway restart required to apply pending configuration changes.
18
+ </p>
19
+ <${UpdateActionButton}
20
+ onClick=${onRestart}
21
+ disabled=${restarting}
22
+ loading=${restarting}
23
+ warning=${true}
24
+ idleLabel="Restart Gateway"
25
+ loadingLabel="Restarting..."
26
+ className="global-restart-banner__button"
27
+ />
28
+ </div>
29
+ </div>
30
+ `;
31
+ };