feat(web): 首页只回答三件事、计划页巡检置顶与分组折叠、任务列表去噪(DEAI-2)
首页:顶部一行状态(自动化开关 + 只在异常时出现的服务 / 下载盘 / 站点账号 / 下载器问题), 下面只有「在忙什么」「要我处理」「最近入库」三块;去掉运行状态、计划、缓存、来源健康、 满足率与 0 值铺陈;0 订阅时写「还没有订阅。去搜索添加」。只读对账移出首页,设置里放进 自动化 →「高级」(与存储预算、流水保留、缓存有效期一起默认收起)。 计划页:「智能体巡检」移到最上面——巡检数量少、由用户自己建、每次会花钱,是这页最需要 看的;自动任务任务多、多数不用管,放在下面。自动任务按分类分组、每组可折叠:有失败或 运行中的组默认展开,其余收起为「N 个任务 · 下次 xx」一行,折叠状态记在 localStorage; 维护类整组叫「数据清理」,未接入调度的备份不列。巡检卡只留名称、开关、间隔、上次结果与 费用,目标 / 能力 / 预算 / 模型 / 最近会话收进「详情」。去掉顶部 6 个计数卡。定时任务说明 去掉表名、字段名与实现备注。 任务页:默认隐藏系统任务(无业务对象的引擎步骤、定时任务的成功运行),可一键显示; 标题不再用 ID;耗时移入技术信息;< 1 秒写「< 1 秒」;费用两位小数,不足一分写「< $0.01」。 下载盘相关文案改用「暂停线 / 恢复线」。 Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
f0d4935fa7
commit
260f3ab27f
23 files changed
+559
-816
No files matched your search
@@ -106,7 +106,7 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se
|
|||||||
key: "downloads.sync",
|
key: "downloads.sync",
|
||||||
defaults: {
|
defaults: {
|
||||||
name: "qB 同步",
|
name: "qB 同步",
|
||||||
description: "同步下载器任务状态,按缓存阈值暂停或恢复",
|
description: "同步下载器任务状态;下载盘到暂停线时暂停,降到恢复线后恢复",
|
||||||
group: "download",
|
group: "download",
|
||||||
schedule: every(syncMs),
|
schedule: every(syncMs),
|
||||||
},
|
},
|
||||||
@@ -125,8 +125,8 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se
|
|||||||
registerScheduledJob(ctx, {
|
registerScheduledJob(ctx, {
|
||||||
key: "cache.threshold",
|
key: "cache.threshold",
|
||||||
defaults: {
|
defaults: {
|
||||||
name: "缓存阈值检查",
|
name: "下载盘检查",
|
||||||
description: "下载缓存超过硬阈值时入队清理",
|
description: "下载盘超过暂停线时清理已入库的下载",
|
||||||
group: "maintain",
|
group: "maintain",
|
||||||
schedule: every(planMs),
|
schedule: every(planMs),
|
||||||
},
|
},
|
||||||
@@ -141,7 +141,7 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se
|
|||||||
key: "downloads.orchestrate",
|
key: "downloads.orchestrate",
|
||||||
defaults: {
|
defaults: {
|
||||||
name: "补提交与补入库",
|
name: "补提交与补入库",
|
||||||
description: "补投已预留未提交的下载、已完成未入库的任务",
|
description: "补交没提交成功的下载,补整理下载完但没入库的任务",
|
||||||
group: "download",
|
group: "download",
|
||||||
schedule: every(planMs),
|
schedule: every(planMs),
|
||||||
},
|
},
|
||||||
@@ -155,7 +155,7 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se
|
|||||||
key: "slots.reevaluate",
|
key: "slots.reevaluate",
|
||||||
defaults: {
|
defaults: {
|
||||||
name: "到期重评",
|
name: "到期重评",
|
||||||
description: "到了重评时间的收录位重新评估(忙时延后的发布、定时)",
|
description: "重新检查等待中的资源",
|
||||||
group: "download",
|
group: "download",
|
||||||
schedule: every(planMs),
|
schedule: every(planMs),
|
||||||
},
|
},
|
||||||
@@ -165,7 +165,7 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se
|
|||||||
key: "subscriptions.search",
|
key: "subscriptions.search",
|
||||||
defaults: {
|
defaults: {
|
||||||
name: "订阅周期搜索",
|
name: "订阅周期搜索",
|
||||||
description: "到期的订阅主动搜索站点",
|
description: "到期的订阅去站点搜索",
|
||||||
group: "collect",
|
group: "collect",
|
||||||
schedule: every(planMs),
|
schedule: every(planMs),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export function createAuthSettingsModule(opts: AuthSettingsModuleOptions = {}):
|
|||||||
key: "auth.cleanup",
|
key: "auth.cleanup",
|
||||||
defaults: {
|
defaults: {
|
||||||
name: "登录会话清理",
|
name: "登录会话清理",
|
||||||
description: "删除 7 天前的登录尝试和 30 天前过期或吊销的会话",
|
description: "删除 7 天前的登录尝试和 30 天前失效的会话",
|
||||||
group: "maintain",
|
group: "maintain",
|
||||||
schedule: { type: "interval", everyMs },
|
schedule: { type: "interval", everyMs },
|
||||||
},
|
},
|
||||||
|
|||||||
+3
-3
@@ -183,7 +183,7 @@ export function createCacheModule(opts: CacheModuleOptions = {}): ServerModule &
|
|||||||
key: "cache.sweep",
|
key: "cache.sweep",
|
||||||
defaults: {
|
defaults: {
|
||||||
name: "缓存清理",
|
name: "缓存清理",
|
||||||
description: "删除 PG 缓存里已过期的条目(TMDB 请求与识别缓存)",
|
description: "删除过期的 TMDB 与识别缓存",
|
||||||
group: "maintain",
|
group: "maintain",
|
||||||
schedule: { type: "interval", everyMs: 10 * 60_000 },
|
schedule: { type: "interval", everyMs: 10 * 60_000 },
|
||||||
firstRun: "after_interval",
|
firstRun: "after_interval",
|
||||||
@@ -194,7 +194,7 @@ export function createCacheModule(opts: CacheModuleOptions = {}): ServerModule &
|
|||||||
key: "cache.images_clean",
|
key: "cache.images_clean",
|
||||||
defaults: {
|
defaults: {
|
||||||
name: "图片缓存清理",
|
name: "图片缓存清理",
|
||||||
description: "删除超过保留天数的图片代理缓存文件(对照 MP 启动时的 clear_temp)",
|
description: "删除过期的海报缓存",
|
||||||
group: "maintain",
|
group: "maintain",
|
||||||
schedule: { type: "interval", everyMs: 24 * 3_600_000 },
|
schedule: { type: "interval", everyMs: 24 * 3_600_000 },
|
||||||
},
|
},
|
||||||
@@ -208,7 +208,7 @@ export function createCacheModule(opts: CacheModuleOptions = {}): ServerModule &
|
|||||||
key: "cache.images_lru",
|
key: "cache.images_lru",
|
||||||
defaults: {
|
defaults: {
|
||||||
name: "图片缓存容量控制",
|
name: "图片缓存容量控制",
|
||||||
description: "图片代理缓存超过容量预算(存储设置,缺省 20 GB)时,按最久未访问删除到预算的 90%",
|
description: "海报缓存超过容量上限时,删掉最久没用的",
|
||||||
group: "maintain",
|
group: "maintain",
|
||||||
schedule: { type: "interval", everyMs: 30 * 60_000 },
|
schedule: { type: "interval", everyMs: 30 * 60_000 },
|
||||||
firstRun: "after_interval",
|
firstRun: "after_interval",
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export function createPiModule(
|
|||||||
key: "pi.token_refresh",
|
key: "pi.token_refresh",
|
||||||
defaults: {
|
defaults: {
|
||||||
name: "模型服务登录刷新",
|
name: "模型服务登录刷新",
|
||||||
description: "订阅登录即将过期的模型服务,提前刷新 token",
|
description: "订阅登录快过期时提前续期",
|
||||||
group: "maintain",
|
group: "maintain",
|
||||||
schedule: { type: "interval", everyMs: interval },
|
schedule: { type: "interval", everyMs: interval },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export function createSchedulesModule(opts: SchedulesModuleOptions = {}): Server
|
|||||||
key: "maintain.backup",
|
key: "maintain.backup",
|
||||||
defaults: {
|
defaults: {
|
||||||
name: "备份",
|
name: "备份",
|
||||||
description: "数据库与数据目录备份;目前用 umt backup(宿主机 cron 调用),未接入调度",
|
description: "数据库与数据目录备份,目前在服务器上用命令执行",
|
||||||
group: "maintain",
|
group: "maintain",
|
||||||
schedule: { type: "cron", cron: "30 3 * * *" },
|
schedule: { type: "cron", cron: "30 3 * * *" },
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export function createSourcesIdentityModule(overrides: RuntimeOverrides = {}): S
|
|||||||
return { summary: `${await fn(rt)} 项` };
|
return { summary: `${await fn(rt)} 项` };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
job("feeds.poll", "Feed 轮询", "扫描到期的 Feed 并入队抓取(每个 Feed 仍按自己的轮询间隔)", tickFeeds);
|
job("feeds.poll", "RSS 轮询", "按各 RSS 自己的间隔抓取新资源", tickFeeds);
|
||||||
job("sites.snapshot", "站点快照", "扫描到周期的站点并入队快照", tickSnapshots);
|
job("sites.snapshot", "站点快照", "扫描到周期的站点并入队快照", tickSnapshots);
|
||||||
job("identity.review", "未识别复查", "到期的未识别条目重新识别", tickReviews);
|
job("identity.review", "未识别复查", "到期的未识别条目重新识别", tickReviews);
|
||||||
// W9-A 站点中心:用户数据每 6 小时、健康检测每小时(可在定时任务页关闭或改周期)
|
// W9-A 站点中心:用户数据每 6 小时、健康检测每小时(可在定时任务页关闭或改周期)
|
||||||
@@ -69,7 +69,7 @@ export function createSourcesIdentityModule(overrides: RuntimeOverrides = {}): S
|
|||||||
key: "sites.user_data",
|
key: "sites.user_data",
|
||||||
defaults: {
|
defaults: {
|
||||||
name: "站点数据刷新",
|
name: "站点数据刷新",
|
||||||
description: "登录状态正常的站点账号刷新上传 / 下载 / 分享率等用户数据;会话失效的账密账号自动重登一次",
|
description: "刷新站点账号的上传、下载和分享率;登录失效的账密账号自动重登一次",
|
||||||
group: "collect",
|
group: "collect",
|
||||||
schedule: { type: "interval", everyMs: 6 * 3_600_000 },
|
schedule: { type: "interval", everyMs: 6 * 3_600_000 },
|
||||||
firstRun: "after_interval",
|
firstRun: "after_interval",
|
||||||
@@ -83,7 +83,7 @@ export function createSourcesIdentityModule(overrides: RuntimeOverrides = {}): S
|
|||||||
key: "sites.health_check",
|
key: "sites.health_check",
|
||||||
defaults: {
|
defaults: {
|
||||||
name: "站点健康检测",
|
name: "站点健康检测",
|
||||||
description: "已启用站点逐个检测首页连通与登录态,结果显示在站点页",
|
description: "检查到期的站点能否打开、是否还在登录(每个站点按自己的检测间隔),结果在站点页",
|
||||||
group: "collect",
|
group: "collect",
|
||||||
schedule: { type: "interval", everyMs: 3_600_000 },
|
schedule: { type: "interval", everyMs: 3_600_000 },
|
||||||
firstRun: "after_interval",
|
firstRun: "after_interval",
|
||||||
@@ -95,8 +95,7 @@ export function createSourcesIdentityModule(overrides: RuntimeOverrides = {}): S
|
|||||||
key: BACKFILL_LANGUAGES_JOB,
|
key: BACKFILL_LANGUAGES_JOB,
|
||||||
defaults: {
|
defaults: {
|
||||||
name: "元数据语言回刷",
|
name: "元数据语言回刷",
|
||||||
description:
|
description: "按语言设置重新拉一遍作品的标题、简介和每集信息;跑完自动停用,可手动再执行",
|
||||||
"对已确认 TMDB 身份的作品按语言回退链完整刷新一次(标题、简介、每集信息),TMDB 每秒最多 2 次请求;跑完自动停用,可手动再执行",
|
|
||||||
group: "maintain",
|
group: "maintain",
|
||||||
schedule: { type: "interval", everyMs: 24 * 3_600_000 },
|
schedule: { type: "interval", everyMs: 24 * 3_600_000 },
|
||||||
firstRun: "now",
|
firstRun: "now",
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ export function createSystemModule(opts: SystemModuleOptions = {}): ServerModule
|
|||||||
key: "system.heartbeat_cleanup",
|
key: "system.heartbeat_cleanup",
|
||||||
defaults: {
|
defaults: {
|
||||||
name: "进程心跳清理",
|
name: "进程心跳清理",
|
||||||
description: "删除 24 小时没有心跳的进程行和超过保留期(存储设置,缺省 24 小时)的心跳采样",
|
description: "删除过期的进程状态记录",
|
||||||
group: "maintain",
|
group: "maintain",
|
||||||
schedule: { type: "interval", everyMs },
|
schedule: { type: "interval", everyMs },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -105,8 +105,8 @@ export const RETENTION_JOBS: RetentionJob[] = [
|
|||||||
{
|
{
|
||||||
target: "observation",
|
target: "observation",
|
||||||
jobId: "storage.retention.observation",
|
jobId: "storage.retention.observation",
|
||||||
name: "观察记录清理",
|
name: "RSS 抓取记录清理",
|
||||||
description: "删除超过保留期的 Feed 观察记录(observation,按 created_at)",
|
description: "删除过期的 RSS 抓取记录",
|
||||||
everyMs: 6 * HOUR,
|
everyMs: 6 * HOUR,
|
||||||
retention: (s) => `${s.retentionDays.observation} 天`,
|
retention: (s) => `${s.retentionDays.observation} 天`,
|
||||||
async run(c) {
|
async run(c) {
|
||||||
@@ -118,8 +118,8 @@ export const RETENTION_JOBS: RetentionJob[] = [
|
|||||||
{
|
{
|
||||||
target: "sse_event",
|
target: "sse_event",
|
||||||
jobId: "storage.retention.sse_event",
|
jobId: "storage.retention.sse_event",
|
||||||
name: "SSE 事件清理",
|
name: "实时事件清理",
|
||||||
description: "删除超过保留期的 SSE 补读事件(sse_event,按 at;发布方另按条数裁剪)",
|
description: "删除过期的页面实时推送记录",
|
||||||
everyMs: HOUR,
|
everyMs: HOUR,
|
||||||
retention: (s) => `${s.retentionDays.sseEvent} 天`,
|
retention: (s) => `${s.retentionDays.sseEvent} 天`,
|
||||||
async run(c) {
|
async run(c) {
|
||||||
@@ -132,7 +132,7 @@ export const RETENTION_JOBS: RetentionJob[] = [
|
|||||||
target: "task_run",
|
target: "task_run",
|
||||||
jobId: "storage.retention.task_run",
|
jobId: "storage.retention.task_run",
|
||||||
name: "任务运行清理",
|
name: "任务运行清理",
|
||||||
description: "删除超过保留期、已结束的业务任务(task)与引擎 run(含步骤、运行元数据)",
|
description: "删除过期的已结束任务",
|
||||||
everyMs: 6 * HOUR,
|
everyMs: 6 * HOUR,
|
||||||
retention: (s) => `${s.retentionDays.taskRun} 天`,
|
retention: (s) => `${s.retentionDays.taskRun} 天`,
|
||||||
async run(c) {
|
async run(c) {
|
||||||
@@ -168,7 +168,7 @@ export const RETENTION_JOBS: RetentionJob[] = [
|
|||||||
target: "agent_event",
|
target: "agent_event",
|
||||||
jobId: "storage.retention.agent_event",
|
jobId: "storage.retention.agent_event",
|
||||||
name: "智能体事件清理",
|
name: "智能体事件清理",
|
||||||
description: "删除超过保留期、会话已结束的智能体事件流与消息(agent_run_event / agent_run_message;会话本身保留)",
|
description: "删除已结束会话的过期消息,会话本身保留",
|
||||||
everyMs: 6 * HOUR,
|
everyMs: 6 * HOUR,
|
||||||
retention: (s) => `${s.retentionDays.agentEvent} 天`,
|
retention: (s) => `${s.retentionDays.agentEvent} 天`,
|
||||||
async run(c) {
|
async run(c) {
|
||||||
@@ -189,7 +189,7 @@ export const RETENTION_JOBS: RetentionJob[] = [
|
|||||||
target: "audit",
|
target: "audit",
|
||||||
jobId: "storage.retention.audit",
|
jobId: "storage.retention.audit",
|
||||||
name: "审计清理",
|
name: "审计清理",
|
||||||
description: "删除超过保留期的审计事件(audit_event,能力调用审计按能力统计的保留期另算)",
|
description: "删除过期的审计日志",
|
||||||
everyMs: 24 * HOUR,
|
everyMs: 24 * HOUR,
|
||||||
retention: (s) => `${s.retentionDays.audit} 天`,
|
retention: (s) => `${s.retentionDays.audit} 天`,
|
||||||
async run(c) {
|
async run(c) {
|
||||||
@@ -207,7 +207,7 @@ export const RETENTION_JOBS: RetentionJob[] = [
|
|||||||
target: "capability_stats",
|
target: "capability_stats",
|
||||||
jobId: "storage.retention.capability_stats",
|
jobId: "storage.retention.capability_stats",
|
||||||
name: "能力统计清理",
|
name: "能力统计清理",
|
||||||
description: "删除超过保留期的能力调用审计(audit_event target_type=capability)与已结束的能力调用记录",
|
description: "删除过期的能力调用记录",
|
||||||
everyMs: 24 * HOUR,
|
everyMs: 24 * HOUR,
|
||||||
retention: (s) => `${s.retentionDays.capabilityStats} 天`,
|
retention: (s) => `${s.retentionDays.capabilityStats} 天`,
|
||||||
async run(c) {
|
async run(c) {
|
||||||
@@ -231,7 +231,7 @@ export const RETENTION_JOBS: RetentionJob[] = [
|
|||||||
target: "identity_pool",
|
target: "identity_pool",
|
||||||
jobId: "storage.retention.identity_pool",
|
jobId: "storage.retention.identity_pool",
|
||||||
name: "未识别资源归档",
|
name: "未识别资源归档",
|
||||||
description: "未识别资源里 unmatched 条目超过保留期没有新证据就归档(不删除,仍可按状态筛出)",
|
description: "长期没有新线索的未识别资源归档,仍可手动指定",
|
||||||
everyMs: 6 * HOUR,
|
everyMs: 6 * HOUR,
|
||||||
retention: (s) => `${s.retentionDays.identityPoolArchive} 天`,
|
retention: (s) => `${s.retentionDays.identityPoolArchive} 天`,
|
||||||
async run(c) {
|
async run(c) {
|
||||||
@@ -252,8 +252,8 @@ export const RETENTION_JOBS: RetentionJob[] = [
|
|||||||
{
|
{
|
||||||
target: "analyze",
|
target: "analyze",
|
||||||
jobId: "storage.analyze",
|
jobId: "storage.analyze",
|
||||||
name: "统计信息更新",
|
name: "数据库维护",
|
||||||
description: "对全库执行 ANALYZE,刷新查询规划用的统计信息(每周)",
|
description: "每周刷新数据库统计信息",
|
||||||
everyMs: 7 * 24 * HOUR,
|
everyMs: 7 * 24 * HOUR,
|
||||||
retention: () => null,
|
retention: () => null,
|
||||||
async run(c) {
|
async run(c) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useDeleteSchedule } from "~/api/schedules.ts";
|
|||||||
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
||||||
import { consolePath } from "~/components/console/nav.ts";
|
import { consolePath } from "~/components/console/nav.ts";
|
||||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||||
|
import { TechInfo } from "~/components/console/tech-info.tsx";
|
||||||
import { Button } from "~/components/ui/button.tsx";
|
import { Button } from "~/components/ui/button.tsx";
|
||||||
import { Switch } from "~/components/ui/switch.tsx";
|
import { Switch } from "~/components/ui/switch.tsx";
|
||||||
import { errorMessage } from "~/lib/api.ts";
|
import { errorMessage } from "~/lib/api.ts";
|
||||||
@@ -16,7 +17,8 @@ import { NextRun, useJobActions } from "./auto-task-list.tsx";
|
|||||||
import { IntervalSelect } from "./interval-select.tsx";
|
import { IntervalSelect } from "./interval-select.tsx";
|
||||||
import { CHECK_TRIGGER_LABEL, formatUsd, RUN_STATUS } from "./labels.ts";
|
import { CHECK_TRIGGER_LABEL, formatUsd, RUN_STATUS } from "./labels.ts";
|
||||||
|
|
||||||
// 计划页「Agent 巡检」:到点按目标模板发起一次 Agent 会话(kind=agent_check),一个巡检一张卡片。
|
// 计划页「智能体巡检」:到点按目标模板发起一次智能体会话(kind=agent_check),一个巡检一张卡片。
|
||||||
|
// 卡片默认只显示名称、开关、间隔、上次结果与费用;目标、能力、预算、模型、最近会话收进「详情」。
|
||||||
|
|
||||||
/** 卡片上能力白名单最多列几个,其余折成"+N" */
|
/** 卡片上能力白名单最多列几个,其余折成"+N" */
|
||||||
const MAX_CAPS = 6;
|
const MAX_CAPS = 6;
|
||||||
@@ -113,103 +115,31 @@ export function AgentCheckCard({
|
|||||||
/>
|
/>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="rounded-tight border border-border/70 px-2.5 py-2">
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-meta text-muted-foreground">
|
||||||
<p className="line-clamp-3 text-caption text-foreground" title={c.goalTemplate}>
|
<IntervalSelect
|
||||||
{c.goalTemplate ?? "—"}
|
label={`${job.name}间隔`}
|
||||||
</p>
|
schedule={job.schedule}
|
||||||
|
disabled={!job.available}
|
||||||
|
pending={a.update.isPending}
|
||||||
|
onSave={a.setSchedule}
|
||||||
|
/>
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
下次 <NextRun job={job} now={now} />
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="flex flex-wrap items-center gap-x-2 gap-y-1 text-meta text-muted-foreground">
|
||||||
<dl className="grid grid-cols-[4.5rem_minmax(0,1fr)] items-baseline gap-x-3 gap-y-1.5">
|
{last ? (
|
||||||
<Field label="能力">
|
job.lastStatus === "ok" ? (
|
||||||
{allow.length ? (
|
<span>上次成功</span>
|
||||||
<span className="flex flex-wrap gap-1">
|
|
||||||
{allow.slice(0, MAX_CAPS).map((id) => (
|
|
||||||
<span
|
|
||||||
key={id}
|
|
||||||
title={id}
|
|
||||||
className="rounded-full border border-border/70 px-1.5 py-px text-meta text-muted-foreground"
|
|
||||||
>
|
|
||||||
{capTitle(id)}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{allow.length > MAX_CAPS ? (
|
|
||||||
<span
|
|
||||||
className="px-1 text-meta text-muted-foreground"
|
|
||||||
title={allow.slice(MAX_CAPS).map(capTitle).join("、")}
|
|
||||||
>
|
|
||||||
+{allow.length - MAX_CAPS}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</span>
|
|
||||||
) : (
|
) : (
|
||||||
"全部只读能力"
|
<StatusBadge tone={last.tone}>{last.label}</StatusBadge>
|
||||||
)}
|
)
|
||||||
{c.allowRecover ? <span className="ml-1 text-meta text-muted-foreground">· 允许授权内恢复</span> : null}
|
) : (
|
||||||
</Field>
|
<span>还没运行过</span>
|
||||||
<Field label="预算">{budgetParts.length ? budgetParts.join(" · ") : "默认"}</Field>
|
)}
|
||||||
<Field label="模型服务">
|
{job.lastRunAt ? <span title={job.lastRunAt}>{formatRelative(job.lastRunAt)}</span> : null}
|
||||||
{stats?.lastModel ? (
|
{stats?.count ? <span className="tabular-nums">· 每次约 {formatUsd(stats.avgCostUsd)}</span> : null}
|
||||||
<span className="break-words" title={modelsTitle(stats.models)}>
|
</p>
|
||||||
{stats.lastProvider ? `${stats.lastProvider} · ` : ""}
|
|
||||||
{stats.lastModel}
|
|
||||||
{stats.models.length > 1 ? (
|
|
||||||
<span className="text-muted-foreground"> 等 {stats.models.length} 个模型</span>
|
|
||||||
) : null}
|
|
||||||
</span>
|
|
||||||
) : upstream ? (
|
|
||||||
<span
|
|
||||||
className="break-words"
|
|
||||||
title={
|
|
||||||
upstream.modelFromCatalog
|
|
||||||
? "模型服务没有指定默认模型,运行时使用模型目录里的第一个"
|
|
||||||
: "还没有运行记录,按默认模型服务显示"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
默认模型服务 {upstream.name} · {upstream.model ?? "模型未指定"}
|
|
||||||
{upstream.modelFromCatalog ? <span className="text-muted-foreground">(目录首个)</span> : null}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-muted-foreground">未配置模型服务</span>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
<Field label="触发条件">
|
|
||||||
{CHECK_TRIGGER_LABEL[c.triggerCondition ?? "always"]}
|
|
||||||
{c.reportAsDecision ? <span className="ml-1 text-meta text-muted-foreground">· 结果写入待处理</span> : null}
|
|
||||||
</Field>
|
|
||||||
<Field label="间隔">
|
|
||||||
<IntervalSelect
|
|
||||||
label={`${job.name}间隔`}
|
|
||||||
schedule={job.schedule}
|
|
||||||
disabled={!job.available}
|
|
||||||
pending={a.update.isPending}
|
|
||||||
onSave={a.setSchedule}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field label="下次">
|
|
||||||
<span className="text-meta">
|
|
||||||
<NextRun job={job} now={now} />
|
|
||||||
</span>
|
|
||||||
</Field>
|
|
||||||
<Field label="每次费用">
|
|
||||||
{stats?.count ? (
|
|
||||||
<span className="tabular-nums">
|
|
||||||
上次 {formatUsd(stats.lastCostUsd)}
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{" "}
|
|
||||||
· 近 {stats.count} 次平均 {formatUsd(stats.avgCostUsd)}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-muted-foreground">还没有会话</span>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
<Field label="最近结果">
|
|
||||||
<span className="flex flex-wrap items-center gap-x-2 gap-y-1 text-meta text-muted-foreground">
|
|
||||||
{last ? <StatusBadge tone={last.tone}>{last.label}</StatusBadge> : <span>未运行</span>}
|
|
||||||
{job.lastRunAt ? <span title={job.lastRunAt}>{formatRelative(job.lastRunAt)}</span> : null}
|
|
||||||
</span>
|
|
||||||
</Field>
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
{job.lastStatus === "failed" && job.lastError ? (
|
{job.lastStatus === "failed" && job.lastError ? (
|
||||||
<details className="group rounded-tight border border-destructive/30 bg-destructive/10 px-2.5 py-1.5 text-meta text-destructive">
|
<details className="group rounded-tight border border-destructive/30 bg-destructive/10 px-2.5 py-1.5 text-meta text-destructive">
|
||||||
@@ -218,33 +148,96 @@ export function AgentCheckCard({
|
|||||||
</details>
|
</details>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{runs.length ? (
|
<TechInfo title="详情" columns={1}>
|
||||||
<section aria-label="最近会话" className="grid min-w-0 gap-1 border-t border-border/70 pt-2.5">
|
<dl className="grid grid-cols-[4.5rem_minmax(0,1fr)] items-baseline gap-x-3 gap-y-1.5">
|
||||||
<h4 className="text-meta text-muted-foreground">最近会话</h4>
|
<Field label="目标">
|
||||||
<ul className="grid min-w-0 grid-cols-[minmax(0,1fr)] gap-1">
|
<span className="line-clamp-4 break-words" title={c.goalTemplate}>
|
||||||
{runs.slice(0, 3).map((r) => {
|
{c.goalTemplate ?? "—"}
|
||||||
const s = agentRunLabel(r.status);
|
</span>
|
||||||
return (
|
</Field>
|
||||||
<li key={r.id} className="min-w-0">
|
<Field label="能力">
|
||||||
<Link
|
{allow.length ? (
|
||||||
to={consolePath("agent", r.id)}
|
<span className="flex flex-wrap gap-1">
|
||||||
className="flex min-w-0 items-center gap-2 rounded-tight px-1 py-0.5 text-meta text-muted-foreground hover:bg-foreground/[0.04] hover:text-foreground"
|
{allow.slice(0, MAX_CAPS).map((id) => (
|
||||||
|
<span
|
||||||
|
key={id}
|
||||||
|
title={id}
|
||||||
|
className="rounded-full border border-border/70 px-1.5 py-px text-meta text-muted-foreground"
|
||||||
>
|
>
|
||||||
<StatusBadge tone={s.tone}>{s.label}</StatusBadge>
|
{capTitle(id)}
|
||||||
<span className="min-w-0 flex-1 truncate" title={r.summary ?? undefined}>
|
</span>
|
||||||
{plainSummary(r.summary ?? r.goal)}
|
))}
|
||||||
</span>
|
{allow.length > MAX_CAPS ? (
|
||||||
<span className="shrink-0 tabular-nums">{formatUsd(r.costUsd)}</span>
|
<span
|
||||||
<span className="shrink-0" title={r.createdAt}>
|
className="px-1 text-meta text-muted-foreground"
|
||||||
{formatRelative(r.finishedAt ?? r.createdAt)}
|
title={allow.slice(MAX_CAPS).map(capTitle).join("、")}
|
||||||
</span>
|
>
|
||||||
</Link>
|
+{allow.length - MAX_CAPS}
|
||||||
</li>
|
</span>
|
||||||
);
|
) : null}
|
||||||
})}
|
</span>
|
||||||
</ul>
|
) : (
|
||||||
</section>
|
"全部只读能力"
|
||||||
) : null}
|
)}
|
||||||
|
{c.allowRecover ? <span className="ml-1 text-meta text-muted-foreground">· 允许授权内恢复</span> : null}
|
||||||
|
</Field>
|
||||||
|
<Field label="触发条件">
|
||||||
|
{CHECK_TRIGGER_LABEL[c.triggerCondition ?? "always"]}
|
||||||
|
{c.reportAsDecision ? <span className="ml-1 text-meta text-muted-foreground">· 结果写入待处理</span> : null}
|
||||||
|
</Field>
|
||||||
|
<Field label="预算">{budgetParts.length ? budgetParts.join(" · ") : "默认"}</Field>
|
||||||
|
<Field label="模型">
|
||||||
|
{stats?.lastModel ? (
|
||||||
|
<span className="break-words" title={modelsTitle(stats.models)}>
|
||||||
|
{stats.lastProvider ? `${stats.lastProvider} · ` : ""}
|
||||||
|
{stats.lastModel}
|
||||||
|
{stats.models.length > 1 ? (
|
||||||
|
<span className="text-muted-foreground"> 等 {stats.models.length} 个模型</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
) : upstream ? (
|
||||||
|
<span className="break-words">
|
||||||
|
{upstream.name} · {upstream.model ?? "模型未指定"}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">未配置模型服务</span>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
{stats?.count ? (
|
||||||
|
<Field label="费用">
|
||||||
|
<span className="tabular-nums">
|
||||||
|
上次 {formatUsd(stats.lastCostUsd)} · 近 {stats.count} 次平均 {formatUsd(stats.avgCostUsd)}
|
||||||
|
</span>
|
||||||
|
</Field>
|
||||||
|
) : null}
|
||||||
|
</dl>
|
||||||
|
{runs.length ? (
|
||||||
|
<section aria-label="最近会话" className="grid min-w-0 gap-1">
|
||||||
|
<h4 className="text-meta text-muted-foreground">最近会话</h4>
|
||||||
|
<ul className="grid min-w-0 grid-cols-[minmax(0,1fr)] gap-1">
|
||||||
|
{runs.slice(0, 3).map((r) => {
|
||||||
|
const st = agentRunLabel(r.status);
|
||||||
|
return (
|
||||||
|
<li key={r.id} className="min-w-0">
|
||||||
|
<Link
|
||||||
|
to={consolePath("agent", r.id)}
|
||||||
|
className="flex min-w-0 items-center gap-2 rounded-tight px-1 py-0.5 text-meta text-muted-foreground hover:bg-foreground/[0.04] hover:text-foreground"
|
||||||
|
>
|
||||||
|
{r.status === "succeeded" ? null : <StatusBadge tone={st.tone}>{st.label}</StatusBadge>}
|
||||||
|
<span className="min-w-0 flex-1 truncate" title={r.summary ?? undefined}>
|
||||||
|
{plainSummary(r.summary ?? r.goal)}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0" title={r.createdAt}>
|
||||||
|
{formatRelative(r.finishedAt ?? r.createdAt)}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
</TechInfo>
|
||||||
|
|
||||||
<footer className="flex flex-wrap items-center gap-1.5">
|
<footer className="flex flex-wrap items-center gap-1.5">
|
||||||
<Button size="sm" disabled={!a.canRun} onClick={a.run}>
|
<Button size="sm" disabled={!a.canRun} onClick={a.run}>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ScheduledJob, ScheduleGroup, ScheduleSpecInput } from "@umt/contracts";
|
import type { ScheduledJob, ScheduleGroup, ScheduleSpecInput } from "@umt/contracts";
|
||||||
import { History, LoaderCircle, Play, RotateCcw, SkipForward } from "lucide-react";
|
import { ChevronRight, History, LoaderCircle, Play, RotateCcw, SkipForward } from "lucide-react";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useRunScheduleNow, useSkipSchedule, useUpdateSchedule } from "~/api/schedules.ts";
|
import { useRunScheduleNow, useSkipSchedule, useUpdateSchedule } from "~/api/schedules.ts";
|
||||||
@@ -9,11 +9,12 @@ import { Button } from "~/components/ui/button.tsx";
|
|||||||
import { Switch } from "~/components/ui/switch.tsx";
|
import { Switch } from "~/components/ui/switch.tsx";
|
||||||
import { errorMessage } from "~/lib/api.ts";
|
import { errorMessage } from "~/lib/api.ts";
|
||||||
import { formatRelative } from "~/lib/labels.ts";
|
import { formatRelative } from "~/lib/labels.ts";
|
||||||
|
import { readCollapseState, writeCollapseState } from "~/lib/ui-prefs.ts";
|
||||||
import { cn } from "~/lib/utils.ts";
|
import { cn } from "~/lib/utils.ts";
|
||||||
import { IntervalSelect } from "./interval-select.tsx";
|
import { IntervalSelect } from "./interval-select.tsx";
|
||||||
import { countdown, formatMs, GROUP_LABEL, RUN_STATUS, scheduleStreamPath, TASK_GROUPS } from "./labels.ts";
|
import { countdown, formatMs, GROUP_LABEL, RUN_STATUS, scheduleStreamPath, TASK_GROUPS } from "./labels.ts";
|
||||||
|
|
||||||
// 计划页「自动任务」:程序按固定周期执行的定时任务(kind=task),按组的紧凑列表。
|
// 计划页「自动任务」:程序按固定周期执行的定时任务(kind=task),按组折叠的紧凑列表;维护类一组叫「数据清理」。
|
||||||
|
|
||||||
/** 下次运行:运行中 > 排队中 > 未配置 > 已停用 > 倒计时 */
|
/** 下次运行:运行中 > 排队中 > 未配置 > 已停用 > 倒计时 */
|
||||||
export function NextRun({ job, now }: { job: ScheduledJob; now: number }) {
|
export function NextRun({ job, now }: { job: ScheduledJob; now: number }) {
|
||||||
@@ -121,9 +122,22 @@ function AutoTaskRow({ job, now, highlight }: { job: ScheduledJob; now: number;
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-start-1 row-start-3 flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-meta text-muted-foreground lg:col-start-auto lg:row-start-auto">
|
<div className="col-start-1 row-start-3 flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-meta text-muted-foreground lg:col-start-auto lg:row-start-auto">
|
||||||
{last ? <StatusBadge tone={last.tone}>{last.label}</StatusBadge> : <span>未运行</span>}
|
{last ? (
|
||||||
{job.lastRunAt ? <span title={job.lastRunAt}>{formatRelative(job.lastRunAt)}</span> : null}
|
job.lastStatus === "ok" ? (
|
||||||
{job.lastDurationMs !== null ? <span className="tabular-nums">{formatMs(job.lastDurationMs)}</span> : null}
|
<span>成功</span>
|
||||||
|
) : (
|
||||||
|
<StatusBadge tone={last.tone}>{last.label}</StatusBadge>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span>未运行</span>
|
||||||
|
)}
|
||||||
|
{job.lastRunAt ? (
|
||||||
|
<span
|
||||||
|
title={`${job.lastRunAt}${job.lastDurationMs !== null ? ` · 用时 ${formatMs(job.lastDurationMs)}` : ""}`}
|
||||||
|
>
|
||||||
|
{formatRelative(job.lastRunAt)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="col-start-2 row-start-3 flex min-w-0 items-center justify-end gap-1 text-meta text-muted-foreground lg:col-start-auto lg:row-start-auto lg:justify-start">
|
<div className="col-start-2 row-start-3 flex min-w-0 items-center justify-end gap-1 text-meta text-muted-foreground lg:col-start-auto lg:row-start-auto lg:justify-start">
|
||||||
<span className="shrink-0 lg:hidden">下次</span>
|
<span className="shrink-0 lg:hidden">下次</span>
|
||||||
@@ -178,6 +192,25 @@ function AutoTaskRow({ job, now, highlight }: { job: ScheduledJob; now: number;
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const COLLAPSE_KEY = "umt.schedules.groups";
|
||||||
|
|
||||||
|
/** 组摘要:"6 个任务 · 下次 3 分后 · 1 个失败" */
|
||||||
|
function groupSummary(list: ScheduledJob[], now: number): string {
|
||||||
|
const next = list
|
||||||
|
.filter((j) => j.enabled && j.available && j.nextRunAt)
|
||||||
|
.map((j) => j.nextRunAt as string)
|
||||||
|
.sort()[0];
|
||||||
|
const failed = list.filter((j) => j.lastStatus === "failed").length;
|
||||||
|
const running = list.filter((j) => j.runningSince).length;
|
||||||
|
return [
|
||||||
|
`${list.length} 个任务`,
|
||||||
|
running ? `${running} 个运行中` : next ? `下次 ${countdown(next, now)}` : null,
|
||||||
|
failed ? `${failed} 个失败` : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
export function AutoTaskList({
|
export function AutoTaskList({
|
||||||
jobs,
|
jobs,
|
||||||
now,
|
now,
|
||||||
@@ -189,37 +222,73 @@ export function AutoTaskList({
|
|||||||
}) {
|
}) {
|
||||||
const byGroup = new Map<ScheduleGroup, ScheduledJob[]>();
|
const byGroup = new Map<ScheduleGroup, ScheduledJob[]>();
|
||||||
for (const j of jobs) {
|
for (const j of jobs) {
|
||||||
|
// 未接入调度的占位任务(如备份)不列
|
||||||
|
if (!j.available && !j.enabled && j.group === "maintain") continue;
|
||||||
const list = byGroup.get(j.group);
|
const list = byGroup.get(j.group);
|
||||||
if (list) list.push(j);
|
if (list) list.push(j);
|
||||||
else byGroup.set(j.group, [j]);
|
else byGroup.set(j.group, [j]);
|
||||||
}
|
}
|
||||||
const groups = TASK_GROUPS.filter((g) => byGroup.get(g)?.length);
|
const groups = TASK_GROUPS.filter((g) => byGroup.get(g)?.length);
|
||||||
|
// 折叠:有失败 / 运行中 / 被高亮的组默认展开,其余收起;点过的按用户的来并记在本机
|
||||||
|
const [saved, setSaved] = useState<Record<string, boolean>>(() => readCollapseState(COLLAPSE_KEY) ?? {});
|
||||||
|
const expanded = (g: ScheduleGroup, list: ScheduledJob[]) =>
|
||||||
|
list.some((j) => j.id === highlightJob) ||
|
||||||
|
(saved[g] ?? list.some((j) => j.lastStatus === "failed" || !!j.runningSince));
|
||||||
|
const toggle = (g: ScheduleGroup, v: boolean) =>
|
||||||
|
setSaved((cur) => {
|
||||||
|
const next = { ...cur, [g]: v };
|
||||||
|
writeCollapseState(COLLAPSE_KEY, next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
const anyOpen = groups.some((g) => expanded(g, byGroup.get(g) ?? []));
|
||||||
return (
|
return (
|
||||||
<div className="pg-slab overflow-hidden rounded-xl">
|
<div className="pg-slab overflow-hidden rounded-xl">
|
||||||
<div
|
{anyOpen ? (
|
||||||
aria-hidden
|
<div
|
||||||
className={cn("hidden border-b border-border/70 px-4 py-2 text-meta text-muted-foreground lg:grid", COLS)}
|
aria-hidden
|
||||||
>
|
className={cn("hidden border-b border-border/70 px-4 py-2 text-meta text-muted-foreground lg:grid", COLS)}
|
||||||
<span>名称</span>
|
>
|
||||||
<span>开关</span>
|
<span>名称</span>
|
||||||
<span>间隔</span>
|
<span>开关</span>
|
||||||
<span>上次结果</span>
|
<span>间隔</span>
|
||||||
<span>下次运行</span>
|
<span>上次结果</span>
|
||||||
<span className="text-right">操作</span>
|
<span>下次运行</span>
|
||||||
</div>
|
<span className="text-right">操作</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{groups.map((g) => {
|
{groups.map((g) => {
|
||||||
const list = byGroup.get(g) ?? [];
|
const list = byGroup.get(g) ?? [];
|
||||||
|
const open = expanded(g, list);
|
||||||
|
const failed = list.some((j) => j.lastStatus === "failed");
|
||||||
return (
|
return (
|
||||||
<section key={g} aria-label={GROUP_LABEL[g]}>
|
<section key={g} aria-label={GROUP_LABEL[g]} className="border-b border-border/70 last:border-b-0">
|
||||||
<h3 className="flex items-baseline gap-2 border-b border-border/70 bg-foreground/[0.03] px-4 py-1.5 text-meta font-medium text-muted-foreground">
|
<h3>
|
||||||
{GROUP_LABEL[g]}
|
<button
|
||||||
<span className="tabular-nums">{list.length}</span>
|
type="button"
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={() => toggle(g, !open)}
|
||||||
|
className="flex w-full cursor-pointer flex-wrap items-baseline gap-x-3 gap-y-0.5 bg-foreground/[0.03] px-4 py-2 text-left transition-colors duration-micro hover:bg-foreground/[0.05]"
|
||||||
|
>
|
||||||
|
<ChevronRight
|
||||||
|
aria-hidden
|
||||||
|
className={cn(
|
||||||
|
"size-3.5 shrink-0 self-center text-muted-foreground transition-transform duration-base",
|
||||||
|
open && "rotate-90",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="text-body font-medium text-foreground">{GROUP_LABEL[g]}</span>
|
||||||
|
<span className={cn("text-meta tabular-nums", failed ? "text-destructive" : "text-muted-foreground")}>
|
||||||
|
{groupSummary(list, now)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
</h3>
|
</h3>
|
||||||
<ul className="divide-y divide-border/70 border-b border-border/70 last:border-b-0">
|
{open ? (
|
||||||
{list.map((j) => (
|
<ul className="divide-y divide-border/70 border-t border-border/70">
|
||||||
<AutoTaskRow key={j.id} job={j} now={now} highlight={highlightJob === j.id} />
|
{list.map((j) => (
|
||||||
))}
|
<AutoTaskRow key={j.id} job={j} now={now} highlight={highlightJob === j.id} />
|
||||||
</ul>
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -12,10 +12,11 @@ describe("计划页标签", () => {
|
|||||||
expect(scheduleStreamPath("trash.purge")).toBe("/console/tasks?tab=all&kind=schedule&ref=trash.purge");
|
expect(scheduleStreamPath("trash.purge")).toBe("/console/tasks?tab=all&kind=schedule&ref=trash.purge");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("formatUsd:小额保留 4 位", () => {
|
it("formatUsd:两位小数,不足一分写 < $0.01", () => {
|
||||||
expect(formatUsd(undefined)).toBe("—");
|
expect(formatUsd(undefined)).toBe("—");
|
||||||
expect(formatUsd(0)).toBe("$0");
|
expect(formatUsd(0)).toBe("$0");
|
||||||
expect(formatUsd(0.0031258)).toBe("$0.0031");
|
expect(formatUsd(0.0031258)).toBe("< $0.01");
|
||||||
|
expect(formatUsd(0.0191)).toBe("$0.02");
|
||||||
expect(formatUsd(1.234)).toBe("$1.23");
|
expect(formatUsd(1.234)).toBe("$1.23");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import type { StatusTone } from "~/components/console/status-badge.tsx";
|
|||||||
// 定时任务看板的标签映射(只在本页用,不放 lib/labels.ts)
|
// 定时任务看板的标签映射(只在本页用,不放 lib/labels.ts)
|
||||||
|
|
||||||
export const GROUP_ORDER: ScheduleGroup[] = ["collect", "download", "organize", "maintain", "agent"];
|
export const GROUP_ORDER: ScheduleGroup[] = ["collect", "download", "organize", "maintain", "agent"];
|
||||||
/** 计划页「自动任务」一节的分组(Agent 巡检单独一节) */
|
/** 计划页「自动任务」一节的分组(智能体巡检单独一节,放在最上面);维护类合成一组「数据清理」 */
|
||||||
export const TASK_GROUPS: ScheduleGroup[] = ["collect", "download", "organize", "maintain"];
|
export const TASK_GROUPS: ScheduleGroup[] = ["collect", "download", "organize", "maintain"];
|
||||||
|
|
||||||
/** 计划页两节的锚点:/console/schedules#auto、#checks */
|
/** 计划页两节的锚点:/console/schedules#auto、#checks */
|
||||||
@@ -28,7 +28,7 @@ export const GROUP_LABEL: Record<ScheduleGroup, string> = {
|
|||||||
collect: "采集",
|
collect: "采集",
|
||||||
download: "下载",
|
download: "下载",
|
||||||
organize: "整理",
|
organize: "整理",
|
||||||
maintain: "维护",
|
maintain: "数据清理",
|
||||||
agent: "智能体巡检",
|
agent: "智能体巡检",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -70,11 +70,12 @@ export const INTERVAL_PRESETS: number[] = [
|
|||||||
7 * 24 * 3600_000,
|
7 * 24 * 3600_000,
|
||||||
];
|
];
|
||||||
|
|
||||||
/** 美元费用:"$0.0031" / "$0.12";空值 "—" */
|
/** 美元费用:两位小数,不足一分写 "< $0.01";空值 "—" */
|
||||||
export function formatUsd(v: number | null | undefined): string {
|
export function formatUsd(v: number | null | undefined): string {
|
||||||
if (v === null || v === undefined) return "—";
|
if (v === null || v === undefined) return "—";
|
||||||
if (v === 0) return "$0";
|
if (v === 0) return "$0";
|
||||||
return `$${v < 0.1 ? v.toFixed(4) : v.toFixed(2)}`;
|
if (v < 0.01) return "< $0.01";
|
||||||
|
return `$${v.toFixed(2)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 毫秒 → 最大整除单位 */
|
/** 毫秒 → 最大整除单位 */
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { SCHEDULE_CHANGED_EVENT, type ScheduledJob } from "@umt/contracts";
|
import { SCHEDULE_CHANGED_EVENT, type ScheduledJob } from "@umt/contracts";
|
||||||
import { AlertTriangle, Bot, CalendarClock, CircleCheck, Coins, LoaderCircle, Plus, XCircle } from "lucide-react";
|
import { AlertTriangle, Bot, CalendarClock, Plus } from "lucide-react";
|
||||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||||
import { useLocation } from "react-router";
|
import { useLocation } from "react-router";
|
||||||
import { usePiUpstreamModels, usePiUpstreams } from "~/api/pi.ts";
|
import { usePiUpstreamModels, usePiUpstreams } from "~/api/pi.ts";
|
||||||
import { useScheduleBoard } from "~/api/schedules.ts";
|
import { useScheduleBoard } from "~/api/schedules.ts";
|
||||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||||
import { StatTile } from "~/components/console/stat-tile.tsx";
|
|
||||||
import { Button } from "~/components/ui/button.tsx";
|
import { Button } from "~/components/ui/button.tsx";
|
||||||
import { qk } from "~/lib/query-keys.ts";
|
import { qk } from "~/lib/query-keys.ts";
|
||||||
import { useSse } from "~/lib/sse.ts";
|
import { useSse } from "~/lib/sse.ts";
|
||||||
@@ -16,8 +15,9 @@ import { AutoTaskList } from "./auto-task-list.tsx";
|
|||||||
import { formatUsd } from "./labels.ts";
|
import { formatUsd } from "./labels.ts";
|
||||||
import { RunsSheet } from "./runs-sheet.tsx";
|
import { RunsSheet } from "./runs-sheet.tsx";
|
||||||
|
|
||||||
// 计划页(FE-6,/console/schedules):「自动任务」= 程序按固定周期执行的定时任务(紧凑列表);
|
// 计划页(FE-6,/console/schedules):「智能体巡检」= 到点发起智能体会话的定时任务(卡片,放最上面:
|
||||||
// 「Agent 巡检」= 到点发起 Agent 会话的定时任务(卡片)。自动任务的运行记录跳任务页按该定时任务筛选。
|
// 数量少、由用户自己建、会花钱,是这页最需要看的);「自动任务」= 程序按固定周期执行的定时任务
|
||||||
|
// (按分类折叠的紧凑列表,维护类合成「数据清理」)。自动任务的运行记录跳任务页按该定时任务筛选。
|
||||||
|
|
||||||
/** 每秒一跳的当前时间(倒计时用) */
|
/** 每秒一跳的当前时间(倒计时用) */
|
||||||
export function useNow(ms = 1000) {
|
export function useNow(ms = 1000) {
|
||||||
@@ -69,7 +69,17 @@ export function planStats(items: ScheduledJob[]) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function SectionHead({ id, title, hint, actions }: { id: string; title: string; hint: string; actions?: ReactNode }) {
|
function SectionHead({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
hint,
|
||||||
|
actions,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
hint: ReactNode;
|
||||||
|
actions?: ReactNode;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-3 flex flex-wrap items-end gap-x-3 gap-y-2">
|
<div className="mb-3 flex flex-wrap items-end gap-x-3 gap-y-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@@ -107,7 +117,6 @@ export function PlanBoard({ highlightJob }: { highlightJob?: string }) {
|
|||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
const s = planStats(items);
|
const s = planStats(items);
|
||||||
const val = (n: number) => (board.isPending ? "…" : board.isError ? "—" : n);
|
|
||||||
|
|
||||||
// 首页计数链到 #auto / #checks:数据到了再滚过去
|
// 首页计数链到 #auto / #checks:数据到了再滚过去
|
||||||
const loaded = !!data;
|
const loaded = !!data;
|
||||||
@@ -117,59 +126,8 @@ export function PlanBoard({ highlightJob }: { highlightJob?: string }) {
|
|||||||
if (id === "auto" || id === "checks") document.getElementById(id)?.scrollIntoView({ block: "start" });
|
if (id === "auto" || id === "checks") document.getElementById(id)?.scrollIntoView({ block: "start" });
|
||||||
}, [loaded, hash, highlightJob]);
|
}, [loaded, hash, highlightJob]);
|
||||||
|
|
||||||
const jump = (id: string) => document.getElementById(id)?.scrollIntoView({ block: "start", behavior: "smooth" });
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mb-4 grid gap-3 lg:grid-cols-2">
|
|
||||||
<div className="grid gap-1.5">
|
|
||||||
<p className="px-1 text-meta font-medium text-muted-foreground">自动任务</p>
|
|
||||||
<div className="grid grid-cols-3 gap-2">
|
|
||||||
<StatTile
|
|
||||||
label="已启用"
|
|
||||||
icon={CircleCheck}
|
|
||||||
value={val(s.auto.enabled)}
|
|
||||||
hint={board.isPending ? undefined : `共 ${s.auto.total} 项`}
|
|
||||||
onClick={() => jump("auto")}
|
|
||||||
/>
|
|
||||||
<StatTile
|
|
||||||
label="上次失败"
|
|
||||||
icon={XCircle}
|
|
||||||
tone={s.auto.failed > 0 ? "destructive" : "neutral"}
|
|
||||||
value={val(s.auto.failed)}
|
|
||||||
onClick={() => jump("auto")}
|
|
||||||
/>
|
|
||||||
<StatTile label="运行中" icon={LoaderCircle} value={val(s.auto.running)} onClick={() => jump("auto")} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-1.5">
|
|
||||||
<p className="px-1 text-meta font-medium text-muted-foreground">智能体巡检</p>
|
|
||||||
<div className="grid grid-cols-3 gap-2">
|
|
||||||
<StatTile
|
|
||||||
label="已启用"
|
|
||||||
icon={Bot}
|
|
||||||
value={val(s.checks.enabled)}
|
|
||||||
hint={board.isPending ? undefined : `共 ${s.checks.total} 项`}
|
|
||||||
onClick={() => jump("checks")}
|
|
||||||
/>
|
|
||||||
<StatTile
|
|
||||||
label="上次失败"
|
|
||||||
icon={XCircle}
|
|
||||||
tone={s.checks.failed > 0 ? "destructive" : "neutral"}
|
|
||||||
value={val(s.checks.failed)}
|
|
||||||
onClick={() => jump("checks")}
|
|
||||||
/>
|
|
||||||
<StatTile
|
|
||||||
label="近 7 天费用"
|
|
||||||
icon={Coins}
|
|
||||||
value={data ? formatUsd(s.checks.weekCost) : "…"}
|
|
||||||
hint={data ? `${s.checks.weekRuns} 次会话` : undefined}
|
|
||||||
onClick={() => jump("checks")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{data && !data.schedulerOnline ? (
|
{data && !data.schedulerOnline ? (
|
||||||
<p
|
<p
|
||||||
role="status"
|
role="status"
|
||||||
@@ -186,24 +144,21 @@ export function PlanBoard({ highlightJob }: { highlightJob?: string }) {
|
|||||||
<ErrorState error={board.error} onRetry={() => board.refetch()} />
|
<ErrorState error={board.error} onRetry={() => board.refetch()} />
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-8">
|
<div className="grid gap-8">
|
||||||
<section aria-labelledby="auto">
|
|
||||||
<SectionHead
|
|
||||||
id="auto"
|
|
||||||
title="自动任务"
|
|
||||||
hint="程序按固定周期执行,不调用模型。间隔可直接改,改过的可恢复默认。"
|
|
||||||
/>
|
|
||||||
{autoJobs.length === 0 ? (
|
|
||||||
<EmptyState icon={CalendarClock} title="还没有自动任务" description="worker 启动后自动登记。" />
|
|
||||||
) : (
|
|
||||||
<AutoTaskList jobs={autoJobs} now={now} highlightJob={highlightJob} />
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section aria-labelledby="checks">
|
<section aria-labelledby="checks">
|
||||||
<SectionHead
|
<SectionHead
|
||||||
id="checks"
|
id="checks"
|
||||||
title="智能体巡检"
|
title="智能体巡检"
|
||||||
hint="到点按目标发起一次智能体会话,只在能力白名单和预算内执行;触发条件不满足时跳过,不调用模型。"
|
hint={
|
||||||
|
s.checks.total
|
||||||
|
? [
|
||||||
|
`${s.checks.enabled} 个启用`,
|
||||||
|
s.checks.failed ? `${s.checks.failed} 个上次失败` : null,
|
||||||
|
s.checks.weekRuns ? `近 7 天 ${s.checks.weekRuns} 次,${formatUsd(s.checks.weekCost)}` : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ")
|
||||||
|
: "到点让智能体按目标检查并汇报。"
|
||||||
|
}
|
||||||
actions={
|
actions={
|
||||||
<Button size="sm" onClick={() => setEditing({ job: null, key: Date.now() })}>
|
<Button size="sm" onClick={() => setEditing({ job: null, key: Date.now() })}>
|
||||||
<Plus />
|
<Plus />
|
||||||
@@ -229,6 +184,24 @@ export function PlanBoard({ highlightJob }: { highlightJob?: string }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
<section aria-labelledby="auto">
|
||||||
|
<SectionHead
|
||||||
|
id="auto"
|
||||||
|
title="自动任务"
|
||||||
|
hint={[
|
||||||
|
`${s.auto.enabled} 个启用`,
|
||||||
|
s.auto.running ? `${s.auto.running} 个运行中` : null,
|
||||||
|
s.auto.failed ? `${s.auto.failed} 个上次失败` : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ")}
|
||||||
|
/>
|
||||||
|
{autoJobs.length === 0 ? (
|
||||||
|
<EmptyState icon={CalendarClock} title="还没有自动任务" description="worker 启动后自动登记。" />
|
||||||
|
) : (
|
||||||
|
<AutoTaskList jobs={autoJobs} now={now} highlightJob={highlightJob} />
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { Pause, Play, ShieldAlert } from "lucide-react";
|
import { ChevronDown, Pause, Play, ShieldAlert } from "lucide-react";
|
||||||
import type { ReactNode } from "react";
|
import { type ReactNode, useEffect, useState } from "react";
|
||||||
|
import { useLocation } from "react-router";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useUpdateSettings } from "~/api/settings.ts";
|
import { useUpdateSettings } from "~/api/settings.ts";
|
||||||
import { Switch } from "~/components/ui/switch.tsx";
|
import { Switch } from "~/components/ui/switch.tsx";
|
||||||
import { errorMessage } from "~/lib/api.ts";
|
import { errorMessage } from "~/lib/api.ts";
|
||||||
import { cn } from "~/lib/utils.ts";
|
import { cn } from "~/lib/utils.ts";
|
||||||
import { type FormValues, num } from "./resource-section.tsx";
|
import { type FormValues, num } from "./resource-section.tsx";
|
||||||
import { SettingsBlock } from "./settings-block.tsx";
|
import { anchorId, SettingsBlock } from "./settings-block.tsx";
|
||||||
import { type CardSpec, n, SettingsCard, SettingsGate } from "./settings-card.tsx";
|
import { type CardSpec, n, SettingsCard, SettingsGate } from "./settings-card.tsx";
|
||||||
|
|
||||||
// 自动化分区:总开关、只读对账、缓存水位、每日下载预算、保留天数、存储预算与保留(CACHE-2)
|
// 自动化分区:总开关、只读对账、缓存水位、每日下载预算、保留天数、存储预算与保留(CACHE-2)
|
||||||
@@ -15,12 +16,12 @@ const GiB = 1024 ** 3;
|
|||||||
|
|
||||||
const CACHE_CARD: CardSpec = {
|
const CACHE_CARD: CardSpec = {
|
||||||
anchor: "cache",
|
anchor: "cache",
|
||||||
title: "缓存水位",
|
title: "下载盘",
|
||||||
description: "缓存卷使用率到硬阈值时停止新增下载并暂停本工具的下载任务;降到恢复水位以下只恢复因阈值暂停的任务。",
|
description: "下载盘用量到暂停线时不再新增下载,并暂停本工具的下载任务;降到恢复线以下只恢复因此暂停的任务。",
|
||||||
fields: [
|
fields: [
|
||||||
{ name: "cache.hardThresholdPercent", label: "硬阈值(%)", kind: "number" },
|
{ name: "cache.hardThresholdPercent", label: "暂停线(%)", kind: "number" },
|
||||||
{ name: "cache.resumePercent", label: "恢复水位(%)", kind: "number", hint: "低于硬阈值" },
|
{ name: "cache.resumePercent", label: "恢复线(%)", kind: "number", hint: "低于暂停线" },
|
||||||
{ name: "cache.cleanupTargetPercent", label: "清理目标(%)", kind: "number", hint: "不高于恢复水位" },
|
{ name: "cache.cleanupTargetPercent", label: "清理目标(%)", kind: "number", hint: "不高于恢复线" },
|
||||||
{ name: "cache.checkIntervalSec", label: "检查间隔(秒)", kind: "number", hint: "10–3600" },
|
{ name: "cache.checkIntervalSec", label: "检查间隔(秒)", kind: "number", hint: "10–3600" },
|
||||||
],
|
],
|
||||||
fromSettings: (s) => ({
|
fromSettings: (s) => ({
|
||||||
@@ -75,7 +76,7 @@ const RETENTION_CARD: CardSpec = {
|
|||||||
kind: "number",
|
kind: "number",
|
||||||
hint: "复制入库后下载源在缓存里至少留这么久;0–365",
|
hint: "复制入库后下载源在缓存里至少留这么久;0–365",
|
||||||
},
|
},
|
||||||
{ name: "retention.logRetentionDays", label: "日志保留天数", kind: "number", hint: "观察与日志类数据" },
|
{ name: "retention.logRetentionDays", label: "日志保留天数", kind: "number" },
|
||||||
],
|
],
|
||||||
fromSettings: (s) => ({
|
fromSettings: (s) => ({
|
||||||
"retention.seedRetentionDays": n(s.retention.seedRetentionDays),
|
"retention.seedRetentionDays": n(s.retention.seedRetentionDays),
|
||||||
@@ -211,6 +212,59 @@ function ToggleRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 高级:只读对账、存储预算、流水保留、缓存有效期;默认收起,从 #automation/advanced 进来时展开 */
|
||||||
|
function Advanced({ children }: { children: ReactNode }) {
|
||||||
|
const location = useLocation();
|
||||||
|
const [open, setOpen] = useState(() => location.hash.endsWith("/advanced"));
|
||||||
|
useEffect(() => {
|
||||||
|
if (location.hash.endsWith("/advanced")) setOpen(true);
|
||||||
|
}, [location.hash]);
|
||||||
|
return (
|
||||||
|
<section id={anchorId("automation", "advanced")} className="scroll-mt-20">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className="inline-flex items-center gap-1.5 text-lead font-semibold text-foreground"
|
||||||
|
>
|
||||||
|
高级
|
||||||
|
<ChevronDown
|
||||||
|
className={cn("size-4 text-muted-foreground transition-transform duration-base", open && "rotate-180")}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<p className="mt-1 text-caption text-muted-foreground">只读对账、存储预算、数据保留和缓存有效期。一般不用改。</p>
|
||||||
|
{open ? <div className="mt-6 grid grid-cols-1 gap-8">{children}</div> : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReadOnlySwitch() {
|
||||||
|
const update = useUpdateSettings();
|
||||||
|
return (
|
||||||
|
<SettingsGate>
|
||||||
|
{(s) => (
|
||||||
|
<SettingsBlock section="automation" anchor="readonly" title="只读对账">
|
||||||
|
<ToggleRow
|
||||||
|
id="tg-readonly"
|
||||||
|
icon={<ShieldAlert className="h-4 w-4" />}
|
||||||
|
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, "切换失败")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SettingsBlock>
|
||||||
|
)}
|
||||||
|
</SettingsGate>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function Switches() {
|
function Switches() {
|
||||||
const update = useUpdateSettings();
|
const update = useUpdateSettings();
|
||||||
const set = async (patch: { enabled?: boolean; readOnlyReconcile?: boolean }, msg: string) => {
|
const set = async (patch: { enabled?: boolean; readOnlyReconcile?: boolean }, msg: string) => {
|
||||||
@@ -236,16 +290,6 @@ function Switches() {
|
|||||||
disabled={update.isPending}
|
disabled={update.isPending}
|
||||||
onChange={(v) => set({ enabled: v }, v ? "自动化已恢复" : "自动化已暂停")}
|
onChange={(v) => set({ enabled: v }, v ? "自动化已恢复" : "自动化已暂停")}
|
||||||
/>
|
/>
|
||||||
<ToggleRow
|
|
||||||
id="tg-readonly"
|
|
||||||
icon={<ShieldAlert className="h-4 w-4" />}
|
|
||||||
tone={s.automation.readOnlyReconcile ? "warning" : "success"}
|
|
||||||
title={`只读对账 · ${s.automation.readOnlyReconcile ? "开启" : "关闭"}`}
|
|
||||||
description="从备份恢复后先开启:只核对库存与路径,不做自动写入。核对完再关闭。"
|
|
||||||
checked={s.automation.readOnlyReconcile}
|
|
||||||
disabled={update.isPending}
|
|
||||||
onChange={(v) => set({ readOnlyReconcile: v }, v ? "已开启只读对账" : "已关闭只读对账")}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</SettingsBlock>
|
</SettingsBlock>
|
||||||
)}
|
)}
|
||||||
@@ -263,9 +307,12 @@ export function AutomationSection() {
|
|||||||
<SettingsCard section="automation" spec={CACHE_CARD} settings={s} />
|
<SettingsCard section="automation" spec={CACHE_CARD} settings={s} />
|
||||||
<SettingsCard section="automation" spec={BUDGET_CARD} settings={s} />
|
<SettingsCard section="automation" spec={BUDGET_CARD} settings={s} />
|
||||||
<SettingsCard section="automation" spec={RETENTION_CARD} settings={s} />
|
<SettingsCard section="automation" spec={RETENTION_CARD} settings={s} />
|
||||||
<SettingsCard section="automation" spec={STORAGE_BUDGET_CARD} settings={s} />
|
<Advanced>
|
||||||
<SettingsCard section="automation" spec={STORAGE_RETENTION_CARD} settings={s} />
|
<ReadOnlySwitch />
|
||||||
<SettingsCard section="automation" spec={STORAGE_TTL_CARD} settings={s} />
|
<SettingsCard section="automation" spec={STORAGE_BUDGET_CARD} settings={s} />
|
||||||
|
<SettingsCard section="automation" spec={STORAGE_RETENTION_CARD} settings={s} />
|
||||||
|
<SettingsCard section="automation" spec={STORAGE_TTL_CARD} settings={s} />
|
||||||
|
</Advanced>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</SettingsGate>
|
</SettingsGate>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export const CLEANUP_LABEL: Record<StorageCleanupTarget, string> = {
|
|||||||
audit: "审计事件",
|
audit: "审计事件",
|
||||||
capability_stats: "能力调用统计",
|
capability_stats: "能力调用统计",
|
||||||
identity_pool: "未识别资源归档",
|
identity_pool: "未识别资源归档",
|
||||||
analyze: "统计信息更新",
|
analyze: "数据库维护",
|
||||||
images: "图片缓存容量",
|
images: "图片缓存容量",
|
||||||
kv_cache: "过期缓存",
|
kv_cache: "过期缓存",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export const RUN_STATUS_OPTIONS = ["pending", "running", "completed", "failed",
|
|||||||
export function runTitle(r: Pick<EngineRun, "business" | "workflowName">): string {
|
export function runTitle(r: Pick<EngineRun, "business" | "workflowName">): string {
|
||||||
const b = r.business;
|
const b = r.business;
|
||||||
if (b?.title) return b.title;
|
if (b?.title) return b.title;
|
||||||
if (b) return `${BUSINESS_KIND_LABEL[b.kind]} ${b.refId.length > 12 ? b.refId.slice(0, 8) : b.refId}`;
|
if (b) return BUSINESS_KIND_LABEL[b.kind];
|
||||||
return workflowLabel(r.workflowName);
|
return workflowLabel(r.workflowName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -254,7 +254,7 @@ export function TaskDetailBody({ r, onOpenRun }: { r: EngineRunDetail; onOpenRun
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DetailSection title="业务摘要">
|
<DetailSection title="概况">
|
||||||
<KvList
|
<KvList
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
@@ -262,11 +262,11 @@ export function TaskDetailBody({ r, onOpenRun }: { r: EngineRunDetail; onOpenRun
|
|||||||
value: b ? (
|
value: b ? (
|
||||||
link ? (
|
link ? (
|
||||||
<Link to={link.to} className="inline-flex items-center gap-1 underline underline-offset-4">
|
<Link to={link.to} className="inline-flex items-center gap-1 underline underline-offset-4">
|
||||||
{BUSINESS_KIND_LABEL[b.kind]} · {b.title ?? b.refId}
|
{b.title ? `${BUSINESS_KIND_LABEL[b.kind]} · ${b.title}` : BUSINESS_KIND_LABEL[b.kind]}
|
||||||
<ExternalLink className="h-3 w-3" />
|
<ExternalLink className="h-3 w-3" />
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
`${BUSINESS_KIND_LABEL[b.kind]} · ${b.title ?? b.refId}`
|
`${BUSINESS_KIND_LABEL[b.kind]}${b.title ? ` · ${b.title}` : ""}`
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
"无"
|
"无"
|
||||||
@@ -279,7 +279,6 @@ export function TaskDetailBody({ r, onOpenRun }: { r: EngineRunDetail; onOpenRun
|
|||||||
label: "开始",
|
label: "开始",
|
||||||
value: r.startedAt ? `${formatDateTime(r.startedAt)}(${formatRelative(r.startedAt)})` : "未开始",
|
value: r.startedAt ? `${formatDateTime(r.startedAt)}(${formatRelative(r.startedAt)})` : "未开始",
|
||||||
},
|
},
|
||||||
{ label: "耗时", value: formatDuration(spanSec(r)) },
|
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
@@ -304,9 +303,11 @@ export function TaskDetailBody({ r, onOpenRun }: { r: EngineRunDetail; onOpenRun
|
|||||||
</ul>
|
</ul>
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
|
|
||||||
<Collapsible title={`引擎步骤(${r.stepAttempts.length})`}>
|
<Collapsible title={`技术信息 · 引擎步骤(${r.stepAttempts.length})`}>
|
||||||
<KvList
|
<KvList
|
||||||
items={[
|
items={[
|
||||||
|
{ label: "耗时", value: formatDuration(spanSec(r)) },
|
||||||
|
{ label: "关联对象 ID", value: b?.refId ?? null, mono: true },
|
||||||
{ label: "工作流", value: `${r.workflowName}${r.version ? ` ${r.version}` : ""}`, mono: true },
|
{ label: "工作流", value: `${r.workflowName}${r.version ? ` ${r.version}` : ""}`, mono: true },
|
||||||
{ label: "运行 ID", value: r.id, mono: true },
|
{ label: "运行 ID", value: r.id, mono: true },
|
||||||
{ label: "尝试次数", value: r.attempts },
|
{ label: "尝试次数", value: r.attempts },
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
|
|||||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||||
import { RunsSheet } from "~/components/schedules/runs-sheet.tsx";
|
import { RunsSheet } from "~/components/schedules/runs-sheet.tsx";
|
||||||
import { Button } from "~/components/ui/button.tsx";
|
import { Button } from "~/components/ui/button.tsx";
|
||||||
import { formatDuration, formatRelative, spanSec } from "~/lib/labels.ts";
|
import { formatRelative } from "~/lib/labels.ts";
|
||||||
import { qk } from "~/lib/query-keys.ts";
|
import { qk } from "~/lib/query-keys.ts";
|
||||||
import { useSse } from "~/lib/sse.ts";
|
import { useSse } from "~/lib/sse.ts";
|
||||||
import {
|
import {
|
||||||
@@ -70,6 +70,8 @@ export type StreamFilters = {
|
|||||||
source: string;
|
source: string;
|
||||||
since: string;
|
since: string;
|
||||||
workflow: string;
|
workflow: string;
|
||||||
|
/** "1" = 显示系统任务 */
|
||||||
|
system: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function SourceBadge({ r }: { r: EngineRun }) {
|
function SourceBadge({ r }: { r: EngineRun }) {
|
||||||
@@ -146,8 +148,13 @@ export function TaskStream({
|
|||||||
true,
|
true,
|
||||||
active ? 10_000 : false,
|
active ? 10_000 : false,
|
||||||
);
|
);
|
||||||
const rows = list.data?.pages.flatMap((p) => p.items);
|
const all = list.data?.pages.flatMap((p) => p.items);
|
||||||
const filtered = Object.values(filters).some(Boolean);
|
// 系统任务(没有业务对象的引擎步骤、定时任务的成功运行)默认不列;按对象 / 定时任务筛选或勾选时显示
|
||||||
|
const showSystem = filters.system === "1" || !!filters.ref || filters.kind === "schedule" || !!filters.workflow;
|
||||||
|
const isSystem = (r: EngineRun) => !r.business || (r.business.kind === "schedule" && r.status === "completed");
|
||||||
|
const rows = showSystem ? all : all?.filter((r) => !isSystem(r));
|
||||||
|
const hidden = (all?.length ?? 0) - (rows?.length ?? 0);
|
||||||
|
const filtered = Object.entries(filters).some(([k, v]) => k !== "system" && !!v);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -196,6 +203,14 @@ export function TaskStream({
|
|||||||
options={SINCE}
|
options={SINCE}
|
||||||
allLabel="不限"
|
allLabel="不限"
|
||||||
/>
|
/>
|
||||||
|
<Button
|
||||||
|
variant={filters.system === "1" ? "secondary" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
aria-pressed={filters.system === "1"}
|
||||||
|
onClick={() => setFilter("system", filters.system === "1" ? "" : "1")}
|
||||||
|
>
|
||||||
|
{filters.system === "1" ? "隐藏系统任务" : hidden ? `显示系统任务(${hidden})` : "显示系统任务"}
|
||||||
|
</Button>
|
||||||
{filters.workflow ? (
|
{filters.workflow ? (
|
||||||
<Button variant="outline" size="sm" onClick={() => setFilter("workflow", "")}>
|
<Button variant="outline" size="sm" onClick={() => setFilter("workflow", "")}>
|
||||||
{workflowLabel(filters.workflow)}
|
{workflowLabel(filters.workflow)}
|
||||||
@@ -234,11 +249,9 @@ export function TaskStream({
|
|||||||
cell: (r) => (
|
cell: (r) => (
|
||||||
<div className="min-w-0 max-w-[15rem] sm:max-w-sm">
|
<div className="min-w-0 max-w-[15rem] sm:max-w-sm">
|
||||||
<p className="truncate font-medium text-foreground">{runTitle(r)}</p>
|
<p className="truncate font-medium text-foreground">{runTitle(r)}</p>
|
||||||
<p className="truncate text-meta text-muted-foreground">
|
{r.business?.title ? (
|
||||||
{r.business
|
<p className="truncate text-meta text-muted-foreground">{workflowLabel(r.workflowName)}</p>
|
||||||
? `${workflowLabel(r.workflowName)} · ${BUSINESS_KIND_LABEL[r.business.kind]}`
|
) : null}
|
||||||
: `未关联业务对象 · ${r.id.slice(0, 8)}`}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -253,7 +266,7 @@ export function TaskStream({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "time",
|
key: "time",
|
||||||
header: "开始 / 耗时",
|
header: "开始",
|
||||||
hideBelow: "sm",
|
hideBelow: "sm",
|
||||||
cell: (r) => (
|
cell: (r) => (
|
||||||
<div className="whitespace-nowrap">
|
<div className="whitespace-nowrap">
|
||||||
@@ -264,7 +277,6 @@ export function TaskStream({
|
|||||||
? `排队 ${formatRelative(r.createdAt)}`
|
? `排队 ${formatRelative(r.createdAt)}`
|
||||||
: `未开始 · ${formatRelative(r.createdAt)}`}
|
: `未开始 · ${formatRelative(r.createdAt)}`}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-meta text-muted-foreground tabular-nums">{formatDuration(spanSec(r))}</p>
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export const engineRunLabel = (v: string | null | undefined) => pick(ENGINE_RUN,
|
|||||||
/** 相对时长:秒 → "3 分钟" */
|
/** 相对时长:秒 → "3 分钟" */
|
||||||
export function formatDuration(sec: number | null | undefined): string {
|
export function formatDuration(sec: number | null | undefined): string {
|
||||||
if (sec === null || sec === undefined || Number.isNaN(sec)) return "—";
|
if (sec === null || sec === undefined || Number.isNaN(sec)) return "—";
|
||||||
if (sec < 1) return `${Math.round(sec * 1000)} 毫秒`;
|
if (sec < 1) return "< 1 秒";
|
||||||
if (sec < 60) return `${Math.round(sec)} 秒`;
|
if (sec < 60) return `${Math.round(sec)} 秒`;
|
||||||
if (sec < 3600) return `${Math.floor(sec / 60)} 分 ${Math.round(sec % 60)} 秒`;
|
if (sec < 3600) return `${Math.floor(sec / 60)} 分 ${Math.round(sec % 60)} 秒`;
|
||||||
if (sec < 86400) return `${Math.floor(sec / 3600)} 小时 ${Math.floor((sec % 3600) / 60)} 分`;
|
if (sec < 86400) return `${Math.floor(sec / 3600)} 小时 ${Math.floor((sec % 3600) / 60)} 分`;
|
||||||
|
|||||||
@@ -5,18 +5,12 @@ import {
|
|||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Bot,
|
Bot,
|
||||||
CalendarClock,
|
|
||||||
CalendarCog,
|
|
||||||
CircleCheck,
|
CircleCheck,
|
||||||
Gauge,
|
Download,
|
||||||
HardDrive,
|
|
||||||
Library as LibraryIcon,
|
Library as LibraryIcon,
|
||||||
ListTodo,
|
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
Pause,
|
Pause,
|
||||||
Play,
|
Play,
|
||||||
Radio,
|
|
||||||
ShieldAlert,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
@@ -24,31 +18,27 @@ import { Link } from "react-router";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useDashboard, useSitesWithAccounts } from "~/api/dashboard.ts";
|
import { useDashboard, useSitesWithAccounts } from "~/api/dashboard.ts";
|
||||||
import { useDecisions } from "~/api/decisions.ts";
|
import { useDecisions } from "~/api/decisions.ts";
|
||||||
import { useEngineRunsPage, useEngineStats } from "~/api/engine.ts";
|
import { useEngineRunsPage } from "~/api/engine.ts";
|
||||||
import { useUpdateSettings } from "~/api/settings.ts";
|
import { useUpdateSettings } from "~/api/settings.ts";
|
||||||
import { useSystemHealth } from "~/api/system.ts";
|
import { useSystemHealth } from "~/api/system.ts";
|
||||||
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
||||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||||
import { consolePath, settingsPath } from "~/components/console/nav.ts";
|
import { consolePath, settingsPath } from "~/components/console/nav.ts";
|
||||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||||
import { StatTile } from "~/components/console/stat-tile.tsx";
|
import { runTitle } from "~/components/tasks/labels.ts";
|
||||||
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
|
|
||||||
import { useVolumeLabel } from "~/components/downloads/cache-volumes.tsx";
|
|
||||||
import { formatUsd, planPath } from "~/components/schedules/labels.ts";
|
|
||||||
import { planStats, useLiveSchedules } from "~/components/schedules/schedule-board.tsx";
|
|
||||||
import { engineRunStatus, runTitle, workflowLabel } from "~/components/tasks/labels.ts";
|
|
||||||
import { useLiveTasks } from "~/components/tasks/task-stream.tsx";
|
import { useLiveTasks } from "~/components/tasks/task-stream.tsx";
|
||||||
import { Button } from "~/components/ui/button.tsx";
|
import { Button } from "~/components/ui/button.tsx";
|
||||||
import { Switch } from "~/components/ui/switch.tsx";
|
|
||||||
import { errorMessage } from "~/lib/api.ts";
|
import { errorMessage } from "~/lib/api.ts";
|
||||||
import { decisionKindLabel, formatDuration, formatRelative, healthLabel, spanSec } from "~/lib/labels.ts";
|
import { decisionKindLabel, formatRelative } from "~/lib/labels.ts";
|
||||||
import { qk } from "~/lib/query-keys.ts";
|
import { qk } from "~/lib/query-keys.ts";
|
||||||
import { useSse, useSseStatus } from "~/lib/sse.ts";
|
import { useSse } from "~/lib/sse.ts";
|
||||||
import { cn, formatBytes } from "~/lib/utils.ts";
|
import { cn } from "~/lib/utils.ts";
|
||||||
|
|
||||||
|
// 首页只回答三件事(DEAI-2):在忙什么、要我处理什么、最近入库了什么;顶部一行一句话状态。
|
||||||
|
// 运行状态、计划、缓存参数、收录版本满足率不在这里,只在出问题时写进状态行。
|
||||||
|
|
||||||
/** 首页关心的事件:任意一个到达就(节流)刷新聚合 */
|
/** 首页关心的事件:任意一个到达就(节流)刷新聚合 */
|
||||||
const DASHBOARD_EVENTS = [
|
const DASHBOARD_EVENTS = [
|
||||||
"schedule.changed",
|
|
||||||
"task.created",
|
"task.created",
|
||||||
"task.status_changed",
|
"task.status_changed",
|
||||||
"download.submitted",
|
"download.submitted",
|
||||||
@@ -56,8 +46,6 @@ const DASHBOARD_EVENTS = [
|
|||||||
"download.threshold",
|
"download.threshold",
|
||||||
"organization.changed",
|
"organization.changed",
|
||||||
"agent.run_status",
|
"agent.run_status",
|
||||||
"feed.fetched",
|
|
||||||
"feed.health_changed",
|
|
||||||
"decision.created",
|
"decision.created",
|
||||||
"decision.updated",
|
"decision.updated",
|
||||||
"settings.changed",
|
"settings.changed",
|
||||||
@@ -82,28 +70,24 @@ function Section({
|
|||||||
to,
|
to,
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
extra,
|
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
to?: string;
|
to?: string;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
/** 标题右侧的摘要(计数、状态) */
|
|
||||||
extra?: ReactNode;
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<section className={cn("pg-slab flex min-w-0 flex-col rounded-xl p-5", className)}>
|
<section className={cn("pg-slab flex min-w-0 flex-col rounded-xl p-5", className)}>
|
||||||
<div className="mb-3 flex items-center gap-2">
|
<div className="mb-3 flex items-center gap-2">
|
||||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
<Icon className="h-4 w-4 text-muted-foreground" aria-hidden />
|
||||||
<h2 className="text-body font-semibold text-foreground">{title}</h2>
|
<h2 className="text-body font-semibold text-foreground">{title}</h2>
|
||||||
{extra}
|
|
||||||
{to ? (
|
{to ? (
|
||||||
<Link
|
<Link
|
||||||
to={to}
|
to={to}
|
||||||
className="ml-auto inline-flex items-center gap-1 text-meta text-muted-foreground hover:text-foreground"
|
className="ml-auto inline-flex items-center gap-1 text-meta text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
查看
|
全部
|
||||||
<ArrowRight className="h-3.5 w-3.5" />
|
<ArrowRight className="h-3.5 w-3.5" />
|
||||||
</Link>
|
</Link>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -117,144 +101,170 @@ function Row({ children }: { children: ReactNode }) {
|
|||||||
return <li className="flex min-w-0 items-center justify-between gap-3 py-1.5 text-sm">{children}</li>;
|
return <li className="flex min-w-0 items-center justify-between gap-3 py-1.5 text-sm">{children}</li>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 自动化总开关:显眼状态 + 切换 ───
|
/** 非零项拼一句:[[3, "个下载"], [0, "个入库中"]] → "3 个下载" */
|
||||||
function AutomationBanner({ a }: { a: Dashboard["automation"] }) {
|
function nonZero(parts: [number, string][]): string {
|
||||||
|
return parts
|
||||||
|
.filter(([n]) => n > 0)
|
||||||
|
.map(([n, s]) => `${n} ${s}`)
|
||||||
|
.join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 顶部一行:自动化状态 + 需要知道的异常 ───
|
||||||
|
function StatusLine({ d }: { d: Dashboard }) {
|
||||||
|
const a = d.automation;
|
||||||
const update = useUpdateSettings();
|
const update = useUpdateSettings();
|
||||||
const setAutomation = async (patch: { enabled?: boolean; readOnlyReconcile?: boolean }, ok: string) => {
|
const health = useSystemHealth(60_000);
|
||||||
|
const sites = useSitesWithAccounts();
|
||||||
|
const setEnabled = async (enabled: boolean) => {
|
||||||
try {
|
try {
|
||||||
await update.mutateAsync({ automation: patch });
|
await update.mutateAsync({ automation: { enabled } });
|
||||||
toast.success(ok);
|
toast.success(enabled ? "自动化已恢复" : "自动化已暂停");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(errorMessage(err, "切换失败"));
|
toast.error(errorMessage(err, "切换失败"));
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const paused = !a.enabled;
|
|
||||||
const readOnly = a.readOnlyReconcile;
|
|
||||||
|
|
||||||
|
// 只列不正常的:服务异常、下载盘到暂停线、站点账号失效、下载器离线
|
||||||
|
const problems: { text: string; to: string }[] = [];
|
||||||
|
const lv = health.data?.level;
|
||||||
|
if (lv === "critical" || lv === "warn")
|
||||||
|
problems.push({ text: lv === "critical" ? "服务异常" : "服务需要关注", to: consolePath("status") });
|
||||||
|
for (const v of d.cache.volumes)
|
||||||
|
if (v.usedPercent !== null && v.usedPercent >= v.hardThresholdPercent)
|
||||||
|
problems.push({ text: `下载盘已用 ${Math.round(v.usedPercent)}%,新下载已暂停`, to: consolePath("downloads") });
|
||||||
|
const badAccounts = (sites.data?.items ?? []).filter(
|
||||||
|
(s) => s.enabled && s.accountStatus && ["invalid", "expired", "challenge"].includes(s.accountStatus),
|
||||||
|
).length;
|
||||||
|
if (badAccounts) problems.push({ text: `${badAccounts} 个站点账号需要重新登录`, to: consolePath("sites") });
|
||||||
|
const offline = d.sources.downloaders.filter((x) => x.enabled && ["offline", "failing"].includes(x.health)).length;
|
||||||
|
if (offline) problems.push({ text: `${offline} 个下载器连不上`, to: settingsPath("downloaders") });
|
||||||
|
|
||||||
|
const paused = !a.enabled;
|
||||||
|
const warn = paused || a.readOnlyReconcile || problems.length > 0;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"pg-slab mb-4 flex flex-col gap-4 rounded-xl p-4 sm:flex-row sm:items-center sm:p-5",
|
"pg-slab mb-4 flex flex-col gap-3 rounded-xl px-4 py-3 sm:flex-row sm:items-center sm:px-5",
|
||||||
(paused || readOnly) && "border border-warning/40",
|
warn && "border border-warning/40",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex min-w-0 flex-1 items-start gap-3">
|
<p className="min-w-0 flex-1 text-sm text-foreground">
|
||||||
<span
|
<span className={cn("mr-2 inline-block size-2 rounded-full", warn ? "bg-warning" : "bg-success")} aria-hidden />
|
||||||
className={cn(
|
|
||||||
"grid h-10 w-10 shrink-0 place-items-center rounded-full",
|
|
||||||
paused || readOnly ? "bg-warning/10 text-warning" : "bg-success/10 text-success",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{readOnly ? (
|
|
||||||
<ShieldAlert className="h-5 w-5" />
|
|
||||||
) : paused ? (
|
|
||||||
<Pause className="h-5 w-5" />
|
|
||||||
) : (
|
|
||||||
<Play className="h-5 w-5" />
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<p className="text-lead font-semibold text-foreground">
|
|
||||||
{paused ? "自动化已暂停" : readOnly ? "只读对账中" : "自动化运行中"}
|
|
||||||
</p>
|
|
||||||
<StatusBadge tone={paused ? "warning" : "success"}>总开关{paused ? "关" : "开"}</StatusBadge>
|
|
||||||
{readOnly ? <StatusBadge tone="warning">只读对账</StatusBadge> : null}
|
|
||||||
</div>
|
|
||||||
<p className="mt-0.5 text-caption text-muted-foreground">
|
|
||||||
{paused
|
|
||||||
? "自动收录、下载、整理已停止,手动操作仍可用。"
|
|
||||||
: readOnly
|
|
||||||
? "只核对,不写入。确认无误后关闭。"
|
|
||||||
: `${a.activeSubscriptions} 个订阅在追踪,自动收录、下载和整理。`}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center gap-x-5 gap-y-2">
|
|
||||||
<div className="flex items-center gap-2 text-caption text-foreground">
|
|
||||||
<label htmlFor="home-readonly">只读对账</label>
|
|
||||||
<Switch
|
|
||||||
id="home-readonly"
|
|
||||||
checked={readOnly}
|
|
||||||
disabled={update.isPending}
|
|
||||||
onCheckedChange={(v) =>
|
|
||||||
setAutomation({ readOnlyReconcile: v }, v ? "已开启只读对账" : "已关闭只读对账").catch(() => {})
|
|
||||||
}
|
|
||||||
aria-label="只读对账模式"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{paused ? (
|
{paused ? (
|
||||||
<Button
|
"自动化已暂停,手动操作仍可用"
|
||||||
size="sm"
|
) : a.readOnlyReconcile ? (
|
||||||
disabled={update.isPending}
|
<>
|
||||||
onClick={() => setAutomation({ enabled: true }, "自动化已恢复").catch(() => {})}
|
只读模式:不会自动下载和整理。
|
||||||
>
|
<Link to={settingsPath("automation", "advanced")} className="underline underline-offset-4">
|
||||||
<Play />
|
去设置关闭
|
||||||
恢复自动化
|
</Link>
|
||||||
</Button>
|
</>
|
||||||
|
) : a.activeSubscriptions > 0 ? (
|
||||||
|
`自动化已开启 · ${a.activeSubscriptions} 个订阅`
|
||||||
) : (
|
) : (
|
||||||
<ConfirmDialog
|
<>
|
||||||
trigger={
|
自动化已开启 · 还没有订阅。
|
||||||
<Button variant="outline" size="sm" disabled={update.isPending}>
|
<Link to={consolePath("search")} className="underline underline-offset-4">
|
||||||
<Pause />
|
去搜索添加
|
||||||
暂停自动化
|
</Link>
|
||||||
</Button>
|
</>
|
||||||
}
|
|
||||||
title="暂停全部自动化?"
|
|
||||||
description="自动收录、下载、整理会停止,已提交的下载任务不受影响。可随时恢复。"
|
|
||||||
confirmLabel="暂停自动化"
|
|
||||||
onConfirm={() => setAutomation({ enabled: false }, "自动化已暂停")}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
{problems.map((p) => (
|
||||||
|
<span key={p.text}>
|
||||||
|
{" · "}
|
||||||
|
<Link to={p.to} className="text-warning underline-offset-4 hover:underline">
|
||||||
|
{p.text}
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</p>
|
||||||
|
{paused ? (
|
||||||
|
<Button size="sm" disabled={update.isPending} onClick={() => setEnabled(true).catch(() => {})}>
|
||||||
|
<Play />
|
||||||
|
恢复
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<ConfirmDialog
|
||||||
|
trigger={
|
||||||
|
<Button variant="outline" size="sm" disabled={update.isPending}>
|
||||||
|
<Pause />
|
||||||
|
暂停
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
title="暂停全部自动化?"
|
||||||
|
description="自动收录、下载、整理会停止,已提交的下载任务不受影响。可随时恢复。"
|
||||||
|
confirmLabel="暂停自动化"
|
||||||
|
onConfirm={() => setEnabled(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 正在进行:任务流 top N ───
|
// ─── 在忙什么:下载中的任务 + 进行中的智能体会话 + 有业务对象的运行 ───
|
||||||
function InProgress({ w }: { w: Dashboard["currentWork"] }) {
|
function Busy({ w }: { w: Dashboard["currentWork"] }) {
|
||||||
const running = useEngineRunsPage({ status: "running" }, 6, 10_000);
|
const running = useEngineRunsPage({ status: "running" }, 10, 10_000);
|
||||||
const stats = useEngineStats();
|
const runs = (running.data?.items ?? []).filter((r) => r.business);
|
||||||
const rows = running.data?.items ?? [];
|
const downloads = w.items.slice(0, 4);
|
||||||
|
const agents = w.agentRuns.slice(0, 2);
|
||||||
|
const summary = nonZero([
|
||||||
|
[w.downloading, "个下载中"],
|
||||||
|
[w.importing, "个入库中"],
|
||||||
|
[w.stalled, "个没有进展"],
|
||||||
|
[w.paused, "个已暂停"],
|
||||||
|
]);
|
||||||
|
const empty = !downloads.length && !agents.length && !runs.length;
|
||||||
return (
|
return (
|
||||||
<Section title="正在进行" icon={Activity} to="/console/tasks?tab=active">
|
<Section title="在忙什么" icon={Activity} to="/console/tasks?tab=active">
|
||||||
{running.isPending ? (
|
{running.isPending && !downloads.length ? (
|
||||||
<LoadingState rows={3} />
|
<LoadingState rows={3} />
|
||||||
) : running.isError ? (
|
) : empty ? (
|
||||||
<ErrorState plain error={running.error} onRetry={() => running.refetch()} />
|
<EmptyState plain icon={CircleCheck} title="现在没有在跑的任务" className="py-6" />
|
||||||
) : rows.length === 0 ? (
|
|
||||||
<EmptyState plain icon={CircleCheck} title="没有进行中的任务" className="py-6" />
|
|
||||||
) : (
|
) : (
|
||||||
<ul className="divide-y divide-border/60">
|
<ul className="divide-y divide-border/60">
|
||||||
{rows.slice(0, 5).map((r) => (
|
{downloads.map((t) => (
|
||||||
|
<Row key={t.downloadTaskId}>
|
||||||
|
<Link to={`/console/downloads/${t.downloadTaskId}`} className="min-w-0 flex-1 hover:underline">
|
||||||
|
<span className="block truncate">{t.name}</span>
|
||||||
|
{t.status === "stalled" ? <span className="block text-meta text-warning">没有进展</span> : null}
|
||||||
|
</Link>
|
||||||
|
<span className="shrink-0 text-meta text-muted-foreground tabular-nums">
|
||||||
|
<Download className="mr-1 inline h-3 w-3" aria-hidden />
|
||||||
|
{Math.round(t.progress * 100)}%
|
||||||
|
</span>
|
||||||
|
</Row>
|
||||||
|
))}
|
||||||
|
{agents.map((r) => (
|
||||||
|
<Row key={r.id}>
|
||||||
|
<Link to={`/console/agent/${r.id}`} className="min-w-0 flex-1 hover:underline">
|
||||||
|
<span className="block truncate">{r.goal}</span>
|
||||||
|
</Link>
|
||||||
|
<Bot className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-label="智能体" />
|
||||||
|
</Row>
|
||||||
|
))}
|
||||||
|
{runs.slice(0, Math.max(0, 5 - downloads.length - agents.length)).map((r) => (
|
||||||
<Row key={r.id}>
|
<Row key={r.id}>
|
||||||
<Link to={`/console/tasks?run=${encodeURIComponent(r.id)}`} className="min-w-0 flex-1 hover:underline">
|
<Link to={`/console/tasks?run=${encodeURIComponent(r.id)}`} className="min-w-0 flex-1 hover:underline">
|
||||||
<span className="block truncate">{runTitle(r)}</span>
|
<span className="block truncate">{runTitle(r)}</span>
|
||||||
<span className="block truncate text-meta text-muted-foreground">{workflowLabel(r.workflowName)}</span>
|
|
||||||
</Link>
|
</Link>
|
||||||
<span className="shrink-0 text-meta text-muted-foreground tabular-nums">
|
|
||||||
{formatDuration(spanSec(r))}
|
|
||||||
</span>
|
|
||||||
</Row>
|
</Row>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
<p className="mt-auto pt-3 text-meta text-muted-foreground">
|
{summary ? <p className="mt-auto pt-3 text-meta text-muted-foreground">{summary}</p> : null}
|
||||||
排队 {stats.data?.byStatus.pending ?? "—"} · 下载中 {w.downloading} · 无进展 {w.stalled} · 入库中 {w.importing}
|
|
||||||
</p>
|
|
||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 需要我处理:计数 + 前 3 条 ───
|
// ─── 要我处理什么:计数 + 前 3 条 ───
|
||||||
function NeedsMe({ n }: { n: Dashboard["needsAttention"] }) {
|
function NeedsMe({ n }: { n: Dashboard["needsAttention"] }) {
|
||||||
const list = useDecisions({});
|
const list = useDecisions({});
|
||||||
const top = (list.data?.pages[0]?.items ?? []).slice(0, 3);
|
const top = (list.data?.pages[0]?.items ?? []).slice(0, 3);
|
||||||
return (
|
return (
|
||||||
<Section title="需要我处理" icon={AlertTriangle} to={consolePath("inbox")}>
|
<Section title="要我处理" icon={AlertTriangle} to={consolePath("inbox")}>
|
||||||
{n.total === 0 ? (
|
{n.total === 0 ? (
|
||||||
<EmptyState plain icon={CircleCheck} title="没有待处理事项" className="py-6" />
|
<EmptyState plain icon={CircleCheck} title="没有要你处理的事" className="py-6" />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p className="mb-2 text-title-sm font-semibold text-warning tabular-nums">
|
<p className="mb-2 text-title-sm font-semibold text-warning tabular-nums">
|
||||||
@@ -268,9 +278,7 @@ function NeedsMe({ n }: { n: Dashboard["needsAttention"] }) {
|
|||||||
<Row key={d.id}>
|
<Row key={d.id}>
|
||||||
<Link to={`/console/inbox?kind=${d.kind}`} className="min-w-0 flex-1 hover:underline">
|
<Link to={`/console/inbox?kind=${d.kind}`} className="min-w-0 flex-1 hover:underline">
|
||||||
<span className="block truncate">{d.title}</span>
|
<span className="block truncate">{d.title}</span>
|
||||||
<span className="block truncate text-meta text-muted-foreground">
|
<span className="block truncate text-meta text-muted-foreground">{formatRelative(d.createdAt)}</span>
|
||||||
{decisionKindLabel(d.kind)} · {formatRelative(d.createdAt)}
|
|
||||||
</span>
|
|
||||||
</Link>
|
</Link>
|
||||||
</Row>
|
</Row>
|
||||||
))}
|
))}
|
||||||
@@ -281,387 +289,39 @@ function NeedsMe({ n }: { n: Dashboard["needsAttention"] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 媒体概览:作品/季/资产计数 + 收录版本满足率 + 今日产出 ───
|
// ─── 最近入库了什么:成功 / 部分成功的整理 ───
|
||||||
function MediaOverview({ a }: { a: Dashboard["automation"] }) {
|
function RecentImports({ d }: { d: Dashboard }) {
|
||||||
const c = a.slotCounts;
|
const rows = d.recentOutput.filter((r) => r.status === "succeeded" || r.status === "partial").slice(0, 6);
|
||||||
const total = Object.values(c).reduce((x, y) => x + y, 0);
|
const today = d.automation.today.filesImported;
|
||||||
const rate = a.slotSatisfiedRate === null ? null : Math.round(a.slotSatisfiedRate * 100);
|
|
||||||
const lib = a.library;
|
|
||||||
return (
|
return (
|
||||||
<Section title="媒体概览" icon={LibraryIcon} to={consolePath("library")}>
|
<Section title="最近入库" icon={LibraryIcon} to={consolePath("organize")}>
|
||||||
<div className="grid grid-cols-3 gap-2">
|
{rows.length === 0 ? (
|
||||||
<StatTile plain label="作品" value={lib.entities} hint={`电影 ${lib.movies} · 剧集 ${lib.series}`} />
|
<EmptyState plain icon={LibraryIcon} title="最近没有新入库的作品" className="py-6" />
|
||||||
<StatTile plain label="季" value={lib.seasons} hint={`${lib.episodes} 集`} />
|
|
||||||
<StatTile plain label="资产" value={lib.assets} hint="已入库视频" />
|
|
||||||
</div>
|
|
||||||
<div className="mt-3">
|
|
||||||
<div className="flex items-baseline justify-between text-sm">
|
|
||||||
<span>收录版本满足率</span>
|
|
||||||
<span className="font-semibold tabular-nums">{rate === null ? "—" : `${rate}%`}</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-foreground/[0.08]" aria-hidden>
|
|
||||||
<div className="h-full rounded-full bg-foreground/60" style={{ width: `${rate ?? 0}%` }} />
|
|
||||||
</div>
|
|
||||||
<p className="mt-1.5 text-meta text-muted-foreground">
|
|
||||||
满足 {c.satisfied} / {total} · 需关注 {c.attention} · 下载中 {c.downloading}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<p className="mt-auto pt-3 text-meta text-muted-foreground">
|
|
||||||
今日 新计划 {a.today.plansCreated} · 提交下载 {a.today.downloadsSubmitted} · 入库文件 {a.today.filesImported} ·
|
|
||||||
升级 {a.today.upgrades}
|
|
||||||
</p>
|
|
||||||
</Section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 计划带:自动任务与巡检分别计数,链到计划页对应节 ───
|
|
||||||
function PlanCount({
|
|
||||||
to,
|
|
||||||
label,
|
|
||||||
icon: Icon,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
to: string;
|
|
||||||
label: string;
|
|
||||||
icon: LucideIcon;
|
|
||||||
children: ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
to={to}
|
|
||||||
className="flex min-w-0 flex-col gap-1 rounded-xl border border-border/70 px-3 py-2.5 transition-colors duration-micro hover:bg-foreground/[0.04]"
|
|
||||||
>
|
|
||||||
<span className="flex items-center gap-1.5 text-meta text-muted-foreground">
|
|
||||||
<Icon className="h-3.5 w-3.5" />
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
<span className="flex flex-wrap items-baseline gap-x-3 gap-y-0.5 text-caption text-muted-foreground">
|
|
||||||
{children}
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Num({ children, tone }: { children: ReactNode; tone?: "destructive" }) {
|
|
||||||
return (
|
|
||||||
<span className={cn("text-lead font-semibold tabular-nums", tone ? "text-destructive" : "text-foreground")}>
|
|
||||||
{children}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PlanStrip() {
|
|
||||||
const board = useLiveSchedules();
|
|
||||||
const s = planStats(board.data?.items ?? []);
|
|
||||||
return (
|
|
||||||
<Section
|
|
||||||
title="计划"
|
|
||||||
icon={CalendarCog}
|
|
||||||
to={planPath()}
|
|
||||||
extra={
|
|
||||||
board.data && !board.data.schedulerOnline ? <span className="text-meta text-warning">调度器未在线</span> : null
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{board.isPending ? (
|
|
||||||
<LoadingState rows={2} />
|
|
||||||
) : board.isError ? (
|
|
||||||
<ErrorState plain error={board.error} onRetry={() => board.refetch()} />
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<PlanCount to={planPath({ section: "auto" })} label="自动任务" icon={CalendarClock}>
|
|
||||||
<span>
|
|
||||||
启用 <Num>{s.auto.enabled}</Num> / {s.auto.total}
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
失败 <Num tone={s.auto.failed ? "destructive" : undefined}>{s.auto.failed}</Num>
|
|
||||||
</span>
|
|
||||||
</PlanCount>
|
|
||||||
<PlanCount to={planPath({ section: "checks" })} label="智能体巡检" icon={Bot}>
|
|
||||||
<span>
|
|
||||||
启用 <Num>{s.checks.enabled}</Num> / {s.checks.total}
|
|
||||||
</span>
|
|
||||||
<span title="近 7 天巡检会话费用合计">
|
|
||||||
近 7 天 <Num>{formatUsd(s.checks.weekCost)}</Num>
|
|
||||||
</span>
|
|
||||||
</PlanCount>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 最近完成 / 失败 ───
|
|
||||||
function RecentRuns() {
|
|
||||||
const done = useEngineRunsPage({ status: "completed" }, 6);
|
|
||||||
const failed = useEngineRunsPage({ status: "failed" }, 6);
|
|
||||||
const rows = [...(done.data?.items ?? []), ...(failed.data?.items ?? [])]
|
|
||||||
.sort((x, y) => (y.finishedAt ?? y.createdAt).localeCompare(x.finishedAt ?? x.createdAt))
|
|
||||||
.slice(0, 6);
|
|
||||||
const loading = done.isPending || failed.isPending;
|
|
||||||
const err = done.error ?? failed.error;
|
|
||||||
return (
|
|
||||||
<Section title="最近完成 / 失败" icon={ListTodo} to="/console/tasks">
|
|
||||||
{loading ? (
|
|
||||||
<LoadingState rows={3} />
|
|
||||||
) : err ? (
|
|
||||||
<ErrorState
|
|
||||||
plain
|
|
||||||
error={err}
|
|
||||||
onRetry={() => {
|
|
||||||
void done.refetch();
|
|
||||||
void failed.refetch();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : rows.length === 0 ? (
|
|
||||||
<EmptyState plain icon={ListTodo} title="还没有结束的任务" className="py-6" />
|
|
||||||
) : (
|
) : (
|
||||||
<ul className="divide-y divide-border/60">
|
<ul className="divide-y divide-border/60">
|
||||||
{rows.map((r) => {
|
{rows.map((r) => (
|
||||||
const s = engineRunStatus(r);
|
<Row key={r.recordId}>
|
||||||
return (
|
<Link
|
||||||
<Row key={r.id}>
|
to={r.mediaEntityId ? `/console/library/${r.mediaEntityId}` : `/console/organize/${r.recordId}`}
|
||||||
<Link to={`/console/tasks?run=${encodeURIComponent(r.id)}`} className="min-w-0 flex-1 hover:underline">
|
className="min-w-0 flex-1 hover:underline"
|
||||||
<span className="block truncate">{runTitle(r)}</span>
|
>
|
||||||
<span className="block truncate text-meta text-muted-foreground">
|
<span className="block truncate">{r.title ?? r.targetPath?.split("/").pop() ?? "未关联作品"}</span>
|
||||||
{workflowLabel(r.workflowName)} · {formatRelative(r.finishedAt)}
|
{r.status === "partial" ? (
|
||||||
|
<span className="block text-meta text-warning">
|
||||||
|
{r.counts.succeeded}/{r.counts.total} 个文件入库
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
) : null}
|
||||||
<StatusBadge tone={s.tone}>{s.label}</StatusBadge>
|
</Link>
|
||||||
</Row>
|
<span className="shrink-0 text-meta text-muted-foreground">{formatRelative(r.finishedAt)}</span>
|
||||||
);
|
</Row>
|
||||||
})}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
{today ? <p className="mt-auto pt-3 text-meta text-muted-foreground">今天入库 {today} 个文件</p> : null}
|
||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 来源健康:站点(含账号状态)+ Feed / 下载器 / 媒体服务器,有问题的排前 ───
|
|
||||||
const ACCOUNT: Record<string, { label: string; tone: StatusTone }> = {
|
|
||||||
unconfigured: { label: "未配置账号", tone: "neutral" },
|
|
||||||
unknown: { label: "账号未验证", tone: "neutral" },
|
|
||||||
active: { label: "账号正常", tone: "success" },
|
|
||||||
invalid: { label: "账号失效", tone: "destructive" },
|
|
||||||
expired: { label: "登录过期", tone: "warning" },
|
|
||||||
challenge: { label: "需人工验证", tone: "warning" },
|
|
||||||
};
|
|
||||||
|
|
||||||
function SourcesSection({ s }: { s: Dashboard["sources"] }) {
|
|
||||||
const sites = useSitesWithAccounts();
|
|
||||||
const siteRows = (sites.data?.items ?? s.sites).map((x) => {
|
|
||||||
const acc = "accountStatus" in x && x.accountStatus ? ACCOUNT[x.accountStatus] : undefined;
|
|
||||||
return {
|
|
||||||
id: x.id,
|
|
||||||
kind: "站点",
|
|
||||||
name: x.name,
|
|
||||||
enabled: x.enabled,
|
|
||||||
health: "healthStatus" in x ? x.healthStatus : x.health,
|
|
||||||
account: acc,
|
|
||||||
to: `/console/sites/${x.id}`,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
const rows = [
|
|
||||||
...siteRows,
|
|
||||||
...s.feeds.map((x) => ({
|
|
||||||
id: x.id,
|
|
||||||
kind: "RSS",
|
|
||||||
name: x.name,
|
|
||||||
enabled: x.enabled,
|
|
||||||
health: x.health,
|
|
||||||
account: undefined,
|
|
||||||
to: consolePath("sources"),
|
|
||||||
})),
|
|
||||||
...s.downloaders.map((x) => ({
|
|
||||||
id: x.id,
|
|
||||||
kind: "下载器",
|
|
||||||
name: x.name,
|
|
||||||
enabled: x.enabled,
|
|
||||||
health: x.health,
|
|
||||||
account: undefined,
|
|
||||||
to: settingsPath("downloaders"),
|
|
||||||
})),
|
|
||||||
];
|
|
||||||
const bad = (r: (typeof rows)[number]) =>
|
|
||||||
(r.enabled && healthLabel(r.health).tone !== "success" ? 2 : 0) +
|
|
||||||
(r.account && r.account.tone !== "success" ? 1 : 0);
|
|
||||||
rows.sort((x, y) => bad(y) - bad(x));
|
|
||||||
const accountIssues = siteRows.filter(
|
|
||||||
(r) => r.account && (r.account.tone === "destructive" || r.account.tone === "warning"),
|
|
||||||
).length;
|
|
||||||
return (
|
|
||||||
<Section title="来源健康" icon={Radio} to={consolePath("sites")}>
|
|
||||||
{rows.length === 0 ? (
|
|
||||||
<EmptyState plain icon={Radio} title="还没有来源" description="登记站点或新建 RSS 后显示。" className="py-6" />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{accountIssues ? <p className="mb-1 text-meta text-warning">{accountIssues} 个站点的账号需要处理</p> : null}
|
|
||||||
<ul className="divide-y divide-border/60">
|
|
||||||
{rows.slice(0, 7).map((r) => {
|
|
||||||
const h = r.enabled ? healthLabel(r.health) : healthLabel("disabled");
|
|
||||||
return (
|
|
||||||
<Row key={`${r.kind}-${r.id}`}>
|
|
||||||
<Link to={r.to} className="min-w-0 flex-1 hover:underline">
|
|
||||||
<span className="block truncate">
|
|
||||||
<span className="mr-1.5 text-meta text-muted-foreground">{r.kind}</span>
|
|
||||||
{r.name}
|
|
||||||
</span>
|
|
||||||
{r.account ? (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"block truncate text-meta",
|
|
||||||
r.account.tone === "destructive"
|
|
||||||
? "text-destructive"
|
|
||||||
: r.account.tone === "warning"
|
|
||||||
? "text-warning"
|
|
||||||
: "text-muted-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{r.account.label}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</Link>
|
|
||||||
<StatusBadge tone={h.tone}>{h.label}</StatusBadge>
|
|
||||||
</Row>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 运行状态摘要(/system/health) ───
|
|
||||||
const LEVEL: Record<string, { label: string; tone: StatusTone }> = {
|
|
||||||
ok: { label: "正常", tone: "success" },
|
|
||||||
warn: { label: "需要关注", tone: "warning" },
|
|
||||||
critical: { label: "需要处理", tone: "destructive" },
|
|
||||||
unknown: { label: "未知", tone: "neutral" },
|
|
||||||
};
|
|
||||||
|
|
||||||
function StatusSummary() {
|
|
||||||
const h = useSystemHealth(30_000);
|
|
||||||
const d = h.data;
|
|
||||||
const stale = d?.processes.filter((p) => p.stale) ?? [];
|
|
||||||
const alive = (d?.processes.length ?? 0) - stale.length;
|
|
||||||
const mountsBad = d?.mounts.filter((m) => m.status !== "online").length ?? 0;
|
|
||||||
const lv = LEVEL[d?.level ?? "unknown"] ?? LEVEL.unknown;
|
|
||||||
return (
|
|
||||||
<Section
|
|
||||||
title="运行状态"
|
|
||||||
icon={Gauge}
|
|
||||||
to={consolePath("status")}
|
|
||||||
className="md:col-span-2 xl:col-span-3"
|
|
||||||
extra={d ? <StatusBadge tone={lv.tone}>{lv.label}</StatusBadge> : null}
|
|
||||||
>
|
|
||||||
{h.isPending ? (
|
|
||||||
<LoadingState rows={1} />
|
|
||||||
) : h.isError ? (
|
|
||||||
<ErrorState plain error={h.error} onRetry={() => h.refetch()} />
|
|
||||||
) : d ? (
|
|
||||||
<div className="flex flex-wrap gap-x-6 gap-y-1.5 text-sm">
|
|
||||||
<span>
|
|
||||||
进程 <span className="font-medium tabular-nums">{alive}</span> 在线
|
|
||||||
{stale.length ? (
|
|
||||||
<span className="text-warning">
|
|
||||||
{" "}
|
|
||||||
· {stale.length} 个无心跳({stale.map((p) => `${p.processKind} ${p.pid}`).join("、")})
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
待执行 <span className="font-medium tabular-nums">{d.engine.pending}</span>
|
|
||||||
{d.engine.oldestPendingAgeSec ? ` · 最久 ${formatDuration(d.engine.oldestPendingAgeSec)}` : ""}
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
24 小时失败率{" "}
|
|
||||||
<span className="font-medium tabular-nums">
|
|
||||||
{d.engine.failureRate24h === null ? "—" : `${(d.engine.failureRate24h * 100).toFixed(1)}%`}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
数据库{" "}
|
|
||||||
<span className="font-medium tabular-nums">{d.db.latencyMs === null ? "—" : `${d.db.latencyMs} 毫秒`}</span>
|
|
||||||
</span>
|
|
||||||
{mountsBad ? <span className="text-destructive">{mountsBad} 个挂载根不可用</span> : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</Section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CacheSection({ c }: { c: Dashboard["cache"] }) {
|
|
||||||
const volumeLabel = useVolumeLabel();
|
|
||||||
return (
|
|
||||||
<Section title="缓存与保留" icon={HardDrive} to={consolePath("downloads")}>
|
|
||||||
{c.volumes.length === 0 ? (
|
|
||||||
<EmptyState
|
|
||||||
plain
|
|
||||||
icon={HardDrive}
|
|
||||||
title="还没有下载器"
|
|
||||||
description="添加下载器后显示缓存水位。"
|
|
||||||
className="py-6"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ul className="space-y-4">
|
|
||||||
{c.volumes.map((v) => {
|
|
||||||
const pct = v.usedPercent;
|
|
||||||
const over = pct !== null && pct >= v.hardThresholdPercent;
|
|
||||||
const near = pct !== null && pct >= v.resumePercent;
|
|
||||||
return (
|
|
||||||
<li key={v.volumeKey} className="min-w-0">
|
|
||||||
<div className="flex items-center justify-between gap-2 text-sm">
|
|
||||||
<span className="truncate font-medium">{volumeLabel(v)}</span>
|
|
||||||
<StatusBadge tone={pct === null ? "neutral" : over ? "destructive" : near ? "warning" : "success"}>
|
|
||||||
{pct === null ? "用量未知" : `${pct.toFixed(0)}%${over ? " 已达阈值" : ""}`}
|
|
||||||
</StatusBadge>
|
|
||||||
</div>
|
|
||||||
<div className="relative mt-2 h-1.5 overflow-hidden rounded-full bg-foreground/[0.08]" aria-hidden>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"h-full rounded-full",
|
|
||||||
over ? "bg-destructive" : near ? "bg-warning" : "bg-foreground/60",
|
|
||||||
)}
|
|
||||||
style={{ width: `${pct ?? 0}%` }}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
aria-hidden
|
|
||||||
className="absolute inset-y-0 w-px bg-destructive"
|
|
||||||
style={{ left: `${v.hardThresholdPercent}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="mt-1.5 text-meta text-muted-foreground">
|
|
||||||
阈值 {v.hardThresholdPercent}% / 恢复 {v.resumePercent}% · 在途 {formatBytes(v.pendingBytes)} · 可清理{" "}
|
|
||||||
{formatBytes(v.cleanableBytes)} · 保留期锁定 {formatBytes(v.retentionLockedBytes)}
|
|
||||||
{v.pausedByThreshold ? ` · 因阈值暂停 ${v.pausedByThreshold} 个` : ""}
|
|
||||||
</p>
|
|
||||||
{v.notes.map((n) => (
|
|
||||||
<p key={n} className="text-meta text-muted-foreground">
|
|
||||||
{n}
|
|
||||||
</p>
|
|
||||||
))}
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
<Link
|
|
||||||
to={settingsPath("automation", "retention")}
|
|
||||||
className="mt-3 text-meta text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
|
|
||||||
>
|
|
||||||
调整阈值与保留天数
|
|
||||||
</Link>
|
|
||||||
</Section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function LiveIndicator() {
|
|
||||||
const s = useSseStatus();
|
|
||||||
const tone = s === "open" ? "success" : s === "reconnecting" ? "warning" : "neutral";
|
|
||||||
const label = s === "open" ? "实时" : s === "reconnecting" ? "重连中" : "连接中";
|
|
||||||
return <StatusBadge tone={tone}>{label}</StatusBadge>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const q = useLiveDashboard();
|
const q = useLiveDashboard();
|
||||||
useLiveTasks();
|
useLiveTasks();
|
||||||
@@ -669,21 +329,15 @@ export default function Home() {
|
|||||||
<>
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="首页"
|
title="首页"
|
||||||
description="正在进行的任务、需要处理的事项、计划和最近结果。"
|
|
||||||
actions={
|
actions={
|
||||||
<div className="flex items-center gap-2">
|
q.data ? (
|
||||||
<LiveIndicator />
|
<span className="text-meta text-muted-foreground">更新于 {formatRelative(q.data.generatedAt)}</span>
|
||||||
{q.data ? (
|
) : null
|
||||||
<span className="hidden text-meta text-muted-foreground sm:inline">
|
|
||||||
更新于 {formatRelative(q.data.generatedAt)}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{q.isPending ? (
|
{q.isPending ? (
|
||||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
{["a", "b", "c", "d", "e", "f"].map((k) => (
|
{["a", "b", "c"].map((k) => (
|
||||||
<div key={k} className="pg-slab rounded-xl p-5">
|
<div key={k} className="pg-slab rounded-xl p-5">
|
||||||
<LoadingState rows={3} />
|
<LoadingState rows={3} />
|
||||||
</div>
|
</div>
|
||||||
@@ -693,16 +347,11 @@ export default function Home() {
|
|||||||
<ErrorState title="首页加载失败" error={q.error} onRetry={() => q.refetch()} />
|
<ErrorState title="首页加载失败" error={q.error} onRetry={() => q.refetch()} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<AutomationBanner a={q.data.automation} />
|
<StatusLine d={q.data} />
|
||||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
<InProgress w={q.data.currentWork} />
|
<Busy w={q.data.currentWork} />
|
||||||
<NeedsMe n={q.data.needsAttention} />
|
<NeedsMe n={q.data.needsAttention} />
|
||||||
<MediaOverview a={q.data.automation} />
|
<RecentImports d={q.data} />
|
||||||
<PlanStrip />
|
|
||||||
<RecentRuns />
|
|
||||||
<CacheSection c={q.data.cache} />
|
|
||||||
<SourcesSection s={q.data.sources} />
|
|
||||||
<StatusSummary />
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ export default function SchedulesPage() {
|
|||||||
<>
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="计划"
|
title="计划"
|
||||||
description="定时任务的开关、间隔和下次运行。每次运行的结果在任务页。"
|
|
||||||
actions={
|
actions={
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export const clientLoader = legacyRedirectLoader;
|
|||||||
|
|
||||||
type Tab = StreamTab;
|
type Tab = StreamTab;
|
||||||
const TABS: Tab[] = ["all", "active", "failed"];
|
const TABS: Tab[] = ["all", "active", "failed"];
|
||||||
const FILTER_KEYS: (keyof StreamFilters)[] = ["status", "kind", "ref", "source", "since", "workflow"];
|
const FILTER_KEYS: (keyof StreamFilters)[] = ["status", "kind", "ref", "source", "since", "workflow", "system"];
|
||||||
|
|
||||||
function Count({ n }: { n: number | undefined }) {
|
function Count({ n }: { n: number | undefined }) {
|
||||||
if (!n) return null;
|
if (!n) return null;
|
||||||
@@ -99,7 +99,6 @@ export default function TasksPage() {
|
|||||||
<>
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="任务"
|
title="任务"
|
||||||
description="每次运行都是一条任务,不论定时、事件、手动还是智能体触发。"
|
|
||||||
actions={
|
actions={
|
||||||
<Button variant="outline" size="sm" onClick={() => qc.invalidateQueries({ queryKey: qk.all("engine") })}>
|
<Button variant="outline" size="sm" onClick={() => qc.invalidateQueries({ queryKey: qk.all("engine") })}>
|
||||||
<RotateCw />
|
<RotateCw />
|
||||||
|
|||||||
@@ -803,14 +803,14 @@ export const SETTINGS_SECTIONS: readonly SettingsSectionDef[] = [
|
|||||||
{
|
{
|
||||||
id: "automation",
|
id: "automation",
|
||||||
label: "自动化",
|
label: "自动化",
|
||||||
description: "总开关、缓存水位、每日预算、保留天数",
|
description: "总开关、下载盘、每日预算、保留天数",
|
||||||
legacyTabs: [{ tab: "automation" }, { tab: "storage", anchor: "cache" }],
|
legacyTabs: [{ tab: "automation" }, { tab: "storage", anchor: "cache" }],
|
||||||
anchors: [
|
anchors: [
|
||||||
{ id: "switches", label: "开关" },
|
{ id: "switches", label: "开关" },
|
||||||
{ id: "cache", label: "缓存水位" },
|
{ id: "cache", label: "下载盘" },
|
||||||
{ id: "budget", label: "每日预算" },
|
{ id: "budget", label: "每日预算" },
|
||||||
{ id: "retention", label: "保留" },
|
{ id: "retention", label: "保留" },
|
||||||
{ id: "storage", label: "存储与保留" },
|
{ id: "advanced", label: "高级" },
|
||||||
],
|
],
|
||||||
fields: [
|
fields: [
|
||||||
"automation.enabled",
|
"automation.enabled",
|
||||||
|
|||||||
Reference in new issue
Block a user