feat(web): D 消息编辑/删除前端入口 + 日志聚合前端(Observability后端tail) + 修api-client契约
- 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:
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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user