diff --git a/frontend/src/views/components/LogSettings/LogSettings.js b/frontend/src/views/components/LogSettings/LogSettings.js index 5ee993e2..e3950824 100644 --- a/frontend/src/views/components/LogSettings/LogSettings.js +++ b/frontend/src/views/components/LogSettings/LogSettings.js @@ -131,6 +131,13 @@ export function useLogs() { aborter?.abort() }) + + function selectSection(section) { + if (['operation', 'mail', 'runtime'].includes(section)) { + category.value = section + } + } + return { category, categories, loading, error, items, lines, offset, canFilterActor, @@ -138,6 +145,6 @@ export function useLogs() { hasMore, actorId, dateRange, detail, details, isRuntime, runtimeDetail, runtimeRows, runtimeSummary, visibleCount, pageRange, currentCategory, resultType, runtimeRowClass, timeParts, - formatTime, resultLabel, actionLabel, displayActor, displayObject, displayOutcome, search, recent, next, previous + formatTime, resultLabel, actionLabel, displayActor, displayObject, displayOutcome, search, recent, next, previous, selectSection } } diff --git a/frontend/src/views/components/LogSettings/LogSettings.vue b/frontend/src/views/components/LogSettings/LogSettings.vue index 4d6b3f4c..b1f3ed6e 100644 --- a/frontend/src/views/components/LogSettings/LogSettings.vue +++ b/frontend/src/views/components/LogSettings/LogSettings.vue @@ -182,8 +182,13 @@ const { hasMore, actorId, dateRange, detail, details, isRuntime, runtimeDetail, runtimeRows, runtimeSummary, pageRange, currentCategory, resultType, runtimeRowClass, timeParts, - formatTime, resultLabel, actionLabel, displayActor, displayObject, displayOutcome, search, recent, next, previous + formatTime, resultLabel, actionLabel, displayActor, displayObject, displayOutcome, search, recent, next, previous, selectSection } = useLogs() +defineExpose({ + selectSection, + category +}) + diff --git a/frontend/src/views/components/LogSettings/MailLogs/MailLogs.css b/frontend/src/views/components/LogSettings/MailLogs/MailLogs.css new file mode 100644 index 00000000..8a0a3f48 --- /dev/null +++ b/frontend/src/views/components/LogSettings/MailLogs/MailLogs.css @@ -0,0 +1,113 @@ +.log-subpage-container { + display: flex; + flex-direction: column; + gap: 16px; + min-height: 100%; +} + +.log-filters { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; +} + +.mail-scope { + width: 140px; +} + +.log-filter-actions { + display: inline-flex; + gap: 8px; + margin-left: auto; +} + +.log-content { + display: flex; + flex-direction: column; + gap: 12px; + min-height: 380px; +} + +.log-table-heading { + display: flex; + justify-content: space-between; + align-items: center; +} + +.log-table-title strong { + font-size: 15px; + color: var(--pm-text-primary, #1e293b); +} + +.log-file-meta { + margin-left: 8px; + font-size: 13px; + color: var(--pm-text-secondary, #64748b); +} + +.log-table { + border: 1px solid var(--pm-border-color, #e2e8f0); + border-radius: 8px; + overflow: hidden; +} + +.log-time { + display: flex; + flex-direction: column; + font-size: 12px; + line-height: 1.3; +} + +.log-action { + display: block; + font-size: 13px; + color: var(--pm-text-primary, #1e293b); +} + +.log-message { + font-size: 12px; + color: var(--pm-text-secondary, #64748b); + word-break: break-all; +} + +.log-source { + font-size: 11px; + color: var(--pm-text-muted, #94a3b8); +} + +.log-level-tag { + font-size: 11px; +} + +.log-pagination { + display: flex; + justify-content: space-between; + align-items: center; + padding-top: 8px; + font-size: 13px; + color: var(--pm-text-secondary, #64748b); +} + +.log-page-actions { + display: inline-flex; + gap: 8px; +} + +.log-detail-grid { + display: grid; + grid-template-columns: 96px 1fr; + gap: 8px 12px; + margin: 16px 0; + font-size: 13px; +} + +.log-detail-grid dt { + color: var(--pm-text-secondary, #64748b); +} + +.log-detail-grid dd { + margin: 0; + color: var(--pm-text-primary, #1e293b); + word-break: break-all; +} \ No newline at end of file diff --git a/frontend/src/views/components/LogSettings/MailLogs/MailLogs.js b/frontend/src/views/components/LogSettings/MailLogs/MailLogs.js new file mode 100644 index 00000000..1e0d7058 --- /dev/null +++ b/frontend/src/views/components/LogSettings/MailLogs/MailLogs.js @@ -0,0 +1,146 @@ +import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' +import { http } from '@/utils/axios' +import { useGlobalStatusStore } from '@/stores/useGlobalStatusStore' +import { timeParts } from '../RuntimeLog.js' + +export function useMailLogs() { + const store = useGlobalStatusStore() + const canFilterActor = computed(() => ['super_admin', 'admin'].includes(store.role)) + const loading = ref(false) + const error = ref('') + const items = ref([]) + const offset = ref(0) + const hasMore = ref(false) + const actorId = ref('') + const mailScope = ref('all') + const canFilterSystemMail = computed(() => store.role === 'super_admin') + const systemMailSelected = computed(() => canFilterSystemMail.value && mailScope.value === 'system') + + const recentRange = () => { + const end = new Date() + return [new Date(end.getTime() - 24 * 60 * 60 * 1000), end] + } + const dateRange = ref(recentRange()) + const detail = ref(null) + const pageSize = 10 + let requestId = 0 + let aborter + const previousOffsets = [] + let appliedFilters = {} + + const formatTime = value => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—' + const resultLabel = value => ({ success: '成功', failure: '失败', unknown: '未确认', accepted: '对端接收', pending: '待验证', started: '已开始', recorded: '状态记录', partial: '部分接收', discarded: '已丢弃', skipped: '已跳过' }[value] || value || '未记录') + const actionLabel = value => ({ update: '保存配置', rollback: '回滚配置', migration: '迁移配置' }[value] || value || '未记录操作') + const displayObject = item => item.payload?.object || item.payload?.stage || '未记录对象' + const displayOutcome = item => item.payload?.outcome || '历史记录未提供结果说明' + const displayActor = item => !item.actorId || item.actorId === '0' ? '系统' : (item.payload?.actorName || item.actorId) + const details = computed(() => detail.value?.payload || {}) + const pageRange = computed(() => items.value.length ? \第 ${offset.value + 1}–${offset.value + items.value.length} 条\ : '暂无记录') + const resultType = value => ({ success: 'success', accepted: 'primary', failure: 'danger', pending: 'warning', partial: 'warning', discarded: 'warning', started: 'primary' }[value] || 'info') + + function requireData(result) { + if (result?.errorNo !== 0 || !result.data) throw new Error(result?.errorMsg || '日志查询失败') + return result.data + } + + async function load() { + const id = ++requestId + aborter?.abort() + aborter = new AbortController() + loading.value = true + error.value = '' + try { + const params = { ...appliedFilters, offset: offset.value, limit: pageSize } + const data = requireData(await http.get('/api/v1/logs', { params, signal: aborter.signal })) + if (id !== requestId) return + items.value = data.items || [] + hasMore.value = Boolean(data.hasMore) + } catch (cause) { + if (id === requestId && cause?.code !== 'ERR_CANCELED') { + error.value = cause.message || '日志查询失败' + items.value = [] + hasMore.value = false + } + } finally { + if (id === requestId) loading.value = false + } + } + + function search() { + if (!dateRange.value?.length) dateRange.value = recentRange() + const [from, to] = (dateRange.value || []).map(value => new Date(value)) + if (!from || !to || !Number.isFinite(from.getTime()) || !Number.isFinite(to.getTime()) || from > to) { + ++requestId + aborter?.abort() + loading.value = false + error.value = '请选择有效的起止时间' + items.value = [] + hasMore.value = false + return + } + appliedFilters = { kind: 'mail', from: from.toISOString(), to: to.toISOString() } + if (systemMailSelected.value) { + appliedFilters.systemOnly = true + } else if (canFilterActor.value && actorId.value.trim()) { + appliedFilters.actorId = actorId.value.trim() + } + offset.value = 0 + previousOffsets.length = 0 + detail.value = null + return load() + } + + function recent() { + dateRange.value = recentRange() + return search() + } + + function next() { + if (loading.value || !hasMore.value) return + previousOffsets.push(offset.value) + offset.value += pageSize + return load() + } + + function previous() { + if (loading.value || offset.value === 0) return + offset.value = previousOffsets.pop() ?? 0 + return load() + } + + watch(mailScope, search) + onMounted(search) + onBeforeUnmount(() => { + ++requestId + aborter?.abort() + }) + + return { + loading, + error, + items, + offset, + hasMore, + actorId, + canFilterActor, + mailScope, + canFilterSystemMail, + systemMailSelected, + dateRange, + detail, + details, + pageRange, + resultType, + timeParts, + formatTime, + resultLabel, + actionLabel, + displayActor, + displayObject, + displayOutcome, + search, + recent, + next, + previous + } +} \ No newline at end of file diff --git a/frontend/src/views/components/LogSettings/MailLogs/MailLogs.vue b/frontend/src/views/components/LogSettings/MailLogs/MailLogs.vue new file mode 100644 index 00000000..cdeec8c3 --- /dev/null +++ b/frontend/src/views/components/LogSettings/MailLogs/MailLogs.vue @@ -0,0 +1,162 @@ + + + + + + + + + + + 最近 24 小时 + 查询 + + + + + + + + + 收发信记录 + 最新在前 · 每页 10 条 + + + + detail = item" + > + + + + {{ timeParts(row.createdAt).time }} + {{ timeParts(row.createdAt).date }} + + + + + + {{ actionLabel(row.action) }} + {{ displayOutcome(row) }} + {{ displayObject(row) }} + + + + {{ displayActor(row) }} + + + + + {{ resultLabel(row.result) }} + + + + + + 查看 + + + + + + + + + + {{ formatTime(detail.createdAt) }} · {{ displayActor(detail) }} · {{ actionLabel(detail.action) }} + + 触发操作 + {{ actionLabel(detail.action) }} + 触发来源 + {{ details.trigger || '未记录' }} + 处理阶段 + {{ displayObject(detail) }} + 操作结果 + {{ resultLabel(detail.result) }} + 结果说明 + {{ displayOutcome(detail) }} + 发件人 + {{ details.sender || '—' }} + 收件人数 + {{ details.recipientCount }} + 邮件大小 + {{ details.bytes }} 字节 + 对端接收人数 + {{ details.acceptedCount ?? '未记录' }} + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/components/LogSettings/OperationLogs/OperationLogs.css b/frontend/src/views/components/LogSettings/OperationLogs/OperationLogs.css new file mode 100644 index 00000000..4737950f --- /dev/null +++ b/frontend/src/views/components/LogSettings/OperationLogs/OperationLogs.css @@ -0,0 +1,109 @@ +.log-subpage-container { + display: flex; + flex-direction: column; + gap: 16px; + min-height: 100%; +} + +.log-filters { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; +} + +.log-filter-actions { + display: inline-flex; + gap: 8px; + margin-left: auto; +} + +.log-content { + display: flex; + flex-direction: column; + gap: 12px; + min-height: 380px; +} + +.log-table-heading { + display: flex; + justify-content: space-between; + align-items: center; +} + +.log-table-title strong { + font-size: 15px; + color: var(--pm-text-primary, #1e293b); +} + +.log-file-meta { + margin-left: 8px; + font-size: 13px; + color: var(--pm-text-secondary, #64748b); +} + +.log-table { + border: 1px solid var(--pm-border-color, #e2e8f0); + border-radius: 8px; + overflow: hidden; +} + +.log-time { + display: flex; + flex-direction: column; + font-size: 12px; + line-height: 1.3; +} + +.log-action { + display: block; + font-size: 13px; + color: var(--pm-text-primary, #1e293b); +} + +.log-message { + font-size: 12px; + color: var(--pm-text-secondary, #64748b); + word-break: break-all; +} + +.log-source { + font-size: 11px; + color: var(--pm-text-muted, #94a3b8); +} + +.log-level-tag { + font-size: 11px; +} + +.log-pagination { + display: flex; + justify-content: space-between; + align-items: center; + padding-top: 8px; + font-size: 13px; + color: var(--pm-text-secondary, #64748b); +} + +.log-page-actions { + display: inline-flex; + gap: 8px; +} + +.log-detail-grid { + display: grid; + grid-template-columns: 96px 1fr; + gap: 8px 12px; + margin: 16px 0; + font-size: 13px; +} + +.log-detail-grid dt { + color: var(--pm-text-secondary, #64748b); +} + +.log-detail-grid dd { + margin: 0; + color: var(--pm-text-primary, #1e293b); + word-break: break-all; +} \ No newline at end of file diff --git a/frontend/src/views/components/LogSettings/OperationLogs/OperationLogs.js b/frontend/src/views/components/LogSettings/OperationLogs/OperationLogs.js new file mode 100644 index 00000000..52f65050 --- /dev/null +++ b/frontend/src/views/components/LogSettings/OperationLogs/OperationLogs.js @@ -0,0 +1,136 @@ +import { computed, onBeforeUnmount, onMounted, ref } from 'vue' +import { http } from '@/utils/axios' +import { useGlobalStatusStore } from '@/stores/useGlobalStatusStore' +import { timeParts } from '../RuntimeLog.js' + +export function useOperationLogs() { + const store = useGlobalStatusStore() + const canFilterActor = computed(() => ['super_admin', 'admin'].includes(store.role)) + const loading = ref(false) + const error = ref('') + const items = ref([]) + const offset = ref(0) + const hasMore = ref(false) + const actorId = ref('') + const recentRange = () => { + const end = new Date() + return [new Date(end.getTime() - 24 * 60 * 60 * 1000), end] + } + const dateRange = ref(recentRange()) + const detail = ref(null) + const pageSize = 10 + let requestId = 0 + let aborter + const previousOffsets = [] + let appliedFilters = {} + + const formatTime = value => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—' + const resultLabel = value => ({ success: '成功', failure: '失败', unknown: '未确认', accepted: '对端接收', pending: '待验证', started: '已开始', recorded: '状态记录', partial: '部分接收', discarded: '已丢弃', skipped: '已跳过' }[value] || value || '未记录') + const actionLabel = value => ({ update: '保存配置', rollback: '回滚配置', migration: '迁移配置' }[value] || value || '未记录操作') + const displayObject = item => item.payload?.object || item.payload?.stage || '未记录对象' + const displayOutcome = item => item.payload?.outcome || '历史记录未提供结果说明' + const displayActor = item => !item.actorId || item.actorId === '0' ? '系统' : (item.payload?.actorName || item.actorId) + const details = computed(() => detail.value?.payload || {}) + const pageRange = computed(() => items.value.length ? \第 ${offset.value + 1}–${offset.value + items.value.length} 条\ : '暂无记录') + const resultType = value => ({ success: 'success', accepted: 'primary', failure: 'danger', pending: 'warning', partial: 'warning', discarded: 'warning', started: 'primary' }[value] || 'info') + + function requireData(result) { + if (result?.errorNo !== 0 || !result.data) throw new Error(result?.errorMsg || '日志查询失败') + return result.data + } + + async function load() { + const id = ++requestId + aborter?.abort() + aborter = new AbortController() + loading.value = true + error.value = '' + try { + const params = { ...appliedFilters, offset: offset.value, limit: pageSize } + const data = requireData(await http.get('/api/v1/logs', { params, signal: aborter.signal })) + if (id !== requestId) return + items.value = data.items || [] + hasMore.value = Boolean(data.hasMore) + } catch (cause) { + if (id === requestId && cause?.code !== 'ERR_CANCELED') { + error.value = cause.message || '日志查询失败' + items.value = [] + hasMore.value = false + } + } finally { + if (id === requestId) loading.value = false + } + } + + function search() { + if (!dateRange.value?.length) dateRange.value = recentRange() + const [from, to] = (dateRange.value || []).map(value => new Date(value)) + if (!from || !to || !Number.isFinite(from.getTime()) || !Number.isFinite(to.getTime()) || from > to) { + ++requestId + aborter?.abort() + loading.value = false + error.value = '请选择有效的起止时间' + items.value = [] + hasMore.value = false + return + } + appliedFilters = { kind: 'operation', from: from.toISOString(), to: to.toISOString() } + if (canFilterActor.value && actorId.value.trim()) { + appliedFilters.actorId = actorId.value.trim() + } + offset.value = 0 + previousOffsets.length = 0 + detail.value = null + return load() + } + + function recent() { + dateRange.value = recentRange() + return search() + } + + function next() { + if (loading.value || !hasMore.value) return + previousOffsets.push(offset.value) + offset.value += pageSize + return load() + } + + function previous() { + if (loading.value || offset.value === 0) return + offset.value = previousOffsets.pop() ?? 0 + return load() + } + + onMounted(search) + onBeforeUnmount(() => { + ++requestId + aborter?.abort() + }) + + return { + loading, + error, + items, + offset, + hasMore, + actorId, + canFilterActor, + dateRange, + detail, + details, + pageRange, + resultType, + timeParts, + formatTime, + resultLabel, + actionLabel, + displayActor, + displayObject, + displayOutcome, + search, + recent, + next, + previous + } +} \ No newline at end of file diff --git a/frontend/src/views/components/LogSettings/OperationLogs/OperationLogs.vue b/frontend/src/views/components/LogSettings/OperationLogs/OperationLogs.vue new file mode 100644 index 00000000..c55205f1 --- /dev/null +++ b/frontend/src/views/components/LogSettings/OperationLogs/OperationLogs.vue @@ -0,0 +1,148 @@ + + + + + + + 最近 24 小时 + 查询 + + + + + + + + + 操作记录 + 最新在前 · 每页 10 条 + + + + detail = item" + > + + + + {{ timeParts(row.createdAt).time }} + {{ timeParts(row.createdAt).date }} + + + + + + {{ actionLabel(row.action) }} + {{ displayOutcome(row) }} + {{ displayObject(row) }} + + + + {{ displayActor(row) }} + + + + + {{ resultLabel(row.result) }} + + + + + + 查看 + + + + + + + + + + {{ formatTime(detail.createdAt) }} · {{ displayActor(detail) }} · {{ actionLabel(detail.action) }} + + 触发操作 + {{ actionLabel(detail.action) }} + 触发来源 + {{ details.trigger || '未记录' }} + 操作对象 + {{ displayObject(detail) }} + 操作结果 + {{ resultLabel(detail.result) }} + 结果说明 + {{ displayOutcome(detail) }} + 权限角色 + {{ details.actorRole || '—' }} + 结果代码 + {{ details.errorCode ?? '—' }} + HTTP 状态 + {{ details.httpStatus || '未记录' }} + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/components/LogSettings/RuntimeLogs/RuntimeLogs.css b/frontend/src/views/components/LogSettings/RuntimeLogs/RuntimeLogs.css new file mode 100644 index 00000000..7bb5c95c --- /dev/null +++ b/frontend/src/views/components/LogSettings/RuntimeLogs/RuntimeLogs.css @@ -0,0 +1,7 @@ +@import '../LogSettings.css'; + +.log-subpage-container { + display: flex; + flex-direction: column; + height: 100%; +} diff --git a/frontend/src/views/components/LogSettings/RuntimeLogs/RuntimeLogs.js b/frontend/src/views/components/LogSettings/RuntimeLogs/RuntimeLogs.js new file mode 100644 index 00000000..4758d940 --- /dev/null +++ b/frontend/src/views/components/LogSettings/RuntimeLogs/RuntimeLogs.js @@ -0,0 +1,160 @@ +import { computed, onMounted, ref } from 'vue' +import { http } from '@/utils/axios' +import { parseRuntimeLine, summarizeRuntime, timeParts } from '../RuntimeLog.js' + +const PAGE_SIZE = 10 + +export function useRuntimeLogs() { + const loading = ref(false) + const error = ref('') + const items = ref([]) + const offset = ref(0) + const hasMore = ref(false) + const recentRange = () => { + const end = new Date() + return [new Date(end.getTime() - 24 * 60 * 60 * 1000), end] + } + const dateRange = ref(recentRange()) + const runtimeDetail = ref(null) + let requestId = 0 + let aborter + const previousOffsets = [] + let appliedFilters = {} + + const runtimeRows = computed(() => items.value.map(line => parseRuntimeLine(line, timeParts))) + const runtimeSummary = computed(() => summarizeRuntime(runtimeRows.value)) + + const pageRange = computed(() => { + if (items.value.length === 0) { + return '????' + } + const start = offset.value + 1 + const end = offset.value + items.value.length + return `? ${start}-${end} ?` + }) + + function resultType(result) { + if (result === 'success') return 'success' + if (result === 'failure' || result === 'failed') return 'danger' + if (result === 'blocked') return 'warning' + return 'info' + } + + function resultLabel(result) { + return { + success: '??', + failure: '??', + failed: '??', + blocked: '???', + unknown: '???', + accepted: '????', + pending: '???', + started: '???', + recorded: '????', + partial: '????', + discarded: '???', + skipped: '???' + }[result] || result || '???' + } + + function runtimeRowClass({ row }) { + if (row.type === 'danger') return 'log-row-danger' + if (row.type === 'warning') return 'log-row-warning' + return '' + } + + function formatTime(isoString) { + if (!isoString) return '?' + const date = new Date(isoString) + if (isNaN(date.getTime())) return isoString + return date.toLocaleString('zh-CN', { hour12: false }) + } + + function requireData(result) { + if (result?.errorNo !== 0 || !result.data) throw new Error(result?.errorMsg || '??????') + return result.data + } + + async function loadLogs() { + const id = ++requestId + aborter?.abort() + aborter = new AbortController() + loading.value = true + error.value = '' + try { + const params = { ...appliedFilters, offset: offset.value, limit: PAGE_SIZE } + const data = requireData(await http.get('/api/v1/logs', { params, signal: aborter.signal })) + if (id !== requestId) return + items.value = data.items || [] + hasMore.value = Boolean(data.hasMore) + } catch (cause) { + if (id === requestId && cause?.code !== 'ERR_CANCELED') { + error.value = cause.message || '??????' + items.value = [] + hasMore.value = false + } + } finally { + if (id === requestId) loading.value = false + } + } + + function search() { + if (!dateRange.value?.length) dateRange.value = recentRange() + const [from, to] = (dateRange.value || []).map(val => new Date(val)) + if (!from || !to || !Number.isFinite(from.getTime()) || !Number.isFinite(to.getTime()) || from > to) { + ++requestId + aborter?.abort() + loading.value = false + error.value = '??????????' + items.value = [] + hasMore.value = false + return + } + appliedFilters = { kind: 'runtime', from: from.toISOString(), to: to.toISOString() } + offset.value = 0 + previousOffsets.length = 0 + runtimeDetail.value = null + return loadLogs() + } + + function recent() { + dateRange.value = recentRange() + return search() + } + + function next() { + if (loading.value || !hasMore.value) return + previousOffsets.push(offset.value) + offset.value += PAGE_SIZE + return loadLogs() + } + + function previous() { + if (loading.value || offset.value === 0) return + offset.value = previousOffsets.pop() ?? 0 + return loadLogs() + } + + onMounted(search) + + return { + loading, + error, + items, + offset, + hasMore, + dateRange, + runtimeDetail, + runtimeRows, + runtimeSummary, + pageRange, + resultType, + runtimeRowClass, + formatTime, + resultLabel, + search, + recent, + next, + previous + } +} diff --git a/frontend/src/views/components/LogSettings/RuntimeLogs/RuntimeLogs.vue b/frontend/src/views/components/LogSettings/RuntimeLogs/RuntimeLogs.vue new file mode 100644 index 00000000..ef8928e4 --- /dev/null +++ b/frontend/src/views/components/LogSettings/RuntimeLogs/RuntimeLogs.vue @@ -0,0 +1,155 @@ + + + + + + ?? 24 ?? + ?? + + + + + + + + + ???? + ???? ? ?? 10 ? + + + ?? {{ runtimeSummary.total }} ? + ?? {{ runtimeSummary.info }} + ?? {{ runtimeSummary.warning }} + ?? {{ runtimeSummary.error }} + + + + runtimeDetail = row" + > + + + + {{ row.time }} + {{ row.date }} + + + + + + + {{ row.label }} + + + + + + {{ row.action }} + {{ row.outcome }} + ???{{ row.object }} + + + + + + {{ resultLabel(row.result) }} + + + + + + + ?? + + + + + + + + + + + + {{ runtimeDetail.label }} + {{ formatTime(runtimeDetail.createdAt) }} + ? {{ runtimeDetail.sequence }} ? + + + ???? + {{ runtimeDetail.action }} + ???? + {{ runtimeDetail.object || '???' }} + ???? + {{ resultLabel(runtimeDetail.result) }} + ???? + {{ runtimeDetail.outcome }} + ???? + {{ runtimeDetail.source || '???' }} + + ???? + {{ runtimeDetail.requestId }} + + + ???? + {{ runtimeDetail.message }} + + ???? + {{ runtimeDetail.raw }} + + + + + + + + +
{{ formatTime(detail.createdAt) }} · {{ displayActor(detail) }} · {{ actionLabel(detail.action) }}
{{ runtimeDetail.message }}
{{ runtimeDetail.raw }}