feat(admin): add detailed send log viewer with extended fields

This commit is contained in:
RhenCloud 2026-08-03 04:13:04 +08:00
parent 3a6a2cbbc5
commit dcbe93be91
No known key found for this signature in database
GPG key ID: A574A617378C4E0B
14 changed files with 460 additions and 42 deletions

View file

@ -911,6 +911,98 @@ main {
padding: 56px 0;
}
.log-entry.clickable {
cursor: pointer;
}
.log-status {
font-size: 11px;
font-weight: 700;
border-radius: 999px;
padding: 2px 9px;
letter-spacing: 0.5px;
}
.log-status.ok {
color: var(--ok);
background: var(--ok-dim);
}
.log-status.bad {
color: var(--bad);
background: var(--bad-dim);
}
.log-detail {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 22px;
max-width: 720px;
width: calc(100vw - 48px);
max-height: 82vh;
overflow-y: auto;
box-shadow: 0 12px 40px rgba(15, 23, 42, 0.25);
}
.modal-overlay {
position: fixed;
inset: 0;
z-index: 60;
background: var(--scrim);
backdrop-filter: blur(2px);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.detail-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
}
.detail-head h3 {
font-size: 15px;
letter-spacing: 0.3px;
}
.detail-grid {
display: grid;
grid-template-columns: minmax(120px, 30%) 1fr;
gap: 6px 16px;
font-size: 13px;
}
.detail-grid dt {
color: var(--faint);
font-weight: 600;
letter-spacing: 0.6px;
padding-top: 6px;
font-size: 11px;
}
.detail-grid dd {
margin: 0;
padding-top: 6px;
color: var(--text);
word-break: break-all;
}
.detail-grid code {
font-family: var(--mono);
font-size: 12px;
background: var(--surface-2);
padding: 2px 7px;
border-radius: 5px;
}
.detail-grid dd code {
white-space: pre-wrap;
}
@media (max-width: 720px) {
.filter-row {
grid-template-columns: 100px 1fr auto;

View file

@ -11,11 +11,18 @@
<p v-else-if="!loading && !logs.length" class="empty-log">{{ t("logs.empty") }}</p>
<div class="log-list">
<article v-for="l in logs" :key="l.ts + l.routeId" class="log-entry" :class="{ fail: !l.ok }">
<article
v-for="l in logs"
:key="l.id ?? l.ts + l.routeId"
class="log-entry"
:class="{ fail: !l.ok, clickable: true }"
@click="openDetail(l)"
>
<div class="log-head">
<span class="dot" :class="l.ok ? 'ok' : 'bad'"></span>
<span class="log-route">{{ l.routeId }}</span>
<span class="log-event">{{ l.event }}</span>
<span v-if="l.status" class="log-status" :class="l.ok ? 'ok' : 'bad'">{{ l.status }}</span>
<span class="log-time">{{ fmtTime(l.ts) }}</span>
</div>
<div class="log-meta">
@ -31,6 +38,30 @@
<div v-if="!l.ok && l.error" class="log-error">{{ l.error }}</div>
</article>
</div>
<div v-if="detailOpen" class="modal-overlay" @click.self="closeDetail">
<div class="log-detail">
<div class="detail-head">
<h3>{{ t("logs.detailTitle") }}</h3>
<button class="icon-btn" @click="closeDetail"></button>
</div>
<p v-if="detailLoading" class="empty-log">{{ t("status.loading") }}</p>
<p v-else-if="detailError" class="err">{{ detailError }}</p>
<dl v-else-if="detail" class="detail-grid">
<template v-for="row in detailRows" :key="row.label">
<dt>{{ row.label }}</dt>
<dd>
<code v-if="row.code">{{ row.value }}</code>
<span v-else>{{ row.value }}</span>
</dd>
</template>
</dl>
<p v-else class="err">{{ t("logs.detailMissing") }}</p>
</div>
</div>
</div>
</template>
@ -38,10 +69,48 @@
import type { SendRecord } from "~/types";
const { t } = useI18n();
const { loadById } = useSendLogs();
const props = defineProps<{ logs: SendRecord[]; loading: boolean }>();
const emit = defineEmits<{ (e: "refresh"): void }>();
const detailOpen = ref(false);
const detail = ref<SendRecord | null>(null);
const detailLoading = ref(false);
const detailError = ref("");
const detailRows = computed(() => {
const l = detail.value;
if (!l) return [] as Array<{ label: string; value: string; code?: boolean }>;
const rows: Array<{ label: string; value: string; code?: boolean }> = [
{ label: t("logs.id"), value: String(l.id ?? "-"), code: true },
{ label: t("logs.time"), value: fmtTime(l.ts) },
{ label: t("logs.route"), value: l.routeId, code: true },
{ label: t("logs.event"), value: l.event, code: true },
{ label: t("logs.action"), value: l.action || "-", code: true },
{ label: t("logs.actor"), value: l.actor || "-", code: true },
{ label: t("logs.deliveryId"), value: l.deliveryId || "-", code: true },
{ label: t("logs.repo"), value: l.repo || "-", code: true },
{ label: t("logs.target"), value: l.target, code: true },
{ label: t("logs.platform"), value: l.platform || "-", code: true },
{ label: t("logs.status"), value: l.status != null ? String(l.status) : "-", code: true },
{ label: t("logs.messageId"), value: l.messageId || "-", code: true },
{ label: t("logs.errorCode"), value: l.errorCode || "-", code: true },
{ label: t("logs.duration"), value: l.durationMs != null ? `${l.durationMs} ms` : "-" },
{ label: t("logs.attempts"), value: l.attempts != null ? String(l.attempts) : "-" },
{ label: t("logs.ok"), value: l.ok ? t("logs.yes") : t("logs.no") },
{ label: t("logs.error"), value: l.error || "-" },
];
if (l.detail && Object.keys(l.detail).length > 0) {
rows.push({
label: t("logs.detail"),
value: JSON.stringify(l.detail, null, 2),
code: true,
});
}
return rows;
});
function fmtTime(ts: number): string {
return new Date(ts).toLocaleString();
}
@ -49,4 +118,24 @@ function fmtTime(ts: number): string {
function refresh(): void {
emit("refresh");
}
async function openDetail(l: SendRecord): Promise<void> {
if (l.id == null) return;
detailOpen.value = true;
detail.value = null;
detailError.value = "";
detailLoading.value = true;
try {
detail.value = await loadById(l.id);
} catch (err) {
detailError.value = err instanceof Error ? err.message : String(err);
} finally {
detailLoading.value = false;
}
}
function closeDetail(): void {
detailOpen.value = false;
detail.value = null;
}
</script>

View file

@ -98,6 +98,26 @@ const en: Dict = {
"logs.empty": "No send records yet — trigger a webhook to see results.",
"logs.repo": "REPO",
"logs.target": "TARGET",
"logs.detailTitle": "Send Detail",
"logs.detailMissing": "Could not load this entry.",
"logs.id": "ID",
"logs.time": "Time",
"logs.route": "Route",
"logs.event": "Event",
"logs.action": "Action",
"logs.actor": "Actor",
"logs.deliveryId": "Delivery ID",
"logs.platform": "Platform",
"logs.status": "HTTP Status",
"logs.messageId": "Message ID",
"logs.errorCode": "Error Code",
"logs.duration": "Duration",
"logs.attempts": "Attempts",
"logs.ok": "Result",
"logs.yes": "OK",
"logs.no": "Failed",
"logs.error": "Error",
"logs.detail": "Extra Info",
"toast.routesSaved": "Routes saved",
"toast.routeDeleted": "Route deleted",
"toast.groupSaved": "Groups saved",
@ -205,6 +225,26 @@ const zh: Dict = {
"logs.empty": "还没有发送记录 —— 触发一次 webhook 即可查看结果。",
"logs.repo": "仓库",
"logs.target": "目标",
"logs.detailTitle": "发送详情",
"logs.detailMissing": "无法加载该条记录。",
"logs.id": "ID",
"logs.time": "时间",
"logs.route": "路由",
"logs.event": "事件",
"logs.action": "动作",
"logs.actor": "操作者",
"logs.deliveryId": "Delivery ID",
"logs.platform": "平台",
"logs.status": "HTTP 状态码",
"logs.messageId": "消息 ID",
"logs.errorCode": "错误码",
"logs.duration": "耗时",
"logs.attempts": "尝试次数",
"logs.ok": "结果",
"logs.yes": "成功",
"logs.no": "失败",
"logs.error": "错误信息",
"logs.detail": "附加信息",
"toast.routesSaved": "路由已保存",
"toast.routeDeleted": "路由已删除",
"toast.groupSaved": "分组已保存",

View file

@ -29,5 +29,19 @@ export function useSendLogs() {
}
}
return { logs, loading, needLogin, error, load };
async function loadById(id: number): Promise<SendRecord | null> {
const res = await fetch(`/admin/api/logs/${id}`, {
headers: { accept: "application/json" },
credentials: "same-origin",
});
if (res.status === 401) {
needLogin.value = true;
return null;
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = (await res.json()) as { log?: SendRecord };
return data.log ?? null;
}
return { logs, loading, needLogin, error, load, loadById };
}

View file

@ -50,6 +50,7 @@ export function fmtMatch(match: string | string[]): string {
}
export interface SendRecord {
id?: number;
ts: number;
routeId: string;
event: string;
@ -57,4 +58,14 @@ export interface SendRecord {
target: string;
ok: boolean;
error?: string;
status?: number;
messageId?: string;
deliveryId?: string;
platform?: string;
actor?: string;
action?: string;
durationMs?: number;
errorCode?: string;
attempts?: number;
detail?: Record<string, unknown>;
}

View file

@ -0,0 +1,10 @@
ALTER TABLE send_logs ADD COLUMN status INTEGER;
ALTER TABLE send_logs ADD COLUMN message_id TEXT;
ALTER TABLE send_logs ADD COLUMN delivery_id TEXT;
ALTER TABLE send_logs ADD COLUMN platform TEXT;
ALTER TABLE send_logs ADD COLUMN actor TEXT;
ALTER TABLE send_logs ADD COLUMN action TEXT;
ALTER TABLE send_logs ADD COLUMN duration_ms INTEGER;
ALTER TABLE send_logs ADD COLUMN error_code TEXT;
ALTER TABLE send_logs ADD COLUMN attempts INTEGER;
ALTER TABLE send_logs ADD COLUMN detail TEXT;

View file

@ -3,21 +3,35 @@ import { recordSend, getSendLog } from "../lib/send-log";
function createMockDB(): D1Database {
const rows: Array<Record<string, unknown>> = [];
const insertCols = [
"ts",
"route_id",
"event",
"repo",
"target",
"ok",
"error",
"status",
"message_id",
"delivery_id",
"platform",
"actor",
"action",
"duration_ms",
"error_code",
"attempts",
"detail",
];
return {
prepare: (sql: string) => ({
bind: (..._args: unknown[]) => ({
run: async (): Promise<{ success: boolean }> => {
if (sql.startsWith("INSERT")) {
const args = _args as unknown[];
rows.push({
ts: args[0],
route_id: args[1],
event: args[2],
repo: args[3],
target: args[4],
ok: args[5],
error: args[6],
const row: Record<string, unknown> = {};
insertCols.forEach((col, i) => {
row[col] = _args[i];
});
rows.push(row);
}
return { success: true };
},

View file

@ -41,30 +41,53 @@ export async function dispatchEvent(config: Config, event: WebhookEvent, env: En
? `${route.target.channelId}/${route.target.threadId}`
: route.target.channelId;
const base: {
ts: number;
routeId: string;
event: string;
repo: string | undefined;
target: string;
deliveryId: string | undefined;
actor: string | undefined;
action: string | undefined;
} = {
ts: Date.now(),
routeId: route.id,
event: event.event,
repo: (event.payload.repository as { full_name?: string } | undefined)?.full_name,
target,
deliveryId: event.deliveryId,
actor: (event.payload.sender as { login?: string } | undefined)?.login,
action: (event.payload.action as string | undefined),
};
const started = Date.now();
try {
const tr = trMap.get(route.lang ?? "en")!;
const group = route.groupId ? groupById.get(route.groupId) : undefined;
const showEmoji = group?.emoji !== false;
const message = formatEvent(route, event, tr, showEmoji);
const result = await getDriver(route.target).send(message, route.target, env);
const driver = getDriver(route.target);
const result = await driver.send(message, route.target, env);
const durationMs = Date.now() - started;
if (!result.ok) throw new Error(result.error ?? "Send failed");
await recordSend(env.DB, {
ts: Date.now(),
routeId: route.id,
event: event.event,
repo: (event.payload.repository as { full_name?: string } | undefined)?.full_name,
target,
...base,
ok: true,
status: result.status,
messageId: result.messageId,
platform: driver.id,
attempts: result.attempts,
durationMs,
errorCode: result.errorCode,
});
} catch (err) {
const durationMs = Date.now() - started;
await recordSend(env.DB, {
ts: Date.now(),
routeId: route.id,
event: event.event,
repo: (event.payload.repository as { full_name?: string } | undefined)?.full_name,
target,
...base,
ok: false,
error: err instanceof Error ? err.message : String(err),
durationMs,
});
log.error({ routeId: route.id, err }, "Route failed");
}

View file

@ -1,13 +1,18 @@
import { log } from "../../lib/log";
import type { SendResult } from "../types";
const DISCORD_API = "https://discord.com/api/v10";
interface DiscordMessage {
id?: string;
}
export async function sendMessage(
token: string,
channelId: string,
message: unknown,
threadId?: string,
): Promise<{ ok: boolean; error?: string }> {
): Promise<SendResult> {
const url = threadId
? `${DISCORD_API}/channels/${threadId}/messages`
: `${DISCORD_API}/channels/${channelId}/messages`;
@ -35,21 +40,43 @@ export async function sendMessage(
const err = await res.text();
if (res.status >= 500) {
log.error({ status: res.status, err, attempt, channelId }, "Discord API 5xx");
if (attempt === 2) return { ok: false, error: err };
if (attempt === 2)
return {
ok: false,
error: err,
errorCode: "DISCORD_5XX",
status: res.status,
attempts: attempt + 1,
};
await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)));
continue;
}
log.error({ status: res.status, err, channelId }, "Discord API error");
return { ok: false, error: err };
return {
ok: false,
error: err,
errorCode: "DISCORD_ERROR",
status: res.status,
attempts: attempt + 1,
};
}
return { ok: true };
let messageId: string | undefined;
try {
const data = (await res.json()) as DiscordMessage;
messageId = data.id;
} catch {
// ignore malformed success body
}
return { ok: true, status: res.status, messageId, attempts: attempt + 1 };
} catch (err) {
log.error({ err, attempt, channelId }, "Failed to send message");
if (attempt === 2) return { ok: false, error: String(err) };
if (attempt === 2)
return { ok: false, error: String(err), errorCode: "NETWORK", attempts: attempt + 1 };
await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)));
}
}
return { ok: false, error: "Max retries exceeded" };
return { ok: false, error: "Max retries exceeded", errorCode: "RETRIES", attempts: 3 };
}

View file

@ -3,6 +3,10 @@ import type { Route, Env, NeutralMessage } from "../types";
export interface SendResult {
ok: boolean;
error?: string;
errorCode?: string;
status?: number;
messageId?: string;
attempts?: number;
}
export interface PlatformDriver {

View file

@ -3,12 +3,13 @@ import type { WebhookEvent } from "../types";
export function parseEvent(headers: Record<string, string>, body: string): WebhookEvent | null {
const event = headers["x-github-event"];
const signature = headers["x-hub-signature-256"];
const deliveryId = headers["x-github-delivery"];
if (!event) return null;
try {
const payload = JSON.parse(body);
return { event, payload, signature };
return { event, payload, signature, deliveryId };
} catch {
return null;
}

View file

@ -1,6 +1,7 @@
import { log } from "./log";
export interface SendRecord {
id?: number;
ts: number;
routeId: string;
event: string;
@ -8,13 +9,71 @@ export interface SendRecord {
target: string;
ok: boolean;
error?: string;
status?: number;
messageId?: string;
deliveryId?: string;
platform?: string;
actor?: string;
action?: string;
durationMs?: number;
errorCode?: string;
attempts?: number;
detail?: Record<string, unknown>;
}
const COLUMNS =
"id, ts, route_id, event, repo, target, ok, error, status, message_id, delivery_id, platform, actor, action, duration_ms, error_code, attempts, detail";
interface LogRow {
id: number;
ts: number;
route_id: string;
event: string;
repo: string | null;
target: string;
ok: number;
error: string | null;
status: number | null;
message_id: string | null;
delivery_id: string | null;
platform: string | null;
actor: string | null;
action: string | null;
duration_ms: number | null;
error_code: string | null;
attempts: number | null;
detail: string | null;
}
function toRecord(r: LogRow): SendRecord {
return {
id: r.id,
ts: r.ts,
routeId: r.route_id,
event: r.event,
repo: r.repo ?? undefined,
target: r.target,
ok: r.ok === 1,
error: r.error ?? undefined,
status: r.status ?? undefined,
messageId: r.message_id ?? undefined,
deliveryId: r.delivery_id ?? undefined,
platform: r.platform ?? undefined,
actor: r.actor ?? undefined,
action: r.action ?? undefined,
durationMs: r.duration_ms ?? undefined,
errorCode: r.error_code ?? undefined,
attempts: r.attempts ?? undefined,
detail: r.detail ? (JSON.parse(r.detail) as Record<string, unknown>) : undefined,
};
}
export async function recordSend(db: D1Database, record: SendRecord): Promise<void> {
try {
await db
.prepare(
"INSERT INTO send_logs (ts, route_id, event, repo, target, ok, error) VALUES (?, ?, ?, ?, ?, ?, ?)",
`INSERT INTO send_logs (ts, route_id, event, repo, target, ok, error, status, message_id, delivery_id, platform, actor, action, duration_ms, error_code, attempts, detail)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
record.ts,
@ -24,6 +83,16 @@ export async function recordSend(db: D1Database, record: SendRecord): Promise<vo
record.target,
record.ok ? 1 : 0,
record.error ?? null,
record.status ?? null,
record.messageId ?? null,
record.deliveryId ?? null,
record.platform ?? null,
record.actor ?? null,
record.action ?? null,
record.durationMs ?? null,
record.errorCode ?? null,
record.attempts ?? null,
record.detail ? JSON.stringify(record.detail) : null,
)
.run();
} catch (err) {
@ -34,20 +103,26 @@ export async function recordSend(db: D1Database, record: SendRecord): Promise<vo
export async function getSendLog(db: D1Database, limit = 50): Promise<SendRecord[]> {
try {
const { results } = await db
.prepare("SELECT * FROM send_logs ORDER BY ts DESC LIMIT ?")
.prepare(`SELECT ${COLUMNS} FROM send_logs ORDER BY ts DESC LIMIT ?`)
.bind(limit)
.all<{ ts: number; route_id: string; event: string; repo: string | null; target: string; ok: number; error: string | null }>();
return results.map((r) => ({
ts: r.ts,
routeId: r.route_id,
event: r.event,
repo: r.repo ?? undefined,
target: r.target,
ok: r.ok === 1,
error: r.error ?? undefined,
}));
.all<LogRow>();
return results.map(toRecord);
} catch (err) {
log.warn({ err }, "Failed to load send log");
return [];
}
}
export async function getSendLogById(db: D1Database, id: number): Promise<SendRecord | null> {
try {
const { results } = await db
.prepare(`SELECT ${COLUMNS} FROM send_logs WHERE id = ? LIMIT 1`)
.bind(id)
.all<LogRow>();
const row = results[0];
return row ? toRecord(row) : null;
} catch (err) {
log.warn({ err, id }, "Failed to load send log entry");
return null;
}
}

View file

@ -83,6 +83,7 @@ export interface WebhookEvent {
event: string;
payload: Record<string, unknown>;
signature?: string;
deliveryId?: string;
}
export interface NeutralAuthor {

View file

@ -8,7 +8,7 @@ import {
type AdminSession,
} from "./session";
import { loadGroups, saveGroups, resolveScope, hasAnyAccess, type AccessScope } from "./groups";
import { getSendLog } from "../lib/send-log";
import { getSendLog, getSendLogById } from "../lib/send-log";
import { log } from "../lib/log";
const VALID_FILTER_TYPES = new Set(["event", "repo", "actor", "action", "branch", "keyword"]);
@ -239,6 +239,23 @@ export function createAdminRoutes(): Hono<{ Bindings: Env }> {
return c.json({ logs });
});
app.get("/api/logs/:id", async (c) => {
const s = await loadScope(c);
if (!s) return c.json({ error: "Unauthorized" }, 401);
const id = Number(c.req.param("id"));
if (!Number.isInteger(id) || id <= 0) return c.json({ error: "Invalid log id" }, 400);
const entry = await getSendLogById(c.env.DB, id);
if (!entry) return c.json({ error: "Log entry not found" }, 404);
if (!s.scope.isSuper) {
const all = await loadRoutes(c.env.KV);
const route = all.find((r) => r.id === entry.routeId);
if (!route?.groupId || !s.scope.groupIds.has(route.groupId)) {
return c.json({ error: "Forbidden" }, 403);
}
}
return c.json({ log: entry });
});
app.put("/api/routes", async (c) => {
const s = await loadScope(c);
if (!s) return c.json({ error: "Unauthorized" }, 401);