Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 17 additions & 10 deletions lib/public/js/components/gateway.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,19 +38,26 @@ export const Gateway = ({
: watchdogStatus?.health;
const hasConfigurationError =
watchdogStatus?.lifecycle === "configuration_error";
// Safe mode: gateway healthy but channel autostart suppressed by its
// crash-loop breaker — a green "healthy" badge would be misleading.
const isSafeMode = !!watchdogStatus?.safeMode;
const watchdogDotClass =
watchdogHealth === "healthy"
? "ac-status-dot ac-status-dot--healthy ac-status-dot--healthy-offset"
: watchdogHealth === "degraded"
? "bg-yellow-500"
: watchdogHealth === "unhealthy" || watchdogHealth === "crash_loop"
? "bg-red-500"
: "bg-gray-500";
isSafeMode && watchdogHealth === "healthy"
? "bg-yellow-500"
: watchdogHealth === "healthy"
? "ac-status-dot ac-status-dot--healthy ac-status-dot--healthy-offset"
: watchdogHealth === "degraded"
? "bg-yellow-500"
: watchdogHealth === "unhealthy" || watchdogHealth === "crash_loop"
? "bg-red-500"
: "bg-gray-500";
const watchdogLabel = hasConfigurationError
? "configuration error"
: watchdogHealth === "unknown"
? "initializing"
: watchdogHealth || "unknown";
: isSafeMode
? "safe mode"
: watchdogHealth === "unknown"
? "initializing"
: watchdogHealth || "unknown";
const isRepairInProgress = repairing || !!watchdogStatus?.operationInProgress;
const showInspectButton = watchdogHealth === "degraded" && !!onOpenWatchdog;
const showRepairButton =
Expand Down
20 changes: 20 additions & 0 deletions lib/public/js/components/watchdog-tab/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,26 @@ export const getIncidentStatusTone = (event) => {
};
};

// OpenClaw 2026.7.1+ can boot into control-plane-safe mode after its
// crash-loop breaker trips: the gateway reports healthy while channel
// autostart stays suppressed. Returns null when no banner should render.
export const buildSafeModeBannerModel = (watchdogStatus = null) => {
if (!watchdogStatus?.safeMode) return null;
const channels = Array.isArray(watchdogStatus.suppressedChannels)
? watchdogStatus.suppressedChannels
.map((entry) => String(entry || "").trim())
.filter(Boolean)
: [];
return {
title: "Gateway is in safe mode",
body:
channels.length > 0
? `Channel autostart was suppressed by the gateway's crash-loop breaker. Suppressed: ${channels.join(", ")}. These channels are not delivering messages.`
: "Channel autostart was suppressed by the gateway's crash-loop breaker.",
channels,
};
};

export const formatWatchdogCopyAllText = ({
logs = "",
generatedAt = null,
Expand Down
7 changes: 7 additions & 0 deletions lib/public/js/components/watchdog-tab/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { h } from "preact";
import htm from "htm";
import { Gateway } from "../gateway.js";
import { useWatchdogTab } from "./use-watchdog-tab.js";
import { WatchdogSafeModeBanner } from "./safe-mode-banner.js";
import { WatchdogResourcesCard } from "./resources/index.js";
import { WatchdogSettingsCard } from "./settings/index.js";
import { WatchdogConsoleCard } from "./console/index.js";
Expand All @@ -26,6 +27,12 @@ export const WatchdogTab = ({

return html`
<div class="space-y-4">
<${WatchdogSafeModeBanner}
watchdogStatus=${state.currentWatchdogStatus}
onResumeChannels=${state.onResumeChannels}
resuming=${state.resumingChannels}
/>

<${Gateway}
status=${gatewayStatus}
openclawVersion=${openclawVersion}
Expand Down
37 changes: 37 additions & 0 deletions lib/public/js/components/watchdog-tab/safe-mode-banner.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { h } from "preact";
import htm from "htm";
import { UpdateActionButton } from "../update-action-button.js";
import { buildSafeModeBannerModel } from "./helpers.js";

const html = htm.bind(h);

export const WatchdogSafeModeBanner = ({
watchdogStatus = null,
onResumeChannels = () => {},
resuming = false,
}) => {
const model = buildSafeModeBannerModel(watchdogStatus);
if (!model) return null;

return html`
<div class="bg-surface border border-yellow-500/40 rounded-xl p-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-yellow-500 animate-pulse"></span>
<span class="text-sm font-medium">${model.title}</span>
</div>
<p class="mt-1 text-sm text-muted">${model.body}</p>
</div>
<${UpdateActionButton}
onClick=${onResumeChannels}
loading=${resuming}
disabled=${resuming}
warning=${true}
idleLabel="Resume channels"
loadingLabel="Resuming..."
/>
</div>
</div>
`;
};
22 changes: 22 additions & 0 deletions lib/public/js/components/watchdog-tab/settings/use-settings.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useEffect, useState } from "preact/hooks";
import {
fetchWatchdogSettings,
resumeWatchdogChannels,
triggerWatchdogRepair,
updateWatchdogSettings,
} from "../../../lib/api.js";
Expand All @@ -17,6 +18,7 @@ export const useWatchdogSettings = ({
});
const [savingSettings, setSavingSettings] = useState(false);
const [repairing, setRepairing] = useState(false);
const [resumingChannels, setResumingChannels] = useState(false);
const isRepairInProgress =
repairing || !!(watchdogStatus || {})?.operationInProgress;

Expand Down Expand Up @@ -106,12 +108,32 @@ export const useWatchdogSettings = ({
}
};

const onResumeChannels = async () => {
if (resumingChannels) return;
setResumingChannels(true);
try {
const data = await resumeWatchdogChannels();
if (!data.ok) throw new Error(data.error || "Resume failed");
showToast("Channels resuming", "success");
setTimeout(() => {
onRefreshStatuses();
onRefreshIncidents();
}, 800);
} catch (error) {
showToast(error.message || "Could not resume channels", "error");
} finally {
setResumingChannels(false);
}
};

return {
settings,
savingSettings,
isRepairInProgress,
onToggleAutoRepair,
onToggleNotifications,
onRepair,
onResumeChannels,
resumingChannels,
};
};
2 changes: 2 additions & 0 deletions lib/public/js/components/watchdog-tab/use-watchdog-tab.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ export const useWatchdogTab = ({
onToggleNotifications: settings.onToggleNotifications,
onRepair: settings.onRepair,
isRepairInProgress: settings.isRepairInProgress,
onResumeChannels: settings.onResumeChannels,
resumingChannels: settings.resumingChannels,
logs: consoleState.logs,
loadingLogs: consoleState.loadingLogs,
copyingAll: consoleState.copyingAll,
Expand Down
7 changes: 7 additions & 0 deletions lib/public/js/lib/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,13 @@ export async function triggerWatchdogRepair() {
return parseJsonOrThrow(res, "Could not trigger watchdog repair");
}

export async function resumeWatchdogChannels() {
const res = await authFetch("/api/watchdog/resume-channels", {
method: "POST",
});
return parseJsonOrThrow(res, "Could not resume channels");
}

export async function fetchWatchdogResources() {
const res = await authFetch("/api/watchdog/resources");
return parseJsonOrThrow(res, "Could not load system resources");
Expand Down
1 change: 1 addition & 0 deletions lib/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,7 @@ const watchdog = createWatchdog({
reloadEnv,
resolveSetupUrl,
resolveGatewayHealthUrl: () => `${getGatewayUrl()}/health`,
resolveGatewayReadyzUrl: () => `${getGatewayUrl()}/readyz`,
});
const watchdogTerminal = createWatchdogTerminalService({
cwd: constants.OPENCLAW_DIR,
Expand Down
13 changes: 13 additions & 0 deletions lib/server/routes/watchdog.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,19 @@ const registerWatchdogRoutes = ({
}
});

app.post("/api/watchdog/resume-channels", requireAuth, async (req, res) => {
try {
const result = await watchdog.resumeChannels();
if (result?.skipped) {
res.status(409).json({ ok: false, error: result.reason, result });
return;
}
res.json({ ok: !!result?.ok, result });
} catch (err) {
res.status(500).json({ ok: false, error: err.message });
}
});

app.get("/api/watchdog/settings", requireAuth, (req, res) => {
try {
res.json({ ok: true, settings: watchdog.getSettings() });
Expand Down
Loading