diff --git a/apps/server/src/modules/auth-settings/auth-routes.ts b/apps/server/src/modules/auth-settings/auth-routes.ts index 73d03b56..59189159 100644 --- a/apps/server/src/modules/auth-settings/auth-routes.ts +++ b/apps/server/src/modules/auth-settings/auth-routes.ts @@ -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); diff --git a/apps/server/src/modules/index.ts b/apps/server/src/modules/index.ts index bd0909b1..a2a432ac 100644 --- a/apps/server/src/modules/index.ts +++ b/apps/server/src/modules/index.ts @@ -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, ]; diff --git a/apps/server/src/modules/logs/index.ts b/apps/server/src/modules/logs/index.ts new file mode 100644 index 00000000..47ab3820 --- /dev/null +++ b/apps/server/src/modules/logs/index.ts @@ -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> = []; + 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((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(); diff --git a/apps/server/src/modules/logs/query.ts b/apps/server/src/modules/logs/query.ts new file mode 100644 index 00000000..76bcd321 --- /dev/null +++ b/apps/server/src/modules/logs/query.ts @@ -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 = { + 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 = { + 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 { + 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 | 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; +} + +export function logQueries(db: Kysely, 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][] = []; + 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[] = []; + 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`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; + 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) : extra, + }; +} diff --git a/apps/server/src/modules/logs/stream.ts b/apps/server/src/modules/logs/stream.ts new file mode 100644 index 00000000..e1a0c6f7 --- /dev/null +++ b/apps/server/src/modules/logs/stream.ts @@ -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; +} + +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(); + 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(); + }, + }; +} diff --git a/apps/server/src/modules/system/heartbeat.ts b/apps/server/src/modules/system/heartbeat.ts index 00cf5f42..860f52d3 100644 --- a/apps/server/src/modules/system/heartbeat.ts +++ b/apps/server/src/modules/system/heartbeat.ts @@ -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 { } } +/** 本进程两次心跳之间的 CPU 占用(% of 一个核);取不到时不写 */ +function cpuExtra(pct: number | null): Record { + return pct == null ? {} : { cpuPercent: pct }; +} + /** 写一次心跳(upsert + 采样);返回写入的 instanceId */ export async function writeHeartbeat( db: Kysely, @@ -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 | 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 }, "心跳写入失败"); diff --git a/apps/server/src/modules/system/index.ts b/apps/server/src/modules/system/index.ts index 1fbbd4f0..1d38ccca 100644 --- a/apps/server/src/modules/system/index.ts +++ b/apps/server/src/modules/system/index.ts @@ -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 extends z.ZodType ? z.infer : Record; @@ -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; }, }; } diff --git a/apps/server/src/modules/system/resources.ts b/apps/server/src/modules/system/resources.ts new file mode 100644 index 00000000..a6f17796 --- /dev/null +++ b/apps/server/src/modules/system/resources.ts @@ -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; + +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(); + 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; + 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 { + 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; +} + +/** 由相邻两次读数算一个点(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(); + 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)[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; + snapshot(minutes: number): Promise; + 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 | 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 | 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; + } + }; +} diff --git a/apps/server/test/auth-settings/login.test.ts b/apps/server/test/auth-settings/login.test.ts index e03e5fb3..8e0fc4e5 100644 --- a/apps/server/test/auth-settings/login.test.ts +++ b/apps/server/test/auth-settings/login.test.ts @@ -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 }>` + 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); diff --git a/apps/server/test/logs/logs.test.ts b/apps/server/test/logs/logs.test.ts new file mode 100644 index 00000000..4ee01ada --- /dev/null +++ b/apps/server/test/logs/logs.test.ts @@ -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; + 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); + }); +}); diff --git a/apps/server/test/modules.test.ts b/apps/server/test/modules.test.ts index 47c45dcb..f8e9181d 100644 --- a/apps/server/test/modules.test.ts +++ b/apps/server/test/modules.test.ts @@ -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", ]); }); diff --git a/apps/server/test/system-resources.test.ts b/apps/server/test/system-resources.test.ts new file mode 100644 index 00000000..c368d13c --- /dev/null +++ b/apps/server/test/system-resources.test.ts @@ -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): 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); + }); +}); diff --git a/apps/server/test/system/system.test.ts b/apps/server/test/system/system.test.ts index 31751f4d..490b2eff 100644 --- a/apps/server/test/system/system.test.ts +++ b/apps/server/test/system/system.test.ts @@ -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); diff --git a/apps/server/vitest.config.ts b/apps/server/vitest.config.ts index 73054598..7c6b5034 100644 --- a/apps/server/vitest.config.ts +++ b/apps/server/vitest.config.ts @@ -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 = [ diff --git a/apps/web/app/api/logs.ts b/apps/web/app/api/logs.ts new file mode 100644 index 00000000..9e884159 --- /dev/null +++ b/apps/web/app/api/logs.ts @@ -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("/logs", { params: { ...params, cursor: pageParam, limit: 50 }, signal }), + getNextPageParam: (last) => last.nextCursor ?? undefined, + }); +} + +export type LogStreamStatus = "off" | "connecting" | "open" | "reconnecting"; + +type EventSourceLike = Pick & { 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 | 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).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([]); + const [status, setStatus] = useState("off"); + const key = JSON.stringify(params); + const client = useRef(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; + }); +} diff --git a/apps/web/app/api/system.ts b/apps/web/app/api/system.ts index 947801c5..505ef1ef 100644 --- a/apps/web/app/api/system.ts +++ b/apps/web/app/api/system.ts @@ -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("/system/resources", { params: { minutes }, signal }), + refetchInterval: refetchMs, + }); +} diff --git a/apps/web/app/components/console/nav.test.ts b/apps/web/app/components/console/nav.test.ts index 1f59cd1b..b5037f67 100644 --- a/apps/web/app/components/console/nav.test.ts +++ b/apps/web/app/components/console/nav.test.ts @@ -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 查所属站点后跳转 diff --git a/apps/web/app/components/console/nav.ts b/apps/web/app/components/console/nav.ts index 542ff2dc..ba8f07b3 100644 --- a/apps/web/app/components/console/nav.ts +++ b/apps/web/app/components/console/nav.ts @@ -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 = { + "/console/tasks": "/console/logs", +}; + /** 不在侧栏但有页面的路由:顶栏标题用 */ const EXTRA_TITLES: Record = { "/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( diff --git a/apps/web/app/components/dashboard/home-runtime.test.ts b/apps/web/app/components/dashboard/home-runtime.test.ts new file mode 100644 index 00000000..c20a89f5 --- /dev/null +++ b/apps/web/app/components/dashboard/home-runtime.test.ts @@ -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 => + ({ + 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(/下载器]*>1\/2/); + expect(html).toMatch(/站点]*>1\/2/); + expect(html).toMatch(/媒体库目录]*>1\/2/); + }); + + it("全部正常时写「全部正常」,没有配置的项不列", () => { + const html = render(health({ level: "ok", mounts: [] }), []); + expect(html).toContain("全部正常"); + expect(html).not.toContain("媒体库目录<"); + expect(html).not.toContain("下载器 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 ( - + + + {label} + + {ok}/{total} + + + ); +} + +/** + * 运行状态简版(界面第二轮,参考 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 ( + {q.isPending ? ( - + ) : q.isError ? ( - q.refetch()} className="py-6" /> - ) : !disks.length ? ( - - ) : ( -
    - {disks.map((d) => { - const pct = d.usedRatio === null ? null : d.usedRatio * 100; - const state = LEVEL[d.level]; - return ( -
  • -
    -

    - {d.name !== SCOPE[d.scope] ? ( - {SCOPE[d.scope]} - ) : null} - {d.name} -

    - - {state.label} - {pct === null ? "" : ` · ${Math.round(pct)}%`} - -
    - {pct !== null ? ( -
    -
    -
    - ) : null} -

    - - 剩余{" "} - - {d.availableBytes === null ? "未知" : formatBytes(d.availableBytes, 1)} - + q.refetch()} className="py-6" /> + ) : h ? ( + <> +

    + {level ? {h.level === "ok" ? "全部正常" : level.label} : null} + 检查于 {formatRelative(h.checkedAt)} +
    +
    + {[ + { label: "CPU", v: res?.cpu.percent, key: "cpuPercent" as const }, + { label: "内存", v: res?.memory.percent, key: "memoryPercent" as const }, + ].map((x) => ( +
    +

    + {x.label} + + {x.v == null ? "—" : `${Math.round(x.v)}%`} - 已用 {d.usedBytes === null ? "未知" : formatBytes(d.usedBytes, 1)} - 总量 {d.totalBytes === null ? "未知" : formatBytes(d.totalBytes, 1)}

    -

    - {d.path} -

    - {d.error ?

    无法读取磁盘用量

    : null} -
  • - ); - })} -
+ {res ? ( + `${v.toFixed(1)}%`} + max={100} + height={32} + label={`${x.label}占用趋势(最近 30 分钟)`} + /> + ) : ( +
+ )} +
+ ))} + + {disks.length ? ( +
    + {disks.map((d) => { + const pct = (d.usedRatio ?? 0) * 100; + return ( +
  • +
    + + {SCOPE[d.scope]} + {d.name} + + + {Math.round(pct)}% · 剩余{" "} + {d.availableBytes === null ? "未知" : formatBytes(d.availableBytes, 1)} + +
    +
    +
    +
    +
  • + ); + })} +
+ ) : null} +

+ + + +

+ + ) : ( + )} - {q.data && !q.isError ? ( -

所在磁盘用量 · 检查于 {formatRelative(q.data.checkedAt)}

- ) : null}
); } @@ -202,7 +259,7 @@ function DownloaderOverview({ downloaders }: { downloaders?: Dashboard["sources" export function HomeRuntime({ downloaders }: { downloaders?: Dashboard["sources"]["downloaders"] }) { return (
- +
); diff --git a/apps/web/app/components/logs/labels.ts b/apps/web/app/components/logs/labels.ts new file mode 100644 index 00000000..eeb2ac08 --- /dev/null +++ b/apps/web/app/components/logs/labels.ts @@ -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 = { + task: "任务", + auto: "定时与自动", + auth: "登录", + audit: "审计", +}; +export const LOG_TYPES = Object.keys(LOG_TYPE_LABEL) as LogType[]; + +export const LOG_LEVEL: Record = { + 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 = { + "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 = { create: "新建", update: "修改", delete: "删除", test: "测试", verify: "校验" }; +const NOUN: Record = { + library_root: "媒体库目录", + downloader_instance: "下载器", + media_server_instance: "媒体服务器", + path_mapping: "路径映射", + pi_upstream: "模型服务", + schedule: "定时任务", + automation_grant: "持续授权", +}; +export const TARGET_LABEL: Record = { + ...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 = { + ok: { label: "成功", tone: "success" }, + failed: { label: "失败", tone: "destructive" }, + skipped: { label: "已跳过", tone: "neutral" }, + running: { label: "进行中", tone: "neutral" }, +}; +const AGENT_STATUS: Record = { + queued: { label: "排队", tone: "neutral" }, + waiting: { label: "等待", tone: "neutral" }, + cancelled: { label: "已停止", tone: "warning" }, + budget_exceeded: { label: "超出预算", tone: "warning" }, +}; +const ENGINE_STATUS: Record = { + pending: { label: "排队", tone: "neutral" }, + canceled: { label: "已取消", tone: "warning" }, +}; + +/** 行上的状态徽章:来源有更具体的说法时用来源的(已跳过、已取消、超出预算),否则用级别 */ +export function logBadge(e: Pick): { + 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; + +/** 时间预设下界按分钟取整,查询 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 & { 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 = { + pending: "running", + running: "running", + completed: "success", + failed: "error", + canceled: "warn", +}; + +/** 业务对象 kind → 日志对象类型(与服务端 BUSINESS_OBJECT_TYPE 一致) */ +export const BUSINESS_OBJECT_TYPE: Record = { + download: "download_task", + organization: "organization_record", + agent: "agent_run", + subscription: "subscription", + feed: "feed_source", + site: "site", + decision: "decision", + media: "media_entity", + schedule: "scheduled_job", +}; diff --git a/apps/web/app/components/logs/log-detail.tsx b/apps/web/app/components/logs/log-detail.tsx new file mode 100644 index 00000000..146cefff --- /dev/null +++ b/apps/web/app/components/logs/log-detail.tsx @@ -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 ( + + ); +} + +function LinkRow({ links }: { links: { to: string; label: string; icon?: ReactNode }[] }) { + return ( +
    + {links.map((l) => ( +
  • + + {l.icon ?? } + {l.label} + +
  • + ))} +
+ ); +} + +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 ( + <> + + + {x.error ?

{x.error}

: null} +
+ + , + }, + ] + : []), + ...(x.agentRunId ? [{ to: `/console/agent/${encodeURIComponent(x.agentRunId)}`, label: "巡检会话" }] : []), + ]} + /> + + + + + + ); +} + +function AgentRunBody({ e }: { e: LogEntry }) { + const x = e.extra as { toolCalls?: number; spawnReason?: string | null; model?: string | null }; + return ( + <> + + {e.title}
, 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 }, + ]} + /> + + + + + + + + + ); +} + +function AuditBody({ e }: { e: LogEntry }) { + const x = e.extra as { action?: string; traceId?: string | null; payload?: unknown }; + const o = e.object; + return ( + <> + + + + {o?.id ? ( + + , + }, + ]} + /> + + ) : null} + + + + + + ); +} + +/** 任务:按引擎运行 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 ( + !o && onClose()} + width="lg" + title={r ? runTitle(r) : entry ? logTitle(entry) : "任务"} + description={r ? : undefined} + footer={ + r ? ( + <> + + {entry ? : null} + + + ) : null + } + > + {q.isPending ? ( + + ) : q.isError ? ( + q.refetch()} /> + ) : r ? ( + + ) : null} + + ); +} + +/** + * 日志详情抽屉。open = 日志 id(:<原始 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 onOpenLog(`engine_run:${id}`)} onClose={onClose} />; + const badge = entry ? logBadge(entry) : null; + return ( + !o && onClose()} + title={entry ? logTitle(entry) : "日志"} + description={ + entry ? ( + + {badge ? {badge.label} : null} + {LOG_TYPE_LABEL[entry.type]} + · {formatDateTime(entry.at)} + {entry.object ? · {objectText(entry.object)} : null} + + ) : undefined + } + footer={entry ? : null} + > + {!entry ? ( + + ) : entry.source === "schedule_run" ? ( + + ) : entry.source === "agent_run" ? ( + + ) : ( + + )} + + ); +} diff --git a/apps/web/app/components/logs/logs.test.ts b/apps/web/app/components/logs/logs.test.ts new file mode 100644 index 00000000..0258f0bb --- /dev/null +++ b/apps/web/app/components/logs/logs.test.ts @@ -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 => ({ + 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) => void)[]>(); + const es = { + onerror: null as ((ev: Event) => unknown) | null, + addEventListener: (t: string, fn: (ev: MessageEvent) => 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); + }, + 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("显示例行记录"); + }); +}); diff --git a/apps/web/app/components/schedules/labels.test.ts b/apps/web/app/components/schedules/labels.test.ts index 16745b2a..e69b517e 100644 --- a/apps/web/app/components/schedules/labels.test.ts +++ b/apps/web/app/components/schedules/labels.test.ts @@ -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", () => { diff --git a/apps/web/app/components/schedules/labels.ts b/apps/web/app/components/schedules/labels.ts index 4b552589..c1984da6 100644 --- a/apps/web/app/components/schedules/labels.ts +++ b/apps/web/app/components/schedules/labels.ts @@ -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 = { collect: "采集", diff --git a/apps/web/app/components/schedules/schedule-board.tsx b/apps/web/app/components/schedules/schedule-board.tsx index 2d96e9a2..f7d8b564 100644 --- a/apps/web/app/components/schedules/schedule-board.tsx +++ b/apps/web/app/components/schedules/schedule-board.tsx @@ -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) { diff --git a/apps/web/app/components/status/resource-section.tsx b/apps/web/app/components/status/resource-section.tsx new file mode 100644 index 00000000..eb614cdd --- /dev/null +++ b/apps/web/app/components/status/resource-section.tsx @@ -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 = { 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; + +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 ( +
+ 正在采样… +
+ ); + return ( +
+ + + + (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) => ( + + ))} + + +
+ ); +} + +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 ( +
+
+ + {title} + {SCOPE_LABEL[scope]} +
+

{value}

+

{hint}

+
{children}
+
+ ); +} + +/** 最近 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 ( +
+ + {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))}`} + + } + > + `${v.toFixed(1)}%`} + max={100} + label="CPU 占用趋势" + /> + + + {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)}` + : ""} + + } + > + `${v.toFixed(1)}%`} + max={100} + label="内存占用趋势" + /> + + + ↓ + {rate(d.network.rxBps)} + + } + hint={`↑ ${rate(d.network.txBps)}${d.network.rxTotalBytes != null ? ` · 累计收 ${formatBytes(d.network.rxTotalBytes, 1)}` : ""}`} + > + `${formatBytes(v, 1)}/s`} + label="网络吞吐趋势(实线接收,虚线发送)" + /> + + + 读 + {rate(d.diskIo.readBps)} + + } + hint={`写 ${rate(d.diskIo.writeBps)}${d.hostUptimeSec != null ? ` · 主机已运行 ${formatDuration(d.hostUptimeSec)}` : ""}`} + > + `${formatBytes(v, 1)}/s`} + label="磁盘读写趋势(实线读,虚线写)" + /> + +
+ ); +} + +const WINDOWS = [15, 60, 360]; + +/** 运行状态页「资源」节:窗口切换 + 四块磁贴 */ +export function ResourceSection() { + const [minutes, setMinutes] = useState(60); + const q = useSystemResources(minutes); + return ( +
+
+ +

资源

+ + 每 {q.data?.intervalSec ?? 5} 秒采样一次,只保留最近 6 小时(重启后重新累计) + + setMinutes(Number(v))} className="ml-auto"> + + {WINDOWS.map((n) => ( + + {n < 60 ? `${n} 分钟` : `${n / 60} 小时`} + + ))} + + +
+ {q.isPending ? ( + + ) : q.isError ? ( + q.refetch()} /> + ) : ( + + )} +
+ ); +} diff --git a/apps/web/app/components/tasks/labels.ts b/apps/web/app/components/tasks/labels.ts index 253316e7..ee9bb803 100644 --- a/apps/web/app/components/tasks/labels.ts +++ b/apps/web/app/components/tasks/labels.ts @@ -15,7 +15,6 @@ export const BUSINESS_KIND_LABEL: Record = { 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 = { manual: "手动", agent: "智能体", }; -/** 来源 → 服务端 triggeredBy 筛选值(逗号分隔多值) */ -export const SOURCE_TRIGGERED_BY: Record = { - 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) { 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): 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"]); diff --git a/apps/web/app/components/tasks/live.ts b/apps/web/app/components/tasks/live.ts new file mode 100644 index 00000000..6f304c64 --- /dev/null +++ b/apps/web/app/components/tasks/live.ts @@ -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 | null>(null); + useSse(TASK_EVENTS, () => { + if (timer.current) return; + timer.current = setTimeout(() => { + timer.current = null; + qc.invalidateQueries({ queryKey: qk.all("engine") }); + }, 2000); + }); +} diff --git a/apps/web/app/components/tasks/task-detail.tsx b/apps/web/app/components/tasks/task-detail.tsx index 986fb0fc..ec1f628b 100644 --- a/apps/web/app/components/tasks/task-detail.tsx +++ b/apps/web/app/components/tasks/task-detail.tsx @@ -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 - +
    {audits.map((a) => (
  • diff --git a/apps/web/app/components/tasks/task-stream.tsx b/apps/web/app/components/tasks/task-stream.tsx deleted file mode 100644 index af849c9b..00000000 --- a/apps/web/app/components/tasks/task-stream.tsx +++ /dev/null @@ -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 | 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 ? {SOURCE_LABEL[s]} : —; -} - -/** 定时任务筛选时,页头给出定时任务自己的运行记录(含跳过的,引擎里没有) */ -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(null); - return ( -
    - - - 定时任务 {job?.name ?? jobId} 触发的任务 - - - - - - - setOpen(null)} /> -
    - ); -} - -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 ? ( - { - setFilter("ref", ""); - setFilter("kind", ""); - }} - /> - ) : filters.ref ? ( -
    - 只看对象 {filters.ref} - -
    - ) : null} - - - setFilter("source", v)} - options={(Object.keys(SOURCE_LABEL) as TaskSource[]).map((value) => ({ value, label: SOURCE_LABEL[value] }))} - /> - setFilter("kind", v)} - options={BUSINESS_KINDS.map((value) => ({ value, label: BUSINESS_KIND_LABEL[value] }))} - /> - {tab === "all" ? ( - setFilter("status", v)} - options={RUN_STATUS_OPTIONS} - /> - ) : null} - setFilter("since", v)} - options={SINCE} - allLabel="不限" - /> - - {filters.workflow ? ( - - ) : null} - - - - 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) => ( -
    -

    {runTitle(r)}

    - {r.business?.title ? ( -

    {workflowLabel(r.workflowName)}

    - ) : null} -
    - ), - }, - { key: "source", header: "来源", hideBelow: "md", cell: (r) => }, - { - key: "status", - header: "状态", - cell: (r) => { - const s = engineRunStatus(r); - return {s.label}; - }, - }, - { - key: "time", - header: "开始", - hideBelow: "sm", - cell: (r) => ( -
    -

    - {r.startedAt - ? formatRelative(r.startedAt) - : ENGINE_ACTIVE.has(r.status) - ? `排队 ${formatRelative(r.createdAt)}` - : `未开始 · ${formatRelative(r.createdAt)}`} -

    -
    - ), - }, - { - key: "link", - header: "关联", - hideBelow: "lg", - cell: (r) => { - const l = businessLink(r.business); - return l ? ( - e.stopPropagation()} - className="inline-flex items-center gap-1 text-caption text-muted-foreground underline-offset-4 hover:text-foreground hover:underline" - > - {l.label} - - - ) : ( - — - ); - }, - }, - ]} - /> - - ); -} diff --git a/apps/web/app/routes.ts b/apps/web/app/routes.ts index bcc4aebf..a4a98eea 100644 --- a/apps/web/app/routes.ts +++ b/apps/web/app/routes.ts @@ -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"), diff --git a/apps/web/app/routes/console/audit.tsx b/apps/web/app/routes/console/audit.tsx index 2df895ab..f6e68845 100644 --- a/apps/web/app/routes/console/audit.tsx +++ b/apps/web/app/routes/console/audit.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 = { - "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 = { create: "新建", update: "修改", delete: "删除", test: "测试", verify: "校验" }; -const NOUN: Record = { - library_root: "媒体库目录", - downloader_instance: "下载器", - media_server_instance: "媒体服务器", - path_mapping: "路径映射", - pi_upstream: "模型服务", - schedule: "定时任务", - automation_grant: "持续授权", -}; -const TARGET_LABEL: Record = { - ...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(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 ( - <> - - { - setTarget(""); - setParams({}, { replace: true }); - } - : undefined - } - > - setParam("actorType", v)} - options={ACTOR_TYPES} - /> - setParam("action", v)} options={ACTION_OPTIONS} /> - {targetId ? ( - - ) : null} - - - 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) => ( - - {formatRelative(r.at)} - - ), - }, - { - key: "action", - header: "动作", - cell: (r) => ( -
    -

    {actionLabel(r.action)}

    -
    - ), - }, - { key: "actor", header: "操作者", hideBelow: "sm", cell: (r) => actorLabel(r.actorType) }, - { - key: "target", - header: "对象", - hideBelow: "md", - cell: (r) => - r.targetType ? ( -

    - {targetText(r.targetType, r.targetId)} -

    - ) : ( - "—" - ), - }, - ]} - /> - !o && setSelected(null)} - title={selected ? actionLabel(selected.action) : ""} - description={selected ? formatDateTime(selected.at) : undefined} - > - {selected ? ( - <> - - - - - - ) : null} - - - ); +export default function LegacyRedirect() { + return null; } diff --git a/apps/web/app/routes/console/home.tsx b/apps/web/app/routes/console/home.tsx index c531eeb3..b0ef6169 100644 --- a/apps/web/app/routes/console/home.tsx +++ b/apps/web/app/routes/console/home.tsx @@ -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 ( -
    +
    {running.isPending && !downloads.length ? ( ) : empty ? ( @@ -246,7 +246,7 @@ function Busy({ w }: { w: Dashboard["currentWork"] }) { ))} {runs.slice(0, Math.max(0, 5 - downloads.length - agents.length)).map((r) => ( - + {runTitle(r)} diff --git a/apps/web/app/routes/console/logs.tsx b/apps/web/app/routes/console/logs.tsx new file mode 100644 index 00000000..14aaae17 --- /dev/null +++ b/apps/web/app/routes/console/logs.tsx @@ -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= / ?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 = { + off: "已暂停", + connecting: "连接中…", + open: "实时", + reconnecting: "重连中…", +}; + +function StreamState({ status }: { status: LogStreamStatus }) { + return ( + + + {STREAM_LABEL[status]} + + ); +} + +function LogsView({ + params, + setParams, +}: { + params: URLSearchParams; + setParams: ReturnType[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>(() => new Set()); + const anchor = useRef(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 ( + <> +
    + + + + {selected.size ? ( + + 已选 {selected.size} 条 + + + + ) : ( + 勾选日志(按住 Shift 选一段)后可以问智能体 + )} +
    + + { + setKeyword(""); + update((p) => { + for (const k of LOG_FILTER_KEYS) p.delete(k); + }); + } + : undefined + } + > + setFilter("type", v)} + options={LOG_TYPES.map((value) => ({ value, label: LOG_TYPE_LABEL[value] }))} + /> + setFilter("level", v)} + options={LOG_LEVELS.map((value) => ({ value, label: LOG_LEVEL[value].label }))} + /> + setFilter("since", v)} + options={LOG_SINCE} + allLabel="不限" + /> + + {objectChip ? ( + + ) : null} + + + + 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: 选择, + className: "w-8", + cell: (r) => ( + { + ev.stopPropagation(); + toggle(r, ev.shiftKey); + }} + onChange={() => {}} + className="h-4 w-4 cursor-pointer accent-foreground" + /> + ), + }, + { + key: "at", + header: "时间", + cell: (r) => ( + + {shortTime(r.at)} + + ), + }, + { + key: "type", + header: "类型", + hideBelow: "sm", + cell: (r) => {LOG_TYPE_LABEL[r.type]}, + }, + { + key: "level", + header: "结果", + cell: (r) => { + const b = logBadge(r); + return {b.label}; + }, + }, + { + key: "title", + header: "摘要", + cell: (r) => { + const sub = logSubtitle(r); + return ( +
    +

    + {logTitle(r)} +

    + {sub ? ( +

    + {sub} +

    + ) : null} +
    + ); + }, + }, + { + key: "object", + header: "对象", + hideBelow: "lg", + cell: (r) => { + const t = objectText(r.object); + return t ? ( + + {t} + + ) : ( + — + ); + }, + }, + ]} + /> + + 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 ( + <> + { + qc.invalidateQueries({ queryKey: qk.all("schedules") }); + qc.invalidateQueries({ queryKey: [...agentKeys.all, "list"] }); + }} + > + + 刷新 + + ) : null + } + /> + + 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 }, + ) + } + > + + 日志 + 定时任务 + + +
    + {tab === "schedules" ? ( + + ) : ( + + )} +
    + + ); +} diff --git a/apps/web/app/routes/console/redirects.ts b/apps/web/app/routes/console/redirects.ts index 5f9ada69..d9043ae7 100644 --- a/apps/web/app/routes/console/redirects.ts +++ b/apps/web/app/routes/console/redirects.ts @@ -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 = { 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(); + 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" }, diff --git a/apps/web/app/routes/console/schedules.tsx b/apps/web/app/routes/console/schedules.tsx index 455260a8..988bfa60 100644 --- a/apps/web/app/routes/console/schedules.tsx +++ b/apps/web/app/routes/console/schedules.tsx @@ -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 ( - <> - { - qc.invalidateQueries({ queryKey: qk.all("schedules") }); - qc.invalidateQueries({ queryKey: [...agentKeys.all, "list"] }); - }} - > - - 刷新 - - } - /> - - - ); +export default function LegacyRedirect() { + return null; } diff --git a/apps/web/app/routes/console/status.tsx b/apps/web/app/routes/console/status.tsx index 3ae511fa..7dc1d24b 100644 --- a/apps/web/app/routes/console/status.tsx +++ b/apps/web/app/routes/console/status.tsx @@ -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 = { ok: { label: "正常", tone: "success" }, @@ -250,10 +252,24 @@ function Processes({ d }: { d: SystemHealth }) { align: "right", cell: (p) => {p.sseConnections ?? "—"}, }, + { + key: "cpu", + header: "CPU", + hideBelow: "md", + align: "right", + cell: (p) => { + const cpu = p.extra.cpuPercent; + return ( + + {typeof cpu === "number" ? `${cpu.toFixed(1)}%` : "—"} + + ); + }, + }, { key: "mem", header: "内存", - hideBelow: "lg", + hideBelow: "md", align: "right", cell: (p) => { const rss = p.extra.rssBytes; @@ -517,6 +533,7 @@ export default function StatusPage() { +
    {d.db.error ?

    {d.db.error}

    : null} @@ -542,7 +559,10 @@ export default function StatusPage() { icon={ListTodo} level={d.engine.level} action={ - + 查看任务 } @@ -567,8 +587,11 @@ export default function StatusPage() { icon={Timer} level={d.schedules.level} action={ - - 查看计划 + + 查看定时任务 } > @@ -584,7 +607,7 @@ export default function StatusPage() { {d.schedules.failing.map((f) => (
  • {f.name} @@ -600,16 +623,16 @@ export default function StatusPage() {
  • - -
    -

    - - 进程 - SSE 连接合计 {d.sse.connections} -

    - - -
    +
    +

    + + 进程 + SSE 连接合计 {d.sse.connections} +

    + + +
    + diff --git a/apps/web/app/routes/console/tasks.$id.tsx b/apps/web/app/routes/console/tasks.$id.tsx index e2714a4f..71bbd7f8 100644 --- a/apps/web/app/routes/console/tasks.$id.tsx +++ b/apps/web/app/routes/console/tasks.$id.tsx @@ -124,9 +124,9 @@ function Operations({ taskId }: { taskId: string }) { const back = ( ); diff --git a/apps/web/app/routes/console/tasks.tsx b/apps/web/app/routes/console/tasks.tsx index ef3e6e28..f912815f 100644 --- a/apps/web/app/routes/console/tasks.tsx +++ b/apps/web/app/routes/console/tasks.tsx @@ -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 {n > 999 ? "999+" : n}; -} - -function RunSheet({ - runId, - onOpen, - onClose, -}: { - runId: string | null; - onOpen: (id: string) => void; - onClose: () => void; -}) { - const q = useEngineRun(runId ?? undefined); - const r = q.data; - return ( - !o && onClose()} - width="lg" - title={r ? runTitle(r) : "任务"} - description={r ? : undefined} - footer={ - r ? ( - <> - - - - ) : null - } - > - {q.isPending ? ( - - ) : q.isError ? ( - q.refetch()} /> - ) : r ? ( - - ) : null} - - ); -} - -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 ( - <> - qc.invalidateQueries({ queryKey: qk.all("engine") })}> - - 刷新 - - } - /> - - update((p) => { - p.set("tab", v); - if (v !== "all") p.delete("status"); - }) - } - > - - 全部 - - 进行中 - - - - 失败 - - - - -
    - - update((p) => { - for (const k of FILTER_KEYS) p.delete(k); - }) - } - onOpen={(id) => update((p) => p.set("run", id))} - selectedId={runId} - /> -
    - update((p) => p.set("run", id))} - onClose={() => update((p) => p.delete("run"))} - /> - - ); +export default function LegacyRedirect() { + return null; } diff --git a/apps/web/scripts/smoke.mjs b/apps/web/scripts/smoke.mjs index 6201cc60..c16a2d0e 100644 --- a/apps/web/scripts/smoke.mjs +++ b/apps/web/scripts/smoke.mjs @@ -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) diff --git a/docs/design/console-ia.md b/docs/design/console-ia.md index 5e3d4c55..1b5255c0 100644 --- a/docs/design/console-ia.md +++ b/docs/design/console-ia.md @@ -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`。 diff --git a/docs/runbook/logs.md b/docs/runbook/logs.md new file mode 100644 index 00000000..295d5c6b --- /dev/null +++ b/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 个磁盘、下载器 / 站点账号 / 媒体库目录的正常数 / 总数,点进去到运行状态页。 diff --git a/docs/runbook/schedules.md b/docs/runbook/schedules.md index 0063384f..7b70eb39 100644 --- a/docs/runbook/schedules.md +++ b/docs/runbook/schedules.md @@ -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))。 ## 组成 diff --git a/packages/contracts/src/api.ts b/packages/contracts/src/api.ts index 47e996e5..eea71add 100644 --- a/packages/contracts/src/api.ts +++ b/packages/contracts/src/api.ts @@ -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, diff --git a/packages/contracts/src/api/logs.ts b/packages/contracts/src/api/logs.ts new file mode 100644 index 00000000..3a446c48 --- /dev/null +++ b/packages/contracts/src/api/logs.ts @@ -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; diff --git a/packages/contracts/src/api/system.ts b/packages/contracts/src/api/system.ts index 47d77277..b9fadae0 100644 --- a/packages/contracts/src/api/system.ts +++ b/packages/contracts/src/api/system.ts @@ -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", diff --git a/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts index 8a77820b..4466c91e 100644 --- a/packages/contracts/src/auth.ts +++ b/packages/contracts/src/auth.ts @@ -194,6 +194,7 @@ export type PasskeyRenameRequest = z.infer; export const AuditAction = z.enum([ "admin.setup", "admin.login_success", + "admin.login_failed", "admin.logout", "admin.password_change", "admin.totp_enable", diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index cf64f28f..23409032 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -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"; diff --git a/packages/contracts/src/logs.ts b/packages/contracts/src/logs.ts new file mode 100644 index 00000000..6f6c7eaa --- /dev/null +++ b/packages/contracts/src/logs.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; + +/** 级别 / 结果:进行中、成功、信息(普通操作)、警告(取消、跳过以外的非成功,如登录失败、被拒绝、超出预算)、失败 */ +export const LogLevel = z.enum(["running", "success", "info", "warn", "error"]); +export type LogLevel = z.infer; + +/** 来源表 */ +export const LogSource = z.enum(["engine_run", "schedule_run", "agent_run", "audit_event"]); +export type LogSource = z.infer; + +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; + +export const LogEntry = z.object({ + /** :<原始 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; + +/** 单值或逗号分隔多值(type=task,auto) */ +function csv(e: z.ZodType, 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; + +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; + +export const LogPage = cursorPage(LogEntry); +export type LogPage = z.infer; + +export const LogStreamQuery = LogFilter.extend({ + /** 只推这个时间之后的新日志(客户端列表里最新一条的时间);缺省为连接时刻 */ + since: Timestamp.optional(), +}); +export type LogStreamQuery = z.infer; + +/** SSE 事件名:entry = 新日志或已推过的日志状态有变化(按 id 覆盖) */ +export const LOG_STREAM_EVENT = "log.entry"; diff --git a/packages/contracts/src/system-resources.ts b/packages/contracts/src/system-resources.ts new file mode 100644 index 00000000..c0997f75 --- /dev/null +++ b/packages/contracts/src/system-resources.ts @@ -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; + +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; + +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; + +export const SystemResourcesQuery = z.object({ + /** 趋势窗口(分钟),5–360,缺省 60 */ + minutes: z.coerce.number().int().min(5).max(360).default(60), +}); +export type SystemResourcesQuery = z.infer; diff --git a/packages/contracts/test/schemas.test.ts b/packages/contracts/test/schemas.test.ts index 97ac2109..6a7d39ea 100644 --- a/packages/contracts/test/schemas.test.ts +++ b/packages/contracts/test/schemas.test.ts @@ -101,6 +101,9 @@ const OVERRIDES: Record) => Record ({ ...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, diff --git a/packages/db/migrations/0083_log_indexes.ts b/packages/db/migrations/0083_log_indexes.ts new file mode 100644 index 00000000..2499a736 --- /dev/null +++ b/packages/db/migrations/0083_log_indexes.ts @@ -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): Promise { + 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): Promise { + await execDdl(db, `drop index if exists scheduled_job_run_started_idx;`); +}