import { ChevronDown, Pause, Play, ShieldAlert } from "lucide-react"; import { type ReactNode, useEffect, useState } from "react"; import { useLocation } from "react-router"; 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 { anchorId, SettingsBlock } from "./settings-block.tsx"; import { type CardSpec, n, SettingsCard, SettingsGate } from "./settings-card.tsx"; // 自动化分区:总开关、只读对账、缓存水位、每日下载预算、保留天数、后台任务并发(WK-1)、存储预算与保留(CACHE-2) const GiB = 1024 ** 3; const CACHE_CARD: CardSpec = { anchor: "cache", title: "下载盘", description: "下载盘用量到暂停线时不再新增下载,并暂停本工具的下载任务;降到恢复线以下只恢复因此暂停的任务。", fields: [ { name: "cache.hardThresholdPercent", label: "暂停线(%)", kind: "number" }, { name: "cache.resumePercent", label: "恢复线(%)", kind: "number", hint: "低于暂停线" }, { name: "cache.cleanupTargetPercent", label: "清理目标(%)", kind: "number", hint: "不高于恢复线" }, { name: "cache.checkIntervalSec", label: "检查间隔(秒)", kind: "number", hint: "10–3600" }, ], fromSettings: (s) => ({ "cache.hardThresholdPercent": n(s.cache.hardThresholdPercent), "cache.resumePercent": n(s.cache.resumePercent), "cache.cleanupTargetPercent": n(s.cache.cleanupTargetPercent), "cache.checkIntervalSec": n(s.cache.checkIntervalSec), }), toPatch: (v) => ({ cache: { hardThresholdPercent: num(v["cache.hardThresholdPercent"]), resumePercent: num(v["cache.resumePercent"]), cleanupTargetPercent: num(v["cache.cleanupTargetPercent"]), checkIntervalSec: num(v["cache.checkIntervalSec"]), }, }), }; const BUDGET_CARD: CardSpec = { anchor: "budget", title: "每日预算", description: "任一项到上限后不再开始新的下载;前两项按自然日计。", fields: [ { name: "budget.maxNewTitlesPerDay", label: "每日新作品数", kind: "number" }, { name: "budget.maxDownloadGiBPerDay", label: "每日下载量(GiB)", kind: "number" }, { name: "budget.maxConcurrentDownloads", label: "最大并发下载", kind: "number", hint: "1–100" }, ], fromSettings: (s) => ({ "budget.maxNewTitlesPerDay": n(s.budget.maxNewTitlesPerDay), "budget.maxDownloadGiBPerDay": n(Math.round((s.budget.maxDownloadBytesPerDay / GiB) * 100) / 100), "budget.maxConcurrentDownloads": n(s.budget.maxConcurrentDownloads), }), toPatch: (v) => { const gib = num(v["budget.maxDownloadGiBPerDay"]); return { budget: { maxNewTitlesPerDay: num(v["budget.maxNewTitlesPerDay"]), maxDownloadBytesPerDay: gib === undefined ? undefined : Math.round(gib * GiB), maxConcurrentDownloads: num(v["budget.maxConcurrentDownloads"]), }, }; }, }; const RETENTION_CARD: CardSpec = { anchor: "retention", title: "保留", fields: [ { name: "retention.seedRetentionDays", label: "源文件保留天数", kind: "number", hint: "复制入库后下载源在缓存里至少留这么久;0–365", }, { name: "retention.logRetentionDays", label: "日志保留天数", kind: "number" }, ], fromSettings: (s) => ({ "retention.seedRetentionDays": n(s.retention.seedRetentionDays), "retention.logRetentionDays": n(s.retention.logRetentionDays), }), toPatch: (v) => ({ retention: { seedRetentionDays: num(v["retention.seedRetentionDays"]), logRetentionDays: num(v["retention.logRetentionDays"]), }, }), }; // 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" | "cacheTtl", 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; 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; 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; 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) } }), }; const TTL_KEYS = ["tmdbSearchDays", "tmdbDetailDays", "tmdbMissDays", "siteDetailDays", "webSearchDays"] as const; const STORAGE_TTL_CARD: CardSpec = { anchor: "storage-ttl", title: "缓存有效期", description: "单位天。追踪中的作品命中 TMDB 详情缓存时自动续期。", fields: [ dayField("cacheTtl", "tmdbSearchDays", "TMDB 搜索与识别结果"), dayField("cacheTtl", "tmdbDetailDays", "TMDB 详情"), dayField("cacheTtl", "tmdbMissDays", "TMDB 空结果", "搜索无结果、未识别时的负缓存"), dayField("cacheTtl", "siteDetailDays", "站点详情页内容"), dayField("cacheTtl", "webSearchDays", "网页搜索结果"), ], fromSettings: (s) => Object.fromEntries(TTL_KEYS.map((k) => [`storage.cacheTtl.${k}`, n(s.storage.cacheTtl[k])])), toPatch: (v) => ({ storage: { cacheTtl: pick(v, "storage.cacheTtl", TTL_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 (
只读对账、后台任务并发、存储预算、数据保留和缓存有效期。一般不用改。
{open ?