From 113a29733ec26c1bd2796efe334f95799a5f03db Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 10 May 2026 00:13:20 +0200 Subject: [PATCH] feat(logs): mobile-friendly log modals with theme-aware colors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both index-page log modals (panel logs and xray access logs) now adapt to narrow viewports and dark / ultra-dark themes: - Render through Vue templates instead of v-html — drops the manual escapeHtml helper and the regex-based string formatting; each line is parsed once into structured fields (date, time, level, body for panel logs; from / to / inbound / outbound / email for xray logs). - Mobile: stacked cards per entry. Panel-log cards show time + a level badge above the wrapped message; xray-log cards show time and event tag above the From → To pair, with inbound / outbound / email as small meta pairs below. Long IPv6 / hostnames wrap instead of overflowing. - Modal goes full-bleed on mobile (100vw, no rounded corners, pinned to viewport height) so cards get full width. - Toolbar wraps cleanly when the row-count, level, syslog checkbox, and download button can't fit on one line. - Theme-aware colour palette via CSS variables on .log-container — brighter shades on body.dark and [data-theme="ultra-dark"] so level text and blocked / proxy rows keep AA contrast against the navy and near-black surfaces. - Cards render flush on the container surface (no separate card bg) so the colour story is identical to the desktop view. --- frontend/src/pages/index/LogModal.vue | 247 ++++++++++++++++++---- frontend/src/pages/index/XrayLogModal.vue | 243 +++++++++++++++++---- 2 files changed, 397 insertions(+), 93 deletions(-) diff --git a/frontend/src/pages/index/LogModal.vue b/frontend/src/pages/index/LogModal.vue index 735ea0ba..8c1e9111 100644 --- a/frontend/src/pages/index/LogModal.vue +++ b/frontend/src/pages/index/LogModal.vue @@ -4,8 +4,10 @@ import { useI18n } from 'vue-i18n'; import { DownloadOutlined, SyncOutlined } from '@ant-design/icons-vue'; import { HttpUtil, FileManager, PromiseUtil } from '@/utils'; +import { useMediaQuery } from '@/composables/useMediaQuery.js'; const { t } = useI18n(); +const { isMobile } = useMediaQuery(); const props = defineProps({ open: { type: Boolean, default: false }, @@ -20,48 +22,41 @@ const loading = ref(false); const logs = ref([]); const LEVELS = ['DEBUG', 'INFO', 'NOTICE', 'WARNING', 'ERROR']; -const LEVEL_COLORS = ['#3c89e8', '#008771', '#008771', '#f37b24', '#e04141', '#bcbcbc']; +const LEVEL_CLASSES = ['level-debug', 'level-info', 'level-notice', 'level-warning', 'level-error']; -function escapeHtml(value) { - if (value == null) return ''; - return String(value) - .replace(/&/g, '&').replace(//g, '>') - .replace(/"/g, '"').replace(/'/g, '''); +// Parses "YYYY-MM-DD HH:MM:SS LEVEL - message". Lines without the +// 3-token header degrade gracefully: the unparsed head becomes the +// level so it still gets color-coded. +function parseLogLine(line) { + const [head, ...rest] = (line || '').split(' - '); + const message = rest.join(' - '); + const parts = head.split(' '); + + let date = ''; + let time = ''; + let levelText; + if (parts.length >= 3) { + [date, time, levelText] = parts; + } else { + levelText = head; + } + + const li = LEVELS.indexOf(levelText); + const levelClass = li >= 0 ? LEVEL_CLASSES[li] : 'level-unknown'; + + let service = ''; + let body = message || ''; + if (body.startsWith('XRAY:')) { + service = 'XRAY:'; + body = body.slice('XRAY:'.length).trimStart(); + } else if (body) { + service = 'X-UI:'; + } + + return { date, time, levelText, levelClass, service, body }; } -function formatLogs(lines) { - // Each line: "YYYY-MM-DD HH:MM:SS LEVEL - message" - // Color the timestamp + level prefix and bold the originating service. - let out = ''; - lines.forEach((log, idx) => { - const [data, message] = log.split(' - ', 2); - const parts = data.split(' '); - if (idx > 0) out += '
'; - - if (parts.length === 3) { - const d = escapeHtml(parts[0]); - const t = escapeHtml(parts[1]); - const levelRaw = parts[2]; - const li = LEVELS.indexOf(levelRaw); - const levelIndex = li >= 0 ? li : 5; - out += `${d} ${t} `; - out += `${escapeHtml(levelRaw)}`; - } else { - const li = LEVELS.indexOf(data); - const levelIndex = li >= 0 ? li : 5; - out += `${escapeHtml(data)}`; - } - - if (message) { - const prefix = message.startsWith('XRAY:') ? 'XRAY: ' : 'X-UI: '; - const tail = message.startsWith('XRAY:') ? message.substring(5) : message; - out += ' - ' + prefix + escapeHtml(tail); - } - }); - return out; -} - -const formattedLogs = computed(() => (logs.value.length > 0 ? formatLogs(logs.value) : 'No Record...')); +const parsedLogs = computed(() => logs.value.map(parseLogLine)); async function refresh() { loading.value = true; @@ -73,8 +68,6 @@ async function refresh() { if (msg?.success) { logs.value = msg.obj || []; } - // Keep the spinner visible long enough that rapid filter changes - // feel intentional rather than flickery. await PromiseUtil.sleep(300); } finally { loading.value = false; @@ -89,19 +82,21 @@ function download() { FileManager.downloadTextFile(logs.value.join('\n'), 'x-ui.log'); } -// Re-fetch whenever the modal opens or any filter changes. watch(() => props.open, (next) => { if (next) refresh(); }); watch([rows, level, syslog], () => { if (props.open) refresh(); }); + +const modalWidth = computed(() => (isMobile.value ? '100vw' : '800px'));