feat(web): D 消息编辑/删除前端入口 + 日志聚合前端(Observability后端tail) + 修api-client契约
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- D前端: lib/api/messages.ts(patchMessage/deleteMessage,兼容裸DTO与{message}) + MessageActions.tsx(仅user消息; 单击/右键/450ms长按开Miuix玻璃菜单; 编辑弹窗预填+字数→乐观updateMessageById→patchMessage→失败回滚; 删除确认→deleteMessage→removeMessageById; 乐观气泡禁用) + chat.tsx挂载 + Icon加edit/trash/more
- 日志前端: types(AdminLogLine time:string|number) + adminSystem.adminObservabilityLogs + Observability.tsx 后端分级tail(level筛选chip注明'该级及以上'+等宽行HH:mm:ss.SSS·level着色·source·msg+30s刷新) 下半保留WS回显
- Docs/api-client §6a/§4.2 修正: PATCH /messages/{id} 实际返回 {data:<DTO>} 非 {data:{message}}(前端已双向兼容,避免误导Android)
- 前端 build 绿(167 modules); SSR冒烟11 checks
- 遗留: 未实机登录; 菜单absolute理论上边缘可裁切(可改portal)
This commit is contained in:
AskaEth committed 2026-10-03 15:23:27 +08:00
1 parent 025dd1796b
commit 1ac126fc9e
9 files changed
+611 -12

No files matched your search

@@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { useAppStore } from "../../store/useAppStore";
import { useAuditStore } from "../audit";
import { CardGrid, Notice, PageHeader, StatCard } from "../components";
@@ -6,8 +6,13 @@ import { Button, Surface, Tag } from "../../components/primitives";
import { Icon } from "../../components/Icon";
import { useAsyncData } from "../../lib/useAsyncData";
import { useAutoReload } from "../hooks";
import { adminObservabilityAudit, adminObservabilityMetrics, adminObservabilityTrace } from "../../lib/api";
import type { AdminTraceSpan, AuditRecord } from "../../lib/types";
import {
adminObservabilityAudit,
adminObservabilityLogs,
adminObservabilityMetrics,
adminObservabilityTrace,
} from "../../lib/api";
import type { AdminLogLevel, AdminLogLine, AdminTraceSpan, AuditRecord } from "../../lib/types";
type Tab = "logs" | "trace" | "metrics" | "audit";
@@ -18,6 +23,38 @@ const TABS: ReadonlyArray<{ id: Tab; label: string }> = [
{ id: "audit", label: "审计" },
];
/** Live backend log tail (GET /admin/observability/logs): level filter + refresh. */
const LOG_LIMIT = 200;
const LOG_RELOAD_MS = 30_000;
const LEVEL_FILTERS: ReadonlyArray<{ id: AdminLogLevel | ""; label: string }> = [
{ id: "", label: "全部" },
{ id: "debug", label: "debug" },
{ id: "info", label: "info" },
{ id: "warn", label: "warn" },
{ id: "error", label: "error" },
];
const LEVEL_TEXT: Record<string, string> = {
debug: "text-[var(--mu-text-lo)]",
info: "text-[var(--mu-accent-hi)]",
warn: "text-[var(--mu-warn)]",
error: "text-[var(--mu-err)]",
};
/** `time` is RFC3339 on current builds but the contract allows epoch ms too, so
* both are normalized; an unparseable value is shown verbatim rather than faked. */
function fmtLogTime(t: string | number): string {
const d = new Date(t);
if (Number.isNaN(d.getTime())) return String(t);
const p = (n: number, w = 2) => String(n).padStart(w, "0");
return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}.${p(d.getMilliseconds(), 3)}`;
}
function levelTextClass(level: string): string {
return LEVEL_TEXT[level.toLowerCase()] ?? "text-[var(--mu-text-lo)]";
}
const KIND_LABEL: Record<string, string> = {
llm_call: "LLM",
tool_call: "工具",
@@ -52,7 +89,7 @@ export function ObservabilityModule(): React.ReactNode {
))}
</div>
{tab === "logs" ? <LogsTail lines={deviceUpdates} /> : null}
{tab === "logs" ? <LogsTab lines={deviceUpdates} /> : null}
{tab === "trace" ? <TraceView /> : null}
{tab === "metrics" ? <MetricsView /> : null}
{tab === "audit" ? <AuditView records={audit} onClear={clearAudit} /> : null}
@@ -60,12 +97,124 @@ export function ObservabilityModule(): React.ReactNode {
);
}
/** 实时日志 tab: the backend's structured log tail first (authoritative, leveled
* and filtered server-side), then the WS event echo as a client-side complement. */
function LogsTab({ lines }: { lines: string[] }): React.ReactNode {
return (
<div className="space-y-4">
<BackendLogs />
<LogsTail lines={lines} />
</div>
);
}
/** Backend log tail: level filter chips over a monospace list, refreshed every
* 30s. Loading / empty / error are each surfaced honestly — no placeholder rows. */
function BackendLogs(): React.ReactNode {
const [level, setLevel] = useState<AdminLogLevel | "">("");
const logs = useAsyncData(
() => adminObservabilityLogs({ level: level || undefined, limit: LOG_LIMIT }),
[level]
);
useAutoReload(logs.reload, LOG_RELOAD_MS);
const rows = logs.data ?? [];
// "更新于 N 秒前" needs a ticking clock; the interval only forces a re-render,
// the timestamp itself is captured when fresh data lands.
const [updatedAt, setUpdatedAt] = useState(0);
useEffect(() => {
if (logs.data) setUpdatedAt(Date.now());
}, [logs.data]);
const [, setTick] = useState(0);
useEffect(() => {
const id = window.setInterval(() => setTick((v) => v + 1), 1000);
return () => window.clearInterval(id);
}, []);
return (
<Surface className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold">实时日志(后端分级 tail)</h3>
<Tag tone="ok">tail</Tag>
<span className="font-mono text-xs text-[var(--mu-text-lo)]">
GET /api/v1/admin/observability/logs
</span>
<span className="ml-auto text-xs text-[var(--mu-text-lo)]">
{updatedAt > 0 ? `${Math.max(0, Math.round((Date.now() - updatedAt) / 1000))}s 前更新` : "尚未更新"}
{" · 30s 自动刷新"}
</span>
</div>
<div className="flex flex-wrap items-center gap-1.5">
{LEVEL_FILTERS.map((f) => (
<button
key={f.id || "all"}
onClick={() => setLevel(f.id)}
aria-pressed={level === f.id}
className={`rounded-[var(--mu-radius)] px-2.5 py-1 font-mono text-xs font-medium transition-all duration-200 ease-[var(--mu-spring)] ${
level === f.id
? "bg-[var(--mu-accent)]/20 text-[var(--mu-accent-hi)]"
: "bg-[var(--mu-surface-4)] text-[var(--mu-text-mid)] hover:bg-[var(--mu-surface-5)]"
}`}
>
{f.label}
</button>
))}
<span className="ml-auto text-xs text-[var(--mu-text-lo)]">
共 {rows.length} 条 · 上限 {LOG_LIMIT}
{level ? ` · 筛选 ${level} 及以上` : ""}
</span>
</div>
{logs.loading && logs.data === null ? (
<Notice>加载日志…</Notice>
) : logs.error && logs.data === null ? (
<Notice tone="warn">日志端点暂不可用:{logs.error}</Notice>
) : rows.length === 0 ? (
<Notice>
暂无日志{level ? `(level=${level})` : ""}。网关写入结构化日志后,此处按 level 过滤实时展示。
</Notice>
) : (
<div className="max-h-96 overflow-auto rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] py-1.5">
{rows.map((line, i) => (
<LogRow key={`${line.time}-${line.source}-${i}`} line={line} />
))}
</div>
)}
{logs.error && logs.data !== null ? (
<Notice tone="warn">上次刷新失败(保留现有日志):{logs.error}</Notice>
) : null}
</Surface>
);
}
/** One monospace log line: time · level · source · msg (source never blank). */
function LogRow({ line }: { line: AdminLogLine }): React.ReactNode {
return (
<div className="flex items-start gap-2 px-3 py-0.5 font-mono text-xs leading-relaxed hover:bg-[var(--mu-surface-3)]/60">
<span className="shrink-0 text-[var(--mu-text-lo)]" title={String(line.time)}>
{fmtLogTime(line.time)}
</span>
<span className={`w-12 shrink-0 font-semibold ${levelTextClass(line.level)}`}>
{line.level || "info"}
</span>
<span className="shrink-0 text-[var(--mu-accent-hi)]">{line.source || "—"}</span>
<span className="min-w-0 flex-1 whitespace-pre-wrap break-words text-[var(--mu-text-mid)]">
{line.msg}
</span>
</div>
);
}
/** Client-side WS event echo — the frames this browser received, kept alongside
* the server log because the two answer different questions (server truth vs.
* what this client actually saw). */
function LogsTail({ lines }: { lines: string[] }): React.ReactNode {
return (
<Surface className="space-y-3">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold">实时日志流(WS 事件回显)</h3>
<Tag tone="ok">tail</Tag>
<h3 className="text-sm font-semibold">本端 WS 事件回显</h3>
<Tag tone="neutral">client</Tag>
</div>
{lines.length === 0 ? (
<Notice>暂无实时事件。接入 /ws/chat 后,此处回显 stream / tool / device_update / reminder 等事件。</Notice>
@@ -74,7 +223,7 @@ function LogsTail({ lines }: { lines: string[] }): React.ReactNode {
{lines.join("\n")}
</pre>
)}
<Notice>完整后端日志分级 tail / 过滤 / 导出需后端 M10+ 提供。</Notice>
<Notice>这是当前浏览器的 WS 帧回显,不是服务端日志;服务端分级日志见上方「实时日志(后端分级 tail)」。</Notice>
</Surface>
);
}