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; 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) { @media (max-width: 720px) {
.filter-row { .filter-row {
grid-template-columns: 100px 1fr auto; 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> <p v-else-if="!loading && !logs.length" class="empty-log">{{ t("logs.empty") }}</p>
<div class="log-list"> <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"> <div class="log-head">
<span class="dot" :class="l.ok ? 'ok' : 'bad'"></span> <span class="dot" :class="l.ok ? 'ok' : 'bad'"></span>
<span class="log-route">{{ l.routeId }}</span> <span class="log-route">{{ l.routeId }}</span>
<span class="log-event">{{ l.event }}</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> <span class="log-time">{{ fmtTime(l.ts) }}</span>
</div> </div>
<div class="log-meta"> <div class="log-meta">
@ -31,6 +38,30 @@
<div v-if="!l.ok && l.error" class="log-error">{{ l.error }}</div> <div v-if="!l.ok && l.error" class="log-error">{{ l.error }}</div>
</article> </article>
</div> </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> </div>
</template> </template>
@ -38,10 +69,48 @@
import type { SendRecord } from "~/types"; import type { SendRecord } from "~/types";
const { t } = useI18n(); const { t } = useI18n();
const { loadById } = useSendLogs();
const props = defineProps<{ logs: SendRecord[]; loading: boolean }>(); const props = defineProps<{ logs: SendRecord[]; loading: boolean }>();
const emit = defineEmits<{ (e: "refresh"): void }>(); 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 { function fmtTime(ts: number): string {
return new Date(ts).toLocaleString(); return new Date(ts).toLocaleString();
} }
@ -49,4 +118,24 @@ function fmtTime(ts: number): string {
function refresh(): void { function refresh(): void {
emit("refresh"); 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> </script>

View file

@ -98,6 +98,26 @@ const en: Dict = {
"logs.empty": "No send records yet — trigger a webhook to see results.", "logs.empty": "No send records yet — trigger a webhook to see results.",
"logs.repo": "REPO", "logs.repo": "REPO",
"logs.target": "TARGET", "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.routesSaved": "Routes saved",
"toast.routeDeleted": "Route deleted", "toast.routeDeleted": "Route deleted",
"toast.groupSaved": "Groups saved", "toast.groupSaved": "Groups saved",
@ -205,6 +225,26 @@ const zh: Dict = {
"logs.empty": "还没有发送记录 —— 触发一次 webhook 即可查看结果。", "logs.empty": "还没有发送记录 —— 触发一次 webhook 即可查看结果。",
"logs.repo": "仓库", "logs.repo": "仓库",
"logs.target": "目标", "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.routesSaved": "路由已保存",
"toast.routeDeleted": "路由已删除", "toast.routeDeleted": "路由已删除",
"toast.groupSaved": "分组已保存", "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 { export interface SendRecord {
id?: number;
ts: number; ts: number;
routeId: string; routeId: string;
event: string; event: string;
@ -57,4 +58,14 @@ export interface SendRecord {
target: string; target: string;
ok: boolean; ok: boolean;
error?: string; 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 { function createMockDB(): D1Database {
const rows: Array<Record<string, unknown>> = []; 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 { return {
prepare: (sql: string) => ({ prepare: (sql: string) => ({
bind: (..._args: unknown[]) => ({ bind: (..._args: unknown[]) => ({
run: async (): Promise<{ success: boolean }> => { run: async (): Promise<{ success: boolean }> => {
if (sql.startsWith("INSERT")) { if (sql.startsWith("INSERT")) {
const args = _args as unknown[]; const row: Record<string, unknown> = {};
rows.push({ insertCols.forEach((col, i) => {
ts: args[0], row[col] = _args[i];
route_id: args[1],
event: args[2],
repo: args[3],
target: args[4],
ok: args[5],
error: args[6],
}); });
rows.push(row);
} }
return { success: true }; 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}/${route.target.threadId}`
: route.target.channelId; : 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 { try {
const tr = trMap.get(route.lang ?? "en")!; const tr = trMap.get(route.lang ?? "en")!;
const group = route.groupId ? groupById.get(route.groupId) : undefined; const group = route.groupId ? groupById.get(route.groupId) : undefined;
const showEmoji = group?.emoji !== false; const showEmoji = group?.emoji !== false;
const message = formatEvent(route, event, tr, showEmoji); 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"); if (!result.ok) throw new Error(result.error ?? "Send failed");
await recordSend(env.DB, { await recordSend(env.DB, {
ts: Date.now(), ...base,
routeId: route.id,
event: event.event,
repo: (event.payload.repository as { full_name?: string } | undefined)?.full_name,
target,
ok: true, ok: true,
status: result.status,
messageId: result.messageId,
platform: driver.id,
attempts: result.attempts,
durationMs,
errorCode: result.errorCode,
}); });
} catch (err) { } catch (err) {
const durationMs = Date.now() - started;
await recordSend(env.DB, { await recordSend(env.DB, {
ts: Date.now(), ...base,
routeId: route.id,
event: event.event,
repo: (event.payload.repository as { full_name?: string } | undefined)?.full_name,
target,
ok: false, ok: false,
error: err instanceof Error ? err.message : String(err), error: err instanceof Error ? err.message : String(err),
durationMs,
}); });
log.error({ routeId: route.id, err }, "Route failed"); log.error({ routeId: route.id, err }, "Route failed");
} }

View file

@ -1,13 +1,18 @@
import { log } from "../../lib/log"; import { log } from "../../lib/log";
import type { SendResult } from "../types";
const DISCORD_API = "https://discord.com/api/v10"; const DISCORD_API = "https://discord.com/api/v10";
interface DiscordMessage {
id?: string;
}
export async function sendMessage( export async function sendMessage(
token: string, token: string,
channelId: string, channelId: string,
message: unknown, message: unknown,
threadId?: string, threadId?: string,
): Promise<{ ok: boolean; error?: string }> { ): Promise<SendResult> {
const url = threadId const url = threadId
? `${DISCORD_API}/channels/${threadId}/messages` ? `${DISCORD_API}/channels/${threadId}/messages`
: `${DISCORD_API}/channels/${channelId}/messages`; : `${DISCORD_API}/channels/${channelId}/messages`;
@ -35,21 +40,43 @@ export async function sendMessage(
const err = await res.text(); const err = await res.text();
if (res.status >= 500) { if (res.status >= 500) {
log.error({ status: res.status, err, attempt, channelId }, "Discord API 5xx"); 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))); await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)));
continue; continue;
} }
log.error({ status: res.status, err, channelId }, "Discord API error"); 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) { } catch (err) {
log.error({ err, attempt, channelId }, "Failed to send message"); 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))); 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 { export interface SendResult {
ok: boolean; ok: boolean;
error?: string; error?: string;
errorCode?: string;
status?: number;
messageId?: string;
attempts?: number;
} }
export interface PlatformDriver { 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 { export function parseEvent(headers: Record<string, string>, body: string): WebhookEvent | null {
const event = headers["x-github-event"]; const event = headers["x-github-event"];
const signature = headers["x-hub-signature-256"]; const signature = headers["x-hub-signature-256"];
const deliveryId = headers["x-github-delivery"];
if (!event) return null; if (!event) return null;
try { try {
const payload = JSON.parse(body); const payload = JSON.parse(body);
return { event, payload, signature }; return { event, payload, signature, deliveryId };
} catch { } catch {
return null; return null;
} }

View file

@ -1,6 +1,7 @@
import { log } from "./log"; import { log } from "./log";
export interface SendRecord { export interface SendRecord {
id?: number;
ts: number; ts: number;
routeId: string; routeId: string;
event: string; event: string;
@ -8,13 +9,71 @@ export interface SendRecord {
target: string; target: string;
ok: boolean; ok: boolean;
error?: string; 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> { export async function recordSend(db: D1Database, record: SendRecord): Promise<void> {
try { try {
await db await db
.prepare( .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( .bind(
record.ts, record.ts,
@ -24,6 +83,16 @@ export async function recordSend(db: D1Database, record: SendRecord): Promise<vo
record.target, record.target,
record.ok ? 1 : 0, record.ok ? 1 : 0,
record.error ?? null, 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(); .run();
} catch (err) { } 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[]> { export async function getSendLog(db: D1Database, limit = 50): Promise<SendRecord[]> {
try { try {
const { results } = await db 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) .bind(limit)
.all<{ ts: number; route_id: string; event: string; repo: string | null; target: string; ok: number; error: string | null }>(); .all<LogRow>();
return results.map((r) => ({ return results.map(toRecord);
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,
}));
} catch (err) { } catch (err) {
log.warn({ err }, "Failed to load send log"); log.warn({ err }, "Failed to load send log");
return []; 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; event: string;
payload: Record<string, unknown>; payload: Record<string, unknown>;
signature?: string; signature?: string;
deliveryId?: string;
} }
export interface NeutralAuthor { export interface NeutralAuthor {

View file

@ -8,7 +8,7 @@ import {
type AdminSession, type AdminSession,
} from "./session"; } from "./session";
import { loadGroups, saveGroups, resolveScope, hasAnyAccess, type AccessScope } from "./groups"; 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"; import { log } from "../lib/log";
const VALID_FILTER_TYPES = new Set(["event", "repo", "actor", "action", "branch", "keyword"]); 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 }); 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) => { app.put("/api/routes", async (c) => {
const s = await loadScope(c); const s = await loadScope(c);
if (!s) return c.json({ error: "Unauthorized" }, 401); if (!s) return c.json({ error: "Unauthorized" }, 401);