Add backend log management
This commit is contained in:
@@ -7,6 +7,8 @@ PORT=3000
|
||||
HOSTNAME=127.0.0.1
|
||||
NEXT_PUBLIC_APP_URL=http://127.0.0.1:3000
|
||||
ZHINIAN_RUNTIME_DIR=.runtime
|
||||
ZHINIAN_LOG_DIR=
|
||||
ZHINIAN_LOG_MAX_BYTES=5242880
|
||||
ZHINIAN_PUBLIC_BASE_URL=http://127.0.0.1:3000
|
||||
|
||||
# Account login / Web SSO.
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
- 统一创作入口:`/create`
|
||||
- 结果资产管理:`/assets`
|
||||
- 服务与引擎配置:`/settings`
|
||||
- 后台日志管理:`/logs`
|
||||
- 图片生成:即梦图片生成 4.6 或 EvoLink GPT Image 2
|
||||
- 图片编辑:局部重绘、智能超清
|
||||
- 视频生成:Seedance 2.0
|
||||
@@ -67,6 +68,7 @@ bash scripts/deploy.sh
|
||||
|
||||
- 创建 `.env.local`
|
||||
- 创建 `.runtime/data`、`.runtime/uploads`、`.runtime/generated-results`
|
||||
- 创建 `.runtime/logs`
|
||||
- 构建 Docker 镜像
|
||||
- 使用 `docker compose up -d --build` 后台启动 Web 服务和 Worker 服务
|
||||
|
||||
@@ -114,6 +116,7 @@ npm run worker
|
||||
```
|
||||
|
||||
生产环境建议把 `NEXT_PUBLIC_APP_URL` 设置成真实域名或公网地址,配置 `ZHINIAN_INTERNAL_WORKER_TOKEN`,并把 `.runtime/` 做定期备份。
|
||||
部署后如果页面或接口报错,登录后台访问 `/logs` 查看最近错误、请求路径、状态码和错误栈。日志文件默认在 `.runtime/logs/server-events.jsonl`。
|
||||
|
||||
## 常用命令
|
||||
|
||||
@@ -136,6 +139,7 @@ npm run info
|
||||
| `/create?mode=inpaint` | 局部重绘模式 |
|
||||
| `/create?mode=upscale` | 智能超清模式 |
|
||||
| `/assets` | 历史任务与资产 |
|
||||
| `/logs` | 后台日志管理 |
|
||||
| `/settings` | 接口、引擎和服务配置 |
|
||||
|
||||
## 账户登录 / SSO
|
||||
|
||||
40
app/api/logs/route.ts
Normal file
40
app/api/logs/route.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { jsonError, jsonOk } from "@/lib/server/api";
|
||||
import { appLogFilePath, appLogMaxBytes, clearAppLogs, listAppLogs, type AppLogLevel } from "@/lib/server/log-manager";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const level = parseLevel(url.searchParams.get("level"));
|
||||
const q = url.searchParams.get("q") || undefined;
|
||||
const limit = Number(url.searchParams.get("limit") || 100);
|
||||
const entries = await listAppLogs({
|
||||
level,
|
||||
q,
|
||||
limit: Number.isFinite(limit) ? limit : 100
|
||||
});
|
||||
return jsonOk({
|
||||
entries,
|
||||
logPath: appLogFilePath(),
|
||||
maxBytes: appLogMaxBytes()
|
||||
});
|
||||
} catch (error) {
|
||||
return jsonError(error, 500, { request, source: "api.logs" });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
try {
|
||||
await clearAppLogs();
|
||||
return jsonOk({ ok: true });
|
||||
} catch (error) {
|
||||
return jsonError(error, 500, { request, source: "api.logs" });
|
||||
}
|
||||
}
|
||||
|
||||
function parseLevel(value: string | null): AppLogLevel | "all" {
|
||||
if (value === "info" || value === "warning" || value === "error") return value;
|
||||
return "all";
|
||||
}
|
||||
169
app/globals.css
169
app/globals.css
@@ -1207,6 +1207,163 @@ h3 {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.log-manager {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.log-actions {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(240px, 0.8fr) minmax(260px, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.log-tabs {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.log-search {
|
||||
min-width: 0;
|
||||
height: 42px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.log-search svg {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
color: var(--muted);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.log-search input {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.log-storage {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.5fr) repeat(2, minmax(150px, 0.6fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.log-storage div {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #f8faf8;
|
||||
}
|
||||
|
||||
.log-storage span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.log-storage strong {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.log-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.log-row {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.log-row-error {
|
||||
border-color: #e6b8ae;
|
||||
background: #fffaf8;
|
||||
}
|
||||
|
||||
.log-row-warning {
|
||||
border-color: #ead2a4;
|
||||
background: #fffaf0;
|
||||
}
|
||||
|
||||
.log-row-main {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.log-message {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.log-message strong {
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.log-message small {
|
||||
min-width: 0;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.log-detail {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.log-detail summary {
|
||||
cursor: pointer;
|
||||
color: var(--green-dark);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.log-detail pre {
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
margin: 8px 0 0;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #f8fbfb;
|
||||
color: #27312d;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.log-empty {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 160px;
|
||||
color: var(--muted);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.image-upload-button {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
@@ -2221,6 +2378,11 @@ button:active:not(:disabled),
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.log-actions,
|
||||
.log-storage {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.settings-engine-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
@@ -2278,6 +2440,7 @@ button:active:not(:disabled),
|
||||
|
||||
.create-mode-bar,
|
||||
.asset-manager-toolbar,
|
||||
.log-actions,
|
||||
.settings-actions {
|
||||
padding: 8px;
|
||||
}
|
||||
@@ -2450,6 +2613,11 @@ button:active:not(:disabled),
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.log-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.asset-view-switch {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
@@ -2459,6 +2627,7 @@ button:active:not(:disabled),
|
||||
}
|
||||
|
||||
.asset-view-switch button,
|
||||
.log-tabs button,
|
||||
.settings-tabs button {
|
||||
min-width: 0;
|
||||
min-height: 38px;
|
||||
|
||||
7
app/logs/page.tsx
Normal file
7
app/logs/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { LogManager } from "@/components/log-manager";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function LogsPage() {
|
||||
return <LogManager />;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Archive,
|
||||
LogIn,
|
||||
LogOut,
|
||||
ScrollText,
|
||||
Settings,
|
||||
Sparkles,
|
||||
UserCircle
|
||||
@@ -19,6 +20,7 @@ import type { AuthUser } from "@/lib/auth/session";
|
||||
const nav = [
|
||||
{ href: "/create", label: "创作", icon: Sparkles },
|
||||
{ href: "/assets", label: "结果", icon: Archive },
|
||||
{ href: "/logs", label: "日志", icon: ScrollText },
|
||||
{ href: "/settings", label: "设置", icon: Settings }
|
||||
];
|
||||
|
||||
|
||||
245
components/log-manager.tsx
Normal file
245
components/log-manager.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Loader2, RefreshCw, Search, Trash2 } from "lucide-react";
|
||||
import { crossfadeIn, pulseFeedback, revealChildren, runScopedMotion } from "@/lib/ui/motion";
|
||||
|
||||
type LogLevel = "info" | "warning" | "error";
|
||||
|
||||
type LogEntry = {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
level: LogLevel;
|
||||
source: string;
|
||||
message: string;
|
||||
status?: number;
|
||||
method?: string;
|
||||
path?: string;
|
||||
stack?: string;
|
||||
details?: unknown;
|
||||
};
|
||||
|
||||
type LogPayload = {
|
||||
entries: LogEntry[];
|
||||
logPath: string;
|
||||
maxBytes: number;
|
||||
};
|
||||
|
||||
const LEVEL_TABS: Array<{ id: LogLevel | "all"; label: string }> = [
|
||||
{ id: "all", label: "全部" },
|
||||
{ id: "error", label: "错误" },
|
||||
{ id: "warning", label: "警告" },
|
||||
{ id: "info", label: "信息" }
|
||||
];
|
||||
|
||||
export function LogManager() {
|
||||
const [payload, setPayload] = useState<LogPayload | null>(null);
|
||||
const [level, setLevel] = useState<LogLevel | "all">("all");
|
||||
const [query, setQuery] = useState("");
|
||||
const [draftQuery, setDraftQuery] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [clearing, setClearing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const logsRef = useRef<HTMLDivElement | null>(null);
|
||||
const contentRef = useRef<HTMLElement | null>(null);
|
||||
const feedbackRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void loadLogs();
|
||||
}, [level, query]);
|
||||
|
||||
useEffect(() => {
|
||||
return runScopedMotion(logsRef, (scope) => revealChildren(scope));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
crossfadeIn(contentRef.current);
|
||||
}, [payload?.entries]);
|
||||
|
||||
useEffect(() => {
|
||||
pulseFeedback(feedbackRef.current);
|
||||
}, [error, message]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const entries = payload?.entries || [];
|
||||
return {
|
||||
total: entries.length,
|
||||
error: entries.filter((entry) => entry.level === "error").length,
|
||||
warning: entries.filter((entry) => entry.level === "warning").length,
|
||||
info: entries.filter((entry) => entry.level === "info").length
|
||||
};
|
||||
}, [payload]);
|
||||
|
||||
async function loadLogs() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params = new URLSearchParams({ level, limit: "200" });
|
||||
if (query.trim()) params.set("q", query.trim());
|
||||
const response = await fetch(`/api/logs?${params.toString()}`, { cache: "no-store" });
|
||||
const nextPayload = await response.json();
|
||||
if (!response.ok) throw new Error(nextPayload.error || "读取日志失败");
|
||||
setPayload(nextPayload);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function clearLogs() {
|
||||
if (!window.confirm("确定清空后台日志?")) return;
|
||||
setClearing(true);
|
||||
setError(null);
|
||||
setMessage(null);
|
||||
try {
|
||||
const response = await fetch("/api/logs", { method: "DELETE" });
|
||||
const result = await response.json();
|
||||
if (!response.ok) throw new Error(result.error || "清空日志失败");
|
||||
setMessage("日志已清空。");
|
||||
await loadLogs();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setClearing(false);
|
||||
}
|
||||
}
|
||||
|
||||
function submitSearch(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setQuery(draftQuery.trim());
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="log-manager" ref={logsRef}>
|
||||
<div className="workspace-head" data-animate>
|
||||
<div>
|
||||
<h1 className="workspace-title">日志管理</h1>
|
||||
</div>
|
||||
<div className="workspace-meta">
|
||||
<span className="status">{stats.total} 条</span>
|
||||
<span className="status failed">{stats.error} 错误</span>
|
||||
<span className="status running">{stats.warning} 警告</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="panel log-actions" data-animate>
|
||||
<div className="segmented log-tabs" aria-label="日志级别">
|
||||
{LEVEL_TABS.map((tab) => (
|
||||
<button
|
||||
className={level === tab.id ? "active" : ""}
|
||||
aria-pressed={level === tab.id}
|
||||
type="button"
|
||||
key={tab.id}
|
||||
onClick={() => setLevel(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<form className="log-search" onSubmit={submitSearch}>
|
||||
<Search aria-hidden="true" />
|
||||
<input
|
||||
value={draftQuery}
|
||||
placeholder="搜索消息、路径、状态"
|
||||
onChange={(event) => setDraftQuery(event.target.value)}
|
||||
/>
|
||||
</form>
|
||||
<div className="toolbar">
|
||||
<button className="button" type="button" onClick={loadLogs} disabled={loading || clearing}>
|
||||
{loading ? <Loader2 className="spin" size={18} /> : <RefreshCw size={18} />}
|
||||
刷新
|
||||
</button>
|
||||
<button className="button danger" type="button" onClick={clearLogs} disabled={loading || clearing}>
|
||||
{clearing ? <Loader2 className="spin" size={18} /> : <Trash2 size={18} />}
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{error || message ? (
|
||||
<div ref={feedbackRef}>
|
||||
{error ? <div className="callout" role="alert">{error}</div> : null}
|
||||
{message ? <div className="callout success-callout" role="status" aria-live="polite">{message}</div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="panel log-storage" data-animate>
|
||||
<div>
|
||||
<span>日志文件</span>
|
||||
<strong>{payload?.logPath || ".runtime/logs/server-events.jsonl"}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>单文件上限</span>
|
||||
<strong>{formatBytes(payload?.maxBytes || 0)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>当前筛选</span>
|
||||
<strong>{levelLabel(level)} / {query || "全部"}</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel log-list" data-animate ref={contentRef}>
|
||||
{loading && !payload ? (
|
||||
<div className="settings-loading">
|
||||
<Loader2 className="spin" size={18} />
|
||||
正在读取日志
|
||||
</div>
|
||||
) : payload?.entries.length ? (
|
||||
payload.entries.map((entry) => <LogRow entry={entry} key={entry.id} />)
|
||||
) : (
|
||||
<div className="log-empty">暂无日志</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LogRow({ entry }: { entry: LogEntry }) {
|
||||
const details = detailText(entry);
|
||||
return (
|
||||
<article className={`log-row log-row-${entry.level}`}>
|
||||
<div className="log-row-main">
|
||||
<span className={`status ${entry.level === "error" ? "failed" : entry.level === "warning" ? "running" : ""}`}>{levelLabel(entry.level)}</span>
|
||||
<div className="log-message">
|
||||
<strong>{entry.message}</strong>
|
||||
<small>{formatTime(entry.createdAt)} · {entry.source}{entry.status ? ` · ${entry.status}` : ""}{entry.method || entry.path ? ` · ${[entry.method, entry.path].filter(Boolean).join(" ")}` : ""}</small>
|
||||
</div>
|
||||
</div>
|
||||
{details ? (
|
||||
<details className="log-detail">
|
||||
<summary>详情</summary>
|
||||
<pre>{details}</pre>
|
||||
</details>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function detailText(entry: LogEntry): string {
|
||||
const parts: string[] = [];
|
||||
if (entry.stack) parts.push(entry.stack);
|
||||
if (entry.details !== undefined) parts.push(JSON.stringify(entry.details, null, 2));
|
||||
return parts.join("\n\n");
|
||||
}
|
||||
|
||||
function levelLabel(level: LogLevel | "all"): string {
|
||||
if (level === "error") return "错误";
|
||||
if (level === "warning") return "警告";
|
||||
if (level === "info") return "信息";
|
||||
return "全部";
|
||||
}
|
||||
|
||||
function formatTime(value: string): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
function formatBytes(value: number): string {
|
||||
if (!value) return "-";
|
||||
if (value >= 1024 * 1024) return `${(value / 1024 / 1024).toFixed(1)} MB`;
|
||||
if (value >= 1024) return `${(value / 1024).toFixed(1)} KB`;
|
||||
return `${value} B`;
|
||||
}
|
||||
@@ -21,6 +21,7 @@ bash scripts/deploy.sh
|
||||
|
||||
- 从 `.env.example` 创建 `.env.local`(如果不存在)
|
||||
- 创建 `.runtime/data`、`.runtime/uploads`、`.runtime/generated-results`
|
||||
- 创建 `.runtime/logs`
|
||||
- 构建镜像
|
||||
- 启动 `zhinian-aigc` Web 服务
|
||||
- 启动 `zhinian-worker` 任务 Worker
|
||||
@@ -95,6 +96,14 @@ docker compose restart
|
||||
docker compose down
|
||||
```
|
||||
|
||||
Web 后台可在登录后访问:
|
||||
|
||||
```text
|
||||
https://你的域名/logs
|
||||
```
|
||||
|
||||
日志默认写入 `.runtime/logs/server-events.jsonl`,用于查看 API 500 错误、Worker 任务异常、错误栈和请求路径。可通过环境变量 `ZHINIAN_LOG_DIR` 调整目录,通过 `ZHINIAN_LOG_MAX_BYTES` 调整单文件轮转大小。
|
||||
|
||||
更新部署:
|
||||
|
||||
```bash
|
||||
@@ -123,6 +132,7 @@ Docker Compose 会挂载:
|
||||
```
|
||||
|
||||
本地 JSON 数据层、上传文件和生成结果都会放在 `.runtime/` 下。生产环境如果未启用 Supabase/Postgres,请定期备份该目录。
|
||||
服务端日志也会放在 `.runtime/logs/` 下,建议和运行时数据一起备份或接入服务器日志采集。
|
||||
|
||||
建议备份:
|
||||
|
||||
@@ -174,6 +184,7 @@ curl https://你的域名/api/v1/openapi.json
|
||||
- 未登录访问 Web 页面会跳转到 `/auth/login`
|
||||
- 登录后 Web 页面可访问
|
||||
- `/api/health` 返回 `ok: true`
|
||||
- `/logs` 可查看后台错误日志
|
||||
- `/api/v1/capabilities` 使用 API Key 可访问
|
||||
- `zhinian-worker` 日志持续输出 `claimed=...`
|
||||
- OSS、EvoLink、火山、Seedance 密钥按业务需要配置完成
|
||||
|
||||
@@ -1,11 +1,30 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { recordAppLog } from "@/lib/server/log-manager";
|
||||
|
||||
export function jsonOk<T>(payload: T, init?: ResponseInit) {
|
||||
return NextResponse.json(payload, init);
|
||||
}
|
||||
|
||||
export function jsonError(error: unknown, status = 400) {
|
||||
export type JsonErrorOptions = {
|
||||
request?: Request;
|
||||
source?: string;
|
||||
details?: unknown;
|
||||
logClientErrors?: boolean;
|
||||
};
|
||||
|
||||
export async function jsonError(error: unknown, status = 400, options: JsonErrorOptions = {}) {
|
||||
const resolvedStatus = statusFromError(error) || status;
|
||||
if (resolvedStatus >= 500 || options.logClientErrors) {
|
||||
await recordAppLog({
|
||||
level: resolvedStatus >= 500 ? "error" : "warning",
|
||||
source: options.source || "api",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
error,
|
||||
status: resolvedStatus,
|
||||
request: options.request,
|
||||
details: options.details
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
return NextResponse.json({
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
}, { status: resolvedStatus });
|
||||
|
||||
210
lib/server/log-manager.ts
Normal file
210
lib/server/log-manager.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { appendFile, mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { logDir } from "@/lib/server/runtime";
|
||||
|
||||
export type AppLogLevel = "info" | "warning" | "error";
|
||||
|
||||
export type AppLogEntry = {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
level: AppLogLevel;
|
||||
source: string;
|
||||
message: string;
|
||||
status?: number;
|
||||
method?: string;
|
||||
path?: string;
|
||||
stack?: string;
|
||||
details?: unknown;
|
||||
};
|
||||
|
||||
export type AppLogInput = {
|
||||
level?: AppLogLevel;
|
||||
source: string;
|
||||
message?: string;
|
||||
error?: unknown;
|
||||
status?: number;
|
||||
request?: Request;
|
||||
details?: unknown;
|
||||
};
|
||||
|
||||
export type AppLogListFilters = {
|
||||
level?: AppLogLevel | "all";
|
||||
q?: string;
|
||||
source?: string;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
const LOG_FILE_NAME = "server-events.jsonl";
|
||||
const DEFAULT_MAX_BYTES = 5 * 1024 * 1024;
|
||||
const MAX_ENTRY_TEXT_LENGTH = 20_000;
|
||||
let writeQueue: Promise<unknown> = Promise.resolve();
|
||||
|
||||
export function appLogFilePath(): string {
|
||||
return join(logDir(), LOG_FILE_NAME);
|
||||
}
|
||||
|
||||
export function appLogMaxBytes(): number {
|
||||
const parsed = Number(process.env.ZHINIAN_LOG_MAX_BYTES);
|
||||
return Number.isFinite(parsed) && parsed > 1024 ? Math.floor(parsed) : DEFAULT_MAX_BYTES;
|
||||
}
|
||||
|
||||
export async function recordAppLog(input: AppLogInput): Promise<AppLogEntry> {
|
||||
const entry = normalizeLogEntry(input);
|
||||
writeQueue = writeQueue
|
||||
.catch(() => undefined)
|
||||
.then(async () => {
|
||||
await mkdir(logDir(), { recursive: true });
|
||||
await rotateLogIfNeeded();
|
||||
await appendFile(appLogFilePath(), `${JSON.stringify(entry)}\n`, "utf8");
|
||||
});
|
||||
await writeQueue;
|
||||
return entry;
|
||||
}
|
||||
|
||||
export async function listAppLogs(filters: AppLogListFilters = {}): Promise<AppLogEntry[]> {
|
||||
const limit = Math.max(1, Math.min(filters.limit || 100, 500));
|
||||
const level = filters.level || "all";
|
||||
const q = (filters.q || "").trim().toLowerCase();
|
||||
const source = (filters.source || "").trim().toLowerCase();
|
||||
const filePath = appLogFilePath();
|
||||
let text = "";
|
||||
try {
|
||||
text = await readFile(filePath, "utf8");
|
||||
} catch (error) {
|
||||
if (isNotFound(error)) return [];
|
||||
throw error;
|
||||
}
|
||||
|
||||
const entries: AppLogEntry[] = [];
|
||||
const lines = text.split("\n").filter(Boolean).reverse();
|
||||
for (const line of lines) {
|
||||
const entry = parseLogLine(line);
|
||||
if (!entry) continue;
|
||||
if (level !== "all" && entry.level !== level) continue;
|
||||
if (source && !entry.source.toLowerCase().includes(source)) continue;
|
||||
if (q && !logEntryMatches(entry, q)) continue;
|
||||
entries.push(entry);
|
||||
if (entries.length >= limit) break;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
export async function clearAppLogs(): Promise<void> {
|
||||
await mkdir(logDir(), { recursive: true });
|
||||
await writeFile(appLogFilePath(), "", "utf8");
|
||||
}
|
||||
|
||||
export function normalizeLogEntry(input: AppLogInput): AppLogEntry {
|
||||
const error = input.error;
|
||||
const requestInfo = input.request ? requestLogInfo(input.request) : {};
|
||||
const message = sanitizeText(input.message || errorMessage(error) || "Unknown log event");
|
||||
return {
|
||||
id: `log_${randomUUID().replace(/-/g, "").slice(0, 18)}`,
|
||||
createdAt: new Date().toISOString(),
|
||||
level: input.level || (input.status && input.status >= 500 ? "error" : "info"),
|
||||
source: sanitizeText(input.source || "server"),
|
||||
message,
|
||||
status: input.status,
|
||||
method: requestInfo.method,
|
||||
path: requestInfo.path,
|
||||
stack: errorStack(error),
|
||||
details: sanitizeForLog(input.details)
|
||||
};
|
||||
}
|
||||
|
||||
function parseLogLine(line: string): AppLogEntry | null {
|
||||
try {
|
||||
const parsed = JSON.parse(line) as Partial<AppLogEntry>;
|
||||
if (!parsed.id || !parsed.createdAt || !parsed.level || !parsed.source || !parsed.message) return null;
|
||||
if (!["info", "warning", "error"].includes(parsed.level)) return null;
|
||||
return parsed as AppLogEntry;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function rotateLogIfNeeded(): Promise<void> {
|
||||
const filePath = appLogFilePath();
|
||||
try {
|
||||
const current = await stat(filePath);
|
||||
if (current.size < appLogMaxBytes()) return;
|
||||
await rename(filePath, `${filePath}.1`);
|
||||
} catch (error) {
|
||||
if (!isNotFound(error)) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function requestLogInfo(request: Request): { method?: string; path?: string } {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
return {
|
||||
method: request.method,
|
||||
path: `${url.pathname}${url.search}`
|
||||
};
|
||||
} catch {
|
||||
return { method: request.method };
|
||||
}
|
||||
}
|
||||
|
||||
function logEntryMatches(entry: AppLogEntry, q: string): boolean {
|
||||
const haystack = [
|
||||
entry.message,
|
||||
entry.source,
|
||||
entry.path,
|
||||
entry.method,
|
||||
entry.status?.toString(),
|
||||
entry.stack,
|
||||
typeof entry.details === "string" ? entry.details : JSON.stringify(entry.details || "")
|
||||
].join(" ").toLowerCase();
|
||||
return haystack.includes(q);
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string | undefined {
|
||||
if (!error) return undefined;
|
||||
if (error instanceof Error) return error.message;
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function errorStack(error: unknown): string | undefined {
|
||||
if (!(error instanceof Error) || !error.stack) return undefined;
|
||||
return truncateText(sanitizeText(error.stack));
|
||||
}
|
||||
|
||||
function sanitizeForLog(value: unknown, depth = 0, seen = new WeakSet<object>()): unknown {
|
||||
if (value === null || value === undefined) return value;
|
||||
if (typeof value === "string") return truncateText(sanitizeText(value));
|
||||
if (typeof value === "number" || typeof value === "boolean") return value;
|
||||
if (typeof value === "bigint") return value.toString();
|
||||
if (typeof value !== "object") return sanitizeText(String(value));
|
||||
if (depth >= 5) return "[truncated]";
|
||||
if (seen.has(value)) return "[circular]";
|
||||
seen.add(value);
|
||||
if (Array.isArray(value)) return value.slice(0, 50).map((item) => sanitizeForLog(item, depth + 1, seen));
|
||||
|
||||
const output: Record<string, unknown> = {};
|
||||
for (const [key, item] of Object.entries(value as Record<string, unknown>).slice(0, 80)) {
|
||||
output[key] = isSensitiveKey(key) ? "[redacted]" : sanitizeForLog(item, depth + 1, seen);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function sanitizeText(text: string): string {
|
||||
return truncateText(text)
|
||||
.replace(/(authorization\s*[:=]\s*)[^\s,;}]+/gi, "$1[redacted]")
|
||||
.replace(/(bearer\s+)[A-Za-z0-9._~+/=-]+/gi, "$1[redacted]")
|
||||
.replace(/((?:api[_-]?key|access[_-]?key|secret|token|password|client[_-]?secret)\s*[:=]\s*)[^\s,;}]+/gi, "$1[redacted]")
|
||||
.replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, "[jwt-redacted]");
|
||||
}
|
||||
|
||||
function truncateText(text: string): string {
|
||||
return text.length > MAX_ENTRY_TEXT_LENGTH ? `${text.slice(0, MAX_ENTRY_TEXT_LENGTH)}...[truncated]` : text;
|
||||
}
|
||||
|
||||
function isSensitiveKey(key: string): boolean {
|
||||
return /api[_-]?key|access[_-]?key|secret|token|password|authorization|credential/i.test(key);
|
||||
}
|
||||
|
||||
function isNotFound(error: unknown): boolean {
|
||||
return typeof error === "object" && error !== null && "code" in error && (error as { code?: unknown }).code === "ENOENT";
|
||||
}
|
||||
@@ -23,11 +23,16 @@ export function resultDir(): string {
|
||||
return process.env.ZHINIAN_RESULT_DIR || join(runtimeDir(), "generated-results");
|
||||
}
|
||||
|
||||
export function logDir(): string {
|
||||
return process.env.ZHINIAN_LOG_DIR || join(runtimeDir(), "logs");
|
||||
}
|
||||
|
||||
export async function ensureRuntimeDirs(): Promise<void> {
|
||||
await Promise.all([
|
||||
mkdir(dataDir(), { recursive: true }),
|
||||
mkdir(uploadDir(), { recursive: true }),
|
||||
mkdir(resultDir(), { recursive: true })
|
||||
mkdir(resultDir(), { recursive: true }),
|
||||
mkdir(logDir(), { recursive: true })
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from "@/lib/server/data-store";
|
||||
import { advanceImageJob } from "@/lib/server/generation-service";
|
||||
import { advanceVideoJob } from "@/lib/server/video-generation-service";
|
||||
import { recordAppLog } from "@/lib/server/log-manager";
|
||||
import { requestOrigin } from "@/lib/server/runtime";
|
||||
import { deliverJobWebhook } from "@/lib/server/webhook";
|
||||
import type { GenerationJob, GenerationStatus } from "@/lib/types";
|
||||
@@ -53,6 +54,19 @@ export async function runWorkerTick(input: {
|
||||
action: settled.action
|
||||
});
|
||||
} catch (error) {
|
||||
await recordAppLog({
|
||||
level: "error",
|
||||
source: "worker.job",
|
||||
message: `任务处理失败:${job.id}`,
|
||||
error,
|
||||
details: {
|
||||
jobId: job.id,
|
||||
capability: job.capability,
|
||||
provider: job.provider,
|
||||
attempts: job.attempts,
|
||||
workerId
|
||||
}
|
||||
}).catch(() => undefined);
|
||||
const failed = await updateGenerationJob(job.id, {
|
||||
status: "failed",
|
||||
error: {
|
||||
|
||||
@@ -32,12 +32,14 @@ export const config = {
|
||||
"/",
|
||||
"/create/:path*",
|
||||
"/assets/:path*",
|
||||
"/logs/:path*",
|
||||
"/settings/:path*",
|
||||
"/image-edit/:path*",
|
||||
"/uploads/:path*",
|
||||
"/generated-results/:path*",
|
||||
"/api/assets/:path*",
|
||||
"/api/generations/:path*",
|
||||
"/api/logs/:path*",
|
||||
"/api/prompt/:path*",
|
||||
"/api/settings/:path*"
|
||||
]
|
||||
|
||||
72
tests/log-manager.test.ts
Normal file
72
tests/log-manager.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { readFile, rm, mkdtemp } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { appLogFilePath, clearAppLogs, listAppLogs, recordAppLog } from "@/lib/server/log-manager";
|
||||
|
||||
let runtimeDir = "";
|
||||
let previousRuntimeDir: string | undefined;
|
||||
let previousLogDir: string | undefined;
|
||||
|
||||
describe("server log manager", () => {
|
||||
beforeEach(async () => {
|
||||
runtimeDir = await mkdtemp(join(tmpdir(), "zhinian-logs-"));
|
||||
previousRuntimeDir = process.env.ZHINIAN_RUNTIME_DIR;
|
||||
previousLogDir = process.env.ZHINIAN_LOG_DIR;
|
||||
process.env.ZHINIAN_RUNTIME_DIR = runtimeDir;
|
||||
delete process.env.ZHINIAN_LOG_DIR;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
restoreEnv("ZHINIAN_RUNTIME_DIR", previousRuntimeDir);
|
||||
restoreEnv("ZHINIAN_LOG_DIR", previousLogDir);
|
||||
await rm(runtimeDir, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
it("records, filters, and clears server logs", async () => {
|
||||
await recordAppLog({
|
||||
level: "error",
|
||||
source: "api.test",
|
||||
message: "EvoLink API_KEY=super-secret failed",
|
||||
error: new Error("token=abc123 failed"),
|
||||
status: 500,
|
||||
request: new Request("http://local.test/api/test?x=1", { method: "POST" }),
|
||||
details: {
|
||||
Authorization: "Bearer secret-token",
|
||||
nested: { password: "123456", prompt: "商品海报" }
|
||||
}
|
||||
});
|
||||
await recordAppLog({
|
||||
level: "warning",
|
||||
source: "worker.test",
|
||||
message: "retry scheduled"
|
||||
});
|
||||
|
||||
const errors = await listAppLogs({ level: "error", q: "evolink" });
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]).toMatchObject({
|
||||
level: "error",
|
||||
source: "api.test",
|
||||
status: 500,
|
||||
method: "POST",
|
||||
path: "/api/test?x=1"
|
||||
});
|
||||
expect(JSON.stringify(errors[0])).not.toContain("super-secret");
|
||||
expect(JSON.stringify(errors[0])).not.toContain("secret-token");
|
||||
expect(JSON.stringify(errors[0])).not.toContain("123456");
|
||||
|
||||
const raw = await readFile(appLogFilePath(), "utf8");
|
||||
expect(raw).toContain("retry scheduled");
|
||||
|
||||
await clearAppLogs();
|
||||
expect(await listAppLogs()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
function restoreEnv(name: string, value: string | undefined) {
|
||||
if (value === undefined) {
|
||||
delete process.env[name];
|
||||
return;
|
||||
}
|
||||
process.env[name] = value;
|
||||
}
|
||||
Reference in New Issue
Block a user