merge: ui2/v-logs-status
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
commit
ee3ebcb25c
53 files changed
+4100
-934
No files matched your search
@@ -135,7 +135,23 @@ export function registerAuthRoutes(app: ModuleApp, ctx: ServerContext) {
|
||||
});
|
||||
|
||||
endpoint(app, D.login, async ({ body, c }) => {
|
||||
const r = await verifyLogin(deps(), body, clientIp(c));
|
||||
const r = await verifyLogin(deps(), body, clientIp(c)).catch(async (err) => {
|
||||
// 日志模块的「登录」类:用户名 / 密码 / 验证码错误留一条(被限速拦下的不写,见 AuditAction 注释)
|
||||
if (err instanceof ApiException && err.code === "unauthorized") {
|
||||
await audit(
|
||||
ctx,
|
||||
undefined,
|
||||
"admin.login_failed",
|
||||
{ type: "admin_user" },
|
||||
{
|
||||
username: body.username,
|
||||
ip: clientIp(c),
|
||||
reason: err.message,
|
||||
},
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
if (r.status === "totp_required") return { status: "totp_required" as const };
|
||||
const s = await startSession(c, r.admin.id);
|
||||
const actor = adminActor(r.admin);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { browserModule } from "./browser/index.ts";
|
||||
import { cacheModule } from "./cache/index.ts";
|
||||
import { desktopModule } from "./desktop/index.ts";
|
||||
import { fileManagerModule } from "./file-manager/index.ts";
|
||||
import { logsModule } from "./logs/index.ts";
|
||||
import { mcpModule } from "./mcp/index.ts";
|
||||
import { piModule } from "./pi/index.ts";
|
||||
import { schedulesModule } from "./schedules/index.ts";
|
||||
@@ -37,4 +38,5 @@ export const SERVER_MODULES: readonly ServerModule[] = [
|
||||
webSearchModule,
|
||||
cacheModule,
|
||||
systemModule,
|
||||
logsModule,
|
||||
];
|
||||
@@ -0,0 +1,130 @@
|
||||
import { createRoute } from "@hono/zod-openapi";
|
||||
import { Api, apiResponse, LOG_STREAM_EVENT, type LogFilter, LogStreamQuery } from "@umt/contracts";
|
||||
import type { Context } from "hono";
|
||||
import { streamSSE } from "hono/streaming";
|
||||
import { requireScope } from "../../http/auth.ts";
|
||||
import { ApiException } from "../../http/errors.ts";
|
||||
import type { ServerModule } from "../types.ts";
|
||||
import { type LogQueries, logQueries } from "./query.ts";
|
||||
import { createLogStreamPoller, STREAM_POLL_MS } from "./stream.ts";
|
||||
|
||||
export { decodeCursor, encodeCursor, logQueries } from "./query.ts";
|
||||
export { createLogStreamPoller } from "./stream.ts";
|
||||
|
||||
/**
|
||||
* 日志模块(界面第二轮 V 线,docs/runbook/logs.md):
|
||||
* - GET /logs:任务 / 定时与自动 / 登录 / 审计合并成一份列表(查询时 union,不新建表),仅 admin;
|
||||
* - GET /logs/stream:同一组筛选的 SSE 流,事件名 log.entry(新日志或状态变化,客户端按 id 覆盖)。
|
||||
*/
|
||||
export interface LogsModuleOptions {
|
||||
/** 引擎 schema(测试覆盖);缺省 openworkflow */
|
||||
engineSchema?: string;
|
||||
/** 流的轮询间隔,缺省 2 秒 */
|
||||
pollMs?: number;
|
||||
keepaliveMs?: number;
|
||||
}
|
||||
|
||||
export function createLogsModule(opts: LogsModuleOptions = {}): ServerModule {
|
||||
return {
|
||||
name: "logs",
|
||||
registerRoutes(app, ctx) {
|
||||
let q: LogQueries | null = null;
|
||||
const queries = () => {
|
||||
q ??= logQueries(ctx.db, { engineSchema: opts.engineSchema });
|
||||
return q;
|
||||
};
|
||||
|
||||
const list = Api.logs.list;
|
||||
app.openapi(
|
||||
createRoute({
|
||||
method: list.method,
|
||||
path: list.path,
|
||||
summary: list.summary,
|
||||
tags: ["logs"],
|
||||
middleware: [requireScope(list.scope)] as never,
|
||||
request: { query: list.query as never },
|
||||
responses: {
|
||||
200: { description: list.summary, content: { "application/json": { schema: apiResponse(list.response) } } },
|
||||
},
|
||||
}),
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 入参已由 zod-openapi 校验
|
||||
(async (c: any) => {
|
||||
const query = c.req.valid("query");
|
||||
const data = list.response.parse(await queries().list(query));
|
||||
return c.json({ ok: true, data }, 200);
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 同上
|
||||
}) as any,
|
||||
);
|
||||
|
||||
const stream = Api.logs.stream;
|
||||
app.openapi(
|
||||
createRoute({
|
||||
method: stream.method,
|
||||
path: stream.path,
|
||||
summary: stream.summary,
|
||||
tags: ["logs"],
|
||||
middleware: [requireScope(stream.scope)] as never,
|
||||
responses: {
|
||||
200: { description: stream.summary, content: { "text/event-stream": { schema: { type: "string" } } } },
|
||||
},
|
||||
}),
|
||||
// SSE 响应不走 JSON 信封
|
||||
(async (c: Context) => {
|
||||
const parsed = LogStreamQuery.safeParse(c.req.query());
|
||||
if (!parsed.success) throw new ApiException("bad_request", parsed.error.issues[0]?.message ?? "筛选参数无效");
|
||||
const { since, ...filter } = parsed.data;
|
||||
const poller = createLogStreamPoller(queries(), filter as LogFilter, {
|
||||
...(since ? { since: new Date(since) } : {}),
|
||||
});
|
||||
const log = ctx.logger.child({ component: "logs-stream" });
|
||||
return streamSSE(c, async (s) => {
|
||||
let closed = false;
|
||||
let wake: (() => void) | null = null;
|
||||
s.onAbort(() => {
|
||||
closed = true;
|
||||
wake?.();
|
||||
});
|
||||
const keepalive = setInterval(() => {
|
||||
void s.write(": keepalive\n\n").catch(() => {
|
||||
closed = true;
|
||||
});
|
||||
}, opts.keepaliveMs ?? 15_000);
|
||||
await s.writeSSE({ event: "ready", data: JSON.stringify({ at: new Date().toISOString() }) });
|
||||
try {
|
||||
while (!closed) {
|
||||
let items: Awaited<ReturnType<typeof poller.poll>> = [];
|
||||
try {
|
||||
items = await poller.poll();
|
||||
} catch (err) {
|
||||
// 库暂时不可达:这一轮跳过,下一轮再查
|
||||
log.debug({ err }, "日志流查询失败");
|
||||
}
|
||||
for (const e of items) {
|
||||
if (closed) break;
|
||||
await s.writeSSE({ id: e.id, event: LOG_STREAM_EVENT, data: JSON.stringify(e) }).catch(() => {
|
||||
closed = true;
|
||||
});
|
||||
}
|
||||
if (closed) break;
|
||||
await new Promise<void>((r) => {
|
||||
const t = setTimeout(r, opts.pollMs ?? STREAM_POLL_MS);
|
||||
wake = () => {
|
||||
clearTimeout(t);
|
||||
r();
|
||||
};
|
||||
});
|
||||
wake = null;
|
||||
}
|
||||
} finally {
|
||||
clearInterval(keepalive);
|
||||
}
|
||||
});
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 同上
|
||||
}) as any,
|
||||
);
|
||||
},
|
||||
registerTasks() {},
|
||||
};
|
||||
}
|
||||
|
||||
export const logsModule = createLogsModule();
|
||||
@@ -0,0 +1,311 @@
|
||||
import type { LogEntry, LogFilter, LogLevel, LogPage, LogSource, LogType } from "@umt/contracts";
|
||||
import { type DB, type Kysely, sql } from "@umt/db";
|
||||
import type { RawBuilder } from "kysely";
|
||||
import { redact } from "../../logger.ts";
|
||||
import { BUSINESS_EXPR } from "../auth-settings/engine-business.ts";
|
||||
|
||||
// 日志聚合查询(界面第二轮 V 线):四个来源各自出同一组列,按 (at, id) 倒序各取 limit+1 条,union 后再取前 limit 条。
|
||||
// 每个来源单独 order + limit,PG 能沿各自的时间索引倒着扫,不需要把全部来源物化再排序。
|
||||
//
|
||||
// 来源与口径(docs/runbook/logs.md):
|
||||
// - engine_run → task:引擎一次运行;时间 = 创建时间;标题 = 业务对象名(没有时为工作流名);对象 = 业务对象
|
||||
// - schedule_run → auto:定时任务运行记录(含智能体巡检);时间 = 开始时间;对象 = 定时任务
|
||||
// - agent_run(trigger=event,非子会话)→ auto:智能体后台调查;对象 = 会话
|
||||
// - audit_event → auth(admin.* / api_key.*)或 audit(其余);对象 = 审计目标
|
||||
// 例行记录(routine,缺省不列,system=1 或按对象筛选时列出):没有业务对象的引擎运行、定时任务触发且成功的运行、
|
||||
// 巡检 / 后台调查对应的引擎运行(已在 auto 里各有一条)、非巡检定时任务的成功 / 跳过运行。
|
||||
|
||||
export type Cursor = { us: number; id: string };
|
||||
|
||||
export function encodeCursor(c: Cursor): string {
|
||||
return btoa(JSON.stringify([c.us, c.id])).replace(/=+$/, "");
|
||||
}
|
||||
|
||||
export function decodeCursor(s: string | undefined | null): Cursor | null {
|
||||
if (!s) return null;
|
||||
try {
|
||||
const [us, id] = JSON.parse(atob(s)) as [unknown, unknown];
|
||||
if (typeof us === "number" && Number.isFinite(us) && typeof id === "string") return { us, id };
|
||||
} catch {
|
||||
// 无效游标当作第一页
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 业务对象 kind → 日志对象类型(与审计 target_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",
|
||||
};
|
||||
|
||||
const SOURCE_TYPES: Record<LogSource, LogType[]> = {
|
||||
engine_run: ["task"],
|
||||
schedule_run: ["auto"],
|
||||
agent_run: ["auto"],
|
||||
audit_event: ["auth", "audit"],
|
||||
};
|
||||
|
||||
const UUID_RE = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$";
|
||||
|
||||
function engineSource(schema: string): RawBuilder<unknown> {
|
||||
if (!/^[a-z_][a-z0-9_]*$/.test(schema)) throw new Error(`非法 schema 名 ${schema}`);
|
||||
const t = sql.raw(`"${schema}".workflow_runs`);
|
||||
const kind = sql`(biz.b->>'kind')`;
|
||||
const ref = sql`(biz.b->>'refId')`;
|
||||
const uuidRef = sql`(case when ${ref} ~* ${UUID_RE} then ${ref}::uuid end)`;
|
||||
const objectType = sql`(case ${kind} ${sql.join(
|
||||
Object.entries(BUSINESS_OBJECT_TYPE).map(([k, v]) => sql`when ${sql.lit(k)} then ${sql.lit(v)}`),
|
||||
sql` `,
|
||||
)} end)`;
|
||||
const title = sql`(case ${kind}
|
||||
when 'schedule' then (select j.name from scheduled_job j where j.id = ${ref})
|
||||
when 'agent' then (select a.goal from agent_run a where a.id = ${uuidRef})
|
||||
when 'download' then (select d.name from download_task d where d.id = ${uuidRef})
|
||||
when 'organization' then (select coalesce(e.title, r.target_path, r.source_path) from organization_record r
|
||||
left join media_entity e on e.id = r.media_entity_id where r.id = ${uuidRef})
|
||||
when 'subscription' then (select coalesce(e.title, s.title_query) from subscription s
|
||||
left join media_entity e on e.id = s.media_entity_id where s.id = ${uuidRef})
|
||||
when 'feed' then (select f.name from feed_source f where f.id = ${uuidRef})
|
||||
when 'site' then (select s.name from site s where s.id = ${uuidRef})
|
||||
when 'decision' then (select d.title from decision d where d.id = ${uuidRef})
|
||||
when 'media' then (select e.title from media_entity e where e.id = ${uuidRef})
|
||||
end)`;
|
||||
const agent = sql`(case when ${kind} = 'agent' then
|
||||
(select jsonb_build_object('status', a.status, 'trigger', a.trigger) from agent_run a where a.id = ${uuidRef}) end)`;
|
||||
const st = sql.raw("workflow_runs.status");
|
||||
return sql`select
|
||||
'engine_run:' || workflow_runs.id as id,
|
||||
'task'::text as type,
|
||||
'engine_run'::text as source,
|
||||
workflow_runs.id::text as ref_id,
|
||||
workflow_runs.created_at as at,
|
||||
workflow_runs.finished_at as finished_at,
|
||||
(case when workflow_runs.finished_at is not null and workflow_runs.started_at is not null
|
||||
then extract(epoch from workflow_runs.finished_at - workflow_runs.started_at) * 1000 end)::float8 as duration_ms,
|
||||
(case
|
||||
when ${st} = 'failed' then 'error'
|
||||
when ${st} = 'canceled' then 'warn'
|
||||
when ${st} in ('completed', 'succeeded') then (case ag.a->>'status'
|
||||
when 'failed' then 'error' when 'budget_exceeded' then 'warn' when 'cancelled' then 'warn' else 'success' end)
|
||||
else 'running' end) as level,
|
||||
${st}::text as status,
|
||||
coalesce(bt.title, workflow_runs.workflow_name) as title,
|
||||
(case when ${st} = 'failed' then left(coalesce(workflow_runs.error->>'message', workflow_runs.error::text), 500) end)
|
||||
as message,
|
||||
${objectType} as object_type,
|
||||
${ref} as object_id,
|
||||
bt.title as object_label,
|
||||
null::text as actor_type,
|
||||
null::text as actor_id,
|
||||
jsonb_build_object('workflowName', workflow_runs.workflow_name, 'businessKind', ${kind},
|
||||
'businessRefId', ${ref}, 'businessTitle', bt.title, 'outcome', ag.a->>'status',
|
||||
'agentTrigger', ag.a->>'trigger', 'attempts', workflow_runs.attempts) as extra,
|
||||
(biz.b is null
|
||||
or coalesce(${kind} = 'schedule' and ${st} in ('completed', 'succeeded'), false)
|
||||
or coalesce((ag.a->>'trigger') in ('event', 'schedule'), false)) as routine,
|
||||
concat_ws(' ', workflow_runs.workflow_name, workflow_runs.id, bt.title, ${ref},
|
||||
left(workflow_runs.error::text, 500)) as search
|
||||
from ${t}
|
||||
cross join lateral (select ${BUSINESS_EXPR} as b) biz
|
||||
cross join lateral (select ${title} as title) bt
|
||||
cross join lateral (select ${agent} as a) ag
|
||||
where workflow_runs.namespace_id = 'default'`;
|
||||
}
|
||||
|
||||
const SCHEDULE_SOURCE = sql`select
|
||||
'schedule_run:' || r.id::text as id,
|
||||
'auto'::text as type,
|
||||
'schedule_run'::text as source,
|
||||
r.id::text as ref_id,
|
||||
r.started_at as at,
|
||||
r.finished_at as finished_at,
|
||||
r.duration_ms::float8 as duration_ms,
|
||||
(case
|
||||
when r.status = 'failed' or r.agent_status = 'failed' then 'error'
|
||||
when r.agent_status in ('budget_exceeded', 'cancelled') then 'warn'
|
||||
when r.status = 'ok' then 'success'
|
||||
when r.status = 'skipped' then 'info'
|
||||
else 'running' end) as level,
|
||||
r.status::text as status,
|
||||
coalesce(j.name, r.job_id) as title,
|
||||
coalesce(r.error, r.summary) as message,
|
||||
'scheduled_job'::text as object_type,
|
||||
r.job_id as object_id,
|
||||
j.name as object_label,
|
||||
r.trigger::text as actor_type,
|
||||
null::text as actor_id,
|
||||
jsonb_build_object('jobId', r.job_id, 'jobKind', j.kind, 'trigger', r.trigger, 'agentRunId', r.agent_run_id,
|
||||
'agentStatus', r.agent_status, 'summary', r.summary, 'error', r.error) as extra,
|
||||
(r.status in ('ok', 'skipped') and coalesce(j.kind::text, 'task') <> 'agent_check'
|
||||
and r.agent_status is distinct from 'failed') as routine,
|
||||
concat_ws(' ', r.job_id, j.name, r.summary, r.error, r.agent_run_id::text) as search
|
||||
from scheduled_job_run r left join scheduled_job j on j.id = r.job_id`;
|
||||
|
||||
const AGENT_SOURCE = sql`select
|
||||
'agent_run:' || a.id::text as id,
|
||||
'auto'::text as type,
|
||||
'agent_run'::text as source,
|
||||
a.id::text as ref_id,
|
||||
a.created_at as at,
|
||||
a.finished_at as finished_at,
|
||||
(case when a.finished_at is not null and a.started_at is not null
|
||||
then extract(epoch from a.finished_at - a.started_at) * 1000 end)::float8 as duration_ms,
|
||||
(case a.status
|
||||
when 'succeeded' then 'success' when 'failed' then 'error'
|
||||
when 'cancelled' then 'warn' when 'budget_exceeded' then 'warn' else 'running' end) as level,
|
||||
a.status::text as status,
|
||||
a.goal as title,
|
||||
coalesce(a.summary, a.error->>'message', a.spawn_reason) as message,
|
||||
'agent_run'::text as object_type,
|
||||
a.id::text as object_id,
|
||||
null::text as object_label,
|
||||
'event'::text as actor_type,
|
||||
a.created_by as actor_id,
|
||||
jsonb_build_object('trigger', a.trigger, 'mode', a.mode, 'toolCalls', a.tool_call_count,
|
||||
'spawnReason', a.spawn_reason, 'model', a.model) as extra,
|
||||
false as routine,
|
||||
concat_ws(' ', a.goal, a.summary, a.id::text, left(a.error::text, 500)) as search
|
||||
from agent_run a where a.trigger = 'event' and a.parent_run_id is null`;
|
||||
|
||||
const AUDIT_SOURCE = sql`select
|
||||
'audit_event:' || e.id::text as id,
|
||||
(case when e.action like 'admin.%' or e.action like 'api_key.%' then 'auth' else 'audit' end) as type,
|
||||
'audit_event'::text as source,
|
||||
e.id::text as ref_id,
|
||||
e.at as at,
|
||||
null::timestamptz as finished_at,
|
||||
null::float8 as duration_ms,
|
||||
(case
|
||||
when e.action in ('admin.login_failed', 'capability.denied') then 'warn'
|
||||
when e.action in ('admin.login_success', 'admin.passkey_login', 'admin.setup') then 'success'
|
||||
else 'info' end) as level,
|
||||
e.action as status,
|
||||
e.action as title,
|
||||
(case when e.action like 'admin.%' then concat_ws(' · ', e.payload->>'username', e.payload->>'ip')
|
||||
else nullif(concat_ws(' · ', e.payload->>'reason', e.payload->>'error'), '') end) as message,
|
||||
e.target_type as object_type,
|
||||
e.target_id as object_id,
|
||||
null::text as object_label,
|
||||
e.actor_type as actor_type,
|
||||
e.actor_id as actor_id,
|
||||
jsonb_build_object('action', e.action, 'traceId', e.trace_id, 'payload', e.payload) as extra,
|
||||
false as routine,
|
||||
concat_ws(' ', e.action, e.target_type, e.target_id, e.actor_id, left(e.payload::text, 2000)) as search
|
||||
from audit_event e`;
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
type: LogType;
|
||||
source: LogSource;
|
||||
ref_id: string;
|
||||
at: Date;
|
||||
finished_at: Date | null;
|
||||
duration_ms: number | null;
|
||||
level: LogLevel;
|
||||
status: string | null;
|
||||
title: string | null;
|
||||
message: string | null;
|
||||
object_type: string | null;
|
||||
object_id: string | null;
|
||||
object_label: string | null;
|
||||
actor_type: string | null;
|
||||
actor_id: string | null;
|
||||
extra: Record<string, unknown> | null;
|
||||
cursor_us: string | number;
|
||||
};
|
||||
|
||||
export interface LogQueryInput extends LogFilter {
|
||||
cursor?: string | null;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
const escapeLike = (s: string) => s.replace(/[\\%_]/g, (c) => `\\${c}`);
|
||||
|
||||
export interface LogQueries {
|
||||
list(q: LogQueryInput): Promise<LogPage>;
|
||||
}
|
||||
|
||||
export function logQueries(db: Kysely<DB>, opts: { engineSchema?: string } = {}): LogQueries {
|
||||
const engineSchema = opts.engineSchema ?? "openworkflow";
|
||||
let engineReady = false;
|
||||
const engineExists = async () => {
|
||||
if (engineReady) return true;
|
||||
const r = await sql<{
|
||||
t: string | null;
|
||||
}>`select to_regclass(${`"${engineSchema}".workflow_runs`})::text as t`.execute(db);
|
||||
engineReady = !!r.rows[0]?.t;
|
||||
return engineReady;
|
||||
};
|
||||
|
||||
return {
|
||||
async list(q) {
|
||||
const types = q.type?.length ? new Set(q.type) : null;
|
||||
const sources: [LogSource, RawBuilder<unknown>][] = [];
|
||||
const want = (s: LogSource) => !types || SOURCE_TYPES[s].some((t) => types.has(t));
|
||||
if (want("engine_run") && (await engineExists())) sources.push(["engine_run", engineSource(engineSchema)]);
|
||||
if (want("schedule_run")) sources.push(["schedule_run", SCHEDULE_SOURCE]);
|
||||
if (want("agent_run")) sources.push(["agent_run", AGENT_SOURCE]);
|
||||
if (want("audit_event")) sources.push(["audit_event", AUDIT_SOURCE]);
|
||||
if (!sources.length) return { items: [], nextCursor: null };
|
||||
|
||||
const conds: RawBuilder<unknown>[] = [];
|
||||
if (types) conds.push(sql`s.type = any(${[...types]}::text[])`);
|
||||
if (q.level?.length) conds.push(sql`s.level = any(${q.level}::text[])`);
|
||||
if (q.from) conds.push(sql`s.at >= ${new Date(q.from)}`);
|
||||
if (q.to) conds.push(sql`s.at < ${new Date(q.to)}`);
|
||||
if (q.q) conds.push(sql`s.search ilike ${`%${escapeLike(q.q)}%`}`);
|
||||
if (q.objectType) conds.push(sql`s.object_type = ${q.objectType}`);
|
||||
if (q.objectId) conds.push(sql`s.object_id = ${q.objectId}`);
|
||||
if (q.system !== "1" && !q.objectId) conds.push(sql`not s.routine`);
|
||||
const cur = decodeCursor(q.cursor);
|
||||
if (cur) {
|
||||
conds.push(sql`(s.at, s.id) < (timestamptz 'epoch' + ${cur.us} * interval '1 microsecond', ${cur.id})`);
|
||||
}
|
||||
const where = conds.length ? sql`where ${sql.join(conds, sql` and `)}` : sql``;
|
||||
const n = q.limit + 1;
|
||||
const parts = sources.map(
|
||||
([, src]) => sql`(select s.* from (${src}) s ${where} order by s.at desc, s.id desc limit ${n})`,
|
||||
);
|
||||
const rows = await sql<Row>`select u.*, (extract(epoch from u.at) * 1000000)::bigint as cursor_us
|
||||
from (${sql.join(parts, sql` union all `)}) u
|
||||
order by u.at desc, u.id desc limit ${n}`.execute(db);
|
||||
const items = rows.rows.slice(0, q.limit);
|
||||
const last = items[items.length - 1];
|
||||
return {
|
||||
items: items.map(toEntry),
|
||||
nextCursor:
|
||||
rows.rows.length > q.limit && last ? encodeCursor({ us: Number(last.cursor_us), id: last.id }) : null,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const iso = (d: Date | string | null) => (d == null ? null : (d instanceof Date ? d : new Date(d)).toISOString());
|
||||
|
||||
export function toEntry(r: Row): LogEntry {
|
||||
const extra = (r.extra ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
id: r.id,
|
||||
type: r.type,
|
||||
source: r.source,
|
||||
refId: r.ref_id,
|
||||
at: iso(r.at)!,
|
||||
finishedAt: iso(r.finished_at),
|
||||
durationMs: r.duration_ms == null ? null : Math.max(0, Math.round(Number(r.duration_ms))),
|
||||
level: r.level,
|
||||
status: r.status,
|
||||
title: r.title || r.ref_id,
|
||||
message: r.message || null,
|
||||
object: r.object_type ? { type: r.object_type, id: r.object_id, label: r.object_label } : null,
|
||||
actor: r.actor_type ? { type: r.actor_type, id: r.actor_id } : null,
|
||||
// 审计负载可能带敏感字段:与审计接口一样脱敏后再给
|
||||
extra: r.source === "audit_event" ? (redact(extra) as Record<string, unknown>) : extra,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { LogEntry, LogFilter } from "@umt/contracts";
|
||||
import type { LogQueries } from "./query.ts";
|
||||
|
||||
// 日志流(GET /logs/stream):按同一组筛选每隔 pollMs 查一次「回看窗口」内的最新日志,
|
||||
// 没推过的推出去(新日志),推过但级别 / 状态 / 摘要变了的再推一次(客户端按 id 覆盖,如任务从进行中变成完成)。
|
||||
// 回看窗口 = 最近 lookbackMs(首次从 since 起),避免事务晚提交的记录(时间早于已推的最新一条)被漏掉。
|
||||
|
||||
export const STREAM_POLL_MS = 2_000;
|
||||
export const STREAM_LOOKBACK_MS = 10 * 60_000;
|
||||
const STREAM_BATCH = 200;
|
||||
|
||||
const signature = (e: LogEntry) => `${e.level}|${e.status ?? ""}|${e.message ?? ""}|${e.title}|${e.finishedAt ?? ""}`;
|
||||
|
||||
export interface LogStreamPoller {
|
||||
/** 查一次,返回需要推送的条目(按时间正序,新的在后) */
|
||||
poll(): Promise<LogEntry[]>;
|
||||
}
|
||||
|
||||
export function createLogStreamPoller(
|
||||
queries: LogQueries,
|
||||
filter: LogFilter,
|
||||
o: { since?: Date; now?: () => number; lookbackMs?: number } = {},
|
||||
): LogStreamPoller {
|
||||
const now = o.now ?? Date.now;
|
||||
const lookbackMs = o.lookbackMs ?? STREAM_LOOKBACK_MS;
|
||||
const since = (o.since ?? new Date(now())).getTime();
|
||||
/** id → 上次推送时的签名;窗口外的定期丢掉 */
|
||||
const seen = new Map<string, { sig: string; at: number }>();
|
||||
let first = true;
|
||||
|
||||
return {
|
||||
async poll() {
|
||||
// 首次从 since 起(客户端列表最新一条之后的都要补上),之后只看回看窗口
|
||||
const windowStart = first ? Math.min(since, now() - lookbackMs) : now() - lookbackMs;
|
||||
const from = filter.from ? Math.max(Date.parse(filter.from), windowStart) : windowStart;
|
||||
const page = await queries.list({ ...filter, from: new Date(from).toISOString(), limit: STREAM_BATCH });
|
||||
const out: LogEntry[] = [];
|
||||
for (const e of page.items) {
|
||||
const at = Date.parse(e.at);
|
||||
const sig = signature(e);
|
||||
const prev = seen.get(e.id);
|
||||
seen.set(e.id, { sig, at });
|
||||
// 首次查询:连接之前(since 之前)的日志客户端已有,只记签名不推
|
||||
if (!prev) {
|
||||
if (first && at <= since) continue;
|
||||
out.push(e);
|
||||
} else if (prev.sig !== sig) out.push(e);
|
||||
}
|
||||
first = false;
|
||||
// 窗口外的不会再查到,签名丢掉
|
||||
const cutoff = now() - lookbackMs;
|
||||
for (const [id, v] of seen) if (v.at < cutoff) seen.delete(id);
|
||||
return out.reverse();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type { ProcessKind } from "@umt/contracts";
|
||||
import { type DB, type Kysely, sql } from "@umt/db";
|
||||
import type { Logger } from "pino";
|
||||
import serverDeno from "../../../deno.json" with { type: "json" };
|
||||
import { createProcessCpuMeter } from "./resources.ts";
|
||||
|
||||
// 进程心跳写入(W9-I):每个常驻进程每 15s upsert process_heartbeat 一行 + 追加一条采样。
|
||||
// 写失败只记 debug(库暂时不可达不影响业务;读取方按 last_beat_at 判 stale)。
|
||||
@@ -86,6 +87,11 @@ function memory(): Record<string, number> {
|
||||
}
|
||||
}
|
||||
|
||||
/** 本进程两次心跳之间的 CPU 占用(% of 一个核);取不到时不写 */
|
||||
function cpuExtra(pct: number | null): Record<string, number> {
|
||||
return pct == null ? {} : { cpuPercent: pct };
|
||||
}
|
||||
|
||||
/** 写一次心跳(upsert + 采样);返回写入的 instanceId */
|
||||
export async function writeHeartbeat(
|
||||
db: Kysely<DB>,
|
||||
@@ -160,6 +166,8 @@ export function startHeartbeatWriter(o: HeartbeatWriterOptions): HeartbeatWriter
|
||||
const intervalMs = o.intervalMs ?? HEARTBEAT_WRITE_MS;
|
||||
const lag = o.lag ?? createLagSampler();
|
||||
const log = o.logger.child({ component: "heartbeat", kind: o.kind });
|
||||
const cpu = createProcessCpuMeter();
|
||||
cpu();
|
||||
let inflight: Promise<void> | null = null;
|
||||
let stopped = false;
|
||||
|
||||
@@ -181,7 +189,15 @@ export function startHeartbeatWriter(o: HeartbeatWriterOptions): HeartbeatWriter
|
||||
startedAt,
|
||||
lagMs: l.lastMs,
|
||||
lagMaxMs: l.windowMaxMs,
|
||||
sample: { ...sample, extra: { ...memory(), lifetimeMaxLagMs: l.lifetimeMaxMs, ...(sample.extra ?? {}) } },
|
||||
sample: {
|
||||
...sample,
|
||||
extra: {
|
||||
...memory(),
|
||||
...cpuExtra(cpu()),
|
||||
lifetimeMaxLagMs: l.lifetimeMaxMs,
|
||||
...(sample.extra ?? {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
log.debug({ err }, "心跳写入失败");
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
safeHostname,
|
||||
startHeartbeatWriter,
|
||||
} from "./heartbeat.ts";
|
||||
import { createResourceSampler, type ResourceSampler } from "./resources.ts";
|
||||
import { type BatchOptions, CLEANUP_JOB_IDS, RETENTION_JOBS } from "./retention.ts";
|
||||
import { buildStorage, type StoragePaths, storageHealth } from "./storage.ts";
|
||||
|
||||
@@ -58,6 +59,8 @@ export interface SystemModuleOptions {
|
||||
retentionJobs?: boolean;
|
||||
/** 保留清理的分批参数(测试) */
|
||||
retentionBatch?: BatchOptions;
|
||||
/** 资源采样器(测试注入);false 不起;缺省 api / all 角色注册路由时起一个每 5 秒读 /proc 的采样器 */
|
||||
resourceSampler?: ResourceSampler | false;
|
||||
}
|
||||
|
||||
type Infer<T> = T extends z.ZodType ? z.infer<T> : Record<string, never>;
|
||||
@@ -100,6 +103,8 @@ export function createSystemModule(opts: SystemModuleOptions = {}): ServerModule
|
||||
} {
|
||||
const startedAt = new Date();
|
||||
let writer: HeartbeatWriter | null = null;
|
||||
let sampler: ResourceSampler | null = null;
|
||||
const ownSampler = !opts.resourceSampler;
|
||||
return {
|
||||
name: "system",
|
||||
apiHeartbeat: () => writer,
|
||||
@@ -113,6 +118,8 @@ export function createSystemModule(opts: SystemModuleOptions = {}): ServerModule
|
||||
sample: () => ({ sseConnections: sseConnectionCount() }),
|
||||
});
|
||||
}
|
||||
if (opts.resourceSampler) sampler = opts.resourceSampler;
|
||||
else if (opts.resourceSampler !== false && !sampler) sampler = createResourceSampler();
|
||||
const selfKind = ctx.role === "all" ? "all" : ctx.role === "api" ? "api" : null;
|
||||
const selfId = selfKind ? instanceIdOf(selfKind, safeHostname(), Deno.pid) : undefined;
|
||||
const paths = (): StoragePaths => {
|
||||
@@ -140,6 +147,11 @@ export function createSystemModule(opts: SystemModuleOptions = {}): ServerModule
|
||||
});
|
||||
});
|
||||
route(app, Api.system.healthHistory, async ({ query }) => await loadHistory(ctx.db, query.hours));
|
||||
route(app, Api.system.resources, async ({ query }) => {
|
||||
// 采样器只在注册路由的进程里起;测试关掉时临时起一个不自动采样的
|
||||
sampler ??= createResourceSampler({ autoStart: false });
|
||||
return await sampler.snapshot(query.minutes);
|
||||
});
|
||||
route(app, Api.system.storage, async () =>
|
||||
buildStorage({
|
||||
db: ctx.db,
|
||||
@@ -226,6 +238,8 @@ export function createSystemModule(opts: SystemModuleOptions = {}): ServerModule
|
||||
async stop() {
|
||||
await writer?.stop();
|
||||
writer = null;
|
||||
if (ownSampler) sampler?.stop();
|
||||
sampler = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
import { availableParallelism, cpus } from "node:os";
|
||||
import process from "node:process";
|
||||
import type { ResourceSample, ResourceScope, SystemResources } from "@umt/contracts";
|
||||
|
||||
// 资源监控(界面第二轮 V 线,参考 MP 仪表盘 CPU / 内存 / 网络组件):api 进程每 5 秒读一次 /proc 与 cgroup v2,
|
||||
// 相邻两次读数的差算速率,采样只放内存(环形,保留 6 小时)。读不到的文件按 null 降级,不报错。
|
||||
// 口径:容器里优先 cgroup(本容器 CPU 占用、内存、磁盘读写);网络读 /proc/net/dev(容器网络命名空间即本容器);
|
||||
// 宿主没有 cgroup 文件时退回 /proc 整机。
|
||||
|
||||
export const RESOURCE_SAMPLE_MS = 5_000;
|
||||
export const RESOURCE_RETENTION_MS = 6 * 3600_000;
|
||||
/** 趋势最多返回的点数 */
|
||||
const MAX_POINTS = 360;
|
||||
|
||||
export type ReadText = (path: string) => Promise<string | null>;
|
||||
|
||||
export const defaultReadText: ReadText = async (path) => {
|
||||
try {
|
||||
return await Deno.readTextFile(path);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// ── 解析(纯函数) ──
|
||||
|
||||
/** /proc/stat 第一行 cpu:total = 前 8 项之和,idle = idle + iowait */
|
||||
export function parseProcStat(text: string | null): { total: number; idle: number } | null {
|
||||
const line = text?.split("\n").find((l) => l.startsWith("cpu "));
|
||||
if (!line) return null;
|
||||
const v = line.trim().split(/\s+/).slice(1, 9).map(Number);
|
||||
if (v.length < 4 || v.some((n) => !Number.isFinite(n))) return null;
|
||||
const total = v.reduce((s, n) => s + n, 0);
|
||||
return { total, idle: (v[3] ?? 0) + (v[4] ?? 0) };
|
||||
}
|
||||
|
||||
/** /proc/meminfo(kB)→ 字节 */
|
||||
export function parseMeminfo(text: string | null): {
|
||||
totalBytes: number;
|
||||
availableBytes: number;
|
||||
swapUsedBytes: number | null;
|
||||
} | null {
|
||||
if (!text) return null;
|
||||
const kv = new Map<string, number>();
|
||||
for (const l of text.split("\n")) {
|
||||
const m = /^(\w+):\s+(\d+)/.exec(l);
|
||||
if (m) kv.set(m[1]!, Number(m[2]) * 1024);
|
||||
}
|
||||
const total = kv.get("MemTotal");
|
||||
const avail = kv.get("MemAvailable") ?? (kv.get("MemFree") ?? 0) + (kv.get("Cached") ?? 0);
|
||||
if (!total) return null;
|
||||
const st = kv.get("SwapTotal");
|
||||
const sf = kv.get("SwapFree");
|
||||
return {
|
||||
totalBytes: total,
|
||||
availableBytes: Math.min(total, avail),
|
||||
swapUsedBytes: st != null && sf != null ? Math.max(0, st - sf) : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseLoadavg(text: string | null): [number, number, number] | null {
|
||||
const v = text?.trim().split(/\s+/).slice(0, 3).map(Number);
|
||||
if (!v || v.length < 3 || v.some((n) => !Number.isFinite(n))) return null;
|
||||
return [v[0]!, v[1]!, v[2]!];
|
||||
}
|
||||
|
||||
/** /proc/net/dev:除 lo 外各接口收 / 发字节合计 */
|
||||
export function parseNetDev(text: string | null): { rx: number; tx: number } | null {
|
||||
if (!text) return null;
|
||||
let rx = 0;
|
||||
let tx = 0;
|
||||
let seen = false;
|
||||
for (const l of text.split("\n")) {
|
||||
const m = /^\s*([^:\s]+):\s*(.*)$/.exec(l);
|
||||
if (!m || m[1] === "lo") continue;
|
||||
const f = m[2]!.trim().split(/\s+/).map(Number);
|
||||
if (f.length < 9) continue;
|
||||
rx += f[0] ?? 0;
|
||||
tx += f[8] ?? 0;
|
||||
seen = true;
|
||||
}
|
||||
return seen ? { rx, tx } : null;
|
||||
}
|
||||
|
||||
/** cgroup v2 cpu.stat 的 usage_usec */
|
||||
export function parseCgroupCpuUsage(text: string | null): number | null {
|
||||
const m = text ? /^usage_usec\s+(\d+)/m.exec(text) : null;
|
||||
return m ? Number(m[1]) : null;
|
||||
}
|
||||
|
||||
/** cgroup v2 cpu.max:"max 100000" → null(不限);"200000 100000" → 2 核 */
|
||||
export function parseCgroupCpuMax(text: string | null): number | null {
|
||||
const [quota, period] = text?.trim().split(/\s+/) ?? [];
|
||||
if (!quota || quota === "max" || !period) return null;
|
||||
const q = Number(quota);
|
||||
const p = Number(period);
|
||||
return q > 0 && p > 0 ? q / p : null;
|
||||
}
|
||||
|
||||
/** memory.max:"max" → null */
|
||||
export function parseCgroupBytes(text: string | null): number | null {
|
||||
const t = text?.trim();
|
||||
if (!t || t === "max") return null;
|
||||
const n = Number(t);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/** memory.stat 的某个字段 */
|
||||
export function parseStatField(text: string | null, key: string): number | null {
|
||||
const m = text ? new RegExp(`^${key}\\s+(\\d+)`, "m").exec(text) : null;
|
||||
return m ? Number(m[1]) : null;
|
||||
}
|
||||
|
||||
/** cgroup v2 io.stat:各设备 rbytes / wbytes 合计 */
|
||||
export function parseIoStat(text: string | null): { read: number; write: number } | null {
|
||||
if (!text?.trim()) return null;
|
||||
let read = 0;
|
||||
let write = 0;
|
||||
let seen = false;
|
||||
for (const l of text.split("\n")) {
|
||||
const r = /\brbytes=(\d+)/.exec(l);
|
||||
const w = /\bwbytes=(\d+)/.exec(l);
|
||||
if (r || w) seen = true;
|
||||
read += r ? Number(r[1]) : 0;
|
||||
write += w ? Number(w[1]) : 0;
|
||||
}
|
||||
return seen ? { read, write } : null;
|
||||
}
|
||||
|
||||
const WHOLE_DISK = /^(sd[a-z]+|vd[a-z]+|xvd[a-z]+|hd[a-z]+|nvme\d+n\d+|mmcblk\d+)$/;
|
||||
|
||||
/** /proc/diskstats:整盘(不含分区、loop、ram)读 / 写扇区 × 512 */
|
||||
export function parseDiskstats(text: string | null): { read: number; write: number } | null {
|
||||
if (!text) return null;
|
||||
let read = 0;
|
||||
let write = 0;
|
||||
let seen = false;
|
||||
for (const l of text.split("\n")) {
|
||||
const f = l.trim().split(/\s+/);
|
||||
if (f.length < 10 || !WHOLE_DISK.test(f[2] ?? "")) continue;
|
||||
read += Number(f[5]) * 512;
|
||||
write += Number(f[9]) * 512;
|
||||
seen = true;
|
||||
}
|
||||
return seen ? { read, write } : null;
|
||||
}
|
||||
|
||||
// ── 读数与采样 ──
|
||||
|
||||
export interface RawReading {
|
||||
atMs: number;
|
||||
host: { total: number; idle: number } | null;
|
||||
cgroupCpuUsec: number | null;
|
||||
cgroupCores: number | null;
|
||||
onlineCores: number;
|
||||
load: [number, number, number] | null;
|
||||
meminfo: ReturnType<typeof parseMeminfo>;
|
||||
cgroupMem: { current: number; inactiveFile: number; max: number | null } | null;
|
||||
net: { rx: number; tx: number } | null;
|
||||
io: { read: number; write: number; scope: ResourceScope } | null;
|
||||
uptimeSec: number | null;
|
||||
}
|
||||
|
||||
const CG = "/sys/fs/cgroup";
|
||||
|
||||
function onlineCores(): number {
|
||||
try {
|
||||
return availableParallelism();
|
||||
} catch {
|
||||
return cpus().length || 1;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readResources(read: ReadText = defaultReadText, now = Date.now()): Promise<RawReading> {
|
||||
const [stat, cpuStat, cpuMax, load, mem, memCur, memMax, memStat, net, ioStat, diskstats, uptime] = await Promise.all(
|
||||
[
|
||||
read("/proc/stat"),
|
||||
read(`${CG}/cpu.stat`),
|
||||
read(`${CG}/cpu.max`),
|
||||
read("/proc/loadavg"),
|
||||
read("/proc/meminfo"),
|
||||
read(`${CG}/memory.current`),
|
||||
read(`${CG}/memory.max`),
|
||||
read(`${CG}/memory.stat`),
|
||||
read("/proc/net/dev"),
|
||||
read(`${CG}/io.stat`),
|
||||
read("/proc/diskstats"),
|
||||
read("/proc/uptime"),
|
||||
],
|
||||
);
|
||||
const current = parseCgroupBytes(memCur);
|
||||
const cgIo = parseIoStat(ioStat);
|
||||
const hostIo = cgIo ? null : parseDiskstats(diskstats);
|
||||
const up = Number(uptime?.trim().split(/\s+/)[0]);
|
||||
return {
|
||||
atMs: now,
|
||||
host: parseProcStat(stat),
|
||||
cgroupCpuUsec: parseCgroupCpuUsage(cpuStat),
|
||||
cgroupCores: parseCgroupCpuMax(cpuMax),
|
||||
onlineCores: onlineCores(),
|
||||
load: parseLoadavg(load),
|
||||
meminfo: parseMeminfo(mem),
|
||||
cgroupMem:
|
||||
current == null
|
||||
? null
|
||||
: {
|
||||
current,
|
||||
inactiveFile: parseStatField(memStat, "inactive_file") ?? 0,
|
||||
max: parseCgroupBytes(memMax),
|
||||
},
|
||||
net: parseNetDev(net),
|
||||
io: cgIo ? { ...cgIo, scope: "cgroup" } : hostIo ? { ...hostIo, scope: "host" } : null,
|
||||
uptimeSec: Number.isFinite(up) ? up : null,
|
||||
};
|
||||
}
|
||||
|
||||
const round1 = (n: number) => Math.round(n * 10) / 10;
|
||||
const clampPct = (n: number) => round1(Math.min(100, Math.max(0, n)));
|
||||
const rate = (cur: number, prev: number, sec: number) => (sec > 0 && cur >= prev ? (cur - prev) / sec : null);
|
||||
|
||||
export interface ResourcePoint {
|
||||
sample: ResourceSample;
|
||||
current: Omit<SystemResources, "at" | "intervalSec" | "bucketSec" | "samples">;
|
||||
}
|
||||
|
||||
/** 由相邻两次读数算一个点(prev 为 null 时速率与 CPU 为 null) */
|
||||
export function computePoint(prev: RawReading | null, cur: RawReading): ResourcePoint {
|
||||
const sec = prev ? (cur.atMs - prev.atMs) / 1000 : 0;
|
||||
const hostPercent =
|
||||
prev?.host && cur.host && cur.host.total > prev.host.total
|
||||
? clampPct((1 - (cur.host.idle - prev.host.idle) / (cur.host.total - prev.host.total)) * 100)
|
||||
: null;
|
||||
const cores = cur.cgroupCores ?? cur.onlineCores;
|
||||
const cgPercent =
|
||||
prev && prev.cgroupCpuUsec != null && cur.cgroupCpuUsec != null && sec > 0
|
||||
? clampPct(((cur.cgroupCpuUsec - prev.cgroupCpuUsec) / (sec * 1e6 * cores)) * 100)
|
||||
: null;
|
||||
const cpuScope: ResourceScope = cur.cgroupCpuUsec != null ? "cgroup" : cur.host ? "host" : "none";
|
||||
const cpuPercent = cpuScope === "cgroup" ? cgPercent : hostPercent;
|
||||
|
||||
const mi = cur.meminfo;
|
||||
const hostUsed = mi ? mi.totalBytes - mi.availableBytes : null;
|
||||
const cg = cur.cgroupMem;
|
||||
const memScope: ResourceScope = cg ? "cgroup" : mi ? "host" : "none";
|
||||
const used = cg ? Math.max(0, cg.current - cg.inactiveFile) : hostUsed;
|
||||
const limit = cg ? (cg.max ?? mi?.totalBytes ?? null) : (mi?.totalBytes ?? null);
|
||||
const memPercent = used != null && limit ? clampPct((used / limit) * 100) : null;
|
||||
|
||||
const rx = prev?.net && cur.net ? rate(cur.net.rx, prev.net.rx, sec) : null;
|
||||
const tx = prev?.net && cur.net ? rate(cur.net.tx, prev.net.tx, sec) : null;
|
||||
const sameIo = prev?.io && cur.io && prev.io.scope === cur.io.scope;
|
||||
const rd = sameIo ? rate(cur.io!.read, prev!.io!.read, sec) : null;
|
||||
const wr = sameIo ? rate(cur.io!.write, prev!.io!.write, sec) : null;
|
||||
|
||||
return {
|
||||
sample: {
|
||||
at: new Date(cur.atMs).toISOString(),
|
||||
cpuPercent,
|
||||
memoryUsedBytes: used,
|
||||
memoryPercent: memPercent,
|
||||
netRxBps: rx == null ? null : Math.round(rx),
|
||||
netTxBps: tx == null ? null : Math.round(tx),
|
||||
diskReadBps: rd == null ? null : Math.round(rd),
|
||||
diskWriteBps: wr == null ? null : Math.round(wr),
|
||||
},
|
||||
current: {
|
||||
cpu: { scope: cpuScope, percent: cpuPercent, cores: round1(cores), hostPercent, load: cur.load },
|
||||
memory: {
|
||||
scope: memScope,
|
||||
usedBytes: used,
|
||||
limitBytes: limit,
|
||||
percent: memPercent,
|
||||
hostTotalBytes: mi?.totalBytes ?? null,
|
||||
hostAvailableBytes: mi?.availableBytes ?? null,
|
||||
swapUsedBytes: mi?.swapUsedBytes ?? null,
|
||||
},
|
||||
network: {
|
||||
scope: cur.net ? (cg ? "cgroup" : "host") : "none",
|
||||
rxBps: rx == null ? null : Math.round(rx),
|
||||
txBps: tx == null ? null : Math.round(tx),
|
||||
rxTotalBytes: cur.net?.rx ?? null,
|
||||
txTotalBytes: cur.net?.tx ?? null,
|
||||
},
|
||||
diskIo: {
|
||||
scope: cur.io?.scope ?? "none",
|
||||
readBps: rd == null ? null : Math.round(rd),
|
||||
writeBps: wr == null ? null : Math.round(wr),
|
||||
},
|
||||
hostUptimeSec: cur.uptimeSec,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 按桶取平均(null 不参与);点数不超过 MAX_POINTS */
|
||||
export function bucketSamples(
|
||||
samples: ResourceSample[],
|
||||
intervalSec: number,
|
||||
minutes: number,
|
||||
): { bucketSec: number; points: ResourceSample[] } {
|
||||
const bucketSec = Math.max(intervalSec, Math.ceil((minutes * 60) / MAX_POINTS / intervalSec) * intervalSec);
|
||||
if (bucketSec <= intervalSec) return { bucketSec: intervalSec, points: samples };
|
||||
const keys: (keyof ResourceSample)[] = [
|
||||
"cpuPercent",
|
||||
"memoryUsedBytes",
|
||||
"memoryPercent",
|
||||
"netRxBps",
|
||||
"netTxBps",
|
||||
"diskReadBps",
|
||||
"diskWriteBps",
|
||||
];
|
||||
const buckets = new Map<number, ResourceSample[]>();
|
||||
for (const s of samples) {
|
||||
const k = Math.floor(Date.parse(s.at) / 1000 / bucketSec) * bucketSec;
|
||||
const arr = buckets.get(k) ?? [];
|
||||
arr.push(s);
|
||||
buckets.set(k, arr);
|
||||
}
|
||||
const points = [...buckets.entries()]
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([k, arr]) => {
|
||||
const out = { at: new Date(k * 1000).toISOString() } as ResourceSample;
|
||||
for (const key of keys) {
|
||||
const vals = arr.map((s) => s[key]).filter((v): v is number => typeof v === "number");
|
||||
(out as Record<string, unknown>)[key] = vals.length
|
||||
? Math.round((vals.reduce((a, b) => a + b, 0) / vals.length) * 10) / 10
|
||||
: null;
|
||||
}
|
||||
return out;
|
||||
});
|
||||
return { bucketSec, points };
|
||||
}
|
||||
|
||||
export interface ResourceSampler {
|
||||
/** 立即采一次(测试 / 首次请求用) */
|
||||
tick(): Promise<void>;
|
||||
snapshot(minutes: number): Promise<SystemResources>;
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
export function createResourceSampler(
|
||||
o: { read?: ReadText; intervalMs?: number; retentionMs?: number; now?: () => number; autoStart?: boolean } = {},
|
||||
): ResourceSampler {
|
||||
const read = o.read ?? defaultReadText;
|
||||
const intervalMs = o.intervalMs ?? RESOURCE_SAMPLE_MS;
|
||||
const retentionMs = o.retentionMs ?? RESOURCE_RETENTION_MS;
|
||||
const now = o.now ?? Date.now;
|
||||
let prev: RawReading | null = null;
|
||||
let last: ResourcePoint | null = null;
|
||||
const ring: ResourceSample[] = [];
|
||||
let inflight: Promise<void> | null = null;
|
||||
|
||||
const tick = () => {
|
||||
inflight ??= (async () => {
|
||||
const cur = await readResources(read, now());
|
||||
const p = computePoint(prev, cur);
|
||||
prev = cur;
|
||||
last = p;
|
||||
// 第一次读数没有速率,不进趋势
|
||||
if (p.sample.cpuPercent != null || p.sample.netRxBps != null || ring.length) ring.push(p.sample);
|
||||
const cutoff = now() - retentionMs;
|
||||
while (ring.length && Date.parse(ring[0]!.at) < cutoff) ring.shift();
|
||||
})().finally(() => {
|
||||
inflight = null;
|
||||
});
|
||||
return inflight;
|
||||
};
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
if (o.autoStart !== false) {
|
||||
void tick();
|
||||
timer = setInterval(() => void tick(), intervalMs);
|
||||
try {
|
||||
Deno.unrefTimer(timer);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tick,
|
||||
async snapshot(minutes) {
|
||||
// 还没有两次读数(刚启动):现采两次,间隔 300ms,让当前值有速率
|
||||
if (!last || !ring.length) {
|
||||
await tick();
|
||||
if (!ring.length) {
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
await tick();
|
||||
}
|
||||
}
|
||||
const intervalSec = intervalMs / 1000;
|
||||
const from = now() - minutes * 60_000;
|
||||
const { bucketSec, points } = bucketSamples(
|
||||
ring.filter((s) => Date.parse(s.at) >= from),
|
||||
intervalSec,
|
||||
minutes,
|
||||
);
|
||||
return { at: new Date(now()).toISOString(), intervalSec, bucketSec, ...last!.current, samples: points };
|
||||
},
|
||||
stop() {
|
||||
if (timer) clearInterval(timer);
|
||||
timer = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── 进程自身 CPU(心跳里带上,运行状态的进程表展示) ──
|
||||
|
||||
/** 返回一个取样函数:每次调用给出距上次调用期间本进程 CPU 占用(% of 一个核);首次为 null */
|
||||
export function createProcessCpuMeter(): () => number | null {
|
||||
let last: { usage: number; at: number } | null = null;
|
||||
return () => {
|
||||
try {
|
||||
// node:process 兼容层的 cpuUsage(微秒)
|
||||
const u = process.cpuUsage();
|
||||
const usage = u.user + u.system;
|
||||
const at = performance.now();
|
||||
const prev = last;
|
||||
last = { usage, at };
|
||||
if (!prev || at <= prev.at) return null;
|
||||
return round1(((usage - prev.usage) / ((at - prev.at) * 1000)) * 100);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -81,6 +81,12 @@ describe.skipIf(!hasPg)("管理员登录门禁(真实 PG)", () => {
|
||||
it("登录:错密码 401;正确密码下发 cookie;会话可访问 admin 端点;未登录 401", async () => {
|
||||
const b = new Browser(h.app);
|
||||
expect((await login(b, "wrong-password-1")).status).toBe(401);
|
||||
// 日志模块「登录」类:失败也留一条审计(用户名、IP、原因,不含密码)
|
||||
const failed = await sql<{ actor_type: string; payload: Record<string, unknown> }>`
|
||||
select actor_type, payload from audit_event where action = 'admin.login_failed'`.execute(h.db);
|
||||
expect(failed.rows).toHaveLength(1);
|
||||
expect(failed.rows[0]!.payload).toMatchObject({ username: "admin", ip: "203.0.113.10" });
|
||||
expect(JSON.stringify(failed.rows[0]!.payload)).not.toContain("wrong-password-1");
|
||||
expect((await b.req("GET", "/settings")).status).toBe(401);
|
||||
const res = await login(b);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
import { LOG_STREAM_EVENT, type LogEntry, LogPage, LogQuery } from "@umt/contracts";
|
||||
import { sql } from "@umt/db";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { createLogStreamPoller, createLogsModule, decodeCursor, encodeCursor } from "../../src/modules/logs/index.ts";
|
||||
import { logQueries } from "../../src/modules/logs/query.ts";
|
||||
import { bearer, createHarness, type Harness, pgAvailable } from "../auth-settings/harness.ts";
|
||||
import { DEV_KEY, json } from "../helpers.ts";
|
||||
|
||||
// 日志模块:四个来源合并、筛选、游标分页、流(真实 PG + 独立引擎 schema)。
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
|
||||
describe("游标", () => {
|
||||
it("编码往返;无效游标当第一页", () => {
|
||||
const c = { us: 1_759_000_000_123_456, id: "engine_run:abc" };
|
||||
expect(decodeCursor(encodeCursor(c))).toEqual(c);
|
||||
expect(decodeCursor("not-base64!!")).toBeNull();
|
||||
expect(decodeCursor(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("type / level 接受逗号分隔多值,未知值 422", () => {
|
||||
expect(LogQuery.parse({ type: "task,auth", level: "error" })).toMatchObject({
|
||||
type: ["task", "auth"],
|
||||
level: ["error"],
|
||||
limit: 50,
|
||||
});
|
||||
expect(LogQuery.safeParse({ type: "nope" }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!hasPg)("日志聚合(真实 PG)", () => {
|
||||
let h: Harness;
|
||||
let admin: ReturnType<typeof bearer>;
|
||||
const base = Date.now() - 3600_000;
|
||||
const at = (min: number) => new Date(base + min * 60_000);
|
||||
const t = (n: string) => sql.raw(`"${h.engineSchema}".${n}`);
|
||||
const run = (id: string, status: string, name: string, input: unknown, created: Date, error: unknown = null) =>
|
||||
sql`insert into ${t("workflow_runs")} (namespace_id, id, workflow_name, version, status, config, context, input,
|
||||
attempts, worker_id, available_at, started_at, finished_at, created_at, updated_at, error)
|
||||
values ('default', ${id}, ${name}, 'v1', ${status}, '{}'::jsonb, null, ${JSON.stringify(input)}::jsonb, 1,
|
||||
null, ${created}, ${created}, ${status === "running" ? null : new Date(created.getTime() + 2000)}, ${created},
|
||||
${created}, ${error ? JSON.stringify(error) : null}::jsonb)`.execute(h.db);
|
||||
const list = async (query: string) => {
|
||||
const res = await admin("GET", `/logs${query}`);
|
||||
expect(res.status, query).toBe(200);
|
||||
return LogPage.parse((await json(res)).data).items;
|
||||
};
|
||||
|
||||
let downloadId = "";
|
||||
|
||||
beforeAll(async () => {
|
||||
h = await createHarness({
|
||||
engine: true,
|
||||
extraModules: [],
|
||||
});
|
||||
const { createApp } = await import("../../src/http/app.ts");
|
||||
const { createAuthSettingsModule } = await import("../../src/modules/auth-settings/index.ts");
|
||||
h.app = createApp({
|
||||
config: h.ctx.config,
|
||||
logger: h.ctx.logger,
|
||||
bus: h.bus,
|
||||
db: h.db,
|
||||
role: "all",
|
||||
version: "0.0.0-test",
|
||||
modules: [
|
||||
createAuthSettingsModule({ setupTokenOnStart: false, engineSchema: h.engineSchema }),
|
||||
createLogsModule({ engineSchema: h.engineSchema, pollMs: 50 }),
|
||||
],
|
||||
ctx: h.ctx,
|
||||
}) as Harness["app"];
|
||||
admin = bearer(h.app, DEV_KEY);
|
||||
|
||||
// 业务对象:下载任务
|
||||
const inst = await sql<{ id: string }>`insert into downloader_instance (name, base_url)
|
||||
values ('qb', 'http://qb') returning id`.execute(h.db);
|
||||
const dl = await sql<{ id: string }>`insert into download_task (instance_id, torrent_hash, name)
|
||||
values (${inst.rows[0]!.id}, ${"a".repeat(40)}, '沙丘2 2160p') returning id`.execute(h.db);
|
||||
downloadId = dl.rows[0]!.id;
|
||||
|
||||
// 任务:有业务对象的失败 / 完成;没有业务对象的(例行)
|
||||
await run("r-dl", "failed", "submitDownload", { input: { downloadTaskId: downloadId } }, at(1), {
|
||||
message: "qB 拒绝了种子",
|
||||
});
|
||||
await run("r-dl2", "completed", "verifyAndImport", { input: { downloadTaskId: downloadId } }, at(2));
|
||||
await run("r-sys", "completed", "snapshotSite", { input: {} }, at(3));
|
||||
await run("r-running", "running", "submitDownload", { input: { downloadTaskId: downloadId } }, at(4));
|
||||
|
||||
// 定时任务:一条失败、一条成功(例行)、一条巡检
|
||||
await sql`insert into scheduled_job (id, name, board_group, kind, enabled, default_enabled, schedule, default_schedule)
|
||||
values ('feeds.poll', 'RSS 抓取', 'collect', 'task', true, true, '{"type":"interval","everyMs":60000}'::jsonb,
|
||||
'{"type":"interval","everyMs":60000}'::jsonb),
|
||||
('check.daily', '每日巡检', 'agent', 'agent_check', true, true, '{"type":"interval","everyMs":86400000}'::jsonb,
|
||||
'{"type":"interval","everyMs":86400000}'::jsonb)`.execute(h.db);
|
||||
await sql`insert into scheduled_job_run (job_id, trigger, status, error, started_at, finished_at, duration_ms) values
|
||||
('feeds.poll', 'schedule', 'failed', '站点超时', ${at(5)}, ${at(5)}, 1200),
|
||||
('feeds.poll', 'schedule', 'ok', null, ${at(6)}, ${at(6)}, 300),
|
||||
('check.daily', 'schedule', 'ok', null, ${at(7)}, ${at(7)}, 9000)`.execute(h.db);
|
||||
|
||||
// 智能体后台调查(trigger=event)与用户会话(不进日志的 auto)
|
||||
await sql`insert into agent_run (trigger, goal, status, summary, created_at, started_at, finished_at) values
|
||||
('event', '调查下载为什么失败', 'succeeded', '种子已被站点删除', ${at(8)}, ${at(8)}, ${at(9)}),
|
||||
('user', '帮我找沙丘', 'succeeded', null, ${at(8)}, ${at(8)}, ${at(9)})`.execute(h.db);
|
||||
|
||||
// 审计:登录成功、登录失败、设置修改、智能体操作被拒
|
||||
await sql`insert into audit_event (at, actor_type, actor_id, action, target_type, target_id, payload) values
|
||||
(${at(10)}, 'user', 'admin', 'admin.login_success', 'admin_session', 's1', '{"ip":"10.0.0.2"}'::jsonb),
|
||||
(${at(11)}, 'system', null, 'admin.login_failed', 'admin_user', null, '{"username":"admin","ip":"10.0.0.9"}'::jsonb),
|
||||
(${at(12)}, 'user', 'admin', 'settings.update', 'settings', 'cache', '{"keys":["cache"],"token":"sk-secret"}'::jsonb),
|
||||
(${at(13)}, 'agent', 'run-1', 'capability.denied', 'download_task', ${downloadId}, '{}'::jsonb)`.execute(h.db);
|
||||
}, 60_000);
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
});
|
||||
|
||||
it("四个来源合并、按时间倒序;例行记录缺省不列", async () => {
|
||||
const items = await list("?limit=100");
|
||||
expect(items.map((e) => e.id.split(":")[0])).toEqual(
|
||||
expect.arrayContaining(["engine_run", "schedule_run", "agent_run", "audit_event"]),
|
||||
);
|
||||
const times = items.map((e) => Date.parse(e.at));
|
||||
expect([...times].sort((a, b) => b - a)).toEqual(times);
|
||||
const refs = items.map((e) => e.refId);
|
||||
// 没有业务对象的引擎运行、成功的普通定时任务运行、用户会话都不在
|
||||
expect(refs).not.toContain("r-sys");
|
||||
expect(items.filter((e) => e.source === "schedule_run").map((e) => e.status)).toEqual(["ok", "failed"]);
|
||||
expect(items.filter((e) => e.source === "agent_run")).toHaveLength(1);
|
||||
// system=1 时列出
|
||||
const all = await list("?limit=100&system=1");
|
||||
expect(all.map((e) => e.refId)).toContain("r-sys");
|
||||
expect(all.filter((e) => e.source === "schedule_run")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("各来源的类型、级别、标题、对象", async () => {
|
||||
const items = await list("?limit=100");
|
||||
const by = (ref: string) => items.find((e) => e.refId === ref)!;
|
||||
expect(by("r-dl")).toMatchObject({
|
||||
type: "task",
|
||||
level: "error",
|
||||
title: "沙丘2 2160p",
|
||||
message: "qB 拒绝了种子",
|
||||
object: { type: "download_task", id: downloadId, label: "沙丘2 2160p" },
|
||||
extra: { workflowName: "submitDownload", businessKind: "download" },
|
||||
});
|
||||
expect(by("r-dl").durationMs).toBe(2000);
|
||||
expect(by("r-running")).toMatchObject({ level: "running", finishedAt: null });
|
||||
const failedRun = items.find((e) => e.source === "schedule_run" && e.status === "failed")!;
|
||||
expect(failedRun).toMatchObject({
|
||||
type: "auto",
|
||||
level: "error",
|
||||
title: "RSS 抓取",
|
||||
message: "站点超时",
|
||||
object: { type: "scheduled_job", id: "feeds.poll" },
|
||||
durationMs: 1200,
|
||||
});
|
||||
const check = items.find((e) => e.source === "schedule_run" && e.status === "ok")!;
|
||||
expect(check).toMatchObject({ title: "每日巡检", level: "success" });
|
||||
expect(items.find((e) => e.source === "agent_run")).toMatchObject({
|
||||
type: "auto",
|
||||
level: "success",
|
||||
title: "调查下载为什么失败",
|
||||
message: "种子已被站点删除",
|
||||
});
|
||||
const audits = items.filter((e) => e.source === "audit_event");
|
||||
expect(audits.map((e) => [e.status, e.type, e.level])).toEqual([
|
||||
["capability.denied", "audit", "warn"],
|
||||
["settings.update", "audit", "info"],
|
||||
["admin.login_failed", "auth", "warn"],
|
||||
["admin.login_success", "auth", "success"],
|
||||
]);
|
||||
expect(audits.find((e) => e.status === "admin.login_failed")!.message).toBe("admin · 10.0.0.9");
|
||||
// 审计负载脱敏
|
||||
const settings = audits.find((e) => e.status === "settings.update")!;
|
||||
expect(JSON.stringify(settings.extra)).not.toContain("sk-secret");
|
||||
});
|
||||
|
||||
it("筛选:类型、级别、关键词、对象、时间", async () => {
|
||||
expect((await list("?type=auth")).map((e) => e.status)).toEqual(["admin.login_failed", "admin.login_success"]);
|
||||
expect((await list("?type=auto")).every((e) => e.type === "auto")).toBe(true);
|
||||
const errors = await list("?level=error");
|
||||
expect(errors.map((e) => e.refId).sort()).toEqual(
|
||||
["r-dl", errors.find((e) => e.source === "schedule_run")!.refId].sort(),
|
||||
);
|
||||
expect((await list("?level=warn,running")).map((e) => e.level).sort()).toEqual(["running", "warn", "warn"]);
|
||||
// 关键词:任务按业务标题、审计按负载、定时任务按错误
|
||||
expect((await list("?q=沙丘")).map((e) => e.refId)).toEqual(expect.arrayContaining(["r-dl", "r-dl2"]));
|
||||
expect((await list("?q=10.0.0.9")).map((e) => e.status)).toEqual(["admin.login_failed"]);
|
||||
expect((await list("?q=超时")).map((e) => e.source)).toEqual(["schedule_run"]);
|
||||
// %、_ 按字面匹配
|
||||
expect(await list("?q=%25")).toEqual([]);
|
||||
// 对象:同一个下载任务的任务与审计都列出
|
||||
const obj = await list(`?objectType=download_task&objectId=${downloadId}`);
|
||||
expect(obj.map((e) => e.refId)).toEqual(expect.arrayContaining(["r-dl", "r-dl2", "r-running"]));
|
||||
expect(obj.some((e) => e.status === "capability.denied")).toBe(true);
|
||||
// 按对象筛选时自动包含例行记录
|
||||
const job = await list("?objectType=scheduled_job&objectId=feeds.poll");
|
||||
expect(job.map((e) => e.status)).toEqual(["ok", "failed"]);
|
||||
// 时间
|
||||
const recent = await list(`?from=${encodeURIComponent(at(10).toISOString())}`);
|
||||
expect(recent.every((e) => Date.parse(e.at) >= at(10).getTime())).toBe(true);
|
||||
expect(recent.map((e) => e.source)).toEqual(Array(4).fill("audit_event"));
|
||||
const older = await list(`?to=${encodeURIComponent(at(2).toISOString())}`);
|
||||
expect(older.map((e) => e.refId)).toEqual(["r-dl"]);
|
||||
});
|
||||
|
||||
it("游标分页:逐页拿到与一次取全相同的序列,不重不漏", async () => {
|
||||
const full = await list("?limit=200&system=1");
|
||||
const seen: string[] = [];
|
||||
let cursor: string | null = null;
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const res = await admin("GET", `/logs?limit=3&system=1${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`);
|
||||
const page = LogPage.parse((await json(res)).data);
|
||||
seen.push(...page.items.map((e) => e.id));
|
||||
cursor = page.nextCursor;
|
||||
if (!cursor) break;
|
||||
}
|
||||
expect(seen).toEqual(full.map((e) => e.id));
|
||||
expect(new Set(seen).size).toBe(seen.length);
|
||||
});
|
||||
|
||||
it("流:首轮不推已有日志;新日志推出;状态变化再推一次", async () => {
|
||||
let now = Date.now();
|
||||
const poller = createLogStreamPoller(
|
||||
logQueries(h.db, { engineSchema: h.engineSchema }),
|
||||
{},
|
||||
{
|
||||
since: new Date(now),
|
||||
now: () => now,
|
||||
},
|
||||
);
|
||||
expect(await poller.poll()).toEqual([]);
|
||||
const created = new Date(now + 1000);
|
||||
now += 2000;
|
||||
await run("r-new", "running", "submitDownload", { input: { downloadTaskId: downloadId } }, created);
|
||||
await sql`insert into audit_event (at, actor_type, action, payload) values (${new Date(now - 500)}, 'user',
|
||||
'api_key.create', '{}'::jsonb)`.execute(h.db);
|
||||
const pushed = await poller.poll();
|
||||
expect(pushed.map((e) => e.refId === "r-new" || e.status === "api_key.create")).toEqual([true, true]);
|
||||
// 正序:旧的在前
|
||||
expect(Date.parse(pushed[0]!.at)).toBeLessThanOrEqual(Date.parse(pushed[1]!.at));
|
||||
expect(await poller.poll()).toEqual([]);
|
||||
await sql`update ${t("workflow_runs")} set status = 'completed', finished_at = ${new Date(now)} where id = 'r-new'`.execute(
|
||||
h.db,
|
||||
);
|
||||
const updated = await poller.poll();
|
||||
expect(updated).toHaveLength(1);
|
||||
expect(updated[0]).toMatchObject({ refId: "r-new", level: "success" });
|
||||
});
|
||||
|
||||
it("流接口:SSE 推 log.entry,筛选生效", async () => {
|
||||
const ctl = new AbortController();
|
||||
const since = new Date().toISOString();
|
||||
const res = await h.app.request(`/api/v1/logs/stream?type=auth&since=${encodeURIComponent(since)}`, {
|
||||
headers: { authorization: `Bearer ${DEV_KEY}` },
|
||||
signal: ctl.signal,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("content-type")).toContain("text/event-stream");
|
||||
const reader = res.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
const readUntil = async (pred: (s: string) => boolean) => {
|
||||
const deadline = Date.now() + 10_000;
|
||||
while (!pred(buf) && Date.now() < deadline) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value);
|
||||
}
|
||||
};
|
||||
await readUntil((s) => s.includes("event: ready"));
|
||||
// 一条不匹配(审计)、一条匹配(登录)
|
||||
await sql`insert into audit_event (actor_type, action, payload) values ('user', 'settings.update', '{}'::jsonb),
|
||||
('system', 'admin.login_failed', '{"username":"x","ip":"1.2.3.4"}'::jsonb)`.execute(h.db);
|
||||
await readUntil((s) => s.includes(LOG_STREAM_EVENT) && s.includes("1.2.3.4"));
|
||||
ctl.abort();
|
||||
await reader.cancel().catch(() => {});
|
||||
const events = buf
|
||||
.split("\n\n")
|
||||
.filter((b) => b.includes(`event: ${LOG_STREAM_EVENT}`))
|
||||
.map(
|
||||
(b) =>
|
||||
JSON.parse(
|
||||
b
|
||||
.split("\n")
|
||||
.find((l) => l.startsWith("data: "))!
|
||||
.slice(6),
|
||||
) as LogEntry,
|
||||
);
|
||||
expect(events.length).toBeGreaterThanOrEqual(1);
|
||||
expect(events.every((e) => e.type === "auth")).toBe(true);
|
||||
expect(events.some((e) => e.message === "x · 1.2.3.4")).toBe(true);
|
||||
});
|
||||
|
||||
it("没有引擎 schema 时只查其它来源", async () => {
|
||||
const q = logQueries(h.db, { engineSchema: "no_such_engine" });
|
||||
const page = await q.list({ limit: 50 });
|
||||
expect(page.items.length).toBeGreaterThan(0);
|
||||
expect(page.items.some((e) => e.source === "engine_run")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -43,7 +43,7 @@ const pingModule: ServerModule = {
|
||||
};
|
||||
|
||||
describe("ServerModule 骨架", () => {
|
||||
it("清单里是四个 W2 模块 + 文件管理 + Pi 上游 + 定时任务 + 全局搜索 + 浏览器 + 桌面反代 + MCP + 网页搜索 + 缓存 + 进程与健康(末尾),名字唯一", () => {
|
||||
it("清单里是四个 W2 模块 + 文件管理 + Pi 上游 + 定时任务 + 全局搜索 + 浏览器 + 桌面反代 + MCP + 网页搜索 + 缓存 + 进程与健康 + 日志(末尾),名字唯一", () => {
|
||||
expect(SERVER_MODULES.map((m) => m.name)).toEqual([
|
||||
"auth-settings",
|
||||
"sources-identity",
|
||||
@@ -60,6 +60,7 @@ describe("ServerModule 骨架", () => {
|
||||
"websearch",
|
||||
"cache",
|
||||
"system",
|
||||
"logs",
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import { SystemResources } from "@umt/contracts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
bucketSamples,
|
||||
computePoint,
|
||||
createProcessCpuMeter,
|
||||
createResourceSampler,
|
||||
parseCgroupBytes,
|
||||
parseCgroupCpuMax,
|
||||
parseCgroupCpuUsage,
|
||||
parseDiskstats,
|
||||
parseIoStat,
|
||||
parseLoadavg,
|
||||
parseMeminfo,
|
||||
parseNetDev,
|
||||
parseProcStat,
|
||||
type ReadText,
|
||||
readResources,
|
||||
} from "../src/modules/system/resources.ts";
|
||||
|
||||
// 资源监控采集:/proc 与 cgroup v2 的解析、速率计算、降级与采样器(不碰 PG;读文件用注入的假实现)。
|
||||
|
||||
const STAT = (user: number, idle: number) => `cpu ${user} 0 100 ${idle} 50 0 0 0 0 0\ncpu0 1 2 3 4 5 6 7 8 0 0\n`;
|
||||
const MEMINFO = `MemTotal: 16000000 kB
|
||||
MemFree: 2000000 kB
|
||||
MemAvailable: 8000000 kB
|
||||
SwapTotal: 1000000 kB
|
||||
SwapFree: 600000 kB
|
||||
`;
|
||||
const NETDEV = (rx: number, tx: number) => `Inter-| Receive | Transmit
|
||||
face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed
|
||||
lo: 999999 10 0 0 0 0 0 0 999999 10 0 0 0 0 0 0
|
||||
eth0: ${rx} 100 0 0 0 0 0 0 ${tx} 80 0 0 0 0 0 0
|
||||
`;
|
||||
const IOSTAT = (r: number, w: number) =>
|
||||
`8:0 rbytes=${r} wbytes=${w} rios=10 wios=5 dbytes=0 dios=0\n259:0 rbytes=0 wbytes=0\n`;
|
||||
|
||||
/** 假文件系统:按路径给内容,没给的读不到 */
|
||||
function fakeFs(files: Record<string, string>): ReadText {
|
||||
return (path) => Promise.resolve(files[path] ?? null);
|
||||
}
|
||||
|
||||
describe("解析", () => {
|
||||
it("/proc 各文件", () => {
|
||||
expect(parseProcStat(STAT(1000, 8000))).toEqual({ total: 1000 + 100 + 8000 + 50, idle: 8050 });
|
||||
expect(parseProcStat("garbage")).toBeNull();
|
||||
expect(parseMeminfo(MEMINFO)).toEqual({
|
||||
totalBytes: 16000000 * 1024,
|
||||
availableBytes: 8000000 * 1024,
|
||||
swapUsedBytes: 400000 * 1024,
|
||||
});
|
||||
expect(parseMeminfo(null)).toBeNull();
|
||||
expect(parseLoadavg("0.52 0.40 0.31 2/345 6789")).toEqual([0.52, 0.4, 0.31]);
|
||||
expect(parseLoadavg("")).toBeNull();
|
||||
// lo 不计
|
||||
expect(parseNetDev(NETDEV(5000, 3000))).toEqual({ rx: 5000, tx: 3000 });
|
||||
// 整盘才计(分区、loop 不计)
|
||||
expect(
|
||||
parseDiskstats(
|
||||
" 8 0 sda 100 0 2000 0 50 0 4000 0 0 0 0\n 8 1 sda1 100 0 2000 0 50 0 4000 0 0 0 0\n 7 0 loop0 1 0 8 0 0 0 0 0 0 0 0\n",
|
||||
),
|
||||
).toEqual({ read: 2000 * 512, write: 4000 * 512 });
|
||||
});
|
||||
|
||||
it("cgroup v2", () => {
|
||||
expect(parseCgroupCpuUsage("usage_usec 123456\nuser_usec 1\n")).toBe(123456);
|
||||
expect(parseCgroupCpuMax("max 100000")).toBeNull();
|
||||
expect(parseCgroupCpuMax("200000 100000\n")).toBe(2);
|
||||
expect(parseCgroupBytes("max\n")).toBeNull();
|
||||
expect(parseCgroupBytes("1048576\n")).toBe(1048576);
|
||||
expect(parseIoStat(IOSTAT(4096, 8192))).toEqual({ read: 4096, write: 8192 });
|
||||
expect(parseIoStat("")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("读数与速率", () => {
|
||||
it("容器:CPU 按 cgroup 用量 / 核数,内存扣掉 inactive_file,磁盘读写取 io.stat", async () => {
|
||||
const files = (usage: number, user: number, idle: number, rx: number, io: number) =>
|
||||
fakeFs({
|
||||
"/proc/stat": STAT(user, idle),
|
||||
"/sys/fs/cgroup/cpu.stat": `usage_usec ${usage}\n`,
|
||||
"/sys/fs/cgroup/cpu.max": "200000 100000",
|
||||
"/proc/loadavg": "1.00 0.50 0.25 1/2 3",
|
||||
"/proc/meminfo": MEMINFO,
|
||||
"/sys/fs/cgroup/memory.current": String(600 * 1024 * 1024),
|
||||
"/sys/fs/cgroup/memory.max": String(1024 * 1024 * 1024),
|
||||
"/sys/fs/cgroup/memory.stat": `anon 1\ninactive_file ${100 * 1024 * 1024}\n`,
|
||||
"/proc/net/dev": NETDEV(rx, rx / 2),
|
||||
"/sys/fs/cgroup/io.stat": IOSTAT(io, io * 2),
|
||||
"/proc/uptime": "3600.5 7000.1",
|
||||
});
|
||||
const a = await readResources(files(0, 1000, 9000, 0, 0), 0);
|
||||
const b = await readResources(files(5_000_000, 2000, 10000, 10_000_000, 1_000_000), 5000);
|
||||
const first = computePoint(null, a);
|
||||
expect(first.sample.cpuPercent).toBeNull();
|
||||
expect(first.sample.netRxBps).toBeNull();
|
||||
const p = computePoint(a, b);
|
||||
// 5 秒用了 5 秒 CPU,限额 2 核 → 50%
|
||||
expect(p.current.cpu).toMatchObject({ scope: "cgroup", percent: 50, cores: 2, load: [1, 0.5, 0.25] });
|
||||
// 整机:total 增 2000、idle 增 1000 → 50%
|
||||
expect(p.current.cpu.hostPercent).toBe(50);
|
||||
expect(p.current.memory).toMatchObject({
|
||||
scope: "cgroup",
|
||||
usedBytes: 500 * 1024 * 1024,
|
||||
limitBytes: 1024 * 1024 * 1024,
|
||||
percent: 48.8,
|
||||
});
|
||||
expect(p.sample).toMatchObject({ netRxBps: 2_000_000, netTxBps: 1_000_000, diskReadBps: 200_000 });
|
||||
expect(p.current.diskIo).toMatchObject({ scope: "cgroup", writeBps: 400_000 });
|
||||
expect(p.current.hostUptimeSec).toBe(3600.5);
|
||||
});
|
||||
|
||||
it("宿主(没有 cgroup 文件):退回 /proc 整机口径", async () => {
|
||||
const files = (user: number, idle: number) =>
|
||||
fakeFs({ "/proc/stat": STAT(user, idle), "/proc/meminfo": MEMINFO, "/proc/net/dev": NETDEV(0, 0) });
|
||||
const p = computePoint(await readResources(files(1000, 9000), 0), await readResources(files(1500, 9500), 1000));
|
||||
expect(p.current.cpu).toMatchObject({ scope: "host", percent: 50 });
|
||||
expect(p.current.memory).toMatchObject({ scope: "host", usedBytes: 8000000 * 1024, percent: 50 });
|
||||
expect(p.current.diskIo).toMatchObject({ scope: "none", readBps: null });
|
||||
expect(p.current.network.scope).toBe("host");
|
||||
});
|
||||
|
||||
it("什么都读不到:全部为 null,不抛错", async () => {
|
||||
const none = fakeFs({});
|
||||
const p = computePoint(await readResources(none, 0), await readResources(none, 1000));
|
||||
expect(p.current.cpu).toMatchObject({ scope: "none", percent: null, hostPercent: null, load: null });
|
||||
expect(p.current.memory).toMatchObject({ scope: "none", usedBytes: null, percent: null });
|
||||
expect(p.current.network).toMatchObject({ scope: "none", rxBps: null });
|
||||
});
|
||||
|
||||
it("计数器回绕 / 重置时速率为 null,不出负数", async () => {
|
||||
const f = (rx: number) => fakeFs({ "/proc/net/dev": NETDEV(rx, rx) });
|
||||
const p = computePoint(await readResources(f(1000), 0), await readResources(f(10), 1000));
|
||||
expect(p.sample.netRxBps).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("采样器", () => {
|
||||
it("环形保留、按桶平均、快照符合契约", async () => {
|
||||
let now = Date.parse("2026-10-06T00:00:00Z");
|
||||
let usage = 0;
|
||||
const read: ReadText = (path) => {
|
||||
if (path === "/sys/fs/cgroup/cpu.stat") return Promise.resolve(`usage_usec ${usage}\n`);
|
||||
if (path === "/sys/fs/cgroup/cpu.max") return Promise.resolve("100000 100000");
|
||||
if (path === "/proc/meminfo") return Promise.resolve(MEMINFO);
|
||||
return Promise.resolve(null);
|
||||
};
|
||||
const s = createResourceSampler({ read, autoStart: false, intervalMs: 5000, retentionMs: 60_000, now: () => now });
|
||||
for (let i = 0; i < 30; i++) {
|
||||
// 每 5 秒用 2.5 秒 CPU(1 核)→ 50%
|
||||
usage += 2_500_000;
|
||||
now += 5000;
|
||||
await s.tick();
|
||||
}
|
||||
const snap = SystemResources.parse(await s.snapshot(5));
|
||||
// 保留 60 秒(含边界)→ 13 个点;第一次读数没有速率,不进趋势
|
||||
expect(snap.samples).toHaveLength(13);
|
||||
expect(snap.samples.every((x) => x.cpuPercent === 50)).toBe(true);
|
||||
expect(snap.cpu).toMatchObject({ scope: "cgroup", percent: 50, cores: 1 });
|
||||
expect(snap.intervalSec).toBe(5);
|
||||
s.stop();
|
||||
|
||||
// 长窗口:按桶取平均,点数不超过 360
|
||||
const many = Array.from({ length: 4000 }, (_, i) => ({
|
||||
at: new Date(Date.parse("2026-10-06T00:00:00Z") + i * 5000).toISOString(),
|
||||
cpuPercent: i % 2 ? 40 : 60,
|
||||
memoryUsedBytes: 100,
|
||||
memoryPercent: 10,
|
||||
netRxBps: null,
|
||||
netTxBps: null,
|
||||
diskReadBps: null,
|
||||
diskWriteBps: null,
|
||||
}));
|
||||
const b = bucketSamples(many, 5, 360);
|
||||
expect(b.bucketSec).toBe(60);
|
||||
expect(b.points.length).toBeLessThanOrEqual(360);
|
||||
expect(b.points[0]).toMatchObject({ cpuPercent: 50, memoryUsedBytes: 100, netRxBps: null });
|
||||
});
|
||||
|
||||
it("刚启动没有趋势时,快照现采两次给出当前值", async () => {
|
||||
let usage = 0;
|
||||
const read: ReadText = (path) => {
|
||||
if (path === "/sys/fs/cgroup/cpu.stat") {
|
||||
usage += 100_000;
|
||||
return Promise.resolve(`usage_usec ${usage}\n`);
|
||||
}
|
||||
return Promise.resolve(null);
|
||||
};
|
||||
const s = createResourceSampler({ read, autoStart: false });
|
||||
const snap = await s.snapshot(60);
|
||||
expect(snap.cpu.scope).toBe("cgroup");
|
||||
expect(snap.cpu.percent).not.toBeNull();
|
||||
expect(snap.samples.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("进程 CPU:首次为 null,之后为非负数", () => {
|
||||
const m = createProcessCpuMeter();
|
||||
expect(m()).toBeNull();
|
||||
let x = 0;
|
||||
for (let i = 0; i < 1e5; i++) x += Math.sqrt(i);
|
||||
expect(x).toBeGreaterThan(0);
|
||||
const v = m();
|
||||
expect(v).not.toBeNull();
|
||||
expect(v!).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { DiskUsage } from "@umt/adapters";
|
||||
import { SystemHealth, SystemHealthHistory } from "@umt/contracts";
|
||||
import { SystemHealth, SystemHealthHistory, SystemResources } from "@umt/contracts";
|
||||
import { sql } from "@umt/db";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { inferTriggeredBy, triggeredByFromActor } from "../../src/modules/auth-settings/engine-business.ts";
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
writeHeartbeat,
|
||||
} from "../../src/modules/system/heartbeat.ts";
|
||||
import { createSystemModule } from "../../src/modules/system/index.ts";
|
||||
import { createResourceSampler } from "../../src/modules/system/resources.ts";
|
||||
import type { ServerContext } from "../../src/modules/types.ts";
|
||||
import { bearer, createHarness, type Harness, pgAvailable } from "../auth-settings/harness.ts";
|
||||
import { DEV_KEY, json } from "../helpers.ts";
|
||||
@@ -217,6 +218,16 @@ describe.skipIf(!hasPg)("W9-I 进程与健康(真实 PG)", () => {
|
||||
engineSchema: h.engineSchema,
|
||||
apiHeartbeat: false,
|
||||
healthDeps: { dataDir: dir, diskUsage: usage, ioTimeoutMs: 200 },
|
||||
// 资源采样:只给 meminfo 与 cgroup cpu.stat,其余读不到 → 降级为 null
|
||||
resourceSampler: createResourceSampler({
|
||||
autoStart: false,
|
||||
read: (path) => {
|
||||
if (path === "/proc/meminfo") return Promise.resolve("MemTotal: 1000 kB\nMemAvailable: 250 kB\n");
|
||||
if (path === "/sys/fs/cgroup/cpu.stat")
|
||||
return Promise.resolve(`usage_usec ${Math.round(performance.now() * 500)}\n`);
|
||||
return Promise.resolve(null);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
],
|
||||
ctx: h.ctx,
|
||||
@@ -295,6 +306,43 @@ describe.skipIf(!hasPg)("W9-I 进程与健康(真实 PG)", () => {
|
||||
expect(d.sse.connections).toBe(0);
|
||||
});
|
||||
|
||||
it("资源监控:GET /system/resources 给当前值与趋势,读不到的字段为 null", async () => {
|
||||
const res = await admin("GET", "/system/resources?minutes=30");
|
||||
expect(res.status).toBe(200);
|
||||
const d = SystemResources.parse((await json(res)).data);
|
||||
expect(d.memory).toMatchObject({ scope: "host", usedBytes: 750 * 1024, percent: 75 });
|
||||
expect(d.cpu.scope).toBe("cgroup");
|
||||
expect(d.cpu.percent).not.toBeNull();
|
||||
expect(d.network).toMatchObject({ scope: "none", rxBps: null });
|
||||
expect(d.diskIo.scope).toBe("none");
|
||||
expect(d.samples.length).toBeGreaterThanOrEqual(1);
|
||||
expect((await admin("GET", "/system/resources?minutes=1")).status).toBe(422);
|
||||
});
|
||||
|
||||
it("心跳带上本进程 CPU 占用", async () => {
|
||||
const w = startHeartbeatWriter({
|
||||
db: h.db,
|
||||
logger: h.ctx.logger,
|
||||
kind: "worker:agent",
|
||||
host: "cpu-host",
|
||||
pid: 7,
|
||||
intervalMs: 60_000,
|
||||
lag: { take: () => ({ lastMs: 0, windowMaxMs: 0, lifetimeMaxMs: 0 }), stop() {} },
|
||||
});
|
||||
await w.beat();
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
await w.beat();
|
||||
const row = await h.db
|
||||
.selectFrom("process_heartbeat")
|
||||
.select("extra")
|
||||
.where("instance_id", "=", w.instanceId)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(row.extra).toMatchObject({ cpuPercent: expect.any(Number), rssBytes: expect.any(Number) });
|
||||
await w.stop();
|
||||
await sql`delete from process_heartbeat where instance_id = ${w.instanceId}`.execute(h.db);
|
||||
await sql`delete from process_heartbeat_sample where instance_id = ${w.instanceId}`.execute(h.db);
|
||||
});
|
||||
|
||||
it("health 各段形状:DB / 磁盘阈值 / 挂载根哨兵 / 引擎 / 定时任务", async () => {
|
||||
await sql`insert into library_root (name, local_path, sentinel_file) values
|
||||
('电影', ${libOk}, '.umt-root'), ('剧集(掉线)', ${join(dir, "missing")}, null)`.execute(h.db);
|
||||
|
||||
@@ -22,6 +22,7 @@ const UNIT = [
|
||||
"test/modules.test.ts",
|
||||
"test/sse.test.ts",
|
||||
"test/static.test.ts",
|
||||
"test/system-resources.test.ts",
|
||||
];
|
||||
|
||||
const SERIAL = [
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { LOG_STREAM_EVENT, type LogEntry, type LogPage } from "@umt/contracts";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { apiData, buildUrl } from "~/lib/api.ts";
|
||||
import { qk } from "~/lib/query-keys.ts";
|
||||
|
||||
// 日志模块(界面第二轮 V 线):GET /logs 列表(游标"加载更多")与 GET /logs/stream(SSE,同一组筛选)。
|
||||
|
||||
export type LogParams = {
|
||||
type?: string;
|
||||
level?: string;
|
||||
from?: string;
|
||||
q?: string;
|
||||
objectType?: string;
|
||||
objectId?: string;
|
||||
system?: string;
|
||||
};
|
||||
|
||||
export function useLogs(params: LogParams) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: qk.list("logs", params),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
queryFn: ({ pageParam, signal }) =>
|
||||
apiData<LogPage>("/logs", { params: { ...params, cursor: pageParam, limit: 50 }, signal }),
|
||||
getNextPageParam: (last) => last.nextCursor ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export type LogStreamStatus = "off" | "connecting" | "open" | "reconnecting";
|
||||
|
||||
type EventSourceLike = Pick<EventSource, "addEventListener" | "close"> & { onerror: ((ev: Event) => unknown) | null };
|
||||
export type EventSourceFactory = (url: string) => EventSourceLike;
|
||||
|
||||
/**
|
||||
* 日志流连接(不依赖 React,便于测试):start 连上 /logs/stream(since = 已知最新一条的时间),
|
||||
* 收到的条目交给 onEntry;stop 断开(暂停)。再 start 时从收到过的最新时间续上,服务端先补推这段时间的日志。
|
||||
* 出错按指数退避重连。
|
||||
*/
|
||||
export class LogStreamClient {
|
||||
private es: EventSourceLike | null = null;
|
||||
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||
private retry = 0;
|
||||
private running = false;
|
||||
since: string | null;
|
||||
|
||||
constructor(
|
||||
private readonly params: LogParams,
|
||||
private readonly handlers: { onEntry: (e: LogEntry) => void; onStatus: (s: LogStreamStatus) => void },
|
||||
since: string | null | undefined,
|
||||
private readonly factory: EventSourceFactory = (url) => new EventSource(url, { withCredentials: true }),
|
||||
) {
|
||||
this.since = since ?? null;
|
||||
}
|
||||
|
||||
/** 列表加载出更新的条目时推进起点 */
|
||||
advance(at: string | null | undefined) {
|
||||
this.since = latest(this.since, at) ?? null;
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.running) return;
|
||||
this.running = true;
|
||||
this.connect();
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.running = false;
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
this.es?.close();
|
||||
this.es = null;
|
||||
this.handlers.onStatus("off");
|
||||
}
|
||||
|
||||
private connect() {
|
||||
this.handlers.onStatus(this.retry ? "reconnecting" : "connecting");
|
||||
const es = this.factory(buildUrl("/logs/stream", { params: { ...this.params, since: this.since ?? undefined } }));
|
||||
this.es = es;
|
||||
es.addEventListener("ready", () => {
|
||||
this.retry = 0;
|
||||
this.handlers.onStatus("open");
|
||||
});
|
||||
es.addEventListener(LOG_STREAM_EVENT, (ev) => {
|
||||
let e: LogEntry;
|
||||
try {
|
||||
e = JSON.parse((ev as MessageEvent<string>).data) as LogEntry;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
this.advance(e.at);
|
||||
this.handlers.onEntry(e);
|
||||
});
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
if (this.es !== es) return;
|
||||
this.es = null;
|
||||
if (!this.running) return;
|
||||
this.retry += 1;
|
||||
this.handlers.onStatus("reconnecting");
|
||||
this.timer = setTimeout(() => this.connect(), Math.min(30_000, 1000 * 2 ** this.retry));
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 日志流 hook:enabled 时连,收到的条目按 id 覆盖、新的在前;enabled=false 即暂停。筛选变化时清空重连。
|
||||
*/
|
||||
export function useLogStream(params: LogParams, opts: { enabled: boolean; since?: string | null }) {
|
||||
const [entries, setEntries] = useState<LogEntry[]>([]);
|
||||
const [status, setStatus] = useState<LogStreamStatus>("off");
|
||||
const key = JSON.stringify(params);
|
||||
const client = useRef<LogStreamClient | null>(null);
|
||||
const sinceRef = useRef(opts.since);
|
||||
sinceRef.current = opts.since;
|
||||
client.current?.advance(opts.since);
|
||||
|
||||
// 筛选变了:换一个连接,之前推来的不再适用
|
||||
useEffect(() => {
|
||||
setEntries([]);
|
||||
const c = new LogStreamClient(
|
||||
JSON.parse(key) as LogParams,
|
||||
{ onEntry: (e) => setEntries((prev) => upsertEntries(prev, [e])), onStatus: setStatus },
|
||||
sinceRef.current,
|
||||
);
|
||||
client.current = c;
|
||||
return () => {
|
||||
c.stop();
|
||||
if (client.current === c) client.current = null;
|
||||
};
|
||||
}, [key]);
|
||||
|
||||
// key:筛选变化后新建的连接也要按当前开关启动
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: 见上
|
||||
useEffect(() => {
|
||||
const c = client.current;
|
||||
if (!c || typeof EventSource === "undefined") return;
|
||||
if (opts.enabled) {
|
||||
c.advance(sinceRef.current);
|
||||
c.start();
|
||||
} else c.stop();
|
||||
}, [opts.enabled, key]);
|
||||
|
||||
return { entries, status, clear: () => setEntries([]) };
|
||||
}
|
||||
|
||||
function latest(a: string | null | undefined, b: string | null | undefined): string | null | undefined {
|
||||
if (!a) return b;
|
||||
if (!b) return a;
|
||||
return Date.parse(b) > Date.parse(a) ? b : a;
|
||||
}
|
||||
|
||||
/** 按 id 覆盖合并,时间倒序(同一时间按 id 倒序,与服务端一致) */
|
||||
export function upsertEntries(base: LogEntry[], incoming: LogEntry[]): LogEntry[] {
|
||||
const map = new Map(base.map((e) => [e.id, e]));
|
||||
for (const e of incoming) map.set(e.id, e);
|
||||
return [...map.values()].sort((a, b) => {
|
||||
const d = Date.parse(b.at) - Date.parse(a.at);
|
||||
return d !== 0 ? d : a.id < b.id ? 1 : a.id > b.id ? -1 : 0;
|
||||
});
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
StorageCleanupTarget,
|
||||
SystemHealth,
|
||||
SystemHealthHistory,
|
||||
SystemResources,
|
||||
SystemStorage,
|
||||
} from "@umt/contracts";
|
||||
import { apiData } from "~/lib/api.ts";
|
||||
@@ -50,3 +51,12 @@ export function useStorageCleanup() {
|
||||
onSuccess: () => void qc.invalidateQueries({ queryKey: qk.sub("system", "storage") }),
|
||||
});
|
||||
}
|
||||
|
||||
/** 资源监控:CPU / 内存 / 网络 / 磁盘读写的当前值与最近 minutes 分钟趋势(服务端每 5 秒采样) */
|
||||
export function useSystemResources(minutes = 60, refetchMs = 5_000) {
|
||||
return useQuery({
|
||||
queryKey: qk.sub("system", "resources", minutes),
|
||||
queryFn: ({ signal }) => apiData<SystemResources>("/system/resources", { params: { minutes }, signal }),
|
||||
refetchInterval: refetchMs,
|
||||
});
|
||||
}
|
||||
@@ -24,24 +24,32 @@ const matchesRoute = (pathname: string) => {
|
||||
};
|
||||
|
||||
describe("控制台导航", () => {
|
||||
it("IA-2:4 组,任务归系统,站点 / 订阅 / 规则合为「获取」,没有独立 RSS 项", () => {
|
||||
it("IA-2:4 组,站点 / 订阅 / 规则合为「获取」,没有独立 RSS 项;系统组只有日志、运行状态、设置", () => {
|
||||
expect(NAV_GROUPS.map((g) => g.title)).toEqual(["概览", ACQUIRE_GROUP_TITLE, "整理", "系统"]);
|
||||
expect(ACQUIRE_GROUP_TITLE).toBe("获取");
|
||||
expect(NAV_GROUPS.map((g) => g.items.map((i) => i.label))).toEqual([
|
||||
["首页", "计划", "智能体会话"],
|
||||
["首页", "智能体会话"],
|
||||
["站点", "发现", "订阅", "规则"],
|
||||
["下载面板", "整理记录", "文件管理", "媒体库"],
|
||||
["任务", "设置", "审计", "运行状态"],
|
||||
["日志", "运行状态", "设置"],
|
||||
]);
|
||||
expect(NAV_ITEMS).toHaveLength(15);
|
||||
expect(NAV_ITEMS).toHaveLength(13);
|
||||
expect(NAV_ITEMS.map((i) => i.to)).not.toContain("/console/sources");
|
||||
expect(new Set(NAV_ITEMS.map((i) => i.to)).size).toBe(NAV_ITEMS.length);
|
||||
expect(NAV_ITEMS.every((i) => i.to.startsWith("/console"))).toBe(true);
|
||||
});
|
||||
|
||||
it("取消的入口不在侧栏:没有待处理(问题回到发生处),引擎面板 / 身份并入别处", () => {
|
||||
it("取消的入口不在侧栏:没有待处理(问题回到发生处),引擎面板 / 身份 / 任务 / 审计 / 计划并入别处", () => {
|
||||
const paths = NAV_ITEMS.map((i) => i.to);
|
||||
for (const p of ["/console/inbox", "/console/engine", "/console/identity"]) expect(paths).not.toContain(p);
|
||||
for (const p of [
|
||||
"/console/inbox",
|
||||
"/console/engine",
|
||||
"/console/identity",
|
||||
"/console/tasks",
|
||||
"/console/audit",
|
||||
"/console/schedules",
|
||||
])
|
||||
expect(paths).not.toContain(p);
|
||||
expect(NAV_ITEMS.map((i) => i.label)).not.toContain("待处理");
|
||||
expect(pageTitle("/console/inbox")).toBeUndefined();
|
||||
});
|
||||
@@ -61,8 +69,10 @@ describe("控制台导航", () => {
|
||||
expect(findNavItem("/console")?.label).toBe("首页");
|
||||
expect(findNavItem("/console/")?.label).toBe("首页");
|
||||
expect(findNavItem("/console/library/abc")?.label).toBe("媒体库");
|
||||
expect(findNavItem("/console/tasks/r1")?.label).toBe("任务");
|
||||
expect(findNavItem("/console/schedules")?.label).toBe("计划");
|
||||
// 任务详情独立页归日志
|
||||
expect(findNavItem("/console/tasks/r1")?.label).toBe("日志");
|
||||
expect(pageTitle("/console/tasks/r1")).toBe("日志");
|
||||
expect(findNavItem("/console/logs")?.label).toBe("日志");
|
||||
expect(findNavItem("/console/downloads")?.label).toBe("下载面板");
|
||||
expect(findNavItem("/console/downloads/plans")?.label).toBe("下载面板");
|
||||
expect(findNavItem("/console/downloads/abc")?.label).toBe("下载面板");
|
||||
@@ -97,10 +107,14 @@ describe("旧链接重定向", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("任务 / 审计 / 计划的旧地址一律落到日志页;任务详情独立页不跳", () => {
|
||||
expect(legacyRedirect("/console/tasks", "?kind=submitDownload")).toBe("/console/logs?type=task");
|
||||
expect(legacyRedirect("/console/tasks", "?tab=failed")).toBe("/console/logs?type=task&level=error");
|
||||
expect(legacyRedirect("/console/tasks/r1", "")).toBeNull();
|
||||
expect(legacyRedirect("/console/logs", "?type=task")).toBeNull();
|
||||
});
|
||||
|
||||
it("不相关的 query 不跳转", () => {
|
||||
expect(legacyRedirect("/console/tasks", "?kind=submitDownload")).toBeNull();
|
||||
expect(legacyRedirect("/console/schedules", "?job=trash.purge")).toBeNull();
|
||||
expect(legacyRedirect("/console/tasks", "?tab=failed")).toBeNull();
|
||||
expect(legacyRedirect("/console/library", "?filter=unidentified")).toBeNull();
|
||||
expect(legacyRedirect("/console/subscriptions", "")).toBeNull();
|
||||
// 旧 RSS 页的 ?feed= 由 sources.tsx 查所属站点后跳转
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
Bot,
|
||||
CalendarClock,
|
||||
CalendarCog,
|
||||
Compass,
|
||||
Download,
|
||||
FileStack,
|
||||
@@ -11,7 +10,6 @@ import {
|
||||
House,
|
||||
Library,
|
||||
ListChecks,
|
||||
ListTodo,
|
||||
type LucideIcon,
|
||||
ScrollText,
|
||||
Settings,
|
||||
@@ -40,9 +38,10 @@ export function consolePath(...segments: (string | number)[]): string {
|
||||
export const ACQUIRE_GROUP_TITLE = "获取";
|
||||
|
||||
/**
|
||||
* 侧栏 4 组 14 项(IA-2,docs/design/console-ia.md):概览 → 获取 → 整理 → 系统。
|
||||
* 任务归系统;RSS 属于站点(站点详情的 RSS Tab),不再单独占一项。
|
||||
* 侧栏 4 组 13 项(IA-2,docs/design/console-ia.md):概览 → 获取 → 整理 → 系统。
|
||||
* RSS 属于站点(站点详情的 RSS Tab),不再单独占一项。
|
||||
* 没有「待处理」(2026-10-06 取消):问题在发生处处理,铃铛只放等你批准的智能体操作与系统健康;移动端抽屉用同一份表。
|
||||
* 系统组只有三项(界面第二轮):日志(任务、定时与自动、登录、审计合一,定时任务设置是其中一个页签)、运行状态、设置。
|
||||
*/
|
||||
export const NAV_GROUPS: NavGroup[] = [
|
||||
{
|
||||
@@ -54,12 +53,6 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
icon: House,
|
||||
description: "在忙什么、要我处理什么、最近入库了什么",
|
||||
},
|
||||
{
|
||||
to: "/console/schedules",
|
||||
label: "计划",
|
||||
icon: CalendarCog,
|
||||
description: "智能体巡检与自动任务的开关和间隔",
|
||||
},
|
||||
{
|
||||
to: "/console/agent",
|
||||
label: "智能体会话",
|
||||
@@ -110,10 +103,16 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
title: "系统",
|
||||
items: [
|
||||
{
|
||||
to: "/console/tasks",
|
||||
label: "任务",
|
||||
icon: ListTodo,
|
||||
description: "最近处理了什么、有没有失败",
|
||||
to: "/console/logs",
|
||||
label: "日志",
|
||||
icon: ScrollText,
|
||||
description: "任务、定时与自动任务、登录与操作留痕,可实时查看",
|
||||
},
|
||||
{
|
||||
to: "/console/status",
|
||||
label: "运行状态",
|
||||
icon: Gauge,
|
||||
description: "服务是否正常、CPU 与内存、磁盘与数据库",
|
||||
},
|
||||
{
|
||||
to: "/console/settings",
|
||||
@@ -121,22 +120,15 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
icon: Settings,
|
||||
description: "下载器、媒体服务器、媒体库目录、站点账号、模型服务与自动化",
|
||||
},
|
||||
{
|
||||
to: "/console/audit",
|
||||
label: "审计",
|
||||
icon: ScrollText,
|
||||
description: "登录、设置、密钥、任务操作的留痕",
|
||||
},
|
||||
{
|
||||
to: "/console/status",
|
||||
label: "运行状态",
|
||||
icon: Gauge,
|
||||
description: "服务是否正常、磁盘与数据库",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** 并进别的导航项的旧页面:按前缀归到对应项(任务详情独立页属于日志) */
|
||||
const NAV_ALIASES: Record<string, string> = {
|
||||
"/console/tasks": "/console/logs",
|
||||
};
|
||||
|
||||
/** 不在侧栏但有页面的路由:顶栏标题用 */
|
||||
const EXTRA_TITLES: Record<string, string> = {
|
||||
"/console/preferences": "偏好标签",
|
||||
@@ -162,6 +154,8 @@ export function settingsPath(section: SettingsSection, anchor?: string): string
|
||||
|
||||
/** 按路径找当前导航项(最长前缀匹配) */
|
||||
export function findNavItem(pathname: string): NavItem | undefined {
|
||||
const alias = Object.entries(NAV_ALIASES).find(([from]) => pathname === from || pathname.startsWith(`${from}/`));
|
||||
if (alias) return NAV_ITEMS.find((i) => i.to === alias[1]);
|
||||
return (
|
||||
NAV_ITEMS.find((i) => i.to === pathname.replace(/\/+$/, "")) ??
|
||||
NAV_ITEMS.filter((i) => i.to !== CONSOLE_BASE && pathname.startsWith(`${i.to}/`)).sort(
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { Dashboard, SystemHealth, SystemResources } from "@umt/contracts";
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { qk } from "~/lib/query-keys.ts";
|
||||
import { HomeRuntime } from "./home-runtime.tsx";
|
||||
|
||||
// 首页运行状态简版卡:整体健康、CPU、内存、磁盘,以及下载器 / 站点 / 媒体库目录摘要,点进去是运行状态页。
|
||||
|
||||
const health = (over: Partial<SystemHealth> = {}): SystemHealth =>
|
||||
({
|
||||
level: "warn",
|
||||
checkedAt: new Date().toISOString(),
|
||||
api: { version: "t", startedAt: new Date().toISOString(), uptimeSec: 10 },
|
||||
processes: [],
|
||||
retiredProcesses: [],
|
||||
sse: { connections: 0 },
|
||||
db: { level: "ok", latencyMs: 1, sizeBytes: 1, connections: null, error: null },
|
||||
disks: [
|
||||
{
|
||||
scope: "library_root",
|
||||
id: "r1",
|
||||
name: "电影",
|
||||
path: "/media/movies",
|
||||
level: "warn",
|
||||
totalBytes: 1000,
|
||||
usedBytes: 870,
|
||||
availableBytes: 130,
|
||||
usedRatio: 0.87,
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
diskThresholds: { warnPercent: 85, criticalPercent: 90 },
|
||||
mounts: [
|
||||
{
|
||||
kind: "library_root",
|
||||
id: "r1",
|
||||
name: "电影",
|
||||
path: "/m",
|
||||
status: "online",
|
||||
reason: null,
|
||||
sentinelChecked: true,
|
||||
},
|
||||
{
|
||||
kind: "library_root",
|
||||
id: "r2",
|
||||
name: "剧集",
|
||||
path: "/t",
|
||||
status: "offline",
|
||||
reason: "root_missing",
|
||||
sentinelChecked: true,
|
||||
},
|
||||
],
|
||||
engine: {
|
||||
level: "ok",
|
||||
pending: 0,
|
||||
running: 0,
|
||||
oldestPendingAgeSec: null,
|
||||
finished24h: 0,
|
||||
failed24h: 0,
|
||||
failureRate24h: null,
|
||||
},
|
||||
schedules: { level: "ok", total: 0, enabled: 0, running: 0, failing: [], failedRuns24h: 0 },
|
||||
storage: { level: "ok", alerts: [] },
|
||||
...over,
|
||||
}) as SystemHealth;
|
||||
|
||||
const resources: SystemResources = {
|
||||
at: new Date().toISOString(),
|
||||
intervalSec: 5,
|
||||
bucketSec: 5,
|
||||
cpu: { scope: "cgroup", percent: 42.4, cores: 4, hostPercent: 30, load: [1, 1, 1] },
|
||||
memory: {
|
||||
scope: "cgroup",
|
||||
usedBytes: 900,
|
||||
limitBytes: 1000,
|
||||
percent: 92,
|
||||
hostTotalBytes: 2000,
|
||||
hostAvailableBytes: 800,
|
||||
swapUsedBytes: 0,
|
||||
},
|
||||
network: { scope: "cgroup", rxBps: 1, txBps: 1, rxTotalBytes: 1, txTotalBytes: 1 },
|
||||
diskIo: { scope: "cgroup", readBps: 1, writeBps: 1 },
|
||||
hostUptimeSec: 100,
|
||||
samples: [],
|
||||
};
|
||||
|
||||
function render(h: SystemHealth, downloaders: Dashboard["sources"]["downloaders"]) {
|
||||
const client = new QueryClient();
|
||||
client.setQueryData(qk.sub("system", "health"), h);
|
||||
client.setQueryData(qk.sub("system", "resources", 30), resources);
|
||||
client.setQueryData([...qk.dashboard, "sites-accounts"], {
|
||||
items: [
|
||||
{ id: "s1", enabled: true, accountStatus: "ok" },
|
||||
{ id: "s2", enabled: true, accountStatus: "expired" },
|
||||
{ id: "s3", enabled: false, accountStatus: "expired" },
|
||||
],
|
||||
nextCursor: null,
|
||||
});
|
||||
return renderToStaticMarkup(
|
||||
createElement(
|
||||
QueryClientProvider,
|
||||
{ client },
|
||||
createElement(MemoryRouter, null, createElement(HomeRuntime, { downloaders })),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
describe("首页运行状态卡", () => {
|
||||
it("整体健康、CPU / 内存、磁盘、下载器 / 站点 / 媒体库目录摘要,链到运行状态页", () => {
|
||||
const html = render(health(), [
|
||||
{ id: "d1", name: "qb", enabled: true, health: "healthy", lastSeenAt: null },
|
||||
{ id: "d2", name: "qb2", enabled: true, health: "offline", lastSeenAt: null },
|
||||
] as Dashboard["sources"]["downloaders"]);
|
||||
expect(html).toContain("运行状态");
|
||||
expect(html).toContain('href="/console/status"');
|
||||
expect(html).toContain("需要关注");
|
||||
expect(html).toContain("42%");
|
||||
// 内存 ≥ 90% 标红
|
||||
expect(html).toMatch(/text-destructive[^"]*">92%/);
|
||||
expect(html).toContain("电影");
|
||||
expect(html).toContain("87%");
|
||||
// 摘要:正常数 / 总数(停用的不算)
|
||||
expect(html).toMatch(/下载器<span[^>]*>1\/2/);
|
||||
expect(html).toMatch(/站点<span[^>]*>1\/2/);
|
||||
expect(html).toMatch(/媒体库目录<span[^>]*>1\/2/);
|
||||
});
|
||||
|
||||
it("全部正常时写「全部正常」,没有配置的项不列", () => {
|
||||
const html = render(health({ level: "ok", mounts: [] }), []);
|
||||
expect(html).toContain("全部正常");
|
||||
expect(html).not.toContain("媒体库目录<");
|
||||
expect(html).not.toContain("下载器<span");
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,15 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { type Dashboard, DownloadBoard, type HealthLevel } from "@umt/contracts";
|
||||
import { ArrowRight, Download, HardDrive, type LucideIcon } from "lucide-react";
|
||||
import { ArrowRight, Download, Gauge, type LucideIcon } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { useSitesWithAccounts } from "~/api/dashboard.ts";
|
||||
import { apiParsed } from "~/api/parse.ts";
|
||||
import { useSystemHealth } from "~/api/system.ts";
|
||||
import { useSystemHealth, useSystemResources } from "~/api/system.ts";
|
||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { consolePath } from "~/components/console/nav.ts";
|
||||
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
|
||||
import { Sparkline, usageTone } from "~/components/status/resource-section.tsx";
|
||||
import { formatRelative } from "~/lib/labels.ts";
|
||||
import { cn, formatBytes } from "~/lib/utils.ts";
|
||||
|
||||
@@ -48,74 +50,129 @@ function OverviewCard({
|
||||
);
|
||||
}
|
||||
|
||||
function StorageOverview() {
|
||||
const q = useSystemHealth(60_000);
|
||||
const mediaDisks = q.data?.disks.filter((d) => d.scope !== "data_dir") ?? [];
|
||||
const disks = mediaDisks.length ? mediaDisks : (q.data?.disks ?? []);
|
||||
const BAD_ACCOUNT = ["invalid", "expired", "challenge"];
|
||||
|
||||
/** 一行「名称 正常数 / 总数」;有异常时数值标黄 */
|
||||
function SummaryItem({ label, ok, total, to }: { label: string; ok: number; total: number; to: string }) {
|
||||
if (!total) return null;
|
||||
return (
|
||||
<OverviewCard title="存储情况" icon={HardDrive} to={consolePath("status")}>
|
||||
<Link to={to} className="inline-flex items-center gap-1 hover:text-foreground">
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn("inline-block size-1.5 rounded-full", ok < total ? "bg-warning" : "bg-success")}
|
||||
/>
|
||||
{label}
|
||||
<span className={cn("tabular-nums", ok < total ? "text-warning" : "text-foreground")}>
|
||||
{ok}/{total}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行状态简版(界面第二轮,参考 MP 仪表盘):整体健康、CPU、内存、磁盘,以及下载器 / 站点 / 媒体库目录在线摘要。
|
||||
* 详细版在系统 → 运行状态。
|
||||
*/
|
||||
function RuntimeOverview({ downloaders }: { downloaders?: Dashboard["sources"]["downloaders"] }) {
|
||||
const q = useSystemHealth(60_000);
|
||||
const r = useSystemResources(30, 10_000);
|
||||
const sites = useSitesWithAccounts();
|
||||
const h = q.data;
|
||||
const res = r.data;
|
||||
const mediaDisks = h?.disks.filter((d) => d.scope !== "data_dir") ?? [];
|
||||
const disks = (mediaDisks.length ? mediaDisks : (h?.disks ?? []))
|
||||
.filter((d) => d.usedRatio !== null)
|
||||
.sort((a, b) => (b.usedRatio ?? 0) - (a.usedRatio ?? 0))
|
||||
.slice(0, 3);
|
||||
const enabledDl = (downloaders ?? []).filter((d) => d.enabled);
|
||||
const okDl = enabledDl.filter((d) => !["offline", "failing"].includes(d.health)).length;
|
||||
const siteRows = (sites.data?.items ?? []).filter((s) => s.enabled);
|
||||
const okSites = siteRows.filter((s) => !(s.accountStatus && BAD_ACCOUNT.includes(s.accountStatus))).length;
|
||||
const roots = h?.mounts.filter((m) => m.kind === "library_root") ?? [];
|
||||
const okRoots = roots.filter((m) => m.status === "online").length;
|
||||
const level = h ? LEVEL[h.level === "unknown" ? "unknown" : h.level] : null;
|
||||
return (
|
||||
<OverviewCard title="运行状态" icon={Gauge} to={consolePath("status")}>
|
||||
{q.isPending ? (
|
||||
<LoadingState rows={2} />
|
||||
<LoadingState rows={3} />
|
||||
) : q.isError ? (
|
||||
<ErrorState plain title="存储状态加载失败" onRetry={() => q.refetch()} className="py-6" />
|
||||
) : !disks.length ? (
|
||||
<EmptyState plain icon={HardDrive} title="暂无存储状态" className="py-6" />
|
||||
) : (
|
||||
<ul className="space-y-4">
|
||||
{disks.map((d) => {
|
||||
const pct = d.usedRatio === null ? null : d.usedRatio * 100;
|
||||
const state = LEVEL[d.level];
|
||||
return (
|
||||
<li key={`${d.scope}-${d.id ?? d.path}`} className="min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="min-w-0 truncate text-sm font-medium" title={d.name}>
|
||||
{d.name !== SCOPE[d.scope] ? (
|
||||
<span className="mr-1.5 text-meta font-normal text-muted-foreground">{SCOPE[d.scope]}</span>
|
||||
) : null}
|
||||
{d.name}
|
||||
</p>
|
||||
<StatusBadge tone={state.tone}>
|
||||
{state.label}
|
||||
{pct === null ? "" : ` · ${Math.round(pct)}%`}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
{pct !== null ? (
|
||||
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-foreground/[0.08]" aria-hidden>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full",
|
||||
d.level === "critical"
|
||||
? "bg-destructive"
|
||||
: d.level === "warn"
|
||||
? "bg-warning"
|
||||
: "bg-foreground/60",
|
||||
)}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<p className="mt-1.5 flex flex-wrap gap-x-3 gap-y-1 text-meta text-muted-foreground tabular-nums">
|
||||
<span>
|
||||
剩余{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{d.availableBytes === null ? "未知" : formatBytes(d.availableBytes, 1)}
|
||||
</span>
|
||||
<ErrorState plain title="运行状态加载失败" onRetry={() => q.refetch()} className="py-6" />
|
||||
) : h ? (
|
||||
<>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
{level ? <StatusBadge tone={level.tone}>{h.level === "ok" ? "全部正常" : level.label}</StatusBadge> : null}
|
||||
<span className="text-meta text-muted-foreground">检查于 {formatRelative(h.checkedAt)}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{[
|
||||
{ label: "CPU", v: res?.cpu.percent, key: "cpuPercent" as const },
|
||||
{ label: "内存", v: res?.memory.percent, key: "memoryPercent" as const },
|
||||
].map((x) => (
|
||||
<div key={x.label} className="min-w-0 rounded-lg border border-border/70 px-3 py-2">
|
||||
<p className="flex items-center justify-between text-meta text-muted-foreground">
|
||||
{x.label}
|
||||
<span className={cn("text-body font-semibold tabular-nums", usageTone(x.v))}>
|
||||
{x.v == null ? "—" : `${Math.round(x.v)}%`}
|
||||
</span>
|
||||
<span>已用 {d.usedBytes === null ? "未知" : formatBytes(d.usedBytes, 1)}</span>
|
||||
<span>总量 {d.totalBytes === null ? "未知" : formatBytes(d.totalBytes, 1)}</span>
|
||||
</p>
|
||||
<p className="mt-1 truncate text-meta text-muted-foreground" title={d.path}>
|
||||
{d.path}
|
||||
</p>
|
||||
{d.error ? <p className="mt-1 text-meta text-warning">无法读取磁盘用量</p> : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{res ? (
|
||||
<Sparkline
|
||||
samples={res.samples}
|
||||
keys={[{ key: x.key, name: x.label }]}
|
||||
format={(v) => `${v.toFixed(1)}%`}
|
||||
max={100}
|
||||
height={32}
|
||||
label={`${x.label}占用趋势(最近 30 分钟)`}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-8" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{disks.length ? (
|
||||
<ul className="mt-3 space-y-2">
|
||||
{disks.map((d) => {
|
||||
const pct = (d.usedRatio ?? 0) * 100;
|
||||
return (
|
||||
<li key={`${d.scope}-${d.id ?? d.path}`} className="min-w-0">
|
||||
<div className="flex items-center justify-between gap-2 text-meta">
|
||||
<span className="min-w-0 truncate" title={d.path}>
|
||||
<span className="mr-1 text-muted-foreground">{SCOPE[d.scope]}</span>
|
||||
{d.name}
|
||||
</span>
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground">
|
||||
{Math.round(pct)}% · 剩余{" "}
|
||||
{d.availableBytes === null ? "未知" : formatBytes(d.availableBytes, 1)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 h-1.5 overflow-hidden rounded-full bg-foreground/[0.08]" aria-hidden>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full",
|
||||
d.level === "critical"
|
||||
? "bg-destructive"
|
||||
: d.level === "warn"
|
||||
? "bg-warning"
|
||||
: "bg-foreground/60",
|
||||
)}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : null}
|
||||
<p className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-meta text-muted-foreground">
|
||||
<SummaryItem label="下载器" ok={okDl} total={enabledDl.length} to={consolePath("downloads")} />
|
||||
<SummaryItem label="站点" ok={okSites} total={siteRows.length} to={consolePath("sites")} />
|
||||
<SummaryItem label="媒体库目录" ok={okRoots} total={roots.length} to={consolePath("status")} />
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<EmptyState plain icon={Gauge} title="暂无运行状态" className="py-6" />
|
||||
)}
|
||||
{q.data && !q.isError ? (
|
||||
<p className="mt-3 text-meta text-muted-foreground">所在磁盘用量 · 检查于 {formatRelative(q.data.checkedAt)}</p>
|
||||
) : null}
|
||||
</OverviewCard>
|
||||
);
|
||||
}
|
||||
@@ -202,7 +259,7 @@ function DownloaderOverview({ downloaders }: { downloaders?: Dashboard["sources"
|
||||
export function HomeRuntime({ downloaders }: { downloaders?: Dashboard["sources"]["downloaders"] }) {
|
||||
return (
|
||||
<div className="mb-4 grid gap-4 lg:grid-cols-2">
|
||||
<StorageOverview />
|
||||
<RuntimeOverview downloaders={downloaders} />
|
||||
<DownloaderOverview downloaders={downloaders} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
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: "路径映射",
|
||||
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",
|
||||
};
|
||||
@@ -0,0 +1,312 @@
|
||||
import type { LogEntry } from "@umt/contracts";
|
||||
import { Bot, ExternalLink, History } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { useEngineRun } from "~/api/engine.ts";
|
||||
import { type AgentContext, openAgentDrawer } from "~/components/agent/agent-store.ts";
|
||||
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
|
||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { JsonView } from "~/components/console/json-view.tsx";
|
||||
import { KvList } from "~/components/console/kv-list.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { TechInfo } from "~/components/console/tech-info.tsx";
|
||||
import { runTitle } from "~/components/tasks/labels.ts";
|
||||
import { RunActions, TaskDetailBody, TaskStatusLine } from "~/components/tasks/task-detail.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { actorTypeLabel, formatDuration } from "~/lib/labels.ts";
|
||||
import { formatDateTime } from "~/lib/utils.ts";
|
||||
import {
|
||||
actorLabel,
|
||||
agentGoalFor,
|
||||
LOG_TYPE_LABEL,
|
||||
logBadge,
|
||||
logsPath,
|
||||
logTitle,
|
||||
objectLogsPath,
|
||||
objectText,
|
||||
TARGET_LABEL,
|
||||
} from "./labels.ts";
|
||||
|
||||
// 日志详情抽屉:任务复用原任务详情(业务摘要、时间线、引擎步骤、取消 / 重跑);定时任务运行、后台调查、审计各自一组字段。
|
||||
// 底部「问智能体」带上这条日志打开智能体抽屉。
|
||||
|
||||
/** 选中的日志 → 智能体上下文(单条时带上能识别的对象 id,服务端据此取数) */
|
||||
export function agentContextFor(entries: LogEntry[]): AgentContext {
|
||||
const ctx: AgentContext = {
|
||||
label: entries.length === 1 ? `日志:${logTitle(entries[0]!)}` : `日志:${entries.length} 条`,
|
||||
goal: agentGoalFor(entries),
|
||||
};
|
||||
if (entries.length === 1) {
|
||||
const e = entries[0]!;
|
||||
if (e.source === "engine_run") ctx.taskId = e.refId;
|
||||
const o = e.object;
|
||||
if (o?.id) {
|
||||
if (o.type === "download_task") ctx.downloadTaskId = o.id;
|
||||
else if (o.type === "organization_record") ctx.recordId = o.id;
|
||||
else if (o.type === "media_entity") ctx.entityId = o.id;
|
||||
else if (o.type === "site") ctx.siteId = o.id;
|
||||
}
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function AskAgentButton({ entries, size = "sm" }: { entries: LogEntry[]; size?: "sm" | "xs" }) {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size={size}
|
||||
disabled={!entries.length}
|
||||
onClick={() => openAgentDrawer(agentContextFor(entries))}
|
||||
>
|
||||
<Bot />
|
||||
问智能体
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkRow({ links }: { links: { to: string; label: string; icon?: ReactNode }[] }) {
|
||||
return (
|
||||
<ul className="flex flex-wrap gap-x-4 gap-y-1 text-caption">
|
||||
{links.map((l) => (
|
||||
<li key={l.to}>
|
||||
<Link
|
||||
to={l.to}
|
||||
className="inline-flex items-center gap-1 text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
|
||||
>
|
||||
{l.icon ?? <ExternalLink className="h-3.5 w-3.5" />}
|
||||
{l.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function ScheduleRunBody({ e }: { e: LogEntry }) {
|
||||
const x = e.extra as {
|
||||
jobId?: string;
|
||||
jobKind?: string;
|
||||
trigger?: string;
|
||||
agentRunId?: string | null;
|
||||
summary?: string | null;
|
||||
error?: string | null;
|
||||
};
|
||||
const jobId = x.jobId ?? e.object?.id ?? "";
|
||||
return (
|
||||
<>
|
||||
<DetailSection title="概况">
|
||||
<KvList
|
||||
items={[
|
||||
{ label: "定时任务", value: e.title, wide: true },
|
||||
{ label: "类型", value: x.jobKind === "agent_check" ? "智能体巡检" : "自动任务" },
|
||||
{ label: "触发", value: actorLabel(e) },
|
||||
{ label: "开始", value: formatDateTime(e.at) },
|
||||
{ label: "耗时", value: e.durationMs == null ? "—" : formatDuration(e.durationMs / 1000) },
|
||||
{ label: "摘要", value: x.summary, wide: true },
|
||||
]}
|
||||
/>
|
||||
{x.error ? <p className="mt-3 text-caption break-words text-destructive">{x.error}</p> : null}
|
||||
</DetailSection>
|
||||
<DetailSection title="相关">
|
||||
<LinkRow
|
||||
links={[
|
||||
...(jobId
|
||||
? [
|
||||
{ to: logsPath({ tab: "schedules", job: jobId }), label: "定时任务设置" },
|
||||
{
|
||||
to: objectLogsPath("scheduled_job", jobId),
|
||||
label: "这个定时任务的全部运行",
|
||||
icon: <History className="h-3.5 w-3.5" />,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(x.agentRunId ? [{ to: `/console/agent/${encodeURIComponent(x.agentRunId)}`, label: "巡检会话" }] : []),
|
||||
]}
|
||||
/>
|
||||
</DetailSection>
|
||||
<TechInfo>
|
||||
<KvList
|
||||
items={[
|
||||
{ label: "运行记录 ID", value: e.refId, mono: true },
|
||||
{ label: "定时任务 ID", value: jobId, mono: true },
|
||||
]}
|
||||
/>
|
||||
</TechInfo>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentRunBody({ e }: { e: LogEntry }) {
|
||||
const x = e.extra as { toolCalls?: number; spawnReason?: string | null; model?: string | null };
|
||||
return (
|
||||
<>
|
||||
<DetailSection title="概况">
|
||||
<KvList
|
||||
items={[
|
||||
{ label: "目标", value: <span className="whitespace-pre-wrap">{e.title}</span>, wide: true },
|
||||
{ label: "开始", value: formatDateTime(e.at) },
|
||||
{ label: "耗时", value: e.durationMs == null ? "—" : formatDuration(e.durationMs / 1000) },
|
||||
{ label: "结果", value: e.message, wide: true },
|
||||
{ label: "工具调用", value: x.toolCalls ?? 0 },
|
||||
]}
|
||||
/>
|
||||
</DetailSection>
|
||||
<DetailSection title="相关">
|
||||
<LinkRow links={[{ to: `/console/agent/${encodeURIComponent(e.refId)}`, label: "打开会话" }]} />
|
||||
</DetailSection>
|
||||
<TechInfo>
|
||||
<KvList
|
||||
items={[
|
||||
{ label: "会话 ID", value: e.refId, mono: true },
|
||||
{ label: "模型", value: x.model, mono: true },
|
||||
]}
|
||||
/>
|
||||
</TechInfo>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AuditBody({ e }: { e: LogEntry }) {
|
||||
const x = e.extra as { action?: string; traceId?: string | null; payload?: unknown };
|
||||
const o = e.object;
|
||||
return (
|
||||
<>
|
||||
<DetailSection title="事件">
|
||||
<KvList
|
||||
items={[
|
||||
{
|
||||
label: "操作者",
|
||||
value: e.actor ? `${actorTypeLabel(e.actor.type)}${e.actor.id ? ` · ${e.actor.id}` : ""}` : "—",
|
||||
wide: true,
|
||||
},
|
||||
{ label: "对象", value: o ? (TARGET_LABEL[o.type] ?? o.type) : "—" },
|
||||
{ label: "摘要", value: e.message },
|
||||
]}
|
||||
/>
|
||||
</DetailSection>
|
||||
{o?.id ? (
|
||||
<DetailSection title="相关">
|
||||
<LinkRow
|
||||
links={[
|
||||
{
|
||||
to: objectLogsPath(o.type, o.id),
|
||||
label: "同一对象的全部日志",
|
||||
icon: <History className="h-3.5 w-3.5" />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</DetailSection>
|
||||
) : null}
|
||||
<JsonView label="负载" value={x.payload} />
|
||||
<TechInfo>
|
||||
<KvList
|
||||
items={[
|
||||
{ label: "动作", value: x.action, mono: true },
|
||||
{ label: "对象类型", value: o?.type, mono: true },
|
||||
{ label: "对象 ID", value: o?.id, mono: true },
|
||||
{ label: "Trace", value: x.traceId, mono: true },
|
||||
{ label: "事件 ID", value: e.refId, mono: true },
|
||||
]}
|
||||
/>
|
||||
</TechInfo>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** 任务:按引擎运行 id 取详情(列表里没有这条也能打开,旧链接 ?run= 用) */
|
||||
function RunSheet({
|
||||
runId,
|
||||
entry,
|
||||
onOpenRun,
|
||||
onClose,
|
||||
}: {
|
||||
runId: string;
|
||||
entry: LogEntry | null;
|
||||
onOpenRun: (id: string) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const q = useEngineRun(runId);
|
||||
const r = q.data;
|
||||
return (
|
||||
<DetailSheet
|
||||
open
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
width="lg"
|
||||
title={r ? runTitle(r) : entry ? logTitle(entry) : "任务"}
|
||||
description={r ? <TaskStatusLine r={r} /> : undefined}
|
||||
footer={
|
||||
r ? (
|
||||
<>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link to={`/console/tasks/${encodeURIComponent(r.id)}`}>
|
||||
<ExternalLink />
|
||||
独立页面
|
||||
</Link>
|
||||
</Button>
|
||||
{entry ? <AskAgentButton entries={[entry]} /> : null}
|
||||
<RunActions r={r} onOpenRun={onOpenRun} />
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{q.isPending ? (
|
||||
<LoadingState rows={6} />
|
||||
) : q.isError ? (
|
||||
<ErrorState plain error={q.error} onRetry={() => q.refetch()} />
|
||||
) : r ? (
|
||||
<TaskDetailBody r={r} onOpenRun={onOpenRun} />
|
||||
) : null}
|
||||
</DetailSheet>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 日志详情抽屉。open = 日志 id(<source>:<原始 id>);任务类只要 id 就能打开,其余要在已加载的列表里找到这条。
|
||||
*/
|
||||
export function LogDetailSheet({
|
||||
logId,
|
||||
entry,
|
||||
onOpenLog,
|
||||
onClose,
|
||||
}: {
|
||||
logId: string | null;
|
||||
entry: LogEntry | null;
|
||||
onOpenLog: (id: string) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
if (!logId) return null;
|
||||
const sep = logId.indexOf(":");
|
||||
const source = logId.slice(0, sep);
|
||||
const refId = logId.slice(sep + 1);
|
||||
if (source === "engine_run")
|
||||
return <RunSheet runId={refId} entry={entry} onOpenRun={(id) => onOpenLog(`engine_run:${id}`)} onClose={onClose} />;
|
||||
const badge = entry ? logBadge(entry) : null;
|
||||
return (
|
||||
<DetailSheet
|
||||
open
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
title={entry ? logTitle(entry) : "日志"}
|
||||
description={
|
||||
entry ? (
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
{badge ? <StatusBadge tone={badge.tone}>{badge.label}</StatusBadge> : null}
|
||||
<span>{LOG_TYPE_LABEL[entry.type]}</span>
|
||||
<span>· {formatDateTime(entry.at)}</span>
|
||||
{entry.object ? <span>· {objectText(entry.object)}</span> : null}
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
footer={entry ? <AskAgentButton entries={[entry]} /> : null}
|
||||
>
|
||||
{!entry ? (
|
||||
<EmptyState plain title="这条日志不在当前列表里" description="换个筛选或时间范围再找。" className="py-6" />
|
||||
) : entry.source === "schedule_run" ? (
|
||||
<ScheduleRunBody e={entry} />
|
||||
) : entry.source === "agent_run" ? (
|
||||
<AgentRunBody e={entry} />
|
||||
) : (
|
||||
<AuditBody e={entry} />
|
||||
)}
|
||||
</DetailSheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { LOG_STREAM_EVENT, type LogEntry } from "@umt/contracts";
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { LogStreamClient, type LogStreamStatus, upsertEntries } from "~/api/logs.ts";
|
||||
import { qk } from "~/lib/query-keys.ts";
|
||||
import LogsPage from "~/routes/console/logs.tsx";
|
||||
import { agentGoalFor, logBadge, logSubtitle, logsPath, logTitle, objectText, sinceToFrom } from "./labels.ts";
|
||||
import { agentContextFor } from "./log-detail.tsx";
|
||||
|
||||
// 日志页:标题 / 徽章 / 摘要、筛选参数、流的暂停与续传、问智能体的上下文、页面渲染。
|
||||
|
||||
const entry = (over: Partial<LogEntry>): LogEntry => ({
|
||||
id: "audit_event:1",
|
||||
type: "audit",
|
||||
source: "audit_event",
|
||||
refId: "1",
|
||||
at: "2026-10-06T08:00:00.000Z",
|
||||
finishedAt: null,
|
||||
durationMs: null,
|
||||
level: "info",
|
||||
status: "settings.update",
|
||||
title: "settings.update",
|
||||
message: null,
|
||||
object: null,
|
||||
actor: { type: "user", id: "admin" },
|
||||
extra: {},
|
||||
...over,
|
||||
});
|
||||
|
||||
const task = entry({
|
||||
id: "engine_run:r1",
|
||||
type: "task",
|
||||
source: "engine_run",
|
||||
refId: "r1",
|
||||
level: "error",
|
||||
status: "failed",
|
||||
title: "沙丘2",
|
||||
message: "qB 拒绝了种子",
|
||||
object: { type: "download_task", id: "d1", label: "沙丘2" },
|
||||
actor: null,
|
||||
extra: { workflowName: "submitDownload", businessKind: "download", businessTitle: "沙丘2" },
|
||||
});
|
||||
|
||||
describe("标题、摘要、徽章", () => {
|
||||
it("任务:业务标题 + 工作流;没有业务对象时写做什么", () => {
|
||||
expect(logTitle(task)).toBe("沙丘2");
|
||||
expect(logSubtitle(task)).toBe("提交下载 · qB 拒绝了种子");
|
||||
expect(logBadge(task)).toEqual({ label: "失败", tone: "destructive" });
|
||||
const sys = { ...task, extra: { workflowName: "snapshotSite" }, message: null, level: "success" as const };
|
||||
expect(logTitle(sys)).toBe("站点抓取");
|
||||
// 智能体会话超出预算:级别 warn,徽章写具体结果
|
||||
const agent = { ...task, level: "warn" as const, status: "completed", extra: { outcome: "budget_exceeded" } };
|
||||
expect(logBadge(agent).label).toBe("超出预算");
|
||||
});
|
||||
|
||||
it("定时任务:已跳过 / 巡检;审计:动作中文名;对象文字", () => {
|
||||
const skipped = entry({ source: "schedule_run", type: "auto", status: "skipped", title: "RSS 抓取" });
|
||||
expect(logBadge(skipped).label).toBe("已跳过");
|
||||
const check = entry({
|
||||
source: "schedule_run",
|
||||
type: "auto",
|
||||
status: "ok",
|
||||
level: "success",
|
||||
extra: { jobKind: "agent_check" },
|
||||
});
|
||||
expect(logSubtitle(check)).toBe("智能体巡检");
|
||||
expect(logTitle(entry({ status: "admin.login_failed", type: "auth" }))).toBe("登录失败");
|
||||
expect(logTitle(entry({ status: "downloader_instance.create" }))).toBe("新建下载器");
|
||||
expect(objectText({ type: "scheduled_job", id: "x", label: null })).toBe("定时任务");
|
||||
expect(objectText({ type: "download_task", id: "x", label: "沙丘2" })).toBe("沙丘2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("筛选参数", () => {
|
||||
it("时间预设按分钟取整;未知预设为不限", () => {
|
||||
const now = Date.parse("2026-10-06T08:30:45.123Z");
|
||||
expect(sinceToFrom("1h", now)).toBe("2026-10-06T07:30:00.000Z");
|
||||
expect(sinceToFrom("", now)).toBeUndefined();
|
||||
expect(sinceToFrom("bogus", now)).toBeUndefined();
|
||||
});
|
||||
it("logsPath 只带非空参数", () => {
|
||||
expect(logsPath()).toBe("/console/logs");
|
||||
expect(logsPath({ type: "task", level: "", objectId: "a b" })).toBe("/console/logs?type=task&objectId=a+b");
|
||||
});
|
||||
});
|
||||
|
||||
describe("流", () => {
|
||||
/** 假 EventSource:记下 URL,可手动派发事件 */
|
||||
function fakeFactory() {
|
||||
const made: { url: string; closed: boolean; fire: (type: string, data?: unknown) => void; error: () => void }[] =
|
||||
[];
|
||||
const factory = (url: string) => {
|
||||
const listeners = new Map<string, ((ev: MessageEvent<string>) => void)[]>();
|
||||
const es = {
|
||||
onerror: null as ((ev: Event) => unknown) | null,
|
||||
addEventListener: (t: string, fn: (ev: MessageEvent<string>) => void) =>
|
||||
listeners.set(t, [...(listeners.get(t) ?? []), fn]),
|
||||
close: () => {
|
||||
rec.closed = true;
|
||||
},
|
||||
};
|
||||
const rec = {
|
||||
url,
|
||||
closed: false,
|
||||
fire: (type: string, data: unknown = {}) => {
|
||||
for (const fn of listeners.get(type) ?? []) fn({ data: JSON.stringify(data) } as MessageEvent<string>);
|
||||
},
|
||||
error: () => es.onerror?.(new Event("error")),
|
||||
};
|
||||
made.push(rec);
|
||||
return es as unknown as EventSource;
|
||||
};
|
||||
return { made, factory };
|
||||
}
|
||||
|
||||
it("连接带筛选与 since;暂停断开;继续从收到的最新一条续上", () => {
|
||||
const { made, factory } = fakeFactory();
|
||||
const got: LogEntry[] = [];
|
||||
const statuses: LogStreamStatus[] = [];
|
||||
const c = new LogStreamClient(
|
||||
{ type: "auth", level: "warn" },
|
||||
{ onEntry: (e) => got.push(e), onStatus: (s) => statuses.push(s) },
|
||||
"2026-10-06T08:00:00.000Z",
|
||||
factory,
|
||||
);
|
||||
c.start();
|
||||
expect(made).toHaveLength(1);
|
||||
const u = new URL(made[0]!.url, "http://x");
|
||||
expect(u.pathname).toBe("/api/v1/logs/stream");
|
||||
expect(Object.fromEntries(u.searchParams)).toEqual({
|
||||
type: "auth",
|
||||
level: "warn",
|
||||
since: "2026-10-06T08:00:00.000Z",
|
||||
});
|
||||
made[0]!.fire("ready");
|
||||
made[0]!.fire(LOG_STREAM_EVENT, entry({ id: "audit_event:2", at: "2026-10-06T08:05:00.000Z" }));
|
||||
expect(got.map((e) => e.id)).toEqual(["audit_event:2"]);
|
||||
expect(statuses).toEqual(["connecting", "open"]);
|
||||
|
||||
// 暂停
|
||||
c.stop();
|
||||
expect(made[0]!.closed).toBe(true);
|
||||
expect(statuses.at(-1)).toBe("off");
|
||||
// 暂停期间列表刷新出更旧的条目不会把起点往回拨
|
||||
c.advance("2026-10-06T07:00:00.000Z");
|
||||
c.start();
|
||||
expect(made).toHaveLength(2);
|
||||
expect(new URL(made[1]!.url, "http://x").searchParams.get("since")).toBe("2026-10-06T08:05:00.000Z");
|
||||
c.stop();
|
||||
});
|
||||
|
||||
it("出错后重连;停止后不再重连", () => {
|
||||
const { made, factory } = fakeFactory();
|
||||
const statuses: LogStreamStatus[] = [];
|
||||
const c = new LogStreamClient({}, { onEntry: () => {}, onStatus: (s) => statuses.push(s) }, null, factory);
|
||||
c.start();
|
||||
made[0]!.error();
|
||||
expect(statuses).toContain("reconnecting");
|
||||
c.stop();
|
||||
made[0]!.error();
|
||||
expect(made).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("按 id 覆盖合并,时间倒序", () => {
|
||||
const a = entry({ id: "a", at: "2026-10-06T08:00:00.000Z" });
|
||||
const b = entry({ id: "b", at: "2026-10-06T08:01:00.000Z", level: "running" });
|
||||
const b2 = { ...b, level: "success" as const };
|
||||
const c = entry({ id: "c", at: "2026-10-06T07:59:00.000Z" });
|
||||
expect(upsertEntries([b, a], [c, b2]).map((e) => [e.id, e.level])).toEqual([
|
||||
["b", "success"],
|
||||
["a", "info"],
|
||||
["c", "info"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("问智能体", () => {
|
||||
it("目标里按时间正序列出日志与 id;单条任务带上任务与下载任务上下文", () => {
|
||||
const later = entry({ id: "audit_event:9", at: "2026-10-06T09:00:00.000Z", status: "capability.denied" });
|
||||
const goal = agentGoalFor([later, task]);
|
||||
expect(goal.startsWith("看看这 2 条日志")).toBe(true);
|
||||
expect(goal.indexOf("engine_run:r1")).toBeLessThan(goal.indexOf("audit_event:9"));
|
||||
expect(goal).toContain("qB 拒绝了种子");
|
||||
const ctx = agentContextFor([task]);
|
||||
expect(ctx).toMatchObject({ taskId: "r1", downloadTaskId: "d1", label: "日志:沙丘2" });
|
||||
expect(agentContextFor([task, later]).label).toBe("日志:2 条");
|
||||
});
|
||||
|
||||
it("太长时截断,不超过智能体目标上限", () => {
|
||||
const many = Array.from({ length: 200 }, (_, i) =>
|
||||
entry({
|
||||
id: `audit_event:${i}`,
|
||||
message: "x".repeat(200),
|
||||
at: new Date(Date.UTC(2026, 9, 6, 0, i)).toISOString(),
|
||||
}),
|
||||
);
|
||||
const goal = agentGoalFor(many);
|
||||
expect(goal.length).toBeLessThanOrEqual(6100);
|
||||
expect(goal).toMatch(/另有 \d+ 条未列出/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("日志页渲染", () => {
|
||||
it("筛选写在 URL;列表显示时间、类型、结果、摘要;默认实时", () => {
|
||||
const client = new QueryClient();
|
||||
client.setQueryData(qk.list("logs", { type: "task" }), {
|
||||
pages: [{ items: [task], nextCursor: null }],
|
||||
pageParams: [undefined],
|
||||
});
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(
|
||||
QueryClientProvider,
|
||||
{ client },
|
||||
createElement(MemoryRouter, { initialEntries: ["/console/logs?type=task"] }, createElement(LogsPage)),
|
||||
),
|
||||
);
|
||||
expect(html).toContain("定时任务");
|
||||
expect(html).toContain("沙丘2");
|
||||
expect(html).toContain("qB 拒绝了种子");
|
||||
expect(html).toContain("暂停");
|
||||
expect(html).toContain("显示例行记录");
|
||||
expect(html).toContain("问智能体");
|
||||
});
|
||||
|
||||
it("定时任务页签渲染计划看板", () => {
|
||||
const client = new QueryClient();
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(
|
||||
QueryClientProvider,
|
||||
{ client },
|
||||
createElement(MemoryRouter, { initialEntries: ["/console/logs?tab=schedules"] }, createElement(LogsPage)),
|
||||
),
|
||||
);
|
||||
expect(html).toContain('aria-selected="true"');
|
||||
expect(html).not.toContain("显示例行记录");
|
||||
});
|
||||
});
|
||||
@@ -20,13 +20,15 @@ describe("计划页标签", () => {
|
||||
});
|
||||
|
||||
it("planPath:节锚点与 ?job= 高亮", () => {
|
||||
expect(planPath()).toBe("/console/schedules");
|
||||
expect(planPath({ section: "auto" })).toBe("/console/schedules#auto");
|
||||
expect(planPath({ section: "checks", job: "check.pending" })).toBe("/console/schedules?job=check.pending#checks");
|
||||
expect(planPath()).toBe("/console/logs?tab=schedules");
|
||||
expect(planPath({ section: "auto" })).toBe("/console/logs?tab=schedules#auto");
|
||||
expect(planPath({ section: "checks", job: "check.pending" })).toBe(
|
||||
"/console/logs?tab=schedules&job=check.pending#checks",
|
||||
);
|
||||
});
|
||||
|
||||
it("运行记录跳任务页按定时任务筛选", () => {
|
||||
expect(scheduleStreamPath("trash.purge")).toBe("/console/tasks?tab=all&kind=schedule&ref=trash.purge");
|
||||
it("运行记录跳日志页按定时任务筛选", () => {
|
||||
expect(scheduleStreamPath("trash.purge")).toBe("/console/logs?objectType=scheduled_job&objectId=trash.purge");
|
||||
});
|
||||
|
||||
it("formatUsd:两位小数,不足一分写 < $0.01", () => {
|
||||
|
||||
@@ -13,16 +13,17 @@ export const GROUP_ORDER: ScheduleGroup[] = ["collect", "download", "organize",
|
||||
/** 计划页「自动任务」一节的分组(智能体巡检单独一节,放在最上面);维护类合成一组「数据清理」 */
|
||||
export const TASK_GROUPS: ScheduleGroup[] = ["collect", "download", "organize", "maintain"];
|
||||
|
||||
/** 计划页两节的锚点:/console/schedules#auto、#checks */
|
||||
/** 定时任务视图两节的锚点:/console/logs?tab=schedules#auto、#checks(界面第二轮:计划页并入日志页的「定时任务」页签) */
|
||||
export type PlanSection = "auto" | "checks";
|
||||
export const PLAN_PATH = "/console/schedules";
|
||||
export const PLAN_PATH = "/console/logs?tab=schedules";
|
||||
export function planPath(opts: { section?: PlanSection; job?: string } = {}): string {
|
||||
const q = opts.job ? `?job=${encodeURIComponent(opts.job)}` : "";
|
||||
const q = opts.job ? `&job=${encodeURIComponent(opts.job)}` : "";
|
||||
return `${PLAN_PATH}${q}${opts.section ? `#${opts.section}` : ""}`;
|
||||
}
|
||||
|
||||
/** 任务页里某个定时任务触发的任务(businessKind=schedule) */
|
||||
export const scheduleStreamPath = (id: string) => `/console/tasks?tab=all&kind=schedule&ref=${encodeURIComponent(id)}`;
|
||||
/** 日志页里某个定时任务的全部运行记录(含它触发的任务与修改留痕) */
|
||||
export const scheduleStreamPath = (id: string) =>
|
||||
`/console/logs?objectType=scheduled_job&objectId=${encodeURIComponent(id)}`;
|
||||
|
||||
export const GROUP_LABEL: Record<ScheduleGroup, string> = {
|
||||
collect: "采集",
|
||||
|
||||
@@ -18,9 +18,9 @@ import { DraftCheckDialog } from "./draft-check-dialog.tsx";
|
||||
import { formatUsd } from "./labels.ts";
|
||||
import { RunsSheet } from "./runs-sheet.tsx";
|
||||
|
||||
// 计划页(FE-6,/console/schedules):「智能体巡检」= 到点发起智能体会话的定时任务(卡片,放最上面:
|
||||
// 定时任务(FE-6 的计划页;界面第二轮并入日志页「定时任务」页签,/console/logs?tab=schedules):「智能体巡检」= 到点发起智能体会话的定时任务(卡片,放最上面:
|
||||
// 数量少、由用户自己建、会花钱,是这页最需要看的);「自动任务」= 程序按固定周期执行的定时任务
|
||||
// (按分类折叠的紧凑列表,维护类合成「数据清理」)。自动任务的运行记录跳任务页按该定时任务筛选。
|
||||
// (按分类折叠的紧凑列表,维护类合成「数据清理」)。自动任务的运行记录跳日志页按该定时任务筛选。
|
||||
|
||||
/** 每秒一跳的当前时间(倒计时用) */
|
||||
export function useNow(ms = 1000) {
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import type { ResourceSample, ResourceScope, SystemResources } from "@umt/contracts";
|
||||
import { Activity, Cpu, HardDrive, MemoryStick, Network } from "lucide-react";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { Line, LineChart, ResponsiveContainer, Tooltip, YAxis } from "recharts";
|
||||
import { useSystemResources } from "~/api/system.ts";
|
||||
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
|
||||
import { formatDuration } from "~/lib/labels.ts";
|
||||
import { cn, formatBytes } from "~/lib/utils.ts";
|
||||
|
||||
// 资源监控(界面第二轮,参考 MP 仪表盘的 CPU / 内存 / 网络组件):四块磁贴,每块一个当前值 + 小趋势线。
|
||||
// 图表沿用运行状态页的无彩口径:前景色细线,一张图一个量;网络收 / 发两条线靠线型与图例区分。
|
||||
|
||||
const SCOPE_LABEL: Record<ResourceScope, string> = { cgroup: "本容器", host: "整机", none: "取不到" };
|
||||
|
||||
const pct = (v: number | null | undefined) => (v == null ? "—" : `${Math.round(v)}%`);
|
||||
const rate = (v: number | null | undefined) => (v == null ? "—" : `${formatBytes(v, 1)}/s`);
|
||||
|
||||
/** 用量分档:≥ 90% 红、≥ 75% 黄(只给数值上色,文字仍写清) */
|
||||
export function usageTone(v: number | null | undefined): string {
|
||||
if (v == null) return "text-foreground";
|
||||
return v >= 90 ? "text-destructive" : v >= 75 ? "text-warning" : "text-foreground";
|
||||
}
|
||||
|
||||
type Key = Exclude<keyof ResourceSample, "at">;
|
||||
|
||||
function clock(iso: string) {
|
||||
return new Date(iso).toLocaleTimeString("zh-CN", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
/** 小趋势线:无坐标轴,悬停看时间与数值 */
|
||||
export function Sparkline({
|
||||
samples,
|
||||
keys,
|
||||
format,
|
||||
max,
|
||||
height = 48,
|
||||
label,
|
||||
}: {
|
||||
samples: ResourceSample[];
|
||||
keys: { key: Key; name: string; dash?: string }[];
|
||||
format: (v: number) => string;
|
||||
/** 纵轴上限(百分比类给 100,保持刻度稳定) */
|
||||
max?: number;
|
||||
height?: number;
|
||||
label: string;
|
||||
}) {
|
||||
const has = samples.some((s) => keys.some((k) => s[k.key] != null));
|
||||
if (!has)
|
||||
return (
|
||||
<div className="flex items-center text-meta text-muted-foreground" style={{ height }}>
|
||||
正在采样…
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div role="img" aria-label={label} className="text-muted-foreground" style={{ height }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={samples} margin={{ top: 4, right: 2, bottom: 2, left: 2 }}>
|
||||
<YAxis hide domain={[0, max ?? "auto"]} />
|
||||
<Tooltip
|
||||
labelFormatter={(_, p) => (p?.[0]?.payload?.at ? clock(String(p[0].payload.at)) : "")}
|
||||
formatter={(v, name) => [format(Number(v)), String(name)]}
|
||||
contentStyle={{
|
||||
background: "var(--color-popover)",
|
||||
border: "1px solid var(--color-border)",
|
||||
borderRadius: 12,
|
||||
fontSize: 12,
|
||||
color: "var(--color-foreground)",
|
||||
}}
|
||||
cursor={{ stroke: "var(--color-muted-foreground)", strokeDasharray: "3 3" }}
|
||||
/>
|
||||
{keys.map((k, i) => (
|
||||
<Line
|
||||
key={k.key}
|
||||
type="monotone"
|
||||
dataKey={k.key}
|
||||
name={k.name}
|
||||
stroke="var(--color-foreground)"
|
||||
strokeOpacity={i ? 0.5 : 0.85}
|
||||
strokeDasharray={k.dash}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 4, strokeWidth: 2, stroke: "var(--color-background)" }}
|
||||
connectNulls
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Tile({
|
||||
icon: Icon,
|
||||
title,
|
||||
scope,
|
||||
value,
|
||||
valueClass,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
icon: typeof Cpu;
|
||||
title: string;
|
||||
scope: ResourceScope;
|
||||
value: ReactNode;
|
||||
valueClass?: string;
|
||||
hint: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="pg-slab min-w-0 rounded-xl p-4">
|
||||
<div className="flex items-center gap-1.5 text-meta text-muted-foreground">
|
||||
<Icon className="h-3.5 w-3.5" aria-hidden />
|
||||
<span>{title}</span>
|
||||
<span className="ml-auto">{SCOPE_LABEL[scope]}</span>
|
||||
</div>
|
||||
<p className={cn("mt-1 text-title-sm font-semibold tabular-nums", valueClass)}>{value}</p>
|
||||
<p className="mt-0.5 truncate text-meta text-muted-foreground tabular-nums">{hint}</p>
|
||||
<div className="mt-2">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** 最近 n 分钟平均(MP 的 1m / 5m / 15m 平均) */
|
||||
function avg(samples: ResourceSample[], key: Key, minutes: number): number | null {
|
||||
const from = Date.now() - minutes * 60_000;
|
||||
const v = samples
|
||||
.filter((s) => Date.parse(s.at) >= from)
|
||||
.map((s) => s[key])
|
||||
.filter((x): x is number => x != null);
|
||||
return v.length ? v.reduce((a, b) => a + b, 0) / v.length : null;
|
||||
}
|
||||
|
||||
export function ResourceTiles({ d }: { d: SystemResources }) {
|
||||
const m = d.memory;
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<Tile
|
||||
icon={Cpu}
|
||||
title="CPU"
|
||||
scope={d.cpu.scope}
|
||||
value={pct(d.cpu.percent)}
|
||||
valueClass={usageTone(d.cpu.percent)}
|
||||
hint={
|
||||
<>
|
||||
{d.cpu.cores ? `${d.cpu.cores} 核` : ""}
|
||||
{d.cpu.load ? ` · 负载 ${d.cpu.load.map((x) => x.toFixed(2)).join(" / ")}` : ""}
|
||||
{` · 5 分钟平均 ${pct(avg(d.samples, "cpuPercent", 5))}`}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Sparkline
|
||||
samples={d.samples}
|
||||
keys={[{ key: "cpuPercent", name: "CPU" }]}
|
||||
format={(v) => `${v.toFixed(1)}%`}
|
||||
max={100}
|
||||
label="CPU 占用趋势"
|
||||
/>
|
||||
</Tile>
|
||||
<Tile
|
||||
icon={MemoryStick}
|
||||
title="内存"
|
||||
scope={m.scope}
|
||||
value={pct(m.percent)}
|
||||
valueClass={usageTone(m.percent)}
|
||||
hint={
|
||||
<>
|
||||
{m.usedBytes != null ? formatBytes(m.usedBytes, 1) : "—"}
|
||||
{m.limitBytes != null ? ` / ${formatBytes(m.limitBytes, 1)}` : ""}
|
||||
{m.scope === "cgroup" && m.hostTotalBytes != null && m.hostAvailableBytes != null
|
||||
? ` · 整机可用 ${formatBytes(m.hostAvailableBytes, 1)}`
|
||||
: ""}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Sparkline
|
||||
samples={d.samples}
|
||||
keys={[{ key: "memoryPercent", name: "内存" }]}
|
||||
format={(v) => `${v.toFixed(1)}%`}
|
||||
max={100}
|
||||
label="内存占用趋势"
|
||||
/>
|
||||
</Tile>
|
||||
<Tile
|
||||
icon={Network}
|
||||
title="网络"
|
||||
scope={d.network.scope}
|
||||
value={
|
||||
<>
|
||||
<span className="text-caption font-normal text-muted-foreground">↓ </span>
|
||||
{rate(d.network.rxBps)}
|
||||
</>
|
||||
}
|
||||
hint={`↑ ${rate(d.network.txBps)}${d.network.rxTotalBytes != null ? ` · 累计收 ${formatBytes(d.network.rxTotalBytes, 1)}` : ""}`}
|
||||
>
|
||||
<Sparkline
|
||||
samples={d.samples}
|
||||
keys={[
|
||||
{ key: "netRxBps", name: "接收" },
|
||||
{ key: "netTxBps", name: "发送", dash: "4 3" },
|
||||
]}
|
||||
format={(v) => `${formatBytes(v, 1)}/s`}
|
||||
label="网络吞吐趋势(实线接收,虚线发送)"
|
||||
/>
|
||||
</Tile>
|
||||
<Tile
|
||||
icon={HardDrive}
|
||||
title="磁盘读写"
|
||||
scope={d.diskIo.scope}
|
||||
value={
|
||||
<>
|
||||
<span className="text-caption font-normal text-muted-foreground">读 </span>
|
||||
{rate(d.diskIo.readBps)}
|
||||
</>
|
||||
}
|
||||
hint={`写 ${rate(d.diskIo.writeBps)}${d.hostUptimeSec != null ? ` · 主机已运行 ${formatDuration(d.hostUptimeSec)}` : ""}`}
|
||||
>
|
||||
<Sparkline
|
||||
samples={d.samples}
|
||||
keys={[
|
||||
{ key: "diskReadBps", name: "读" },
|
||||
{ key: "diskWriteBps", name: "写", dash: "4 3" },
|
||||
]}
|
||||
format={(v) => `${formatBytes(v, 1)}/s`}
|
||||
label="磁盘读写趋势(实线读,虚线写)"
|
||||
/>
|
||||
</Tile>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const WINDOWS = [15, 60, 360];
|
||||
|
||||
/** 运行状态页「资源」节:窗口切换 + 四块磁贴 */
|
||||
export function ResourceSection() {
|
||||
const [minutes, setMinutes] = useState(60);
|
||||
const q = useSystemResources(minutes);
|
||||
return (
|
||||
<section className="min-w-0">
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
<Activity className="h-4 w-4 text-muted-foreground" aria-hidden />
|
||||
<h2 className="text-body font-semibold text-foreground">资源</h2>
|
||||
<span className="text-meta text-muted-foreground">
|
||||
每 {q.data?.intervalSec ?? 5} 秒采样一次,只保留最近 6 小时(重启后重新累计)
|
||||
</span>
|
||||
<Tabs value={String(minutes)} onValueChange={(v) => setMinutes(Number(v))} className="ml-auto">
|
||||
<TabsList className="pb-0">
|
||||
{WINDOWS.map((n) => (
|
||||
<TabsTrigger key={n} value={String(n)} className="h-7 px-2.5 text-caption">
|
||||
{n < 60 ? `${n} 分钟` : `${n / 60} 小时`}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
{q.isPending ? (
|
||||
<LoadingState rows={3} />
|
||||
) : q.isError ? (
|
||||
<ErrorState plain error={q.error} onRetry={() => q.refetch()} />
|
||||
) : (
|
||||
<ResourceTiles d={q.data} />
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -15,7 +15,6 @@ export const BUSINESS_KIND_LABEL: Record<EngineBusinessKind, string> = {
|
||||
decision: "待处理事项",
|
||||
media: "作品",
|
||||
};
|
||||
export const BUSINESS_KINDS = Object.keys(BUSINESS_KIND_LABEL) as EngineBusinessKind[];
|
||||
|
||||
/** 来源:四类(定时/事件/手动/Agent);API Key 调用算手动 */
|
||||
export type TaskSource = "schedule" | "event" | "manual" | "agent";
|
||||
@@ -25,13 +24,6 @@ export const SOURCE_LABEL: Record<TaskSource, string> = {
|
||||
manual: "手动",
|
||||
agent: "智能体",
|
||||
};
|
||||
/** 来源 → 服务端 triggeredBy 筛选值(逗号分隔多值) */
|
||||
export const SOURCE_TRIGGERED_BY: Record<TaskSource, string> = {
|
||||
schedule: "schedule",
|
||||
event: "event",
|
||||
manual: "user,api_key",
|
||||
agent: "agent",
|
||||
};
|
||||
export function sourceOf(t: EngineTriggeredBy | null | undefined): TaskSource | null {
|
||||
if (!t) return null;
|
||||
if (t === "user" || t === "api_key") return "manual";
|
||||
@@ -86,10 +78,6 @@ export function engineRunStatus(r: Pick<EngineRun, "status" | "business">) {
|
||||
return OUTCOME_STATUS[outcome];
|
||||
return runStatus(r.status);
|
||||
}
|
||||
export const RUN_STATUS_OPTIONS = ["pending", "running", "completed", "failed", "canceled"].map((value) => ({
|
||||
value,
|
||||
label: runStatus(value).label,
|
||||
}));
|
||||
|
||||
/** 行标题:业务对象名 > 业务类型 + 短 id > 工作流做什么 */
|
||||
export function runTitle(r: Pick<EngineRun, "business" | "workflowName">): string {
|
||||
@@ -118,7 +106,7 @@ export function businessLink(b: EngineRun["business"], input?: unknown): { to: s
|
||||
case "agent":
|
||||
return { to: `/console/agent/${id}`, label: "智能体会话" };
|
||||
case "schedule":
|
||||
return { to: `/console/schedules?job=${id}`, label: "定时任务" };
|
||||
return { to: `/console/logs?tab=schedules&job=${id}`, label: "定时任务" };
|
||||
case "feed":
|
||||
// RSS 属于站点(IA-2):旧 RSS 页按 ?feed= 查所属站点后跳到站点的 RSS Tab
|
||||
return { to: `${consolePath("sources")}?feed=${encodeURIComponent(id)}`, label: "RSS" };
|
||||
@@ -133,5 +121,3 @@ export function businessLink(b: EngineRun["business"], input?: unknown): { to: s
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const ENGINE_ACTIVE = new Set(["pending", "running", "sleeping"]);
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useRef } from "react";
|
||||
import { qk } from "~/lib/query-keys.ts";
|
||||
import { useSse } from "~/lib/sse.ts";
|
||||
|
||||
/** 引擎本身不发事件;业务事件(outbox → SSE 桥)足以代表任务在动,2 秒节流刷新任务相关查询 */
|
||||
const TASK_EVENTS = [
|
||||
"task.created",
|
||||
"task.status_changed",
|
||||
"download.status_changed",
|
||||
"download.threshold",
|
||||
"organization.changed",
|
||||
"feed.fetched",
|
||||
"decision.created",
|
||||
"decision.updated",
|
||||
"agent.run_status",
|
||||
"schedule.changed",
|
||||
];
|
||||
|
||||
export function useLiveTasks() {
|
||||
const qc = useQueryClient();
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useSse(TASK_EVENTS, () => {
|
||||
if (timer.current) return;
|
||||
timer.current = setTimeout(() => {
|
||||
timer.current = null;
|
||||
qc.invalidateQueries({ queryKey: qk.all("engine") });
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { JsonView } from "~/components/console/json-view.tsx";
|
||||
import { KvList } from "~/components/console/kv-list.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { Timeline, type TimelineItem } from "~/components/console/timeline.tsx";
|
||||
import { BUSINESS_OBJECT_TYPE, objectLogsPath } from "~/components/logs/labels.ts";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { formatDuration, formatRelative, spanSec } from "~/lib/labels.ts";
|
||||
@@ -240,13 +241,14 @@ export function TaskDetailBody({ r, onOpenRun }: { r: EngineRunDetail; onOpenRun
|
||||
const link = businessLink(b, r.input);
|
||||
const src = sourceOf(b?.triggeredBy);
|
||||
const hasError = r.error !== null && r.error !== undefined;
|
||||
// 相关日志(日志页按对象筛选):本任务的操作留痕;关联对象的全部日志(任务、定时任务运行、审计)
|
||||
const audits = [
|
||||
{ to: `/console/audit?targetType=engine_run&targetId=${encodeURIComponent(r.id)}`, label: "本任务的操作记录" },
|
||||
...(b?.kind === "schedule"
|
||||
{ to: objectLogsPath("engine_run", r.id), label: "本任务的操作记录" },
|
||||
...(b && BUSINESS_OBJECT_TYPE[b.kind]
|
||||
? [
|
||||
{
|
||||
to: `/console/audit?targetType=scheduled_job&targetId=${encodeURIComponent(b.refId)}`,
|
||||
label: "定时任务的修改记录",
|
||||
to: objectLogsPath(BUSINESS_OBJECT_TYPE[b.kind]!, b.refId),
|
||||
label: b.kind === "schedule" ? "定时任务的全部日志" : "关联对象的全部日志",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
@@ -287,7 +289,7 @@ export function TaskDetailBody({ r, onOpenRun }: { r: EngineRunDetail; onOpenRun
|
||||
<Timeline items={businessTimeline(r)} />
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="相关审计">
|
||||
<DetailSection title="相关日志">
|
||||
<ul className="flex flex-wrap gap-x-4 gap-y-1 text-caption">
|
||||
{audits.map((a) => (
|
||||
<li key={a.to}>
|
||||
|
||||
@@ -1,307 +0,0 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { EngineBusinessKind, EngineRun, ScheduledJob } from "@umt/contracts";
|
||||
import { CalendarClock, ExternalLink, History, ListTodo, X } from "lucide-react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { useEngineRuns } from "~/api/engine.ts";
|
||||
import { useScheduleBoard } from "~/api/schedules.ts";
|
||||
import { DataTable } from "~/components/console/data-table.tsx";
|
||||
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { RunsSheet } from "~/components/schedules/runs-sheet.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { formatRelative } from "~/lib/labels.ts";
|
||||
import { qk } from "~/lib/query-keys.ts";
|
||||
import { useSse } from "~/lib/sse.ts";
|
||||
import {
|
||||
BUSINESS_KIND_LABEL,
|
||||
BUSINESS_KINDS,
|
||||
businessLink,
|
||||
ENGINE_ACTIVE,
|
||||
engineRunStatus,
|
||||
RUN_STATUS_OPTIONS,
|
||||
runTitle,
|
||||
SOURCE_LABEL,
|
||||
SOURCE_TRIGGERED_BY,
|
||||
sourceOf,
|
||||
type TaskSource,
|
||||
workflowLabel,
|
||||
} from "./labels.ts";
|
||||
|
||||
export type StreamTab = "all" | "active" | "failed";
|
||||
|
||||
/** 引擎本身不发事件;业务事件(outbox → SSE 桥)足以代表任务在动,2 秒节流刷新 */
|
||||
const TASK_EVENTS = [
|
||||
"task.created",
|
||||
"task.status_changed",
|
||||
"download.status_changed",
|
||||
"download.threshold",
|
||||
"organization.changed",
|
||||
"feed.fetched",
|
||||
"decision.created",
|
||||
"decision.updated",
|
||||
"agent.run_status",
|
||||
"schedule.changed",
|
||||
];
|
||||
|
||||
export function useLiveTasks() {
|
||||
const qc = useQueryClient();
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useSse(TASK_EVENTS, () => {
|
||||
if (timer.current) return;
|
||||
timer.current = setTimeout(() => {
|
||||
timer.current = null;
|
||||
qc.invalidateQueries({ queryKey: qk.all("engine") });
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
const 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 },
|
||||
];
|
||||
|
||||
export type StreamFilters = {
|
||||
status: string;
|
||||
kind: string;
|
||||
ref: string;
|
||||
source: string;
|
||||
since: string;
|
||||
workflow: string;
|
||||
/** "1" = 显示系统任务 */
|
||||
system: string;
|
||||
};
|
||||
|
||||
function SourceBadge({ r }: { r: EngineRun }) {
|
||||
const s = sourceOf(r.business?.triggeredBy);
|
||||
return s ? <span className="text-caption">{SOURCE_LABEL[s]}</span> : <span className="text-muted-foreground">—</span>;
|
||||
}
|
||||
|
||||
/** 定时任务筛选时,页头给出定时任务自己的运行记录(含跳过的,引擎里没有) */
|
||||
function ScheduleBanner({ jobId, onClear }: { jobId: string; onClear: () => void }) {
|
||||
const board = useScheduleBoard();
|
||||
const job = board.data?.items.find((j) => j.id === jobId) ?? null;
|
||||
const [open, setOpen] = useState<ScheduledJob | null>(null);
|
||||
return (
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2 rounded-xl border border-border/70 px-3 py-2 text-caption">
|
||||
<CalendarClock className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="min-w-0 truncate">
|
||||
定时任务 <span className="font-medium text-foreground">{job?.name ?? jobId}</span> 触发的任务
|
||||
</span>
|
||||
<span className="ml-auto flex items-center gap-1">
|
||||
<Button variant="ghost" size="xs" disabled={!job} onClick={() => setOpen(job)}>
|
||||
<History />
|
||||
运行记录
|
||||
</Button>
|
||||
<Button asChild variant="ghost" size="xs">
|
||||
<Link to={`/console/schedules?job=${encodeURIComponent(jobId)}`}>
|
||||
<ExternalLink />
|
||||
计划
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon-xs" aria-label="清除定时任务筛选" onClick={onClear}>
|
||||
<X />
|
||||
</Button>
|
||||
</span>
|
||||
<RunsSheet job={open} onClose={() => setOpen(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskStream({
|
||||
tab,
|
||||
filters,
|
||||
setFilter,
|
||||
resetFilters,
|
||||
onOpen,
|
||||
selectedId,
|
||||
}: {
|
||||
tab: StreamTab;
|
||||
filters: StreamFilters;
|
||||
setFilter: (k: keyof StreamFilters, v: string) => void;
|
||||
resetFilters: () => void;
|
||||
onOpen: (id: string) => void;
|
||||
selectedId: string | null;
|
||||
}) {
|
||||
useLiveTasks();
|
||||
// 时间下界按分钟取整,查询 key 稳定
|
||||
const from = useMemo(() => {
|
||||
const ms = SINCE.find((s) => s.value === filters.since)?.ms;
|
||||
return ms ? new Date(Math.floor((Date.now() - ms) / 60_000) * 60_000).toISOString() : undefined;
|
||||
}, [filters.since]);
|
||||
const base = {
|
||||
businessKind: (filters.kind || undefined) as EngineBusinessKind | undefined,
|
||||
businessRefId: filters.ref || undefined,
|
||||
workflowName: filters.workflow || undefined,
|
||||
triggeredBy: SOURCE_TRIGGERED_BY[filters.source as TaskSource] ?? undefined,
|
||||
from,
|
||||
};
|
||||
const active = tab === "active";
|
||||
// 进行中 = 进行中 + 排队(status 多值,服务端分页);来源筛选同样在服务端做
|
||||
const list = useEngineRuns(
|
||||
{
|
||||
...base,
|
||||
status: active ? "running,pending" : tab === "failed" ? "failed" : filters.status || undefined,
|
||||
},
|
||||
true,
|
||||
active ? 10_000 : false,
|
||||
);
|
||||
const all = list.data?.pages.flatMap((p) => p.items);
|
||||
// 系统任务(没有业务对象的引擎步骤、定时任务的成功运行)默认不列;按对象 / 定时任务筛选或勾选时显示
|
||||
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 (
|
||||
<>
|
||||
{filters.kind === "schedule" && filters.ref ? (
|
||||
<ScheduleBanner
|
||||
jobId={filters.ref}
|
||||
onClear={() => {
|
||||
setFilter("ref", "");
|
||||
setFilter("kind", "");
|
||||
}}
|
||||
/>
|
||||
) : filters.ref ? (
|
||||
<div className="mb-3 flex items-center gap-2 text-caption text-muted-foreground">
|
||||
只看对象 <code className="font-mono text-foreground">{filters.ref}</code>
|
||||
<Button variant="ghost" size="icon-xs" aria-label="清除对象筛选" onClick={() => setFilter("ref", "")}>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<FilterBar onReset={filtered ? resetFilters : undefined}>
|
||||
<FilterSelect
|
||||
label="来源"
|
||||
value={filters.source}
|
||||
onChange={(v) => setFilter("source", v)}
|
||||
options={(Object.keys(SOURCE_LABEL) as TaskSource[]).map((value) => ({ value, label: SOURCE_LABEL[value] }))}
|
||||
/>
|
||||
<FilterSelect
|
||||
label="业务"
|
||||
value={filters.kind}
|
||||
onChange={(v) => setFilter("kind", v)}
|
||||
options={BUSINESS_KINDS.map((value) => ({ value, label: BUSINESS_KIND_LABEL[value] }))}
|
||||
/>
|
||||
{tab === "all" ? (
|
||||
<FilterSelect
|
||||
label="状态"
|
||||
value={filters.status}
|
||||
onChange={(v) => setFilter("status", v)}
|
||||
options={RUN_STATUS_OPTIONS}
|
||||
/>
|
||||
) : null}
|
||||
<FilterSelect
|
||||
label="时间"
|
||||
value={filters.since}
|
||||
onChange={(v) => setFilter("since", v)}
|
||||
options={SINCE}
|
||||
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 ? (
|
||||
<Button variant="outline" size="sm" onClick={() => setFilter("workflow", "")}>
|
||||
{workflowLabel(filters.workflow)}
|
||||
<X />
|
||||
</Button>
|
||||
) : null}
|
||||
</FilterBar>
|
||||
|
||||
<DataTable<EngineRun>
|
||||
paginationKey={JSON.stringify({ tab, filters })}
|
||||
rows={rows}
|
||||
rowKey={(r) => r.id}
|
||||
loading={list.isPending}
|
||||
error={list.error}
|
||||
onRetry={() => void list.refetch()}
|
||||
onRowClick={(r) => onOpen(r.id)}
|
||||
selectedKey={selectedId}
|
||||
hasMore={!!list.hasNextPage}
|
||||
onLoadMore={() => list.fetchNextPage()}
|
||||
loadingMore={list.isFetchingNextPage}
|
||||
empty={{
|
||||
icon: ListTodo,
|
||||
title: filtered
|
||||
? "没有符合条件的任务"
|
||||
: tab === "active"
|
||||
? "没有进行中的任务"
|
||||
: tab === "failed"
|
||||
? "没有失败的任务"
|
||||
: "还没有任务",
|
||||
description: filters.source && list.hasNextPage ? "按来源筛选时一页可能较少,可翻页继续查找。" : undefined,
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
key: "title",
|
||||
header: "任务",
|
||||
cell: (r) => (
|
||||
<div className="min-w-0 max-w-[15rem] sm:max-w-sm">
|
||||
<p className="truncate font-medium text-foreground">{runTitle(r)}</p>
|
||||
{r.business?.title ? (
|
||||
<p className="truncate text-meta text-muted-foreground">{workflowLabel(r.workflowName)}</p>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: "source", header: "来源", hideBelow: "md", cell: (r) => <SourceBadge r={r} /> },
|
||||
{
|
||||
key: "status",
|
||||
header: "状态",
|
||||
cell: (r) => {
|
||||
const s = engineRunStatus(r);
|
||||
return <StatusBadge tone={s.tone}>{s.label}</StatusBadge>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "time",
|
||||
header: "开始",
|
||||
hideBelow: "sm",
|
||||
cell: (r) => (
|
||||
<div className="whitespace-nowrap">
|
||||
<p className="text-muted-foreground" title={r.startedAt ?? r.createdAt}>
|
||||
{r.startedAt
|
||||
? formatRelative(r.startedAt)
|
||||
: ENGINE_ACTIVE.has(r.status)
|
||||
? `排队 ${formatRelative(r.createdAt)}`
|
||||
: `未开始 · ${formatRelative(r.createdAt)}`}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "link",
|
||||
header: "关联",
|
||||
hideBelow: "lg",
|
||||
cell: (r) => {
|
||||
const l = businessLink(r.business);
|
||||
return l ? (
|
||||
<Link
|
||||
to={l.to}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="inline-flex items-center gap-1 text-caption text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
|
||||
>
|
||||
{l.label}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -37,6 +37,7 @@ export default [
|
||||
route("engine", "routes/console/engine.tsx"),
|
||||
route("engine/:runId", "routes/console/engine.$runId.tsx"),
|
||||
route("audit", "routes/console/audit.tsx"),
|
||||
route("logs", "routes/console/logs.tsx"),
|
||||
route("settings", "routes/console/settings.tsx"),
|
||||
route("schedules", "routes/console/schedules.tsx"),
|
||||
route("search", "routes/console/search.tsx"),
|
||||
|
||||
@@ -1,266 +1,8 @@
|
||||
import { AuditAction, type AuditEvent, CAPABILITY_CATALOG, type KnownCapabilityId } from "@umt/contracts";
|
||||
import { X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { useAuditEvents } from "~/api/audit.ts";
|
||||
import { DataTable } from "~/components/console/data-table.tsx";
|
||||
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
|
||||
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
|
||||
import { JsonView } from "~/components/console/json-view.tsx";
|
||||
import { KvList } from "~/components/console/kv-list.tsx";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { ACTOR_TYPES, actorTypeLabel, formatRelative } from "~/lib/labels.ts";
|
||||
import { formatDateTime } from "~/lib/utils.ts";
|
||||
import { legacyRedirectLoader } from "./redirects.ts";
|
||||
|
||||
export const meta = () => [{ title: "审计 · UHD 媒体自动化控制台" }];
|
||||
// 界面第二轮:审计页并入日志页(登录、API Key 在「登录」类,其余在「审计」类);?targetType=&targetId= 换成对象筛选。
|
||||
export const clientLoader = legacyRedirectLoader;
|
||||
|
||||
const actorLabel = actorTypeLabel;
|
||||
|
||||
const ACTION_LABEL: Record<string, string> = {
|
||||
"admin.setup": "初始化管理员",
|
||||
"admin.login_success": "登录",
|
||||
"admin.logout": "退出登录",
|
||||
"admin.password_change": "修改密码",
|
||||
"admin.totp_enable": "启用二次验证",
|
||||
"admin.totp_disable": "停用二次验证",
|
||||
"api_key.create": "创建 API Key",
|
||||
"api_key.update": "修改 API Key",
|
||||
"api_key.revoke": "撤销 API Key",
|
||||
"settings.update": "修改设置",
|
||||
"secret.create": "新建凭证",
|
||||
"secret.rotate": "轮换凭证",
|
||||
"secret.delete": "删除凭证",
|
||||
"site_account.credentials_set": "录入站点凭证",
|
||||
"engine.run_cancel": "取消任务",
|
||||
"pi_upstream.oauth_start": "开始订阅登录",
|
||||
"pi_upstream.oauth_login": "完成订阅登录",
|
||||
"pi_upstream.oauth_logout": "退出订阅登录",
|
||||
"schedule.run_now": "立即执行定时任务",
|
||||
"schedule.skip": "跳过定时任务",
|
||||
"acquisition_plan.manual_submit": "手动提交下载",
|
||||
"acquisition_plan.cancel": "取消下载",
|
||||
"engine.run_retry": "重跑任务",
|
||||
"capability.invoke": "智能体操作",
|
||||
"capability.denied": "智能体操作被拒绝",
|
||||
"site.created": "登记站点",
|
||||
"site.updated": "修改站点",
|
||||
"feed_source.created": "新建 RSS",
|
||||
"identity.release_assigned": "指定资源身份",
|
||||
"schedule.update": "修改定时任务",
|
||||
"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: "路径映射",
|
||||
pi_upstream: "模型服务",
|
||||
schedule: "定时任务",
|
||||
automation_grant: "持续授权",
|
||||
};
|
||||
const TARGET_LABEL: Record<string, string> = {
|
||||
...NOUN,
|
||||
admin_user: "管理员",
|
||||
api_key: "API Key",
|
||||
settings: "设置",
|
||||
secret: "凭证",
|
||||
site: "站点",
|
||||
site_account: "站点账号",
|
||||
feed_source: "RSS",
|
||||
engine_run: "任务",
|
||||
acquisition_plan: "下载",
|
||||
download_task: "下载任务",
|
||||
media_entity: "作品",
|
||||
source_release: "资源",
|
||||
organization_record: "整理记录",
|
||||
file: "文件",
|
||||
capability: "能力",
|
||||
};
|
||||
/** 对象列:能力显示中文名,其余显示对象类型;ID 只在详情里 */
|
||||
function targetText(type: string, id: string | null): string {
|
||||
if (type === "capability" && id) return CAPABILITY_CATALOG[id as KnownCapabilityId]?.title ?? id;
|
||||
return TARGET_LABEL[type] ?? type;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
// 前缀组 + 具体动作
|
||||
const ACTION_OPTIONS = [
|
||||
...[
|
||||
"admin",
|
||||
"api_key",
|
||||
"settings",
|
||||
"secret",
|
||||
"library_root",
|
||||
"downloader_instance",
|
||||
"media_server_instance",
|
||||
"path_mapping",
|
||||
].map((p) => ({ value: `${p}.*`, label: `${p}.*(全部)` })),
|
||||
...AuditAction.options.map((a) => ({ value: a, label: `${actionLabel(a)} · ${a}` })),
|
||||
];
|
||||
|
||||
export default function AuditPage() {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const actorType = params.get("actorType") ?? "";
|
||||
const action = params.get("action") ?? "";
|
||||
const targetId = params.get("targetId") ?? "";
|
||||
const [target, setTarget] = useState(params.get("targetType") ?? "");
|
||||
const [targetType, setTargetType] = useState(target);
|
||||
const [selected, setSelected] = useState<AuditEvent | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => {
|
||||
const v = target.trim();
|
||||
setTargetType(v);
|
||||
setParams(
|
||||
(p) => {
|
||||
if (v) p.set("targetType", v);
|
||||
else p.delete("targetType");
|
||||
return p;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}, 350);
|
||||
return () => clearTimeout(t);
|
||||
}, [target, setParams]);
|
||||
|
||||
const setParam = (k: string, v: string) =>
|
||||
setParams(
|
||||
(p) => {
|
||||
if (v) p.set(k, v);
|
||||
else p.delete(k);
|
||||
return p;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
|
||||
const q = useAuditEvents({
|
||||
actorType: actorType || undefined,
|
||||
action: action || undefined,
|
||||
targetType: targetType || undefined,
|
||||
targetId: targetId || undefined,
|
||||
});
|
||||
const rows = q.data?.pages.flatMap((p) => p.items);
|
||||
const filtered = !!(actorType || action || targetType || targetId);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="审计" />
|
||||
<FilterBar
|
||||
search={{ value: target, onChange: setTarget, placeholder: "对象类型,如 downloader_instance" }}
|
||||
onReset={
|
||||
filtered
|
||||
? () => {
|
||||
setTarget("");
|
||||
setParams({}, { replace: true });
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<FilterSelect
|
||||
label="操作者"
|
||||
value={actorType}
|
||||
onChange={(v) => setParam("actorType", v)}
|
||||
options={ACTOR_TYPES}
|
||||
/>
|
||||
<FilterSelect label="动作" value={action} onChange={(v) => setParam("action", v)} options={ACTION_OPTIONS} />
|
||||
{targetId ? (
|
||||
<Button variant="outline" size="sm" onClick={() => setParam("targetId", "")} title={targetId}>
|
||||
对象 <span className="max-w-40 truncate font-mono">{targetId}</span>
|
||||
<X />
|
||||
</Button>
|
||||
) : null}
|
||||
</FilterBar>
|
||||
<DataTable<AuditEvent>
|
||||
paginationKey={JSON.stringify({ actorType, action, targetType, targetId })}
|
||||
rows={rows}
|
||||
rowKey={(r) => r.id}
|
||||
loading={q.isPending}
|
||||
error={q.error}
|
||||
onRetry={() => q.refetch()}
|
||||
empty={{
|
||||
title: filtered ? "没有符合条件的审计事件" : "还没有审计事件",
|
||||
}}
|
||||
onRowClick={setSelected}
|
||||
selectedKey={selected?.id ?? null}
|
||||
hasMore={!!q.hasNextPage}
|
||||
onLoadMore={() => q.fetchNextPage()}
|
||||
loadingMore={q.isFetchingNextPage}
|
||||
columns={[
|
||||
{
|
||||
key: "at",
|
||||
header: "时间",
|
||||
cell: (r) => (
|
||||
<span className="whitespace-nowrap tabular-nums" title={formatDateTime(r.at)}>
|
||||
{formatRelative(r.at)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "action",
|
||||
header: "动作",
|
||||
cell: (r) => (
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">{actionLabel(r.action)}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: "actor", header: "操作者", hideBelow: "sm", cell: (r) => actorLabel(r.actorType) },
|
||||
{
|
||||
key: "target",
|
||||
header: "对象",
|
||||
hideBelow: "md",
|
||||
cell: (r) =>
|
||||
r.targetType ? (
|
||||
<p className="min-w-0 max-w-[18rem] truncate" title={`${r.targetType} · ${r.targetId ?? ""}`}>
|
||||
{targetText(r.targetType, r.targetId)}
|
||||
</p>
|
||||
) : (
|
||||
"—"
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<DetailSheet
|
||||
open={!!selected}
|
||||
onOpenChange={(o) => !o && setSelected(null)}
|
||||
title={selected ? actionLabel(selected.action) : ""}
|
||||
description={selected ? formatDateTime(selected.at) : undefined}
|
||||
>
|
||||
{selected ? (
|
||||
<>
|
||||
<DetailSection title="事件">
|
||||
<KvList
|
||||
items={[
|
||||
{ label: "动作", value: selected.action, mono: true },
|
||||
{
|
||||
label: "操作者",
|
||||
value: `${actorLabel(selected.actorType)}${selected.actorId ? ` · ${selected.actorId}` : ""}`,
|
||||
wide: true,
|
||||
},
|
||||
{ label: "对象类型", value: selected.targetType },
|
||||
{ label: "对象 ID", value: selected.targetId, mono: true },
|
||||
{ label: "Trace", value: selected.traceId, mono: true },
|
||||
{ label: "事件 ID", value: selected.id, mono: true },
|
||||
]}
|
||||
/>
|
||||
</DetailSection>
|
||||
<JsonView label="负载" value={selected.payload} />
|
||||
</>
|
||||
) : null}
|
||||
</DetailSheet>
|
||||
</>
|
||||
);
|
||||
export default function LegacyRedirect() {
|
||||
return null;
|
||||
}
|
||||
@@ -28,7 +28,7 @@ import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { AttentionList, attentionRows } from "~/components/dashboard/attention-summary.tsx";
|
||||
import { HomeRuntime } from "~/components/dashboard/home-runtime.tsx";
|
||||
import { runTitle } from "~/components/tasks/labels.ts";
|
||||
import { useLiveTasks } from "~/components/tasks/task-stream.tsx";
|
||||
import { useLiveTasks } from "~/components/tasks/live.ts";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { formatRelative } from "~/lib/labels.ts";
|
||||
@@ -217,7 +217,7 @@ function Busy({ w }: { w: Dashboard["currentWork"] }) {
|
||||
]);
|
||||
const empty = !downloads.length && !agents.length && !runs.length;
|
||||
return (
|
||||
<Section title="在忙什么" icon={Activity} to="/console/tasks?tab=active">
|
||||
<Section title="在忙什么" icon={Activity} to="/console/logs?type=task&level=running">
|
||||
{running.isPending && !downloads.length ? (
|
||||
<LoadingState rows={3} />
|
||||
) : empty ? (
|
||||
@@ -246,7 +246,7 @@ function Busy({ w }: { w: Dashboard["currentWork"] }) {
|
||||
))}
|
||||
{runs.slice(0, Math.max(0, 5 - downloads.length - agents.length)).map((r) => (
|
||||
<Row key={r.id}>
|
||||
<Link to={`/console/tasks?run=${encodeURIComponent(r.id)}`} className="min-w-0 flex-1 hover:underline">
|
||||
<Link to={`/console/logs?run=${encodeURIComponent(r.id)}`} className="min-w-0 flex-1 hover:underline">
|
||||
<span className="block truncate">{runTitle(r)}</span>
|
||||
</Link>
|
||||
</Row>
|
||||
|
||||
@@ -0,0 +1,460 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { LogEntry } from "@umt/contracts";
|
||||
import { Pause, Play, RotateCw, ScrollText, X } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { agentKeys } from "~/api/agent.ts";
|
||||
import { type LogParams, type LogStreamStatus, upsertEntries, useLogStream, useLogs } from "~/api/logs.ts";
|
||||
import { DataTable } from "~/components/console/data-table.tsx";
|
||||
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import {
|
||||
LOG_FILTER_KEYS,
|
||||
LOG_LEVEL,
|
||||
LOG_LEVELS,
|
||||
LOG_SINCE,
|
||||
LOG_TYPE_LABEL,
|
||||
LOG_TYPES,
|
||||
type LogFilterKey,
|
||||
type LogFilters,
|
||||
logBadge,
|
||||
logSubtitle,
|
||||
logTitle,
|
||||
objectText,
|
||||
sinceToFrom,
|
||||
TARGET_LABEL,
|
||||
} from "~/components/logs/labels.ts";
|
||||
import { AskAgentButton, LogDetailSheet } from "~/components/logs/log-detail.tsx";
|
||||
import { PlanBoard } from "~/components/schedules/schedule-board.tsx";
|
||||
import { useLiveTasks } from "~/components/tasks/live.ts";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
|
||||
import { formatRelative } from "~/lib/labels.ts";
|
||||
import { qk } from "~/lib/query-keys.ts";
|
||||
import { cn, formatDateTime } from "~/lib/utils.ts";
|
||||
|
||||
export const meta = () => [{ title: "日志 · UHD 媒体自动化控制台" }];
|
||||
|
||||
// 日志(界面第二轮 V 线):任务、定时与自动任务、登录、审计合成一份列表(GET /logs),实时追加(GET /logs/stream);
|
||||
// 「定时任务」页签是原计划页(定时任务的开关、间隔、立即执行;运行记录回到日志按定时任务筛选)。
|
||||
// URL:?type= &level= &since= &q= &objectType= &objectId= &system=1 筛选;?log=<id> / ?run=<引擎运行 id> 打开详情;
|
||||
// ?tab=schedules[&job=] 定时任务页签。
|
||||
|
||||
type Tab = "logs" | "schedules";
|
||||
|
||||
/** 列表里的时间:今天只写时分秒,其余带月日 */
|
||||
function shortTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
const hms = `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
const today = new Date();
|
||||
return d.toDateString() === today.toDateString() ? hms : `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${hms}`;
|
||||
}
|
||||
|
||||
const STREAM_LABEL: Record<LogStreamStatus, string> = {
|
||||
off: "已暂停",
|
||||
connecting: "连接中…",
|
||||
open: "实时",
|
||||
reconnecting: "重连中…",
|
||||
};
|
||||
|
||||
function StreamState({ status }: { status: LogStreamStatus }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 text-meta text-muted-foreground" role="status">
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"inline-block size-2 rounded-full",
|
||||
status === "open" ? "animate-pulse bg-success" : status === "off" ? "bg-foreground/30" : "bg-warning",
|
||||
)}
|
||||
/>
|
||||
{STREAM_LABEL[status]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function LogsView({
|
||||
params,
|
||||
setParams,
|
||||
}: {
|
||||
params: URLSearchParams;
|
||||
setParams: ReturnType<typeof useSearchParams>[1];
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
useLiveTasks();
|
||||
const filters = Object.fromEntries(LOG_FILTER_KEYS.map((k) => [k, params.get(k) ?? ""])) as LogFilters;
|
||||
const [keyword, setKeyword] = useState(filters.q);
|
||||
const [live, setLive] = useState(true);
|
||||
const [selected, setSelected] = useState<Set<string>>(() => new Set());
|
||||
const anchor = useRef<string | null>(null);
|
||||
|
||||
const update = (fn: (p: URLSearchParams) => void) =>
|
||||
setParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
fn(next);
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
const setFilter = (k: LogFilterKey, v: string) =>
|
||||
update((p) => {
|
||||
if (v) p.set(k, v);
|
||||
else p.delete(k);
|
||||
});
|
||||
|
||||
// 关键词输入防抖写进 URL
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: 只跟输入走
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => {
|
||||
if (keyword.trim() !== filters.q) setFilter("q", keyword.trim());
|
||||
}, 350);
|
||||
return () => clearTimeout(t);
|
||||
}, [keyword]);
|
||||
|
||||
const from = useMemo(() => sinceToFrom(filters.since), [filters.since]);
|
||||
const query: LogParams = {
|
||||
type: filters.type || undefined,
|
||||
level: filters.level || undefined,
|
||||
from,
|
||||
q: filters.q || undefined,
|
||||
objectType: filters.objectType || undefined,
|
||||
objectId: filters.objectId || undefined,
|
||||
system: filters.system || undefined,
|
||||
};
|
||||
const list = useLogs(query);
|
||||
const listRows = useMemo(() => list.data?.pages.flatMap((p) => p.items) ?? [], [list.data]);
|
||||
// 流:从列表里最新一条之后接着推;时间范围只决定下界,新日志总在范围内
|
||||
const stream = useLogStream(query, { enabled: live && !list.isPending, since: listRows[0]?.at ?? null });
|
||||
const rows = useMemo(
|
||||
() => (stream.entries.length ? upsertEntries(listRows, stream.entries) : listRows),
|
||||
[listRows, stream.entries],
|
||||
);
|
||||
const fresh = useMemo(() => new Set(stream.entries.map((e) => e.id)), [stream.entries]);
|
||||
|
||||
// 筛选变了:清掉选择
|
||||
const filterKey = JSON.stringify(query);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: 只在筛选变化时清空
|
||||
useEffect(() => {
|
||||
setSelected(new Set());
|
||||
anchor.current = null;
|
||||
}, [filterKey]);
|
||||
|
||||
const openId = params.get("log") ?? (params.get("run") ? `engine_run:${params.get("run")}` : null);
|
||||
const openEntry = openId ? (rows.find((e) => e.id === openId) ?? null) : null;
|
||||
const selectedRows = rows.filter((e) => selected.has(e.id));
|
||||
|
||||
/** 勾选:按住 Shift 选中与上一次勾选之间的一段 */
|
||||
const toggle = (e: LogEntry, shift: boolean) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
const on = !prev.has(e.id);
|
||||
if (shift && anchor.current) {
|
||||
const a = rows.findIndex((r) => r.id === anchor.current);
|
||||
const b = rows.findIndex((r) => r.id === e.id);
|
||||
if (a >= 0 && b >= 0) {
|
||||
for (const r of rows.slice(Math.min(a, b), Math.max(a, b) + 1)) {
|
||||
if (on) next.add(r.id);
|
||||
else next.delete(r.id);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
}
|
||||
if (on) next.add(e.id);
|
||||
else next.delete(e.id);
|
||||
return next;
|
||||
});
|
||||
anchor.current = e.id;
|
||||
};
|
||||
|
||||
const filtered = LOG_FILTER_KEYS.some((k) => !!filters[k]);
|
||||
const objectChip = filters.objectType || filters.objectId;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
<StreamState status={live ? stream.status : "off"} />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setLive((v) => !v)}
|
||||
aria-pressed={live}
|
||||
aria-label={live ? "暂停实时" : "继续实时"}
|
||||
>
|
||||
{live ? <Pause /> : <Play />}
|
||||
{live ? "暂停" : "继续"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
stream.clear();
|
||||
void qc.invalidateQueries({ queryKey: qk.all("logs") });
|
||||
}}
|
||||
>
|
||||
<RotateCw />
|
||||
刷新
|
||||
</Button>
|
||||
{selected.size ? (
|
||||
<span className="ml-auto flex flex-wrap items-center gap-2" data-floating-bar>
|
||||
<span className="text-caption text-muted-foreground">已选 {selected.size} 条</span>
|
||||
<AskAgentButton entries={selectedRows} />
|
||||
<Button variant="ghost" size="sm" onClick={() => setSelected(new Set())}>
|
||||
<X />
|
||||
清除选择
|
||||
</Button>
|
||||
</span>
|
||||
) : (
|
||||
<span className="ml-auto text-meta text-muted-foreground">勾选日志(按住 Shift 选一段)后可以问智能体</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FilterBar
|
||||
search={{ value: keyword, onChange: setKeyword, placeholder: "搜索标题、错误、对象 ID" }}
|
||||
onReset={
|
||||
filtered
|
||||
? () => {
|
||||
setKeyword("");
|
||||
update((p) => {
|
||||
for (const k of LOG_FILTER_KEYS) p.delete(k);
|
||||
});
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<FilterSelect
|
||||
label="类型"
|
||||
value={filters.type}
|
||||
onChange={(v) => setFilter("type", v)}
|
||||
options={LOG_TYPES.map((value) => ({ value, label: LOG_TYPE_LABEL[value] }))}
|
||||
/>
|
||||
<FilterSelect
|
||||
label="结果"
|
||||
value={filters.level}
|
||||
onChange={(v) => setFilter("level", v)}
|
||||
options={LOG_LEVELS.map((value) => ({ value, label: LOG_LEVEL[value].label }))}
|
||||
/>
|
||||
<FilterSelect
|
||||
label="时间"
|
||||
value={filters.since}
|
||||
onChange={(v) => setFilter("since", v)}
|
||||
options={LOG_SINCE}
|
||||
allLabel="不限"
|
||||
/>
|
||||
<Button
|
||||
variant={filters.system === "1" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
aria-pressed={filters.system === "1"}
|
||||
title="没有业务对象的任务、例行定时任务的成功运行"
|
||||
onClick={() => setFilter("system", filters.system === "1" ? "" : "1")}
|
||||
>
|
||||
{filters.system === "1" ? "隐藏例行记录" : "显示例行记录"}
|
||||
</Button>
|
||||
{objectChip ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
title={`${filters.objectType} ${filters.objectId}`}
|
||||
onClick={() =>
|
||||
update((p) => {
|
||||
p.delete("objectType");
|
||||
p.delete("objectId");
|
||||
})
|
||||
}
|
||||
>
|
||||
{filters.objectType ? (TARGET_LABEL[filters.objectType] ?? filters.objectType) : "对象"}
|
||||
{filters.objectId ? <span className="max-w-40 truncate font-mono">{filters.objectId}</span> : null}
|
||||
<X />
|
||||
</Button>
|
||||
) : null}
|
||||
</FilterBar>
|
||||
|
||||
<DataTable<LogEntry>
|
||||
paginationKey={filterKey}
|
||||
rows={list.isPending ? undefined : rows}
|
||||
rowKey={(r) => r.id}
|
||||
loading={list.isPending}
|
||||
error={list.error}
|
||||
onRetry={() => void list.refetch()}
|
||||
onRowClick={(r) =>
|
||||
update((p) => {
|
||||
p.delete("run");
|
||||
p.set("log", r.id);
|
||||
})
|
||||
}
|
||||
selectedKey={openId}
|
||||
hasMore={!!list.hasNextPage}
|
||||
onLoadMore={() => list.fetchNextPage()}
|
||||
loadingMore={list.isFetchingNextPage}
|
||||
empty={{
|
||||
icon: ScrollText,
|
||||
title: filtered ? "没有符合条件的日志" : "还没有日志",
|
||||
description: filtered ? "换个筛选或放宽时间范围。" : "任务运行、定时任务执行、登录和设置修改都会记在这里。",
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
key: "pick",
|
||||
header: <span className="sr-only">选择</span>,
|
||||
className: "w-8",
|
||||
cell: (r) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`选择 ${logTitle(r)}`}
|
||||
checked={selected.has(r.id)}
|
||||
onClick={(ev) => {
|
||||
ev.stopPropagation();
|
||||
toggle(r, ev.shiftKey);
|
||||
}}
|
||||
onChange={() => {}}
|
||||
className="h-4 w-4 cursor-pointer accent-foreground"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "at",
|
||||
header: "时间",
|
||||
cell: (r) => (
|
||||
<span
|
||||
className={cn(
|
||||
"whitespace-nowrap font-mono text-caption tabular-nums",
|
||||
fresh.has(r.id) ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
title={`${formatDateTime(r.at)}(${formatRelative(r.at)})`}
|
||||
>
|
||||
{shortTime(r.at)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "type",
|
||||
header: "类型",
|
||||
hideBelow: "sm",
|
||||
cell: (r) => <span className="whitespace-nowrap text-caption">{LOG_TYPE_LABEL[r.type]}</span>,
|
||||
},
|
||||
{
|
||||
key: "level",
|
||||
header: "结果",
|
||||
cell: (r) => {
|
||||
const b = logBadge(r);
|
||||
return <StatusBadge tone={b.tone}>{b.label}</StatusBadge>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "title",
|
||||
header: "摘要",
|
||||
cell: (r) => {
|
||||
const sub = logSubtitle(r);
|
||||
return (
|
||||
<div className="min-w-0 max-w-[16rem] sm:max-w-md lg:max-w-xl">
|
||||
<p className="truncate font-medium text-foreground" title={logTitle(r)}>
|
||||
{logTitle(r)}
|
||||
</p>
|
||||
{sub ? (
|
||||
<p
|
||||
className={cn(
|
||||
"truncate text-meta",
|
||||
r.level === "error" ? "text-destructive" : "text-muted-foreground",
|
||||
)}
|
||||
title={sub}
|
||||
>
|
||||
{sub}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "object",
|
||||
header: "对象",
|
||||
hideBelow: "lg",
|
||||
cell: (r) => {
|
||||
const t = objectText(r.object);
|
||||
return t ? (
|
||||
<span className="block max-w-[14rem] truncate text-caption text-muted-foreground" title={t}>
|
||||
{t}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<LogDetailSheet
|
||||
logId={openId}
|
||||
entry={openEntry}
|
||||
onOpenLog={(id) =>
|
||||
update((p) => {
|
||||
p.delete("run");
|
||||
p.set("log", id);
|
||||
})
|
||||
}
|
||||
onClose={() =>
|
||||
update((p) => {
|
||||
p.delete("log");
|
||||
p.delete("run");
|
||||
})
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LogsPage() {
|
||||
const qc = useQueryClient();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const tab: Tab = params.get("tab") === "schedules" ? "schedules" : "logs";
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="日志"
|
||||
actions={
|
||||
tab === "schedules" ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
qc.invalidateQueries({ queryKey: qk.all("schedules") });
|
||||
qc.invalidateQueries({ queryKey: [...agentKeys.all, "list"] });
|
||||
}}
|
||||
>
|
||||
<RotateCw />
|
||||
刷新
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(v) =>
|
||||
setParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (v === "schedules") next.set("tab", "schedules");
|
||||
else {
|
||||
next.delete("tab");
|
||||
next.delete("job");
|
||||
}
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
)
|
||||
}
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="logs">日志</TabsTrigger>
|
||||
<TabsTrigger value="schedules">定时任务</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<div className="mt-4">
|
||||
{tab === "schedules" ? (
|
||||
<PlanBoard highlightJob={params.get("job") ?? undefined} />
|
||||
) : (
|
||||
<LogsView params={params} setParams={setParams} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { redirect } from "react-router";
|
||||
import { BUSINESS_OBJECT_TYPE, RUN_STATUS_LEVEL } from "~/components/logs/labels.ts";
|
||||
|
||||
// 控制台 IA 重构后的旧链接 → 新路由(IA-1:docs/design/console-ia.md §3.3;IA-3:§7;IA-2 站点类型:RSS 并入站点)。
|
||||
// 纯函数,各路由模块的 clientLoader 调用;nav.test 与冒烟都读 LEGACY_REDIRECTS。
|
||||
// 设置页的 ?tab= 旧链接由设置页自己处理(IA-2)。
|
||||
// 界面第二轮(V 线):任务、审计、引擎面板、计划合进日志页 /console/logs;任务详情 /console/tasks/:id 保留为独立页。
|
||||
|
||||
const IDENTITY_TAB: Record<string, string> = {
|
||||
pool: "filter=unidentified",
|
||||
@@ -19,10 +21,14 @@ export function legacyRedirect(pathname: string, search: string): string | null
|
||||
const rest = q.toString();
|
||||
const withRest = (to: string) => (rest ? `${to}${to.includes("?") ? "&" : "?"}${rest}` : to);
|
||||
|
||||
// IA-3:引擎面板并入任务页;运行详情用任务页抽屉(?run=)
|
||||
if (path === "/console/engine") return withRest(tab ? `/console/tasks?tab=${tab}` : "/console/tasks");
|
||||
// 引擎面板 → 日志页「任务」;运行详情用日志页抽屉(?run=)
|
||||
if (path === "/console/engine") return tasksToLogs(new URLSearchParams(search));
|
||||
const engineRun = /^\/console\/engine\/([^/]+)$/.exec(path);
|
||||
if (engineRun) return withRest(`/console/tasks?run=${engineRun[1]}`);
|
||||
if (engineRun)
|
||||
return logsUrl([
|
||||
["type", "task"],
|
||||
["run", decodeURIComponent(engineRun[1]!)],
|
||||
]);
|
||||
// IA-3:身份与元数据取消,待识别 / 外部身份 / 别名并入媒体概览
|
||||
if (path === "/console/identity") return withRest(`/console/library?${IDENTITY_TAB[tab ?? ""] ?? IDENTITY_TAB.pool}`);
|
||||
// DL-3:下载计划页签取消,计划并入下载面板;带计划 id 的深链(?plan= 或 /plans/:id)打开对应行
|
||||
@@ -42,20 +48,70 @@ export function legacyRedirect(pathname: string, search: string): string | null
|
||||
// 2026-10-06:待处理中心取消,问题回到发生处;旧链接按问题类型落到对应页面
|
||||
if (path === "/console/inbox") return inboxRedirect(q.get("kind"));
|
||||
|
||||
if (!tab) return null;
|
||||
if (path === "/console/tasks") {
|
||||
// FE-6:计划从任务页拆出,回到独立的计划页(?job= 透传)
|
||||
if (tab === "plan") return withRest("/console/schedules");
|
||||
// FE-6 / DL-3 的旧页签
|
||||
if (tab === "plan") return withRest("/console/logs?tab=schedules");
|
||||
if (tab === "downloads") return withRest("/console/downloads");
|
||||
if (tab === "plans") return withRest("/console/downloads");
|
||||
// 旧"业务任务"页签:统一任务流
|
||||
if (tab === "tasks" || tab === "business") return withRest("/console/tasks");
|
||||
return null;
|
||||
return tasksToLogs(new URLSearchParams(search));
|
||||
}
|
||||
// 审计 → 日志页(审计里的登录、API Key 事件在「登录」类)
|
||||
if (path === "/console/audit") return auditToLogs(new URLSearchParams(search));
|
||||
// 计划 → 日志页「定时任务」页签(?job= 透传)
|
||||
if (path === "/console/schedules") return withRest("/console/logs?tab=schedules");
|
||||
|
||||
if (!tab) return null;
|
||||
if (path === "/console/subscriptions" && tab === "policies") return withRest("/console/rules");
|
||||
return null;
|
||||
}
|
||||
|
||||
const LOGS_PATH = "/console/logs";
|
||||
|
||||
function logsUrl(pairs: [string, string | null | undefined][]): string {
|
||||
const q = new URLSearchParams();
|
||||
for (const [k, v] of pairs) if (v) q.set(k, v);
|
||||
const s = q.toString();
|
||||
return s ? `${LOGS_PATH}?${s}` : LOGS_PATH;
|
||||
}
|
||||
|
||||
/** 旧任务页 / 引擎面板的筛选 → 日志页:页签与状态换成级别,业务对象换成对象筛选(按对象时不限类型) */
|
||||
function tasksToLogs(q: URLSearchParams): string {
|
||||
const tab = q.get("tab");
|
||||
const levels = new Set<string>();
|
||||
if (tab === "active") levels.add("running");
|
||||
else if (tab === "failed") levels.add("error");
|
||||
else
|
||||
for (const st of (q.get("status") ?? "").split(",")) {
|
||||
const l = RUN_STATUS_LEVEL[st.trim()];
|
||||
if (l) levels.add(l);
|
||||
}
|
||||
const kind = q.get("kind");
|
||||
const ref = q.get("ref");
|
||||
return logsUrl([
|
||||
["type", ref ? null : "task"],
|
||||
["level", [...levels].join(",")],
|
||||
["since", q.get("since")],
|
||||
["q", q.get("workflow") ?? q.get("workflowName")],
|
||||
["objectType", ref && kind ? (BUSINESS_OBJECT_TYPE[kind] ?? null) : null],
|
||||
["objectId", ref],
|
||||
["system", q.get("system")],
|
||||
["run", q.get("run")],
|
||||
]);
|
||||
}
|
||||
|
||||
/** 旧审计页:对象筛选照搬;动作当关键词(去掉 .* 前缀组的通配) */
|
||||
function auditToLogs(q: URLSearchParams): string {
|
||||
const targetType = q.get("targetType");
|
||||
const targetId = q.get("targetId");
|
||||
const action = q.get("action")?.replace(/\.\*$/, "") ?? null;
|
||||
return logsUrl([
|
||||
["type", targetType || targetId || action ? null : "audit"],
|
||||
["q", action],
|
||||
["objectType", targetType],
|
||||
["objectId", targetId],
|
||||
]);
|
||||
}
|
||||
|
||||
/** 整理侧的问题(下载完识别不到、路径冲突、文件被排除、整理卡住 / 失败)→ 整理记录「需要处理」 */
|
||||
export const ORGANIZE_ATTENTION_PATH = "/console/organize?tab=records&attention=1";
|
||||
|
||||
@@ -88,8 +144,27 @@ export const LEGACY_REDIRECTS: { from: string; to: string }[] = [
|
||||
{ from: "/console/downloads/plans", to: "/console/downloads" },
|
||||
{ from: "/console/downloads/plans?plan=p-1", to: "/console/downloads?plan=p-1" },
|
||||
{ from: "/console/downloads/plans/p-1", to: "/console/downloads?plan=p-1" },
|
||||
{ from: "/console/tasks?tab=tasks", to: "/console/tasks" },
|
||||
{ from: "/console/tasks?tab=business", to: "/console/tasks" },
|
||||
{ from: "/console/tasks?tab=tasks", to: "/console/logs?type=task" },
|
||||
{ from: "/console/tasks?tab=business", to: "/console/logs?type=task" },
|
||||
{ from: "/console/tasks", to: "/console/logs?type=task" },
|
||||
{ from: "/console/tasks?tab=active", to: "/console/logs?type=task&level=running" },
|
||||
{ from: "/console/tasks?tab=failed&since=24h", to: "/console/logs?type=task&level=error&since=24h" },
|
||||
{ from: "/console/tasks?tab=all&status=failed", to: "/console/logs?type=task&level=error" },
|
||||
{ from: "/console/tasks?run=run-1", to: "/console/logs?type=task&run=run-1" },
|
||||
{
|
||||
from: "/console/tasks?tab=all&kind=schedule&ref=trash.purge",
|
||||
to: "/console/logs?objectType=scheduled_job&objectId=trash.purge",
|
||||
},
|
||||
{ from: "/console/tasks?kind=download&ref=d-1", to: "/console/logs?objectType=download_task&objectId=d-1" },
|
||||
{ from: "/console/audit", to: "/console/logs?type=audit" },
|
||||
{
|
||||
from: "/console/audit?targetType=engine_run&targetId=r1",
|
||||
to: "/console/logs?objectType=engine_run&objectId=r1",
|
||||
},
|
||||
{ from: "/console/audit?action=settings.update", to: "/console/logs?q=settings.update" },
|
||||
{ from: "/console/audit?action=api_key.*", to: "/console/logs?q=api_key" },
|
||||
{ from: "/console/schedules", to: "/console/logs?tab=schedules" },
|
||||
{ from: "/console/schedules?job=trash.purge", to: "/console/logs?tab=schedules&job=trash.purge" },
|
||||
{ from: "/console/subscriptions?tab=policies", to: "/console/rules" },
|
||||
{ from: "/console/sources?tab=snapshots", to: "/console/sites" },
|
||||
{ from: "/console/sources?tab=snapshots&siteId=abc", to: "/console/sites/abc?tab=snapshots" },
|
||||
@@ -98,11 +173,11 @@ export const LEGACY_REDIRECTS: { from: string; to: string }[] = [
|
||||
{ from: "/console/sources?siteId=abc", to: "/console/sites/abc?tab=rss" },
|
||||
{ from: "/console/sources?tab=feeds&siteId=abc", to: "/console/sites/abc?tab=rss" },
|
||||
{ from: "/console/sources?new=1", to: "/console/sites?new=rss_feed" },
|
||||
{ from: "/console/engine", to: "/console/tasks" },
|
||||
{ from: "/console/engine?status=failed", to: "/console/tasks?status=failed" },
|
||||
{ from: "/console/engine/run-1", to: "/console/tasks?run=run-1" },
|
||||
{ from: "/console/tasks?tab=plan", to: "/console/schedules" },
|
||||
{ from: "/console/tasks?tab=plan&job=trash.purge", to: "/console/schedules?job=trash.purge" },
|
||||
{ from: "/console/engine", to: "/console/logs?type=task" },
|
||||
{ from: "/console/engine?status=failed", to: "/console/logs?type=task&level=error" },
|
||||
{ from: "/console/engine/run-1", to: "/console/logs?type=task&run=run-1" },
|
||||
{ from: "/console/tasks?tab=plan", to: "/console/logs?tab=schedules" },
|
||||
{ from: "/console/tasks?tab=plan&job=trash.purge", to: "/console/logs?tab=schedules&job=trash.purge" },
|
||||
{ from: "/console/identity", to: "/console/library?filter=unidentified" },
|
||||
{ from: "/console/identity?tab=external", to: "/console/library?view=identities" },
|
||||
{ from: "/console/identity?tab=aliases", to: "/console/library?view=aliases" },
|
||||
|
||||
@@ -1,38 +1,8 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { RotateCw } from "lucide-react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { agentKeys } from "~/api/agent.ts";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { PlanBoard } from "~/components/schedules/schedule-board.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { qk } from "~/lib/query-keys.ts";
|
||||
import { legacyRedirectLoader } from "./redirects.ts";
|
||||
|
||||
// FE-6:计划页(定时任务配置)与任务页(运行记录)分开。?job= 高亮并滚到该定时任务;#auto / #checks 定位到节。
|
||||
// 界面第二轮:计划页并入日志页的「定时任务」页签(/console/logs?tab=schedules,?job= 透传)。
|
||||
export const clientLoader = legacyRedirectLoader;
|
||||
|
||||
export const meta = () => [{ title: "计划 · UHD 媒体自动化控制台" }];
|
||||
|
||||
export default function SchedulesPage() {
|
||||
const qc = useQueryClient();
|
||||
const [params] = useSearchParams();
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="计划"
|
||||
actions={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
qc.invalidateQueries({ queryKey: qk.all("schedules") });
|
||||
qc.invalidateQueries({ queryKey: [...agentKeys.all, "list"] });
|
||||
}}
|
||||
>
|
||||
<RotateCw />
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PlanBoard highlightJob={params.get("job") ?? undefined} />
|
||||
</>
|
||||
);
|
||||
export default function LegacyRedirect() {
|
||||
return null;
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import { StatusBadge, type StatusTone } from "~/components/console/status-badge.
|
||||
import { TechInfo } from "~/components/console/tech-info.tsx";
|
||||
import { AgentSpendCard, AttentionCard } from "~/components/status/attention-cards.tsx";
|
||||
import { HistoryChart } from "~/components/status/history-chart.tsx";
|
||||
import { ResourceSection } from "~/components/status/resource-section.tsx";
|
||||
import { StorageSection } from "~/components/status/storage-section.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
|
||||
@@ -35,6 +36,7 @@ export const meta = () => [{ title: "运行状态 · UHD 媒体自动化控制
|
||||
|
||||
// 运行状态(W9-I):进程心跳、事件循环、SSE、DB、磁盘、挂载根、引擎与定时任务;存储节(FIX-5)见 storage-section.tsx。阈值见 docs/runbook/ops.md。
|
||||
// 2026-10-06:顶部加「需要关注」汇总(系统健康逐条列出,铃铛点进来落到 #attention)与智能体用量。
|
||||
// 界面第二轮:加「资源」节(CPU / 内存 / 网络 / 磁盘读写与趋势,参考 MP 仪表盘);进程表直接展示并带 CPU 列。
|
||||
|
||||
const LEVEL: Record<HealthLevel, { label: string; tone: StatusTone }> = {
|
||||
ok: { label: "正常", tone: "success" },
|
||||
@@ -250,10 +252,24 @@ function Processes({ d }: { d: SystemHealth }) {
|
||||
align: "right",
|
||||
cell: (p) => <span className="tabular-nums">{p.sseConnections ?? "—"}</span>,
|
||||
},
|
||||
{
|
||||
key: "cpu",
|
||||
header: "CPU",
|
||||
hideBelow: "md",
|
||||
align: "right",
|
||||
cell: (p) => {
|
||||
const cpu = p.extra.cpuPercent;
|
||||
return (
|
||||
<span className="tabular-nums" title="两次心跳之间本进程占一个核的百分比">
|
||||
{typeof cpu === "number" ? `${cpu.toFixed(1)}%` : "—"}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "mem",
|
||||
header: "内存",
|
||||
hideBelow: "lg",
|
||||
hideBelow: "md",
|
||||
align: "right",
|
||||
cell: (p) => {
|
||||
const rss = p.extra.rssBytes;
|
||||
@@ -517,6 +533,7 @@ export default function StatusPage() {
|
||||
<AttentionCard />
|
||||
<AgentSpendCard />
|
||||
</div>
|
||||
<ResourceSection />
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<Card title="数据库" icon={Database} level={d.db.level}>
|
||||
{d.db.error ? <p className="mb-2 text-caption text-destructive">{d.db.error}</p> : null}
|
||||
@@ -542,7 +559,10 @@ export default function StatusPage() {
|
||||
icon={ListTodo}
|
||||
level={d.engine.level}
|
||||
action={
|
||||
<Link to="/console/tasks?tab=active" className="text-meta text-muted-foreground hover:text-foreground">
|
||||
<Link
|
||||
to="/console/logs?type=task&level=running"
|
||||
className="text-meta text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
查看任务
|
||||
</Link>
|
||||
}
|
||||
@@ -567,8 +587,11 @@ export default function StatusPage() {
|
||||
icon={Timer}
|
||||
level={d.schedules.level}
|
||||
action={
|
||||
<Link to="/console/schedules" className="text-meta text-muted-foreground hover:text-foreground">
|
||||
查看计划
|
||||
<Link
|
||||
to="/console/logs?tab=schedules"
|
||||
className="text-meta text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
查看定时任务
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
@@ -584,7 +607,7 @@ export default function StatusPage() {
|
||||
{d.schedules.failing.map((f) => (
|
||||
<li key={f.id} className="min-w-0 text-caption">
|
||||
<Link
|
||||
to={`/console/schedules?job=${encodeURIComponent(f.id)}`}
|
||||
to={`/console/logs?objectType=scheduled_job&objectId=${encodeURIComponent(f.id)}`}
|
||||
className="font-medium text-foreground hover:underline"
|
||||
>
|
||||
{f.name}
|
||||
@@ -600,16 +623,16 @@ export default function StatusPage() {
|
||||
<Mounts d={d} />
|
||||
</div>
|
||||
<StorageSection health={d} />
|
||||
<TechInfo title="进程与趋势" columns={1}>
|
||||
<section className="min-w-0">
|
||||
<h2 className="mb-2 flex items-center gap-2 text-body font-semibold text-foreground">
|
||||
<Cpu className="h-4 w-4 text-muted-foreground" />
|
||||
进程
|
||||
<span className="text-meta font-normal text-muted-foreground">SSE 连接合计 {d.sse.connections}</span>
|
||||
</h2>
|
||||
<Processes d={d} />
|
||||
<RetiredProcesses rows={d.retiredProcesses} />
|
||||
</section>
|
||||
<section className="min-w-0">
|
||||
<h2 className="mb-2 flex items-center gap-2 text-body font-semibold text-foreground">
|
||||
<Cpu className="h-4 w-4 text-muted-foreground" />
|
||||
进程
|
||||
<span className="text-meta font-normal text-muted-foreground">SSE 连接合计 {d.sse.connections}</span>
|
||||
</h2>
|
||||
<Processes d={d} />
|
||||
<RetiredProcesses rows={d.retiredProcesses} />
|
||||
</section>
|
||||
<TechInfo title="事件循环与 SSE 趋势" columns={1}>
|
||||
<History />
|
||||
</TechInfo>
|
||||
</div>
|
||||
|
||||
@@ -124,9 +124,9 @@ function Operations({ taskId }: { taskId: string }) {
|
||||
|
||||
const back = (
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link to={consolePath("tasks")}>
|
||||
<Link to={`${consolePath("logs")}?type=task`}>
|
||||
<ArrowLeft />
|
||||
任务
|
||||
日志
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -1,151 +1,8 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { ExternalLink, RotateCw } from "lucide-react";
|
||||
import { Link, useSearchParams } from "react-router";
|
||||
import { useEngineRun, useEngineStats } from "~/api/engine.ts";
|
||||
import { DetailSheet } from "~/components/console/detail-sheet.tsx";
|
||||
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { runTitle } from "~/components/tasks/labels.ts";
|
||||
import { RunActions, TaskDetailBody, TaskStatusLine } from "~/components/tasks/task-detail.tsx";
|
||||
import { type StreamFilters, type StreamTab, TaskStream } from "~/components/tasks/task-stream.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
|
||||
import { qk } from "~/lib/query-keys.ts";
|
||||
import { legacyRedirectLoader } from "./redirects.ts";
|
||||
|
||||
export const meta = () => [{ title: "任务 · UHD 媒体自动化控制台" }];
|
||||
|
||||
// 旧的 ?tab=downloads / plans / business / plan 跳到下载面板 / 下载面板 / 任务流 / 计划页
|
||||
// 界面第二轮:任务页并入日志页(/console/logs?type=task);旧链接与筛选在 redirects.ts 里换算。任务详情 /console/tasks/:id 保留。
|
||||
export const clientLoader = legacyRedirectLoader;
|
||||
|
||||
type Tab = StreamTab;
|
||||
const TABS: Tab[] = ["all", "active", "failed"];
|
||||
const FILTER_KEYS: (keyof StreamFilters)[] = ["status", "kind", "ref", "source", "since", "workflow", "system"];
|
||||
|
||||
function Count({ n }: { n: number | undefined }) {
|
||||
if (!n) return null;
|
||||
return <span className="text-meta text-muted-foreground tabular-nums">{n > 999 ? "999+" : n}</span>;
|
||||
}
|
||||
|
||||
function RunSheet({
|
||||
runId,
|
||||
onOpen,
|
||||
onClose,
|
||||
}: {
|
||||
runId: string | null;
|
||||
onOpen: (id: string) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const q = useEngineRun(runId ?? undefined);
|
||||
const r = q.data;
|
||||
return (
|
||||
<DetailSheet
|
||||
open={!!runId}
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
width="lg"
|
||||
title={r ? runTitle(r) : "任务"}
|
||||
description={r ? <TaskStatusLine r={r} /> : undefined}
|
||||
footer={
|
||||
r ? (
|
||||
<>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link to={`/console/tasks/${encodeURIComponent(r.id)}`}>
|
||||
<ExternalLink />
|
||||
独立页面
|
||||
</Link>
|
||||
</Button>
|
||||
<RunActions r={r} onOpenRun={onOpen} />
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{q.isPending ? (
|
||||
<LoadingState rows={6} />
|
||||
) : q.isError ? (
|
||||
<ErrorState plain error={q.error} onRetry={() => q.refetch()} />
|
||||
) : r ? (
|
||||
<TaskDetailBody r={r} onOpenRun={onOpen} />
|
||||
) : null}
|
||||
</DetailSheet>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TasksPage() {
|
||||
const qc = useQueryClient();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const raw = params.get("tab") as Tab | null;
|
||||
const tab: Tab = raw && TABS.includes(raw) ? raw : "all";
|
||||
const runId = params.get("run");
|
||||
const filters = Object.fromEntries(FILTER_KEYS.map((k) => [k, params.get(k) ?? ""])) as StreamFilters;
|
||||
const stats = useEngineStats();
|
||||
const by = stats.data?.byStatus;
|
||||
|
||||
const update = (fn: (p: URLSearchParams) => void) =>
|
||||
setParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
fn(next);
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
const setFilter = (k: keyof StreamFilters, v: string) =>
|
||||
update((p) => {
|
||||
if (v) p.set(k, v);
|
||||
else p.delete(k);
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="任务"
|
||||
actions={
|
||||
<Button variant="outline" size="sm" onClick={() => qc.invalidateQueries({ queryKey: qk.all("engine") })}>
|
||||
<RotateCw />
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(v) =>
|
||||
update((p) => {
|
||||
p.set("tab", v);
|
||||
if (v !== "all") p.delete("status");
|
||||
})
|
||||
}
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="all">全部</TabsTrigger>
|
||||
<TabsTrigger value="active">
|
||||
进行中
|
||||
<Count n={by ? (by.running ?? 0) + (by.pending ?? 0) : undefined} />
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="failed">
|
||||
失败
|
||||
<Count n={by?.failed} />
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<div className="mt-4">
|
||||
<TaskStream
|
||||
tab={tab}
|
||||
filters={filters}
|
||||
setFilter={setFilter}
|
||||
resetFilters={() =>
|
||||
update((p) => {
|
||||
for (const k of FILTER_KEYS) p.delete(k);
|
||||
})
|
||||
}
|
||||
onOpen={(id) => update((p) => p.set("run", id))}
|
||||
selectedId={runId}
|
||||
/>
|
||||
</div>
|
||||
<RunSheet
|
||||
runId={runId}
|
||||
onOpen={(id) => update((p) => p.set("run", id))}
|
||||
onClose={() => update((p) => p.delete("run"))}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
export default function LegacyRedirect() {
|
||||
return null;
|
||||
}
|
||||
@@ -57,6 +57,13 @@ const STATIC_ROUTES = [
|
||||
"/console/agent",
|
||||
"/console/agent?mock=1",
|
||||
"/console/audit",
|
||||
// 日志(界面第二轮):任务 / 审计 / 计划的旧地址都跳到这里
|
||||
"/console/logs",
|
||||
"/console/logs?type=auth",
|
||||
"/console/logs?level=error&since=24h",
|
||||
"/console/logs?system=1&q=feeds",
|
||||
"/console/logs?tab=schedules",
|
||||
"/console/logs?tab=schedules&job=trash.purge",
|
||||
"/console/status",
|
||||
"/console/search?q=沙丘",
|
||||
"/console/settings",
|
||||
@@ -87,6 +94,7 @@ const DETAIL_ROUTES = [
|
||||
{ route: "/console/downloads/:id", list: "/api/v1/download-tasks" },
|
||||
{ route: "/console/tasks/:id", list: "/api/v1/engine/runs" },
|
||||
{ route: "/console/tasks?run=:id", list: "/api/v1/engine/runs" },
|
||||
{ route: "/console/logs?run=:id", list: "/api/v1/engine/runs" },
|
||||
{ route: "/console/agent/:id", list: "/api/v1/agent/runs", after: "?mock=0" },
|
||||
];
|
||||
|
||||
@@ -98,7 +106,7 @@ const REDIRECT_ROUTES = [
|
||||
{ from: "/console/tasks?tab=downloads", to: "/console/downloads" },
|
||||
{ from: "/console/tasks?tab=downloads&status=paused", to: "/console/downloads?status=paused" },
|
||||
{ from: "/console/tasks?tab=plans", to: "/console/downloads" },
|
||||
{ from: "/console/tasks?tab=tasks", to: "/console/tasks" },
|
||||
{ from: "/console/tasks?tab=tasks", to: "/console/logs?type=task" },
|
||||
{ from: "/console/subscriptions?tab=policies", to: "/console/rules" },
|
||||
// 偏好标签并入规则页(origin 筛选透传)
|
||||
{ from: "/console/preferences", to: "/console/rules" },
|
||||
@@ -107,13 +115,15 @@ const REDIRECT_ROUTES = [
|
||||
{ from: "/console/sources?tab=snapshots&siteId=:id", to: "/console/sites/:id?tab=snapshots", list: "/api/v1/sites" },
|
||||
// 旧的下载详情地址:先查业务任务(404 属预期),再跳到下载详情
|
||||
{ from: "/console/tasks/:id", to: "/console/downloads/:id", list: "/api/v1/download-tasks", expect404: true },
|
||||
// IA-3:引擎面板 / 定时任务 / 身份与元数据并入任务页与媒体概览
|
||||
{ from: "/console/tasks?tab=business", to: "/console/tasks" },
|
||||
{ from: "/console/engine", to: "/console/tasks" },
|
||||
{ from: "/console/engine/:id", to: "/console/tasks?run=:id", list: "/api/v1/engine/runs" },
|
||||
// FE-6:计划从任务页拆回独立的计划页
|
||||
{ from: "/console/tasks?tab=plan", to: "/console/schedules" },
|
||||
{ from: "/console/tasks?tab=plan&job=trash.purge", to: "/console/schedules?job=trash.purge" },
|
||||
// 界面第二轮:任务 / 引擎面板 / 审计 / 计划并入日志页;身份与元数据并入媒体概览(IA-3)
|
||||
{ from: "/console/tasks?tab=business", to: "/console/logs?type=task" },
|
||||
{ from: "/console/tasks?tab=failed", to: "/console/logs?type=task&level=error" },
|
||||
{ from: "/console/engine", to: "/console/logs?type=task" },
|
||||
{ from: "/console/engine/:id", to: "/console/logs?type=task&run=:id", list: "/api/v1/engine/runs" },
|
||||
{ from: "/console/audit", to: "/console/logs?type=audit" },
|
||||
{ from: "/console/schedules", to: "/console/logs?tab=schedules" },
|
||||
{ from: "/console/tasks?tab=plan", to: "/console/logs?tab=schedules" },
|
||||
{ from: "/console/tasks?tab=plan&job=trash.purge", to: "/console/logs?tab=schedules&job=trash.purge" },
|
||||
{ from: "/console/identity", to: "/console/library?filter=unidentified" },
|
||||
{ from: "/console/identity?tab=external", to: "/console/library?view=identities" },
|
||||
// 设置旧页签 ?tab= → 分区 hash(IA-2)
|
||||
|
||||
@@ -379,3 +379,9 @@ W10 已补(2026-09-28):
|
||||
- 铃铛(`inbox-bell.tsx`)只放两组:等你批准、系统健康;数据来自 `GET /attention`。
|
||||
- 首页「要我处理」与运行状态「需要关注」(`#attention`)放汇总计数(「3 条整理需要处理」「1 个站点不可达」),点进去到对应页面;只列非零项。
|
||||
- 运行状态另有「智能体用量」(今天 / 最近 7 天按会话类型的次数与花费);智能体不设次数和花费上限。
|
||||
|
||||
## 8. 界面第二轮:系统组只留日志、运行状态、设置(2026-10-06)
|
||||
|
||||
- 侧栏 4 组 13 项:概览(首页、智能体会话)/ 获取 / 整理 / 系统(日志、运行状态、设置)。任务、审计、引擎面板、计划不再有入口。
|
||||
- 日志页 `/console/logs`:任务、定时与自动、登录、审计合成一份列表,可实时追加、勾选后问智能体;第二个页签「定时任务」是原计划页。旧地址:`/console/tasks`(含 `?tab=` `?status=` `?kind=&ref=` `?run=`)、`/console/engine(/:id)`、`/console/audit`(`?targetType=&targetId=` → 对象筛选)、`/console/schedules(?job=)` 都跳到日志页对应筛选;`/console/tasks/:id` 独立页保留,侧栏高亮「日志」。
|
||||
- 运行状态页加「资源」节(CPU / 内存 / 网络 / 磁盘读写与趋势),首页「存储情况」卡换成运行状态简版卡。细节见 `docs/runbook/logs.md`。
|
||||
@@ -0,0 +1,39 @@
|
||||
# 日志与运行状态
|
||||
|
||||
界面第二轮(2026-10-06)起,侧栏「系统」只有三项:**日志**、**运行状态**、**设置**。原来的任务、审计、工作流引擎面板、计划页都并进日志页,旧地址自动跳转(`apps/web/app/routes/console/redirects.ts`)。
|
||||
|
||||
## 日志页 `/console/logs`
|
||||
|
||||
一份按时间倒序的列表,不新建表,查询时把四个来源合起来(`apps/server/src/modules/logs/`):
|
||||
|
||||
| 类型 | 来源 | 时间 | 对象 | 结果 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 任务 `task` | 引擎运行 `openworkflow.workflow_runs` | 创建时间 | 业务对象(下载任务、整理记录、订阅、定时任务…) | 进行中 / 成功 / 失败;取消、智能体超出预算或停止为警告 |
|
||||
| 定时与自动 `auto` | 定时任务运行 `scheduled_job_run`(含智能体巡检)、智能体后台调查 `agent_run`(trigger=event,非子会话) | 开始时间 | 定时任务 / 会话 | 成功 / 失败 / 已跳过(信息)/ 进行中 |
|
||||
| 登录 `auth` | 审计里 `admin.*`、`api_key.*`(登录、登录失败、退出、二次验证、Passkey、改密、API Key 增改撤) | 事件时间 | 审计目标 | 登录成功为成功,登录失败为警告,其余为信息 |
|
||||
| 审计 `audit` | 其余审计事件 | 事件时间 | 审计目标 | 智能体操作被拒绝为警告,其余为信息 |
|
||||
|
||||
- **筛选**(都写在 URL):`type`、`level`(running / success / info / warn / error,可逗号多值)、`since`(1h / 24h / 7d / 30d)、`q`(标题、错误、动作、对象 ID、审计负载,不区分大小写)、`objectType` + `objectId`(同一对象的任务、定时任务运行与审计一起列出)、`system=1`。
|
||||
- **例行记录**缺省不列:没有业务对象的引擎运行、定时任务触发且成功的引擎运行、巡检 / 后台调查对应的引擎运行(在「定时与自动」里已有一条)、普通定时任务的成功 / 跳过运行。点「显示例行记录」或按对象筛选时列出。
|
||||
- **分页**:游标 = (时间, 日志 id),各来源各取 limit+1 再合并,逐页与一次取全顺序一致。
|
||||
- **实时**:页面默认连 `GET /logs/stream`(SSE,同一组筛选),服务端每 2 秒查一次最近 10 分钟,新日志和状态有变化的日志(任务从进行中变成完成)推给页面,按 id 覆盖。「暂停」断开连接,「继续」从已知最新一条续上,暂停期间的日志会补推。
|
||||
- **详情**:点一行开抽屉。任务沿用原任务详情(业务摘要、时间线、引擎步骤、取消 / 重跑,`?run=<引擎运行 id>` 也能直接打开);其余显示字段、负载与「同一对象的全部日志」。`/console/tasks/:id` 独立页保留。
|
||||
- **问智能体**:勾选一条或一段(按住 Shift)后点「问智能体」,打开智能体抽屉,目标里按时间正序列出这些日志(时间、类型、结果、摘要、日志 id、对象);单条时带上任务 / 下载任务 / 整理记录等上下文 id。超过 6000 字截断。
|
||||
- 登录失败(用户名 / 密码 / 验证码错误)写一条 `admin.login_failed` 审计(用户名、IP、原因,不含密码);被限速拦下的尝试不写。
|
||||
|
||||
### 「定时任务」页签
|
||||
|
||||
原计划页(智能体巡检卡片 + 自动任务列表)放在日志页的第二个页签 `/console/logs?tab=schedules`(`?job=` 高亮)。定时任务是配置,但这一页的主要内容是运行状态(上次结果、下次运行、失败原因、立即执行 / 跳过),与它的运行日志放在一起;设置页按约定只放全局参数与凭证(`docs/design/console-ia.md` §3)。卡片的「运行记录」跳日志按该定时任务筛选。
|
||||
|
||||
## 运行状态 `/console/status`
|
||||
|
||||
在原有横幅、需要关注、数据库、任务引擎、定时任务、磁盘、挂载根、存储之外:
|
||||
|
||||
- **资源**(参考 MoviePilot 仪表盘):CPU、内存、网络、磁盘读写四块,当前值 + 小趋势(15 分钟 / 1 小时 / 6 小时)。api 进程每 5 秒读一次 `/proc` 与 cgroup v2,采样只放内存、保留 6 小时,进程重启后重新累计(`GET /system/resources?minutes=`)。
|
||||
- CPU:有 cgroup `cpu.stat` 时按本容器用量 ÷ 可用核数(`cpu.max` 限额,否则在线核数);否则 `/proc/stat` 整机。另给整机 CPU 与 1 / 5 / 15 分钟负载。
|
||||
- 内存:有 cgroup `memory.current` 时 = current − `memory.stat` 的 inactive_file(同 `docker stats`),上限 `memory.max`(不限时为整机总量);否则 `/proc/meminfo` 整机。
|
||||
- 网络:`/proc/net/dev` 除 lo 外合计(容器里即本容器网卡)。磁盘读写:cgroup `io.stat`,否则 `/proc/diskstats` 整盘。
|
||||
- 读不到的文件按 null 显示「—」,磁贴右上角写口径(本容器 / 整机 / 取不到)。
|
||||
- **进程**表直接展示(不再折叠),每个角色进程(API / Worker(后台任务)/ Worker(智能体)/ 单进程)带 CPU(两次心跳之间占一个核的百分比,心跳 `extra.cpuPercent`)与内存(RSS)。事件循环与 SSE 的趋势仍在折叠区。
|
||||
|
||||
首页「运行状态」卡是简版:整体健康、CPU / 内存(最近 30 分钟小趋势)、用量最高的 3 个磁盘、下载器 / 站点账号 / 媒体库目录的正常数 / 总数,点进去到运行状态页。
|
||||
@@ -1,6 +1,6 @@
|
||||
# 定时任务与看板
|
||||
|
||||
所有周期任务都登记在 `scheduled_job` 表里,由 io worker 上唯一的调度器按表驱动。控制台「计划」页(`/console/schedules`,分「自动任务」与「Agent 巡检」两节)可以查看和调整。
|
||||
所有周期任务都登记在 `scheduled_job` 表里,由 io worker 上唯一的调度器按表驱动。控制台日志页的「定时任务」页签(`/console/logs?tab=schedules`,原计划页 `/console/schedules` 跳转过来;分「自动任务」与「Agent 巡检」两节)可以查看和调整,运行记录在日志页按定时任务筛选(见 [logs.md](logs.md))。
|
||||
|
||||
## 组成
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { episodeMappingApi } from "./api/episode-mapping.ts";
|
||||
import { executionApi } from "./api/execution.ts";
|
||||
import { instancesApi } from "./api/instances.ts";
|
||||
import { libraryApi } from "./api/library.ts";
|
||||
import { logsApi } from "./api/logs.ts";
|
||||
import { opsApi } from "./api/ops.ts";
|
||||
import { organizeApi } from "./api/organize.ts";
|
||||
import { platformApi } from "./api/platform.ts";
|
||||
@@ -41,6 +42,7 @@ export const Api = {
|
||||
...organizeApi,
|
||||
...platformApi,
|
||||
...systemApi,
|
||||
...logsApi,
|
||||
...libraryApi,
|
||||
...browserApi,
|
||||
...schedulesApi,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import * as logs from "../logs.ts";
|
||||
import { ep } from "./shared.ts";
|
||||
|
||||
// 日志模块(界面第二轮 V 线,docs/runbook/logs.md):业务任务、定时 / 自动任务、登录、审计合成一份列表。仅 admin。
|
||||
export const logsApi = {
|
||||
logs: {
|
||||
list: ep({
|
||||
method: "get",
|
||||
path: "/logs",
|
||||
scope: "admin",
|
||||
summary: "日志列表:任务 / 定时与自动 / 登录 / 审计合并,按时间倒序,游标分页",
|
||||
query: logs.LogQuery,
|
||||
response: logs.LogPage,
|
||||
}),
|
||||
stream: ep({
|
||||
method: "get",
|
||||
path: "/logs/stream",
|
||||
scope: "admin",
|
||||
summary: "日志流(text/event-stream):按同一组筛选推新日志与状态变化,事件名 log.entry",
|
||||
query: logs.LogStreamQuery,
|
||||
response: logs.LogEntry,
|
||||
}),
|
||||
},
|
||||
} as const;
|
||||
@@ -3,6 +3,7 @@ import * as auth from "../auth.ts";
|
||||
import { cursorPage, HealthStatus, IdParam } from "../common.ts";
|
||||
import * as events from "../events.ts";
|
||||
import * as ops from "../ops.ts";
|
||||
import * as resources from "../system-resources.ts";
|
||||
import { EngineRunIdParam, ep, OkResult } from "./shared.ts";
|
||||
|
||||
export const systemApi = {
|
||||
@@ -170,6 +171,14 @@ export const systemApi = {
|
||||
query: ops.SystemHealthHistoryQuery,
|
||||
response: ops.SystemHealthHistory,
|
||||
}),
|
||||
resources: ep({
|
||||
method: "get",
|
||||
path: "/system/resources",
|
||||
scope: "admin",
|
||||
summary: "资源监控:CPU / 内存 / 网络 / 磁盘读写的当前值与最近一段时间的趋势(api 进程内存采样)",
|
||||
query: resources.SystemResourcesQuery,
|
||||
response: resources.SystemResources,
|
||||
}),
|
||||
storage: ep({
|
||||
method: "get",
|
||||
path: "/system/storage",
|
||||
|
||||
@@ -194,6 +194,7 @@ export type PasskeyRenameRequest = z.infer<typeof PasskeyRenameRequest>;
|
||||
export const AuditAction = z.enum([
|
||||
"admin.setup",
|
||||
"admin.login_success",
|
||||
"admin.login_failed",
|
||||
"admin.logout",
|
||||
"admin.password_change",
|
||||
"admin.totp_enable",
|
||||
|
||||
@@ -18,6 +18,7 @@ export * from "./identity.ts";
|
||||
export * from "./instances.ts";
|
||||
export * from "./library-versions.ts";
|
||||
export * from "./local-metadata.ts";
|
||||
export * from "./logs.ts";
|
||||
export * from "./ops.ts";
|
||||
export * from "./organization.ts";
|
||||
export * from "./organize-sources.ts";
|
||||
@@ -29,3 +30,4 @@ export * from "./scrape.ts";
|
||||
export * from "./settings.ts";
|
||||
export * from "./sources.ts";
|
||||
export * from "./subtitles.ts";
|
||||
export * from "./system-resources.ts";
|
||||
@@ -0,0 +1,117 @@
|
||||
import { z } from "zod";
|
||||
import { cursorPage, Timestamp } from "./common.ts";
|
||||
|
||||
// 日志模块(界面第二轮 V 线):业务任务、定时 / 自动任务运行、登录、审计合成一份按时间倒序的列表。
|
||||
// 不新建表:GET /logs 在查询时把各来源表 union 起来(engine workflow_runs / scheduled_job_run /
|
||||
// agent_run(trigger=event)/ audit_event),游标按 (时间, id) 分页;GET /logs/stream 用 SSE 追加新日志。
|
||||
|
||||
/**
|
||||
* 类型:
|
||||
* - task 业务任务(工作流引擎的一次运行)
|
||||
* - auto 定时与自动:定时任务运行记录(含智能体巡检)、智能体后台调查
|
||||
* - auth 登录:管理员登录 / 退出 / 登录失败 / 二次验证 / Passkey / 改密、API Key 的增改撤
|
||||
* - audit 其余审计:设置、凭证、实例、文件操作、智能体操作等
|
||||
*/
|
||||
export const LogType = z.enum(["task", "auto", "auth", "audit"]);
|
||||
export type LogType = z.infer<typeof LogType>;
|
||||
|
||||
/** 级别 / 结果:进行中、成功、信息(普通操作)、警告(取消、跳过以外的非成功,如登录失败、被拒绝、超出预算)、失败 */
|
||||
export const LogLevel = z.enum(["running", "success", "info", "warn", "error"]);
|
||||
export type LogLevel = z.infer<typeof LogLevel>;
|
||||
|
||||
/** 来源表 */
|
||||
export const LogSource = z.enum(["engine_run", "schedule_run", "agent_run", "audit_event"]);
|
||||
export type LogSource = z.infer<typeof LogSource>;
|
||||
|
||||
export const LogObject = z.object({
|
||||
/** 对象类型:审计的 target_type;任务按业务对象换算(download_task / organization_record / agent_run / scheduled_job …) */
|
||||
type: z.string(),
|
||||
id: z.string().nullable(),
|
||||
/** 展示名(作品、下载任务、定时任务名等);取不到为 null */
|
||||
label: z.string().nullable(),
|
||||
});
|
||||
export type LogObject = z.infer<typeof LogObject>;
|
||||
|
||||
export const LogEntry = z.object({
|
||||
/** <source>:<原始 id>,全局唯一,也是游标的一部分 */
|
||||
id: z.string(),
|
||||
type: LogType,
|
||||
source: LogSource,
|
||||
/** 原始记录 id(引擎 run id、scheduled_job_run id、agent_run id、audit_event id) */
|
||||
refId: z.string(),
|
||||
/** 发生时间:任务为创建时间、定时任务为开始时间、审计为事件时间 */
|
||||
at: Timestamp,
|
||||
finishedAt: Timestamp.nullable(),
|
||||
durationMs: z.number().nonnegative().nullable(),
|
||||
level: LogLevel,
|
||||
/** 来源表的原始状态(completed / failed / ok / skipped / admin.login_success …) */
|
||||
status: z.string().nullable(),
|
||||
/** 一行摘要:任务业务标题或工作流名、定时任务名、智能体目标、审计动作 */
|
||||
title: z.string(),
|
||||
/** 第二行:错误、运行摘要、登录 IP 等 */
|
||||
message: z.string().nullable(),
|
||||
object: LogObject.nullable(),
|
||||
/** 操作者(审计)或触发来源(任务 / 定时任务) */
|
||||
actor: z.object({ type: z.string(), id: z.string().nullable() }).nullable(),
|
||||
/** 各来源特有字段(workflowName、business、trigger、action、payload …),详情页用 */
|
||||
extra: z.record(z.string(), z.unknown()),
|
||||
});
|
||||
export type LogEntry = z.infer<typeof LogEntry>;
|
||||
|
||||
/** 单值或逗号分隔多值(type=task,auto) */
|
||||
function csv<T extends string>(e: z.ZodType<T>, name: string) {
|
||||
return z
|
||||
.string()
|
||||
.max(200)
|
||||
.transform((s, ctx) => {
|
||||
const out: T[] = [];
|
||||
for (const p of s
|
||||
.split(",")
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean)) {
|
||||
const r = e.safeParse(p);
|
||||
if (!r.success) {
|
||||
ctx.addIssue({ code: "custom", message: `未知 ${name}: ${p}` });
|
||||
return z.NEVER;
|
||||
}
|
||||
out.push(r.data);
|
||||
}
|
||||
return out;
|
||||
})
|
||||
.optional();
|
||||
}
|
||||
|
||||
/** 列表与流共用的筛选 */
|
||||
export const LogFilter = z.object({
|
||||
/** 逗号分隔多值;缺省全部 */
|
||||
type: csv(LogType, "type"),
|
||||
level: csv(LogLevel, "level"),
|
||||
from: Timestamp.optional(),
|
||||
to: Timestamp.optional(),
|
||||
/** 关键词:标题、摘要、错误、动作、对象 id(不区分大小写) */
|
||||
q: z.string().trim().max(200).optional(),
|
||||
/** 对象:与 LogEntry.object 的 type / id 匹配;只给 objectId 时不限类型 */
|
||||
objectType: z.string().max(100).optional(),
|
||||
objectId: z.string().max(200).optional(),
|
||||
/** "1" = 也列系统任务(没有业务对象的引擎运行、定时任务触发的成功运行);缺省隐藏,按对象筛选时自动包含 */
|
||||
system: z.enum(["0", "1"]).optional(),
|
||||
});
|
||||
export type LogFilter = z.infer<typeof LogFilter>;
|
||||
|
||||
export const LogQuery = LogFilter.extend({
|
||||
cursor: z.string().optional(),
|
||||
limit: z.coerce.number().int().min(1).max(200).default(50),
|
||||
});
|
||||
export type LogQuery = z.infer<typeof LogQuery>;
|
||||
|
||||
export const LogPage = cursorPage(LogEntry);
|
||||
export type LogPage = z.infer<typeof LogPage>;
|
||||
|
||||
export const LogStreamQuery = LogFilter.extend({
|
||||
/** 只推这个时间之后的新日志(客户端列表里最新一条的时间);缺省为连接时刻 */
|
||||
since: Timestamp.optional(),
|
||||
});
|
||||
export type LogStreamQuery = z.infer<typeof LogStreamQuery>;
|
||||
|
||||
/** SSE 事件名:entry = 新日志或已推过的日志状态有变化(按 id 覆盖) */
|
||||
export const LOG_STREAM_EVENT = "log.entry";
|
||||
@@ -0,0 +1,72 @@
|
||||
import { z } from "zod";
|
||||
import { Timestamp } from "./common.ts";
|
||||
|
||||
// 资源监控(界面第二轮 V 线,参考 MoviePilot 仪表盘的 CPU / 内存 / 网络组件):
|
||||
// api 进程每 5 秒读一次 /proc 与 cgroup v2,采样只放内存(保留 6 小时,进程重启后从头攒)。
|
||||
// 容器里优先用 cgroup(本容器的 CPU 占用、内存、磁盘读写),取不到时退回 /proc 整机口径;都取不到的字段为 null。
|
||||
|
||||
/** 采集口径:cgroup = 本容器(cgroup v2);host = /proc 整机;none = 取不到 */
|
||||
export const ResourceScope = z.enum(["cgroup", "host", "none"]);
|
||||
export type ResourceScope = z.infer<typeof ResourceScope>;
|
||||
|
||||
const Num = z.number().nonnegative().nullable();
|
||||
|
||||
export const ResourceSample = z.object({
|
||||
at: Timestamp,
|
||||
/** CPU 占用 %(相对可用核数,0–100) */
|
||||
cpuPercent: Num,
|
||||
memoryUsedBytes: Num,
|
||||
memoryPercent: Num,
|
||||
netRxBps: Num,
|
||||
netTxBps: Num,
|
||||
diskReadBps: Num,
|
||||
diskWriteBps: Num,
|
||||
});
|
||||
export type ResourceSample = z.infer<typeof ResourceSample>;
|
||||
|
||||
export const SystemResources = z.object({
|
||||
at: Timestamp,
|
||||
/** 采样间隔(秒) */
|
||||
intervalSec: z.number().positive(),
|
||||
/** 返回的趋势每点代表的秒数(长时间窗口按桶取平均) */
|
||||
bucketSec: z.number().positive(),
|
||||
cpu: z.object({
|
||||
scope: ResourceScope,
|
||||
percent: Num,
|
||||
/** 可用核数:cgroup cpu.max 的限额,否则为在线核数 */
|
||||
cores: Num,
|
||||
/** 整机 CPU 占用 %(/proc/stat),与 scope 无关 */
|
||||
hostPercent: Num,
|
||||
/** 整机 1 / 5 / 15 分钟平均负载(3 个数) */
|
||||
load: z.array(z.number()).nullable(),
|
||||
}),
|
||||
memory: z.object({
|
||||
scope: ResourceScope,
|
||||
/** 已用:cgroup 为 memory.current − inactive_file(同 docker stats);整机为 MemTotal − MemAvailable */
|
||||
usedBytes: Num,
|
||||
/** 上限:cgroup memory.max(不限时退回整机总量) */
|
||||
limitBytes: Num,
|
||||
percent: Num,
|
||||
hostTotalBytes: Num,
|
||||
hostAvailableBytes: Num,
|
||||
swapUsedBytes: Num,
|
||||
}),
|
||||
network: z.object({
|
||||
scope: ResourceScope,
|
||||
rxBps: Num,
|
||||
txBps: Num,
|
||||
rxTotalBytes: Num,
|
||||
txTotalBytes: Num,
|
||||
}),
|
||||
diskIo: z.object({ scope: ResourceScope, readBps: Num, writeBps: Num }),
|
||||
/** 整机已运行秒数(/proc/uptime) */
|
||||
hostUptimeSec: Num,
|
||||
samples: z.array(ResourceSample),
|
||||
});
|
||||
export type SystemResources = z.infer<typeof SystemResources>;
|
||||
|
||||
export const SystemResourcesQuery = z.object({
|
||||
/** 趋势窗口(分钟),5–360,缺省 60 */
|
||||
minutes: z.coerce.number().int().min(5).max(360).default(60),
|
||||
});
|
||||
export type SystemResourcesQuery = z.infer<typeof SystemResourcesQuery>;
|
||||
@@ -101,6 +101,9 @@ const OVERRIDES: Record<string, (v: Record<string, unknown>) => Record<string, u
|
||||
OrganizeRequest: (v) => ({ ...v, sourcePaths: undefined }),
|
||||
FilesListQuery: (v) => ({ ...v, filter: "video,unidentified" }),
|
||||
EngineRunQuery: (v) => ({ ...v, status: "running,pending", triggeredBy: "user,api_key" }),
|
||||
LogFilter: (v) => ({ ...v, type: "task,auth", level: "error,warn" }),
|
||||
LogQuery: (v) => ({ ...v, type: "task,auth", level: "error,warn" }),
|
||||
LogStreamQuery: (v) => ({ ...v, type: "auto", level: "running" }),
|
||||
CatalogLookupInput: (v) => v,
|
||||
IdentityResolveInput: (v) => v,
|
||||
AssetLocationCreate: (v) => v,
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Kysely } from "kysely";
|
||||
import { execDdl } from "../src/migration-helpers.ts";
|
||||
|
||||
// 日志模块(界面第二轮 V 线):GET /logs 把定时任务运行记录按开始时间倒序与其它来源合并,
|
||||
// 原有索引只有 (job_id, started_at),全局倒序要整表排序;补一个按时间的索引。
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await execDdl(db, `create index if not exists scheduled_job_run_started_idx on scheduled_job_run (started_at desc);`);
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 同上
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await execDdl(db, `drop index if exists scheduled_job_run_started_idx;`);
|
||||
}
|
||||
Reference in new issue
Block a user