merge: pcdc/main — integrate config backup/restore, log viewer, improved agent state detection

- config/route.ts: keep both shouldHidePlatformChannel and detectChangeAndBackup imports
- gateway-logs/route.ts: add recentLines (last 30 lines) to response
- gateway-status.tsx: adopt pcdc/main backup restore UI, log viewer, config change prompt
- agent-activity/route.ts: keep cronJobs from HEAD; adopt pcdc/main's 50-line read, nuanced state detection, and unindexed session handling

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Dan Lee
2026-03-22 10:15:15 +08:00
co-authored by Claude Sonnet 4.6
9 changed files with 704 additions and 29 deletions
+1
View File
@@ -7,3 +7,4 @@ next-env.d.ts
/public/assets/pixel-office/*.mp3
.env.local
.env.*.local
.DS_Store
+34 -13
View File
@@ -822,6 +822,7 @@ async function parseCronJobs(agentSessionsDir: string, cronJobsForAgent: CronSto
return cronJobs
}
/**
* Extract the actual user-typed text from a session message block.
*
@@ -930,7 +931,7 @@ async function detectStateFromSession(
try {
const content = await fs.readFile(sessionFilePath, 'utf8')
const lines = content.split('\n').filter(l => l.trim()).slice(-30)
const lines = content.split('\n').filter(l => l.trim()).slice(-50)
let lastRole: string | null = null
let lastStopReason: string | null = null
@@ -948,12 +949,16 @@ async function detectStateFromSession(
} catch { /* skip malformed line */ }
}
// Work completed → immediately idle
if (lastStopReason === 'stop') return 'idle'
// Still processing — but cap at 10 min, after that force idle
// Work completed → idle ONLY if the last message itself was the assistant finishing
if (lastRole === 'assistant' && lastStopReason === 'stop') return 'idle'
// New user/tool message after assistant stop, or tool call in flight → working
if (lastRole === 'user' || lastRole === 'toolResult' || lastStopReason === 'toolUse') {
return timeDiff <= WORKING_MAX_MS ? 'working' : 'idle'
}
// assistant with no stopReason = mid-generation (streaming), treat as working
if (lastRole === 'assistant' && !lastStopReason) {
return timeDiff <= WORKING_MAX_MS ? 'working' : 'idle'
}
} catch { /* file unreadable — fall through */ }
// Fallback within the 10-min window
@@ -1044,19 +1049,29 @@ export async function GET() {
}
} catch { /* ignore */ }
// Prefer the sessions.json-mapped file over raw scan when available
// (ensures we read a proper session, not a probe or temp file)
// If the most-recent .jsonl is not in sessions.json, keep it when it's
// recent (< 5 min) — it may be a brand-new session not yet indexed.
// Only fall back to the sessions.json-indexed file when the un-indexed
// file is stale, to avoid reading probe/temp files from old runs.
if (mostRecentSessionFile) {
const sessionId = path.basename(mostRecentSessionFile, '.jsonl')
if (!sessionIdToFile.has(sessionId)) {
// Most-recent file is not in sessions.json — find best known session by mtime
let bestMtime = 0
for (const [, fp] of sessionIdToFile) {
try {
const s = await fs.stat(fp)
if (s.mtimeMs > bestMtime) { bestMtime = s.mtimeMs; mostRecentSessionFile = fp }
} catch { /* ignore */ }
const unindexedAge = now - lastActive
if (unindexedAge > 5 * 60 * 1000) {
// Stale un-indexed file — prefer the best sessions.json entry
let bestMtime = 0
for (const [, fp] of sessionIdToFile) {
try {
const s = await fs.stat(fp)
if (s.mtimeMs > bestMtime) {
bestMtime = s.mtimeMs
mostRecentSessionFile = fp
}
} catch { /* ignore */ }
}
if (bestMtime > 0) lastActive = bestMtime
}
// else: keep the un-indexed file — it's a fresh active session
}
}
}
@@ -1092,6 +1107,12 @@ export async function GET() {
lastTask = (await extractLastUserTask(mostRecentSessionFile)) ?? undefined
}
// Extract last user task for working agents
let lastTask: string | undefined
if (state === 'working' && mostRecentSessionFile && existsSync(mostRecentSessionFile)) {
lastTask = (await extractLastUserTask(mostRecentSessionFile)) ?? undefined
}
agents.push({
agentId: agent.id,
name: agent.name || agent.id,
+61
View File
@@ -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 }
);
}
}
+13
View File
@@ -4,6 +4,7 @@ import path from "path";
import { getConfigCache, setConfigCache } from "@/lib/config-cache";
import { OPENCLAW_CONFIG_PATH, OPENCLAW_HOME } from "@/lib/openclaw-paths";
import { shouldHidePlatformChannel } from "@/lib/platforms";
import { detectChangeAndBackup } from "@/lib/config-backup";
// 配置文件路径:优先使用 OPENCLAW_HOME 环境变量,否则默认 ~/.openclaw
const CONFIG_PATH = OPENCLAW_CONFIG_PATH;
@@ -282,6 +283,10 @@ export async function GET() {
try {
const raw = fs.readFileSync(CONFIG_PATH, "utf-8");
// 偵測 openclaw.json 是否有變更,若有則自動備份
detectChangeAndBackup(raw);
const config = JSON.parse(raw);
// 提取 agents 信息
@@ -557,6 +562,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,
@@ -567,6 +579,7 @@ export async function GET() {
host: process.env.NEXT_PUBLIC_GATEWAY_CHAT_BASE_URL || config.gateway?.host || config.gateway?.hostname || "",
},
groupChats,
configLastModified,
};
setConfigCache({ data, ts: Date.now() });
return NextResponse.json(data);
+4
View File
@@ -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
View File
@@ -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
View File
@@ -556,8 +556,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>
);
}
+235
View File
@@ -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;
}
+57
View File
@@ -249,6 +249,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 辦公室",
@@ -537,6 +556,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办公室",
@@ -825,6 +863,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",