mirror of
https://github.com/xmanrui/OpenClaw-bot-review.git
synced 2026-08-14 00:47:49 +00:00
feat: openclaw.json 自動備份、偵測損毀與還原 UI
## 備份機制 - 新增 lib/config-backup.ts:以 SHA-256 hash 偵測 openclaw.json 變更,自動備份 - Hash 持久化至 ~/.openclaw/backups/config/.last-hash,Next.js 重啟後仍能正確偵測變更 - 備份保留策略:滾動 8 個 + 昨天錨點 + 上週錨點(各取最後一個正常備份,≥ 1 KB) - 新增 API:GET /api/config-backup(列出備份)、POST /api/config-backup(還原) ## Dashboard 偵測與還原 UI(gateway-status.tsx) - Gateway 第一次失敗即觸發備份清單抓取,不等 30 秒 - 備份清單顯示大小、[建議] / [可能損毀] 標籤,並自動推薦第一個正常備份 - 還原後自動重啟 Gateway,並倒數 5 秒後自動重新整理頁面 - 提示使用者點選機器人卡片「測試」確認是否正常 - 「查看錯誤日誌」按鈕固定顯示於面板,可展開/收起最後 30 行 log - config 損毀導致頁面載入失敗時,錯誤頁仍顯示 GatewayStatus 還原入口 ## 多語系支援 - 所有新增文字支援繁中/简中/English(i18n.tsx 新增 8 個 key) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
0d1501f7ad
commit
c826424add
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
+289
-14
@@ -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<string | null>(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<BackupEntry[]>([]);
|
||||
const [restoring, setRestoring] = useState(false);
|
||||
const [restoreMsg, setRestoreMsg] = useState<string | null>(null);
|
||||
const [reloadCountdown, setReloadCountdown] = useState<number | null>(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<string | null>(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 (
|
||||
<div className={`relative inline-flex items-center gap-1.5 ${className}`.trim()}>
|
||||
@@ -220,6 +323,149 @@ export function GatewayStatus({ compact = false, className = "", hideIconOnMobil
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Log toggle — always visible when gateway is down */}
|
||||
{health && !health.ok && (
|
||||
<button
|
||||
onClick={() => { fetchLogs(); setShowLogs(v => !v); }}
|
||||
className="w-full text-left px-2 py-1 rounded text-[11px] text-[var(--text-muted)] bg-white/5 hover:bg-white/10 border border-[var(--border)] transition-colors cursor-pointer"
|
||||
>
|
||||
{t("gateway.viewLogs")} {showLogs ? "▲" : "▼"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Log viewer */}
|
||||
{showLogs && (
|
||||
<div className="rounded border border-[var(--border)] bg-black/30 px-2 py-2">
|
||||
{logResult?.recentLines && logResult.recentLines.length > 0 ? (
|
||||
<pre className="text-[9px] text-red-300/80 leading-relaxed overflow-x-auto max-h-40 overflow-y-auto whitespace-pre-wrap break-all">
|
||||
{logResult.recentLines.join("\n")}
|
||||
</pre>
|
||||
) : (
|
||||
<p className="text-[10px] text-[var(--text-muted)]">—</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Config change prompt — prominent banner when config recently changed */}
|
||||
{showConfigChangePrompt && (
|
||||
<div className="rounded-lg border border-amber-500/40 bg-amber-500/10 px-3 py-2.5 space-y-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="shrink-0 text-base">⚠️</span>
|
||||
<div>
|
||||
<div className="text-amber-300 font-semibold">{t("gateway.noResponse")}</div>
|
||||
<div className="text-[var(--text-muted)] mt-1 leading-relaxed text-[11px]">{t("gateway.configChanged")}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(() => {
|
||||
const recommended = findRecommendedBackup(backups);
|
||||
return recommended ? (
|
||||
<button
|
||||
onClick={() => handleRestore(recommended.filename)}
|
||||
disabled={restoring}
|
||||
className="px-2.5 py-1 rounded-md text-[11px] font-medium bg-amber-500/20 text-amber-300 border border-amber-500/40 hover:bg-amber-500/35 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
||||
>
|
||||
{restoring ? t("gateway.restoring") : `${t("gateway.restorePrev")} (${formatBackupTime(recommended.timestamp)}, ${formatSize(recommended.sizeBytes)})`}
|
||||
</button>
|
||||
) : null;
|
||||
})()}
|
||||
<button
|
||||
onClick={() => setConfigPromptDismissed(true)}
|
||||
className="px-2.5 py-1 rounded-md text-[11px] font-medium bg-white/5 text-[var(--text-muted)] border border-[var(--border)] hover:bg-white/10 transition-colors cursor-pointer"
|
||||
>
|
||||
{t("gateway.dismiss")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Config error hint + backup restore (when no recent change detected, or dismissed the prompt) */}
|
||||
{showConfigHint && !showConfigChangePrompt && (
|
||||
<div className="rounded border border-amber-500/30 bg-amber-500/10 px-2 py-2 space-y-2">
|
||||
<div className="flex items-start gap-1.5">
|
||||
<span className="shrink-0">📋</span>
|
||||
<div>
|
||||
<div className="text-amber-300 font-medium">{t("gateway.configError")}</div>
|
||||
<div className="text-[var(--text-muted)] mt-0.5 leading-relaxed">{t("gateway.configErrorDesc")}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Backup list */}
|
||||
<div className="space-y-1">
|
||||
<div className="text-[var(--text-muted)] text-[10px] uppercase tracking-wider">
|
||||
{t("gateway.backupAvailable")} ({backups.length})
|
||||
</div>
|
||||
{backups.map((b) => {
|
||||
const isRecommended = b.sizeBytes >= 1024;
|
||||
const isSuspect = b.sizeBytes < 1024;
|
||||
return (
|
||||
<div key={b.filename} className={`flex items-center justify-between gap-2 rounded px-1.5 py-1 transition-colors ${isRecommended ? "bg-emerald-500/10 hover:bg-emerald-500/15" : "bg-white/5 hover:bg-white/10"}`}>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className={`text-[11px] shrink-0 ${isSuspect ? "text-red-400/70 line-through" : "text-[var(--text)]"}`} title={b.filename}>
|
||||
{formatBackupTime(b.timestamp)}
|
||||
</span>
|
||||
<span className={`text-[10px] shrink-0 ${isSuspect ? "text-red-400/60" : "text-[var(--text-muted)]"}`}>
|
||||
{formatSize(b.sizeBytes)}
|
||||
</span>
|
||||
{isRecommended && (
|
||||
<span className="text-[9px] px-1 py-0.5 rounded bg-emerald-500/20 text-emerald-400 font-medium shrink-0">{t("gateway.backupRecommended")}</span>
|
||||
)}
|
||||
{isSuspect && (
|
||||
<span className="text-[9px] px-1 py-0.5 rounded bg-red-500/20 text-red-400 font-medium shrink-0">{t("gateway.backupSuspect")}</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRestore(b.filename)}
|
||||
disabled={restoring}
|
||||
className="shrink-0 px-2 py-0.5 rounded text-[10px] font-medium bg-amber-500/20 text-amber-300 border border-amber-500/40 hover:bg-amber-500/35 transition-colors disabled:opacity-30 disabled:cursor-not-allowed cursor-pointer"
|
||||
>
|
||||
{restoring ? t("gateway.restoring") : t("gateway.restoreBackup")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* When gateway is down but no backups available */}
|
||||
{!health?.ok && consecutiveDownCount >= 3 && backups.length === 0 && (
|
||||
<div className="text-[var(--text-muted)] bg-white/5 rounded px-2 py-1.5 leading-relaxed text-[11px]">
|
||||
📋 {t("gateway.noBackups")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Restore result message */}
|
||||
{restoreMsg && (
|
||||
<div className={`rounded px-2 py-1.5 leading-relaxed ${
|
||||
restoreMsg.startsWith("✅") ? "text-green-300 bg-green-500/10" : "text-red-300 bg-red-500/10"
|
||||
}`}>
|
||||
{restoreMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Countdown to reload */}
|
||||
{reloadCountdown !== null && (
|
||||
<div className="rounded-lg border border-emerald-500/30 bg-emerald-500/10 px-3 py-2.5 space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-emerald-300 font-semibold text-[11px]">
|
||||
🔄 {reloadCountdown} {t("gateway.reloadCountdown")}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="text-[10px] px-2 py-0.5 rounded bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 hover:bg-emerald-500/35 transition-colors cursor-pointer"
|
||||
>
|
||||
{t("gateway.reloadNow")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-[10px] text-emerald-200/70 leading-relaxed">
|
||||
{t("gateway.reloadHint")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Restart result message */}
|
||||
{restartMsg && (
|
||||
<div className={`rounded px-2 py-1.5 leading-relaxed ${
|
||||
@@ -245,3 +491,32 @@ export function GatewayStatus({ compact = false, className = "", hideIconOnMobil
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
+10
-2
@@ -526,8 +526,16 @@ export default function Home() {
|
||||
|
||||
if (error && !data) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<p className="text-red-400">{t("common.loadError")}: {error}</p>
|
||||
<div className="min-h-screen flex flex-col items-center justify-center gap-6 p-8">
|
||||
<div className="w-full max-w-md">
|
||||
<GatewayStatus />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-red-400 text-sm">{t("common.loadError")}: {error}</p>
|
||||
<p className="text-[var(--text-muted)] text-xs mt-1">
|
||||
{t("gateway.configCorruptHint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string>();
|
||||
|
||||
// 昨天錨點
|
||||
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;
|
||||
}
|
||||
@@ -221,6 +221,25 @@ const translations: Record<Locale, Record<string, string>> = {
|
||||
"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<Locale, Record<string, string>> = {
|
||||
"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<Locale, Record<string, string>> = {
|
||||
"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",
|
||||
|
||||
Reference in New Issue
Block a user