- 设置 → 媒体库与目录 → 存储:登记 Alist / OpenList(令牌 / 账号密码 / 访客、路径密码、限速与监控选项), 测试连接显示版本、用户与容量;媒体库目录可选存储 - 文件管理左栏多一组「Alist 存储」,路径显示「存储名:/路径」;移动 / 复制目标可选 Alist 存储; 上传按钮;Alist 根点刷新才让 Alist 刷新目录缓存 - 运行状态的挂载根列出存储实例(令牌失效单独提示) Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
300 lines
11 KiB
TypeScript
300 lines
11 KiB
TypeScript
import {
|
||
CAPABILITY_CATALOG,
|
||
type EngineBusinessKind,
|
||
type KnownCapabilityId,
|
||
type LogEntry,
|
||
type LogLevel,
|
||
type LogType,
|
||
} from "@umt/contracts";
|
||
import type { StatusTone } from "~/components/console/status-badge.tsx";
|
||
import { BUSINESS_KIND_LABEL, workflowLabel } from "~/components/tasks/labels.ts";
|
||
import { actorTypeLabel } from "~/lib/labels.ts";
|
||
import { formatDateTime } from "~/lib/utils.ts";
|
||
|
||
// 日志页的标签与纯函数(界面第二轮 V 线):类型、级别、审计动作、标题 / 摘要、筛选参数、给智能体的上下文。
|
||
|
||
export const LOG_PATH = "/console/logs";
|
||
|
||
export const LOG_TYPE_LABEL: Record<LogType, string> = {
|
||
task: "任务",
|
||
auto: "定时与自动",
|
||
auth: "登录",
|
||
audit: "审计",
|
||
};
|
||
export const LOG_TYPES = Object.keys(LOG_TYPE_LABEL) as LogType[];
|
||
|
||
export const LOG_LEVEL: Record<LogLevel, { label: string; tone: StatusTone }> = {
|
||
running: { label: "进行中", tone: "neutral" },
|
||
success: { label: "成功", tone: "success" },
|
||
info: { label: "信息", tone: "neutral" },
|
||
warn: { label: "警告", tone: "warning" },
|
||
error: { label: "失败", tone: "destructive" },
|
||
};
|
||
export const LOG_LEVELS = Object.keys(LOG_LEVEL) as LogLevel[];
|
||
|
||
/** 时间范围预设 → 毫秒 */
|
||
export const LOG_SINCE: { value: string; label: string; ms: number }[] = [
|
||
{ value: "1h", label: "1 小时内", ms: 3600_000 },
|
||
{ value: "24h", label: "24 小时内", ms: 86400_000 },
|
||
{ value: "7d", label: "7 天内", ms: 7 * 86400_000 },
|
||
{ value: "30d", label: "30 天内", ms: 30 * 86400_000 },
|
||
];
|
||
|
||
// ── 审计动作(原审计页) ──
|
||
|
||
const ACTION_LABEL: Record<string, string> = {
|
||
"admin.setup": "初始化管理员",
|
||
"admin.login_success": "登录",
|
||
"admin.login_failed": "登录失败",
|
||
"admin.logout": "退出登录",
|
||
"admin.password_change": "修改密码",
|
||
"admin.totp_enable": "启用二次验证",
|
||
"admin.totp_disable": "停用二次验证",
|
||
"admin.recovery_codes_regenerate": "重新生成恢复码",
|
||
"admin.recovery_code_login": "用恢复码登录",
|
||
"admin.passkey_register": "登记 Passkey",
|
||
"admin.passkey_rename": "重命名 Passkey",
|
||
"admin.passkey_remove": "删除 Passkey",
|
||
"admin.passkey_login": "用 Passkey 登录",
|
||
"api_key.create": "创建 API Key",
|
||
"api_key.update": "修改 API Key",
|
||
"api_key.revoke": "撤销 API Key",
|
||
"settings.update": "修改设置",
|
||
"secret.create": "新建凭证",
|
||
"secret.rotate": "轮换凭证",
|
||
"secret.delete": "删除凭证",
|
||
"secret.master_key_rotate": "轮换主密钥",
|
||
"site_account.credentials_set": "录入站点凭证",
|
||
"engine.run_cancel": "取消任务",
|
||
"engine.run_retry": "重跑任务",
|
||
"pi_upstream.oauth_start": "开始订阅登录",
|
||
"pi_upstream.oauth_login": "完成订阅登录",
|
||
"pi_upstream.oauth_logout": "退出订阅登录",
|
||
"schedule.run_now": "立即执行定时任务",
|
||
"schedule.skip": "跳过定时任务",
|
||
"schedule.update": "修改定时任务",
|
||
"storage.cleanup": "立即清理存储",
|
||
"acquisition_plan.manual_submit": "手动提交下载",
|
||
"acquisition_plan.cancel": "取消下载",
|
||
"capability.invoke": "智能体操作",
|
||
"capability.denied": "智能体操作被拒绝",
|
||
"site.created": "登记站点",
|
||
"site.updated": "修改站点",
|
||
"feed_source.created": "新建 RSS",
|
||
"identity.release_assigned": "指定资源身份",
|
||
"files.mkdir": "新建目录",
|
||
"files.rename": "重命名",
|
||
"files.move": "移动文件",
|
||
"files.copy": "复制文件",
|
||
"files.organize": "整理文件",
|
||
"files.delete": "删除文件",
|
||
"files.trash_restore": "从回收站还原",
|
||
};
|
||
const VERB: Record<string, string> = { create: "新建", update: "修改", delete: "删除", test: "测试", verify: "校验" };
|
||
const NOUN: Record<string, string> = {
|
||
library_root: "媒体库目录",
|
||
downloader_instance: "下载器",
|
||
media_server_instance: "媒体服务器",
|
||
path_mapping: "路径映射",
|
||
storage_instance: "存储",
|
||
pi_upstream: "模型服务",
|
||
schedule: "定时任务",
|
||
automation_grant: "持续授权",
|
||
};
|
||
export const TARGET_LABEL: Record<string, string> = {
|
||
...NOUN,
|
||
admin_user: "管理员",
|
||
admin_session: "登录会话",
|
||
api_key: "API Key",
|
||
settings: "设置",
|
||
secret: "凭证",
|
||
site: "站点",
|
||
site_account: "站点账号",
|
||
feed_source: "RSS",
|
||
engine_run: "任务",
|
||
scheduled_job: "定时任务",
|
||
agent_run: "智能体会话",
|
||
acquisition_plan: "下载",
|
||
download_task: "下载任务",
|
||
media_entity: "作品",
|
||
source_release: "资源",
|
||
organization_record: "整理记录",
|
||
subscription: "订阅",
|
||
decision: "事项",
|
||
file: "文件",
|
||
capability: "能力",
|
||
};
|
||
|
||
export function actionLabel(a: string): string {
|
||
if (ACTION_LABEL[a]) return ACTION_LABEL[a];
|
||
const [noun, verb] = a.split(".");
|
||
if (noun && verb && NOUN[noun] && VERB[verb]) return `${VERB[verb]}${NOUN[noun]}`;
|
||
return a;
|
||
}
|
||
|
||
/** 对象文字:有展示名用展示名;能力显示中文名;其余显示对象类型 */
|
||
export function objectText(o: LogEntry["object"]): string | null {
|
||
if (!o) return null;
|
||
if (o.label) return o.label;
|
||
if (o.type === "capability" && o.id) return CAPABILITY_CATALOG[o.id as KnownCapabilityId]?.title ?? o.id;
|
||
return TARGET_LABEL[o.type] ?? o.type;
|
||
}
|
||
|
||
// ── 标题、摘要、状态 ──
|
||
|
||
const SCHEDULE_STATUS: Record<string, { label: string; tone: StatusTone }> = {
|
||
ok: { label: "成功", tone: "success" },
|
||
failed: { label: "失败", tone: "destructive" },
|
||
skipped: { label: "已跳过", tone: "neutral" },
|
||
running: { label: "进行中", tone: "neutral" },
|
||
};
|
||
const AGENT_STATUS: Record<string, { label: string; tone: StatusTone }> = {
|
||
queued: { label: "排队", tone: "neutral" },
|
||
waiting: { label: "等待", tone: "neutral" },
|
||
cancelled: { label: "已停止", tone: "warning" },
|
||
budget_exceeded: { label: "超出预算", tone: "warning" },
|
||
};
|
||
const ENGINE_STATUS: Record<string, { label: string; tone: StatusTone }> = {
|
||
pending: { label: "排队", tone: "neutral" },
|
||
canceled: { label: "已取消", tone: "warning" },
|
||
};
|
||
|
||
/** 行上的状态徽章:来源有更具体的说法时用来源的(已跳过、已取消、超出预算),否则用级别 */
|
||
export function logBadge(e: Pick<LogEntry, "source" | "status" | "level" | "extra">): {
|
||
label: string;
|
||
tone: StatusTone;
|
||
} {
|
||
const s = e.status ?? "";
|
||
if (e.source === "schedule_run") {
|
||
const agent = typeof e.extra.agentStatus === "string" ? AGENT_STATUS[e.extra.agentStatus] : undefined;
|
||
return agent ?? SCHEDULE_STATUS[s] ?? LOG_LEVEL[e.level];
|
||
}
|
||
if (e.source === "agent_run") return AGENT_STATUS[s] ?? LOG_LEVEL[e.level];
|
||
if (e.source === "engine_run") {
|
||
const outcome = typeof e.extra.outcome === "string" ? AGENT_STATUS[e.extra.outcome] : undefined;
|
||
return (e.level === "warn" ? outcome : undefined) ?? ENGINE_STATUS[s] ?? LOG_LEVEL[e.level];
|
||
}
|
||
return LOG_LEVEL[e.level];
|
||
}
|
||
|
||
/** 一行标题:任务 = 业务对象名 > 业务类型 > 工作流做什么;审计 = 动作;其余用服务端标题 */
|
||
export function logTitle(e: LogEntry): string {
|
||
if (e.source === "engine_run") {
|
||
const x = e.extra as {
|
||
businessTitle?: string | null;
|
||
businessKind?: EngineBusinessKind | null;
|
||
workflowName?: string;
|
||
};
|
||
if (x.businessTitle) return x.businessTitle;
|
||
if (x.businessKind && BUSINESS_KIND_LABEL[x.businessKind]) return BUSINESS_KIND_LABEL[x.businessKind];
|
||
return workflowLabel(x.workflowName ?? e.title);
|
||
}
|
||
if (e.source === "audit_event") return actionLabel(e.status ?? e.title);
|
||
if (e.source === "agent_run") return firstLine(e.title);
|
||
return e.title;
|
||
}
|
||
|
||
/** 第二行:任务的工作流(有业务标题时)、错误或摘要 */
|
||
export function logSubtitle(e: LogEntry): string | null {
|
||
const parts: string[] = [];
|
||
if (e.source === "engine_run" && (e.extra as { businessTitle?: string }).businessTitle)
|
||
parts.push(workflowLabel(String(e.extra.workflowName ?? "")));
|
||
if (e.source === "schedule_run" && e.extra.jobKind === "agent_check") parts.push("智能体巡检");
|
||
if (e.source === "agent_run") parts.push("智能体后台调查");
|
||
if (e.message) parts.push(firstLine(e.message));
|
||
return parts.length ? parts.join(" · ") : null;
|
||
}
|
||
|
||
/** 操作者 / 触发来源 */
|
||
export function actorLabel(e: LogEntry): string | null {
|
||
if (!e.actor) return null;
|
||
if (e.source === "schedule_run")
|
||
return e.actor.type === "manual" ? "手动" : e.actor.type === "skip" ? "跳过" : "定时";
|
||
if (e.source === "agent_run") return "事件";
|
||
return actorTypeLabel(e.actor.type);
|
||
}
|
||
|
||
function firstLine(s: string, max = 140): string {
|
||
const line = s.split("\n").find((l) => l.trim()) ?? s;
|
||
return line.length > max ? `${line.slice(0, max)}…` : line;
|
||
}
|
||
|
||
// ── 筛选参数(URL query) ──
|
||
|
||
export const LOG_FILTER_KEYS = ["type", "level", "since", "q", "objectType", "objectId", "system"] as const;
|
||
export type LogFilterKey = (typeof LOG_FILTER_KEYS)[number];
|
||
export type LogFilters = Record<LogFilterKey, string>;
|
||
|
||
/** 时间预设下界按分钟取整,查询 key 稳定 */
|
||
export function sinceToFrom(since: string, now = Date.now()): string | undefined {
|
||
const ms = LOG_SINCE.find((s) => s.value === since)?.ms;
|
||
return ms ? new Date(Math.floor((now - ms) / 60_000) * 60_000).toISOString() : undefined;
|
||
}
|
||
|
||
/** 日志页地址(其它页面链到日志用) */
|
||
export function logsPath(p: Partial<LogFilters> & { tab?: string; job?: string; log?: string } = {}): string {
|
||
const q = new URLSearchParams();
|
||
for (const [k, v] of Object.entries(p)) if (v) q.set(k, v);
|
||
const s = q.toString();
|
||
return s ? `${LOG_PATH}?${s}` : LOG_PATH;
|
||
}
|
||
|
||
/** 某个对象的全部日志 */
|
||
export const objectLogsPath = (type: string, id: string) => logsPath({ objectType: type, objectId: id });
|
||
|
||
// ── 给智能体的上下文 ──
|
||
|
||
const GOAL_MAX = 6000;
|
||
|
||
/** 一条日志的文字版(给智能体看,带 id 方便它查) */
|
||
export function logLine(e: LogEntry): string {
|
||
const obj = e.object ? ` 对象 ${e.object.type}${e.object.id ? `=${e.object.id}` : ""}` : "";
|
||
const msg = e.message ? ` · ${firstLine(e.message, 300)}` : "";
|
||
return `- ${formatDateTime(e.at)} [${LOG_TYPE_LABEL[e.type]} / ${logBadge(e).label}] ${logTitle(e)}${msg}(${e.id}${obj})`;
|
||
}
|
||
|
||
/** 选中的日志 → 问智能体的目标(按时间正序,超长截断) */
|
||
export function agentGoalFor(entries: LogEntry[]): string {
|
||
const head =
|
||
entries.length === 1
|
||
? "看看这条日志,说明发生了什么;有问题的话找出原因并给出处理建议。"
|
||
: `看看这 ${entries.length} 条日志,说明发生了什么;有问题的话找出原因并给出处理建议。`;
|
||
const lines: string[] = [];
|
||
let size = head.length;
|
||
const sorted = [...entries].sort((a, b) => Date.parse(a.at) - Date.parse(b.at));
|
||
for (const e of sorted) {
|
||
const l = logLine(e);
|
||
if (size + l.length + 1 > GOAL_MAX) {
|
||
lines.push(`- …另有 ${sorted.length - lines.length} 条未列出`);
|
||
break;
|
||
}
|
||
lines.push(l);
|
||
size += l.length + 1;
|
||
}
|
||
return `${head}\n${lines.join("\n")}`;
|
||
}
|
||
|
||
// ── 旧链接 ──
|
||
|
||
/** 旧任务页的 status / tab → 级别 */
|
||
export const RUN_STATUS_LEVEL: Record<string, LogLevel> = {
|
||
pending: "running",
|
||
running: "running",
|
||
completed: "success",
|
||
failed: "error",
|
||
canceled: "warn",
|
||
};
|
||
|
||
/** 业务对象 kind → 日志对象类型(与服务端 BUSINESS_OBJECT_TYPE 一致) */
|
||
export const BUSINESS_OBJECT_TYPE: Record<string, string> = {
|
||
download: "download_task",
|
||
organization: "organization_record",
|
||
agent: "agent_run",
|
||
subscription: "subscription",
|
||
feed: "feed_source",
|
||
site: "site",
|
||
decision: "decision",
|
||
media: "media_entity",
|
||
schedule: "scheduled_job",
|
||
};
|