diff --git a/app/api/config-backup/route.ts b/app/api/config-backup/route.ts new file mode 100644 index 0000000..482f884 --- /dev/null +++ b/app/api/config-backup/route.ts @@ -0,0 +1,61 @@ +import { NextResponse, NextRequest } from "next/server"; +import { + listBackupFiles, + restoreFromBackup, + getBackupDir, +} from "@/lib/config-backup"; + +/** + * GET /api/config-backup + * 列出所有可用的 openclaw.json 備份 + */ +export async function GET() { + try { + const backups = listBackupFiles(); + return NextResponse.json({ + backupDir: getBackupDir(), + backups, + }); + } catch (err: any) { + return NextResponse.json( + { error: err.message }, + { status: 500 } + ); + } +} + +/** + * POST /api/config-backup + * 從指定備份還原 openclaw.json + * + * Request body: { filename: "openclaw.2026-03-15T08-30-00.json" } + */ +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { filename } = body; + + if (!filename || typeof filename !== "string") { + return NextResponse.json( + { error: "Missing or invalid 'filename' in request body" }, + { status: 400 } + ); + } + + const result = restoreFromBackup(filename); + + if (!result.success) { + return NextResponse.json( + { error: result.message }, + { status: 400 } + ); + } + + return NextResponse.json(result); + } catch (err: any) { + return NextResponse.json( + { error: err.message }, + { status: 500 } + ); + } +} diff --git a/app/api/config/route.ts b/app/api/config/route.ts index c66f279..faf3a57 100644 --- a/app/api/config/route.ts +++ b/app/api/config/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import fs from "fs"; import path from "path"; import { OPENCLAW_CONFIG_PATH, OPENCLAW_HOME } from "@/lib/openclaw-paths"; +import { detectChangeAndBackup } from "@/lib/config-backup"; // 配置文件路径:优先使用 OPENCLAW_HOME 环境变量,否则默认 ~/.openclaw const CONFIG_PATH = OPENCLAW_CONFIG_PATH; @@ -262,6 +263,10 @@ export async function GET() { try { const raw = fs.readFileSync(CONFIG_PATH, "utf-8"); + + // 偵測 openclaw.json 是否有變更,若有則自動備份 + detectChangeAndBackup(raw); + const config = JSON.parse(raw); // 提取 agents 信息 @@ -536,6 +541,13 @@ export async function GET() { } } + // 取得 openclaw.json 的最後修改時間,用於前端偵測近期 config 變更 + let configLastModified: string | null = null; + try { + const stat = fs.statSync(CONFIG_PATH); + configLastModified = stat.mtime.toISOString(); + } catch { /* ignore */ } + const data = { agents: agentsWithStatus, providers, @@ -546,6 +558,7 @@ export async function GET() { host: process.env.NEXT_PUBLIC_GATEWAY_CHAT_BASE_URL || config.gateway?.host || config.gateway?.hostname || "", }, groupChats, + configLastModified, }; configCache = { data, ts: Date.now() }; return NextResponse.json(data); diff --git a/app/api/gateway-logs/route.ts b/app/api/gateway-logs/route.ts index 4a27565..5079a26 100644 --- a/app/api/gateway-logs/route.ts +++ b/app/api/gateway-logs/route.ts @@ -39,11 +39,15 @@ export async function GET() { ? Date.now() - new Date(lastStallAt).getTime() < STALL_RECENT_MS : false; + // 回傳最後 30 行作為原始紀錄供 UI 顯示 + const recentLines = lines.slice(-30); + return NextResponse.json({ ok: true, issues: [...issues], lastStallAt, stallActive, + recentLines, }); } catch { return NextResponse.json({ ok: false, issues: [], lastStallAt: null, stallActive: false }); diff --git a/app/gateway-status.tsx b/app/gateway-status.tsx index e0ea97a..4fe42b3 100644 --- a/app/gateway-status.tsx +++ b/app/gateway-status.tsx @@ -25,6 +25,13 @@ interface LogResult { issues: string[]; lastStallAt: string | null; stallActive: boolean; + recentLines?: string[]; +} + +interface BackupEntry { + filename: string; + timestamp: string; + sizeBytes: number; } interface GatewayStatusProps { @@ -42,16 +49,17 @@ export function GatewayStatus({ compact = false, className = "", hideIconOnMobil const [restarting, setRestarting] = useState(false); const [restartMsg, setRestartMsg] = useState(null); - const checkHealth = useCallback(() => { - fetch("/api/gateway-health") - .then((r) => r.json()) - .then((d: HealthResult) => { - setHealth(d); - // If health is down, also fetch logs for more context - if (!d.ok) fetchLogs(); - }) - .catch(() => setHealth({ ok: false, error: t("gateway.fetchError") })); - }, [t]); + // Config backup/restore state + const [backups, setBackups] = useState([]); + const [restoring, setRestoring] = useState(false); + const [restoreMsg, setRestoreMsg] = useState(null); + const [reloadCountdown, setReloadCountdown] = useState(null); + const [showLogs, setShowLogs] = useState(false); + // Track consecutive failures to detect persistent config problems + const [consecutiveDownCount, setConsecutiveDownCount] = useState(0); + // Config change detection + const [configLastModified, setConfigLastModified] = useState(null); + const [configPromptDismissed, setConfigPromptDismissed] = useState(false); const fetchLogs = useCallback(() => { fetch("/api/gateway-logs") @@ -60,6 +68,46 @@ export function GatewayStatus({ compact = false, className = "", hideIconOnMobil .catch(() => {}); }, []); + const fetchBackups = useCallback(() => { + fetch("/api/config-backup") + .then((r) => r.json()) + .then((d) => setBackups(d.backups || [])) + .catch(() => setBackups([])); + }, []); + + const fetchConfigMtime = useCallback(() => { + fetch("/api/config") + .then((r) => r.json()) + .then((d) => { if (d.configLastModified) setConfigLastModified(d.configLastModified); }) + .catch(() => {}); + }, []); + + const checkHealth = useCallback(() => { + fetch("/api/gateway-health") + .then((r) => r.json()) + .then((d: HealthResult) => { + setHealth(d); + if (!d.ok) { + fetchLogs(); + setConsecutiveDownCount((c) => { + // 第一次失敗就抓備份,讓使用者一開面板就能看到 + if (c === 0) { + fetchBackups(); + fetchConfigMtime(); + } + return c + 1; + }); + } else { + setConsecutiveDownCount(0); + setConfigPromptDismissed(false); + } + }) + .catch(() => { + setHealth({ ok: false, error: t("gateway.fetchError") }); + setConsecutiveDownCount((c) => c + 1); + }); + }, [t, fetchLogs, fetchBackups, fetchConfigMtime]); + useEffect(() => { checkHealth(); const timer = setInterval(checkHealth, 10000); @@ -67,10 +115,15 @@ export function GatewayStatus({ compact = false, className = "", hideIconOnMobil }, [checkHealth]); const handleDetailClick = useCallback(() => { - setShowDetail((v) => !v); - // Fetch fresh logs whenever the user opens the detail panel - fetchLogs(); - }, [fetchLogs]); + setShowDetail((v) => { + if (!v) { + // Opening panel — fetch fresh data + fetchLogs(); + fetchBackups(); + } + return !v; + }); + }, [fetchLogs, fetchBackups]); const handleRestart = useCallback(async () => { if (restarting) return; @@ -96,6 +149,45 @@ export function GatewayStatus({ compact = false, className = "", hideIconOnMobil } }, [restarting, checkHealth]); + const handleRestore = useCallback(async (filename: string) => { + if (restoring) return; + setRestoring(true); + setRestoreMsg(null); + try { + const res = await fetch("/api/config-backup", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ filename }), + }); + const data = await res.json(); + if (data.success) { + setRestoreMsg(t("gateway.restoreSuccess")); + // Auto-restart gateway after restore, then countdown to reload + setTimeout(async () => { + await fetch("/api/gateway-restart", { method: "POST" }).catch(() => {}); + // Start 5-second countdown + let count = 5; + setReloadCountdown(count); + const tick = setInterval(() => { + count -= 1; + if (count <= 0) { + clearInterval(tick); + window.location.reload(); + } else { + setReloadCountdown(count); + } + }, 1000); + }, 500); + } else { + setRestoreMsg(`${t("gateway.restoreFailed")}:${data.error || ""}`); + } + } catch (err: any) { + setRestoreMsg(`${t("gateway.restoreFailed")}:${err.message}`); + } finally { + setRestoring(false); + } + }, [restoring, checkHealth, t]); + const gatewayTitle = health?.openclawVersion ? `OpenClaw ${health.openclawVersion}` : "OpenClaw"; @@ -105,6 +197,17 @@ export function GatewayStatus({ compact = false, className = "", hideIconOnMobil const showWarning = telegramStall; // Show restart button when: down, or Telegram stalled const showRestart = health !== null; + // 只要 gateway 下線且有備份,就顯示還原清單(不等 3 次失敗) + const showConfigHint = !health?.ok && backups.length > 0; + + // Detect recent config change: modified within last 5 minutes + const configRecentlyChanged = (() => { + if (!configLastModified) return false; + const mtime = new Date(configLastModified).getTime(); + return Date.now() - mtime < 5 * 60 * 1000; + })(); + // 只要 gateway 下線 + config 近期有改動,就顯示醒目提示 + const showConfigChangePrompt = !health?.ok && configRecentlyChanged && !configPromptDismissed; return (
@@ -220,6 +323,149 @@ export function GatewayStatus({ compact = false, className = "", hideIconOnMobil
)} + {/* Log toggle — always visible when gateway is down */} + {health && !health.ok && ( + + )} + + {/* Log viewer */} + {showLogs && ( +
+ {logResult?.recentLines && logResult.recentLines.length > 0 ? ( +
+                    {logResult.recentLines.join("\n")}
+                  
+ ) : ( +

+ )} +
+ )} + + {/* Config change prompt — prominent banner when config recently changed */} + {showConfigChangePrompt && ( +
+
+ ⚠️ +
+
{t("gateway.noResponse")}
+
{t("gateway.configChanged")}
+
+
+ + {/* Action buttons */} +
+ {(() => { + const recommended = findRecommendedBackup(backups); + return recommended ? ( + + ) : null; + })()} + +
+
+ )} + + {/* Config error hint + backup restore (when no recent change detected, or dismissed the prompt) */} + {showConfigHint && !showConfigChangePrompt && ( +
+
+ 📋 +
+
{t("gateway.configError")}
+
{t("gateway.configErrorDesc")}
+
+
+ + {/* Backup list */} +
+
+ {t("gateway.backupAvailable")} ({backups.length}) +
+ {backups.map((b) => { + const isRecommended = b.sizeBytes >= 1024; + const isSuspect = b.sizeBytes < 1024; + return ( +
+
+ + {formatBackupTime(b.timestamp)} + + + {formatSize(b.sizeBytes)} + + {isRecommended && ( + {t("gateway.backupRecommended")} + )} + {isSuspect && ( + {t("gateway.backupSuspect")} + )} +
+ +
+ ); + })} +
+
+ )} + + {/* When gateway is down but no backups available */} + {!health?.ok && consecutiveDownCount >= 3 && backups.length === 0 && ( +
+ 📋 {t("gateway.noBackups")} +
+ )} + + {/* Restore result message */} + {restoreMsg && ( +
+ {restoreMsg} +
+ )} + + {/* Countdown to reload */} + {reloadCountdown !== null && ( +
+
+ + 🔄 {reloadCountdown} {t("gateway.reloadCountdown")} + + +
+
+ {t("gateway.reloadHint")} +
+
+ )} + {/* Restart result message */} {restartMsg && (
); } + +/** Format backup timestamp for display: "3/15 08:30" */ +function formatBackupTime(timestamp: string): string { + try { + const d = new Date(timestamp); + if (isNaN(d.getTime())) return timestamp; + const month = d.getMonth() + 1; + const day = d.getDate(); + const hour = String(d.getHours()).padStart(2, "0"); + const min = String(d.getMinutes()).padStart(2, "0"); + return `${month}/${day} ${hour}:${min}`; + } catch { + return timestamp; + } +} + +/** Format file size for display */ +function formatSize(bytes: number): string { + if (bytes >= 1024) return (bytes / 1024).toFixed(1) + " KB"; + return bytes + " B"; +} + +/** + * Find the recommended backup: the latest one with size >= 1 KB. + * Small files (< 1024 bytes) are likely broken/empty configs. + */ +function findRecommendedBackup(backups: BackupEntry[]): BackupEntry | null { + return backups.find((b) => b.sizeBytes >= 1024) ?? null; +} diff --git a/app/page.tsx b/app/page.tsx index cef3a75..3eb404c 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -526,8 +526,16 @@ export default function Home() { if (error && !data) { return ( -
-

{t("common.loadError")}: {error}

+
+
+ +
+
+

{t("common.loadError")}: {error}

+

+ {t("gateway.configCorruptHint")} +

+
); } diff --git a/lib/config-backup.ts b/lib/config-backup.ts new file mode 100644 index 0000000..b9d5c61 --- /dev/null +++ b/lib/config-backup.ts @@ -0,0 +1,235 @@ +/** + * openclaw.json 備份與還原工具模組 + * + * 功能: + * 1. 透過 SHA-256 hash 偵測設定檔變更 + * 2. 變更時自動備份上一個版本 + * 3. 列出可用備份 + * 4. 從備份還原 + */ +import fs from "fs"; +import path from "path"; +import crypto from "crypto"; +import { OPENCLAW_HOME, OPENCLAW_CONFIG_PATH } from "./openclaw-paths"; + +// ── 常數 ──────────────────────────────────────────────── +const BACKUP_DIR = path.join(OPENCLAW_HOME, "backups", "config"); +const HASH_FILE = path.join(BACKUP_DIR, ".last-hash"); +const MAX_ROLLING = 8; // 一般滾動備份保留數 +const MIN_GOOD_SIZE = 1024; // 小於此 bytes 視為損毀,不計入錨點 + +// ── 持久化 hash(讀寫磁碟,重啟後仍有效)──────────────── +function readPersistedHash(): string | null { + try { + const h = fs.readFileSync(HASH_FILE, "utf-8").trim(); + return h.length === 64 ? h : null; // SHA-256 = 64 hex chars + } catch { + return null; + } +} + +function writePersistedHash(hash: string): void { + try { + ensureBackupDir(); + fs.writeFileSync(HASH_FILE, hash, "utf-8"); + } catch { /* ignore */ } +} + +// ── Hash ──────────────────────────────────────────────── +export function computeHash(content: string): string { + return crypto.createHash("sha256").update(content).digest("hex"); +} + +// ── 備份目錄初始化 ────────────────────────────────────── +function ensureBackupDir(): void { + if (!fs.existsSync(BACKUP_DIR)) { + fs.mkdirSync(BACKUP_DIR, { recursive: true }); + } +} + +// ── 產生備份檔名 ──────────────────────────────────────── +function makeBackupFilename(): string { + // openclaw.2026-03-15T08-30-00.json + const ts = new Date() + .toISOString() + .replace(/:/g, "-") + .replace(/\.\d+Z$/, ""); + return `openclaw.${ts}.json`; +} + +// ── 執行備份(將「目前磁碟上的版本」存到備份資料夾)────── +export function backupCurrentConfig(): { filename: string } | null { + try { + const content = fs.readFileSync(OPENCLAW_CONFIG_PATH, "utf-8"); + ensureBackupDir(); + const filename = makeBackupFilename(); + const dest = path.join(BACKUP_DIR, filename); + fs.writeFileSync(dest, content, "utf-8"); + pruneOldBackups(); + return { filename }; + } catch { + return null; + } +} + +// ── 清理備份,保留策略:────────────────────────────────── +// - 昨天錨點:昨天最後一個正常備份(sizeBytes >= MIN_GOOD_SIZE) +// - 上週錨點:2~7 天前最後一個正常備份 +// - 滾動視窗:最新 MAX_ROLLING 個(不含上述兩個錨點) +function pruneOldBackups(): void { + try { + const files = listBackupFiles(); // 最新在前 + + const now = new Date(); + const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); + const yesterdayStart = todayStart - 86400000; + const weekAgoStart = todayStart - 7 * 86400000; + + const toKeep = new Set(); + + // 昨天錨點 + const yesterdayAnchor = files.find((f) => { + const t = new Date(f.timestamp).getTime(); + return t >= yesterdayStart && t < todayStart && f.sizeBytes >= MIN_GOOD_SIZE; + }); + if (yesterdayAnchor) toKeep.add(yesterdayAnchor.filename); + + // 上週錨點(2~7 天前) + const weekAnchor = files.find((f) => { + const t = new Date(f.timestamp).getTime(); + return t >= weekAgoStart && t < yesterdayStart && f.sizeBytes >= MIN_GOOD_SIZE; + }); + if (weekAnchor) toKeep.add(weekAnchor.filename); + + // 滾動視窗:最新 MAX_ROLLING 個(錨點不佔名額) + let rollingCount = 0; + for (const f of files) { + if (toKeep.has(f.filename)) continue; + if (rollingCount < MAX_ROLLING) { + toKeep.add(f.filename); + rollingCount++; + } + } + + // 刪除不在保留名單的備份 + for (const f of files) { + if (!toKeep.has(f.filename)) { + try { fs.unlinkSync(path.join(BACKUP_DIR, f.filename)); } catch { /* ignore */ } + } + } + } catch { /* ignore */ } +} + +// ── 列出所有備份 ──────────────────────────────────────── +export interface BackupEntry { + filename: string; + timestamp: string; // ISO 格式 + sizeBytes: number; +} + +export function listBackupFiles(): BackupEntry[] { + try { + ensureBackupDir(); + const files = fs.readdirSync(BACKUP_DIR) + .filter((f) => f.startsWith("openclaw.") && f.endsWith(".json")); + + return files + .map((filename) => { + const stat = fs.statSync(path.join(BACKUP_DIR, filename)); + // 從檔名解析時間戳:openclaw.2026-03-15T08-30-00.json + const tsMatch = filename.match(/^openclaw\.(.+)\.json$/); + const timestamp = tsMatch + ? tsMatch[1].replace(/-(\d{2})-(\d{2})$/, ":$1:$2").replace(/T(\d{2})-/, "T$1:") + : stat.mtime.toISOString(); + return { filename, timestamp, sizeBytes: stat.size }; + }) + .sort((a, b) => b.timestamp.localeCompare(a.timestamp)); // 最新在前 + } catch { + return []; + } +} + +// ── 從備份還原 ────────────────────────────────────────── +export interface RestoreResult { + success: boolean; + message: string; + restoredFrom?: string; + backedUpAs?: string; +} + +export function restoreFromBackup(filename: string): RestoreResult { + const backupPath = path.join(BACKUP_DIR, filename); + + // 安全檢查:防止 path traversal + if (filename.includes("..") || filename.includes("/") || filename.includes("\\")) { + return { success: false, message: "Invalid filename" }; + } + + if (!fs.existsSync(backupPath)) { + return { success: false, message: `Backup not found: ${filename}` }; + } + + try { + // 讀取備份內容並驗證是否為合法 JSON + const backupContent = fs.readFileSync(backupPath, "utf-8"); + JSON.parse(backupContent); // 驗證 JSON 格式 + + // 先備份當前版本(還原前的安全網) + const currentBackup = backupCurrentConfig(); + + // 執行還原 + fs.writeFileSync(OPENCLAW_CONFIG_PATH, backupContent, "utf-8"); + + // 還原後持久化 hash,讓下次 polling 不會再觸發備份 + writePersistedHash(computeHash(backupContent)); + + return { + success: true, + message: `Restored from ${filename}`, + restoredFrom: filename, + backedUpAs: currentBackup?.filename, + }; + } catch (err: any) { + return { success: false, message: `Restore failed: ${err.message}` }; + } +} + +// ── 偵測變更並自動備份(在 /api/config GET 中呼叫)────── +export interface ChangeDetectionResult { + changed: boolean; + currentHash: string; + backedUp: boolean; + backupFilename?: string; +} + +export function detectChangeAndBackup(rawContent: string): ChangeDetectionResult { + const currentHash = computeHash(rawContent); + const lastKnownHash = readPersistedHash(); + + // 第一次執行(無持久化記錄):記錄 hash,不觸發備份 + if (lastKnownHash === null) { + writePersistedHash(currentHash); + return { changed: false, currentHash, backedUp: false }; + } + + // Hash 未變:無需備份 + if (currentHash === lastKnownHash) { + return { changed: false, currentHash, backedUp: false }; + } + + // Hash 已變:備份目前版本,更新持久化 hash + const backup = backupCurrentConfig(); + writePersistedHash(currentHash); + + return { + changed: true, + currentHash, + backedUp: backup !== null, + backupFilename: backup?.filename, + }; +} + +// ── 取得備份目錄路徑(供外部使用)──────────────────────── +export function getBackupDir(): string { + return BACKUP_DIR; +} diff --git a/lib/i18n.tsx b/lib/i18n.tsx index 210af68..4813505 100644 --- a/lib/i18n.tsx +++ b/lib/i18n.tsx @@ -221,6 +221,25 @@ const translations: Record> = { "gateway.healthy": "Gateway 運作正常", "gateway.unhealthy": "Gateway 異常", "gateway.fetchError": "無法檢查 Gateway 狀態", + "gateway.noResponse": "Gateway 無回應", + "gateway.configChanged": "偵測到 openclaw.json 最近有變更,可能是設定錯誤導致。", + "gateway.configError": "設定檔可能有誤", + "gateway.configErrorDesc": "Gateway 無法啟動,可能是 openclaw.json 設定錯誤", + "gateway.restorePrev": "🔄 還原上一版設定", + "gateway.viewLogs": "📋 查看錯誤日誌", + "gateway.dismiss": "❌ 不處理", + "gateway.backupAvailable": "有可用備份", + "gateway.restoreBackup": "還原備份", + "gateway.restoring": "還原中…", + "gateway.restoreSuccess": "✅ 已還原,正在重啟 Gateway…", + "gateway.restoreFailed": "❌ 還原失敗", + "gateway.noBackups": "沒有可用的備份", + "gateway.backupRecommended": "建議", + "gateway.backupSuspect": "可能損毀", + "gateway.reloadCountdown": "秒後自動重新整理…", + "gateway.reloadNow": "立即重新整理", + "gateway.reloadHint": "重新整理後,可點選機器人卡片上的「測試」確認是否正常運作", + "gateway.configCorruptHint": "設定檔可能損毀,請使用上方 Gateway 面板還原備份", // pixel office "pixelOffice.title": "OpenClaw Agents 辦公室", @@ -481,6 +500,25 @@ const translations: Record> = { "gateway.healthy": "Gateway 运行正常", "gateway.unhealthy": "Gateway 异常", "gateway.fetchError": "无法检查 Gateway 状态", + "gateway.noResponse": "Gateway 无响应", + "gateway.configChanged": "检测到 openclaw.json 最近有变更,可能是配置错误导致。", + "gateway.configError": "配置文件可能有误", + "gateway.configErrorDesc": "Gateway 无法启动,可能是 openclaw.json 配置错误", + "gateway.restorePrev": "🔄 还原上一版配置", + "gateway.viewLogs": "📋 查看错误日志", + "gateway.dismiss": "❌ 不处理", + "gateway.backupAvailable": "有可用备份", + "gateway.restoreBackup": "还原备份", + "gateway.restoring": "还原中…", + "gateway.restoreSuccess": "✅ 已还原,正在重启 Gateway…", + "gateway.restoreFailed": "❌ 还原失败", + "gateway.noBackups": "没有可用的备份", + "gateway.backupRecommended": "建议", + "gateway.backupSuspect": "可能损坏", + "gateway.reloadCountdown": "秒后自动刷新…", + "gateway.reloadNow": "立即刷新", + "gateway.reloadHint": "刷新后,可点击机器人卡片上的「测试」确认是否正常运作", + "gateway.configCorruptHint": "配置文件可能损坏,请使用上方 Gateway 面板还原备份", // pixel office "pixelOffice.title": "OpenClaw Agents办公室", @@ -741,6 +779,25 @@ const translations: Record> = { "gateway.healthy": "Gateway is running", "gateway.unhealthy": "Gateway is down", "gateway.fetchError": "Cannot check Gateway status", + "gateway.noResponse": "Gateway is not responding", + "gateway.configChanged": "openclaw.json was recently modified. This may be caused by a config error.", + "gateway.configError": "Config file may have errors", + "gateway.configErrorDesc": "Gateway failed to start, possibly due to openclaw.json config errors", + "gateway.restorePrev": "🔄 Restore previous config", + "gateway.viewLogs": "📋 View error logs", + "gateway.dismiss": "❌ Dismiss", + "gateway.backupAvailable": "Backup available", + "gateway.restoreBackup": "Restore backup", + "gateway.restoring": "Restoring…", + "gateway.restoreSuccess": "✅ Restored, restarting Gateway…", + "gateway.restoreFailed": "❌ Restore failed", + "gateway.noBackups": "No backups available", + "gateway.backupRecommended": "Recommended", + "gateway.backupSuspect": "Possibly corrupt", + "gateway.reloadCountdown": "s until auto-refresh…", + "gateway.reloadNow": "Refresh now", + "gateway.reloadHint": "After refresh, click the Test button on each bot card to verify it's working.", + "gateway.configCorruptHint": "Config may be corrupt. Use the Gateway panel above to restore a backup.", // pixel office "pixelOffice.title": "OpenClaw Agents Office",