mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-23 00:21:28 +00:00
feat(admin): add detailed send log viewer with extended fields
This commit is contained in:
parent
3a6a2cbbc5
commit
dcbe93be91
14 changed files with 460 additions and 42 deletions
|
|
@ -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 };
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ export interface WebhookEvent {
|
|||
event: string;
|
||||
payload: Record<string, unknown>;
|
||||
signature?: string;
|
||||
deliveryId?: string;
|
||||
}
|
||||
|
||||
export interface NeutralAuthor {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue