mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-22 16:11:29 +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
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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": "分组已保存",
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
10
migrations/0002_log_detail.sql
Normal file
10
migrations/0002_log_detail.sql
Normal 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;
|
||||||
|
|
@ -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 };
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue