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

@ -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);