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;
}
.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;

View file

@ -11,11 +11,18 @@
<p v-else-if="!loading && !logs.length" class="empty-log">{{ t("logs.empty") }}</p>
<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">
<span class="dot" :class="l.ok ? 'ok' : 'bad'"></span>
<span class="log-route">{{ l.routeId }}</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>
</div>
<div class="log-meta">
@ -31,6 +38,30 @@
<div v-if="!l.ok && l.error" class="log-error">{{ l.error }}</div>
</article>
</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>
</template>
@ -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<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 {
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<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>

View file

@ -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": "分组已保存",

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 {
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<string, unknown>;
}