From dcbe93be91863d586c09be41cf0cea71cab8afa4 Mon Sep 17 00:00:00 2001 From: RhenCloud Date: Mon, 3 Aug 2026 04:13:04 +0800 Subject: [PATCH] feat(admin): add detailed send log viewer with extended fields --- admin/assets/css/main.css | 92 +++++++++++++++++++++++++++++++ admin/components/SendLogs.vue | 91 ++++++++++++++++++++++++++++++- admin/composables/useI18n.ts | 40 ++++++++++++++ admin/composables/useLogs.ts | 16 +++++- admin/types.ts | 11 ++++ migrations/0002_log_detail.sql | 10 ++++ src/__tests__/send-log.test.ts | 32 +++++++---- src/core/dispatch.ts | 45 ++++++++++++---- src/drivers/discord/rest.ts | 39 +++++++++++--- src/drivers/types.ts | 4 ++ src/events/parse.ts | 3 +- src/lib/send-log.ts | 99 +++++++++++++++++++++++++++++----- src/types.ts | 1 + src/web/admin-routes.ts | 19 ++++++- 14 files changed, 460 insertions(+), 42 deletions(-) create mode 100644 migrations/0002_log_detail.sql diff --git a/admin/assets/css/main.css b/admin/assets/css/main.css index 70afb4f..a624275 100644 --- a/admin/assets/css/main.css +++ b/admin/assets/css/main.css @@ -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; diff --git a/admin/components/SendLogs.vue b/admin/components/SendLogs.vue index 1575a3c..80f569d 100644 --- a/admin/components/SendLogs.vue +++ b/admin/components/SendLogs.vue @@ -11,11 +11,18 @@

{{ t("logs.empty") }}

-
+
{{ l.routeId }} {{ l.event }} + {{ l.status }} {{ fmtTime(l.ts) }}
@@ -31,6 +38,30 @@
{{ l.error }}
+ + @@ -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(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 { + 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; +} diff --git a/admin/composables/useI18n.ts b/admin/composables/useI18n.ts index 7737bff..225b299 100644 --- a/admin/composables/useI18n.ts +++ b/admin/composables/useI18n.ts @@ -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": "分组已保存", diff --git a/admin/composables/useLogs.ts b/admin/composables/useLogs.ts index 8727fa8..db86ce4 100644 --- a/admin/composables/useLogs.ts +++ b/admin/composables/useLogs.ts @@ -29,5 +29,19 @@ export function useSendLogs() { } } - return { logs, loading, needLogin, error, load }; + async function loadById(id: number): Promise { + 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 }; } diff --git a/admin/types.ts b/admin/types.ts index 63d9726..9021bde 100644 --- a/admin/types.ts +++ b/admin/types.ts @@ -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; } diff --git a/migrations/0002_log_detail.sql b/migrations/0002_log_detail.sql new file mode 100644 index 0000000..cc4084e --- /dev/null +++ b/migrations/0002_log_detail.sql @@ -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; diff --git a/src/__tests__/send-log.test.ts b/src/__tests__/send-log.test.ts index fa3a859..0844e5a 100644 --- a/src/__tests__/send-log.test.ts +++ b/src/__tests__/send-log.test.ts @@ -3,21 +3,35 @@ import { recordSend, getSendLog } from "../lib/send-log"; function createMockDB(): D1Database { const rows: Array> = []; + 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 = {}; + insertCols.forEach((col, i) => { + row[col] = _args[i]; }); + rows.push(row); } return { success: true }; }, diff --git a/src/core/dispatch.ts b/src/core/dispatch.ts index 7849e27..23c4194 100644 --- a/src/core/dispatch.ts +++ b/src/core/dispatch.ts @@ -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"); } diff --git a/src/drivers/discord/rest.ts b/src/drivers/discord/rest.ts index 62c221c..8409255 100644 --- a/src/drivers/discord/rest.ts +++ b/src/drivers/discord/rest.ts @@ -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 { 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 }; } diff --git a/src/drivers/types.ts b/src/drivers/types.ts index 35b3a7c..c082ce1 100644 --- a/src/drivers/types.ts +++ b/src/drivers/types.ts @@ -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 { diff --git a/src/events/parse.ts b/src/events/parse.ts index 36905b2..269a1ca 100644 --- a/src/events/parse.ts +++ b/src/events/parse.ts @@ -3,12 +3,13 @@ import type { WebhookEvent } from "../types"; export function parseEvent(headers: Record, 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; } diff --git a/src/lib/send-log.ts b/src/lib/send-log.ts index c0a133e..e23878f 100644 --- a/src/lib/send-log.ts +++ b/src/lib/send-log.ts @@ -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; +} + +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) : undefined, + }; } export async function recordSend(db: D1Database, record: SendRecord): Promise { 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 { 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(); + return results.map(toRecord); } catch (err) { log.warn({ err }, "Failed to load send log"); return []; } } + +export async function getSendLogById(db: D1Database, id: number): Promise { + try { + const { results } = await db + .prepare(`SELECT ${COLUMNS} FROM send_logs WHERE id = ? LIMIT 1`) + .bind(id) + .all(); + const row = results[0]; + return row ? toRecord(row) : null; + } catch (err) { + log.warn({ err, id }, "Failed to load send log entry"); + return null; + } +} diff --git a/src/types.ts b/src/types.ts index 747f59e..c4480db 100644 --- a/src/types.ts +++ b/src/types.ts @@ -83,6 +83,7 @@ export interface WebhookEvent { event: string; payload: Record; signature?: string; + deliveryId?: string; } export interface NeutralAuthor { diff --git a/src/web/admin-routes.ts b/src/web/admin-routes.ts index baec23c..0934463 100644 --- a/src/web/admin-routes.ts +++ b/src/web/admin-routes.ts @@ -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);