From 8cef8a9c6129b40c2e37c81fe378269c800d21f9 Mon Sep 17 00:00:00 2001 From: smail Date: Mon, 19 Oct 2026 22:58:23 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8C=89=E7=85=A7=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E5=88=86=E7=B1=BB=E6=8B=86=E5=88=86=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E9=A1=B5=E9=9D=A2=EF=BC=88=E6=93=8D=E4=BD=9C=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E3=80=81=E6=94=B6=E5=8F=91=E4=BF=A1=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E3=80=81=E8=BF=90=E8=A1=8C=E6=97=A5=E5=BF=97=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/LogSettings/LogSettings.js | 9 +- .../components/LogSettings/LogSettings.vue | 7 +- .../LogSettings/MailLogs/MailLogs.css | 113 ++++++++++++ .../LogSettings/MailLogs/MailLogs.js | 146 ++++++++++++++++ .../LogSettings/MailLogs/MailLogs.vue | 162 ++++++++++++++++++ .../OperationLogs/OperationLogs.css | 109 ++++++++++++ .../OperationLogs/OperationLogs.js | 136 +++++++++++++++ .../OperationLogs/OperationLogs.vue | 148 ++++++++++++++++ .../LogSettings/RuntimeLogs/RuntimeLogs.css | 7 + .../LogSettings/RuntimeLogs/RuntimeLogs.js | 160 +++++++++++++++++ .../LogSettings/RuntimeLogs/RuntimeLogs.vue | 155 +++++++++++++++++ 11 files changed, 1150 insertions(+), 2 deletions(-) create mode 100644 frontend/src/views/components/LogSettings/MailLogs/MailLogs.css create mode 100644 frontend/src/views/components/LogSettings/MailLogs/MailLogs.js create mode 100644 frontend/src/views/components/LogSettings/MailLogs/MailLogs.vue create mode 100644 frontend/src/views/components/LogSettings/OperationLogs/OperationLogs.css create mode 100644 frontend/src/views/components/LogSettings/OperationLogs/OperationLogs.js create mode 100644 frontend/src/views/components/LogSettings/OperationLogs/OperationLogs.vue create mode 100644 frontend/src/views/components/LogSettings/RuntimeLogs/RuntimeLogs.css create mode 100644 frontend/src/views/components/LogSettings/RuntimeLogs/RuntimeLogs.js create mode 100644 frontend/src/views/components/LogSettings/RuntimeLogs/RuntimeLogs.vue 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 @@ + + + + + \ 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 @@ + + + + + \ 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 @@ + + + + +