import { Pause, Play, ShieldAlert } from "lucide-react"; import type { ReactNode } from "react"; import { toast } from "sonner"; import { useUpdateSettings } from "~/api/settings.ts"; import { Switch } from "~/components/ui/switch.tsx"; import { errorMessage } from "~/lib/api.ts"; import { cn } from "~/lib/utils.ts"; import { type FormValues, num } from "./resource-section.tsx"; import { SettingsBlock } from "./settings-block.tsx"; import { type CardSpec, n, SettingsCard, SettingsGate } from "./settings-card.tsx"; // 系统分区:自动化总开关、只读对账、后台任务并发(WK-1)、存储预算与流水数据保留(CACHE-2)。 // 下载盘水位与保种在「下载器」,每日下载预算在「获取」,缓存有效期在「缓存」。 // CACHE-2:存储预算、流水保留期(settings.storage,docs/runbook/storage.md) const gbField = (k: string, label: string, hint?: string) => ({ name: `storage.budgets.${k}`, label, kind: "number" as const, hint, }); const dayField = (group: "retentionDays", k: string, label: string, hint?: string) => ({ name: `storage.${group}.${k}`, label, kind: "number" as const, hint, }); const pick = (v: FormValues, prefix: string, keys: readonly string[]) => Object.fromEntries(keys.map((k) => [k, num(v[`${prefix}.${k}`])])); // WK-1:io worker 三条道的并发(settings.automation.workerLanes,docs/runbook/dev.md「IO Worker 分道」) const LANE_KEYS = ["file", "network", "maintenance"] as const; export const WORKER_LANES_CARD: CardSpec = { anchor: "worker-lanes", title: "后台任务并发", description: "后台任务分三条道各自排队,互不占用。保存后几秒内生效,不用重启;服务器上设置了对应的环境变量时以环境变量为准。", fields: [ { name: "automation.workerLanes.file", label: "文件道", kind: "number", hint: "整理、硬链接、移动、校验;1–16" }, { name: "automation.workerLanes.network", label: "网络道", kind: "number", hint: "站点、RSS、TMDB、下载器、搜索;单站限流另计;1–16", }, { name: "automation.workerLanes.maintenance", label: "维护道", kind: "number", hint: "清理、回填、对账;前两道有活时让路;1–16", }, ], fromSettings: (s) => Object.fromEntries(LANE_KEYS.map((k) => [`automation.workerLanes.${k}`, n(s.automation.workerLanes[k])])), toPatch: (v) => ({ automation: { workerLanes: pick(v, "automation.workerLanes", LANE_KEYS) } }), }; const BUDGET_KEYS = ["pgDataGb", "pgDataWarnGb", "imagesGb", "backupsGb", "sessionsEvidenceGb"] as const; export const STORAGE_BUDGET_CARD: CardSpec = { anchor: "storage", title: "存储预算", description: "超过预算时健康页告警;图片缓存超出容量按最久未访问删除。单位 GB。", fields: [ gbField("pgDataGb", "数据库预算", "超过为严重"), gbField("pgDataWarnGb", "数据库告警线", "不高于数据库预算"), gbField("imagesGb", "图片缓存容量"), gbField("backupsGb", "备份目录预算"), gbField("sessionsEvidenceGb", "智能体会话与截图证据预算"), ], fromSettings: (s) => Object.fromEntries(BUDGET_KEYS.map((k) => [`storage.budgets.${k}`, n(s.storage.budgets[k])])), toPatch: (v) => ({ storage: { budgets: pick(v, "storage.budgets", BUDGET_KEYS) } }), }; const RETENTION_KEYS = [ "observation", "sseEvent", "heartbeatHours", "taskRun", "agentEvent", "audit", "capabilityStats", "identityPoolArchive", ] as const; export const STORAGE_RETENTION_CARD: CardSpec = { anchor: "storage-retention", title: "数据保留", description: "到期由定时清理任务分批删除。单位天,心跳为小时。", fields: [ dayField("retentionDays", "observation", "RSS 抓取记录"), dayField("retentionDays", "sseEvent", "实时事件"), dayField("retentionDays", "heartbeatHours", "进程心跳(小时)"), dayField("retentionDays", "taskRun", "任务运行记录"), dayField("retentionDays", "agentEvent", "智能体会话事件", "只删已结束的会话"), dayField("retentionDays", "audit", "审计日志", "能力调用审计除外"), dayField("retentionDays", "capabilityStats", "能力调用统计"), dayField( "retentionDays", "identityPoolArchive", "未识别资源归档", "未命中的条目这么久没有新证据就归档,仍可手动指定", ), ], fromSettings: (s) => Object.fromEntries(RETENTION_KEYS.map((k) => [`storage.retentionDays.${k}`, n(s.storage.retentionDays[k])])), toPatch: (v) => ({ storage: { retentionDays: pick(v, "storage.retentionDays", RETENTION_KEYS) } }), }; function ToggleRow({ id, icon, title, description, checked, onChange, disabled, tone, }: { id: string; icon: ReactNode; title: string; description: string; checked: boolean; onChange: (v: boolean) => void; disabled: boolean; tone: "success" | "warning"; }) { return (
{icon} {description}
); } function ReadOnlySwitch() { const update = useUpdateSettings(); return ( {(s) => ( } tone={s.automation.readOnlyReconcile ? "warning" : "success"} title={`只读对账 · ${s.automation.readOnlyReconcile ? "开启" : "关闭"}`} description="只核对库存与路径,不做自动写入。" checked={s.automation.readOnlyReconcile} disabled={update.isPending} onChange={(v) => update.mutateAsync({ automation: { readOnlyReconcile: v } }).then( () => toast.success(v ? "已开启只读对账" : "已关闭只读对账"), (err) => toast.error(errorMessage(err, "切换失败")), ) } /> )} ); } function Switches() { const update = useUpdateSettings(); const set = async (patch: { enabled?: boolean; readOnlyReconcile?: boolean }, msg: string) => { try { await update.mutateAsync({ automation: patch }); toast.success(msg); } catch (err) { toast.error(errorMessage(err, "切换失败")); } }; return ( {(s) => (
: } tone={s.automation.enabled ? "success" : "warning"} title={`自动化总开关 · ${s.automation.enabled ? "运行中" : "已暂停"}`} description="关闭后自动收录、下载、整理全部停止;查询与手动操作不受影响。" checked={s.automation.enabled} disabled={update.isPending} onChange={(v) => set({ enabled: v }, v ? "自动化已恢复" : "自动化已暂停")} />
)}
); } export function SystemSection() { return (
{(s) => ( <> )}
); }