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'));
-
+
{{ t('pages.index.logs') }}
-
+
@@ -123,7 +118,7 @@ watch([rows, level, syslog], () => { if (props.open) refresh(); });
SysLog
-
+
@@ -132,7 +127,43 @@ watch([rows, level, syslog], () => { if (props.open) refresh(); });
-
+
+
No Record...
+
+
+
+
+
+ {{ log.time }}
+ {{ log.date }}
+
+
+ {{ log.levelText }}
+
+
+
+ {{ log.service }}
+ {{ log.body }}
+
+
+
+
+
+
+
+ {{ log.date }} {{ log.time }}
+
+
+ {{ log.levelText }}
+
+
+ -
+ {{ log.service }}
+ {{ log.body }}
+
+
+
+
@@ -143,7 +174,26 @@ watch([rows, level, syslog], () => { if (props.open) refresh(); });
margin-left: 10px;
}
+.log-toolbar {
+ flex-wrap: wrap;
+ row-gap: 8px;
+}
+.log-toolbar .download-item {
+ margin-left: auto;
+}
+
.log-container {
+ /* Per-theme palette — overridden in body.dark / [data-theme="ultra-dark"]
+ below so each level keeps ≥4.5:1 contrast against the container. */
+ --log-stamp: #3c89e8;
+ --log-debug: #3c89e8;
+ --log-info: #008771;
+ --log-notice: #008771;
+ --log-warning: #f37b24;
+ --log-error: #e04141;
+ --log-unknown: #595959;
+ --log-divider: rgba(128, 128, 128, 0.18);
+
margin-top: 12px;
padding: 10px 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
@@ -158,8 +208,113 @@ watch([rows, level, syslog], () => { if (props.open) refresh(); });
background: rgba(0, 0, 0, 0.04);
}
+.log-stamp { color: var(--log-stamp); }
+.log-level { margin-left: 4px; }
+.level-debug { color: var(--log-debug); }
+.level-info { color: var(--log-info); }
+.level-notice { color: var(--log-notice); }
+.level-warning { color: var(--log-warning); }
+.level-error { color: var(--log-error); }
+.level-unknown { color: var(--log-unknown); }
+
+.log-container-mobile {
+ padding: 8px;
+ white-space: normal;
+ max-height: 70vh;
+}
+
+.log-empty {
+ text-align: center;
+ opacity: 0.5;
+ padding: 20px 0;
+}
+
+.log-line + .log-line {
+ margin-top: 2px;
+}
+
+.log-card {
+ border-bottom: 1px solid var(--log-divider);
+ padding: 8px 0;
+}
+.log-card:last-child { border-bottom: 0; }
+.log-card-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ margin-bottom: 4px;
+}
+.log-time {
+ display: inline-flex;
+ align-items: baseline;
+ gap: 6px;
+ font-weight: 600;
+ font-size: 12px;
+ letter-spacing: 0.02em;
+}
+.log-date {
+ font-size: 10px;
+ font-weight: 500;
+ opacity: 0.55;
+}
+.log-level-badge {
+ display: inline-block;
+ font-size: 10px;
+ line-height: 14px;
+ padding: 0 6px;
+ border-radius: 4px;
+ border: 1px solid currentColor;
+ letter-spacing: 0.04em;
+ font-weight: 600;
+ white-space: nowrap;
+ background: color-mix(in srgb, currentColor 14%, transparent);
+}
+.log-body {
+ font-size: 12px;
+ word-break: break-word;
+}
+.log-body-text {
+ margin-left: 4px;
+}
+
:global(body.dark) .log-container {
background: rgba(255, 255, 255, 0.03);
border-color: rgba(255, 255, 255, 0.1);
+ color: rgba(255, 255, 255, 0.88);
+
+ --log-stamp: #6aa6ee;
+ --log-debug: #6aa6ee;
+ --log-info: #4ed3a6;
+ --log-notice: #4ed3a6;
+ --log-warning: #ffb872;
+ --log-error: #ff7575;
+ --log-unknown: #b5b5b5;
+ --log-divider: rgba(255, 255, 255, 0.1);
+}
+
+:global([data-theme="ultra-dark"]) .log-container {
+ --log-stamp: #7fb6f1;
+ --log-debug: #7fb6f1;
+ --log-info: #5fd9b0;
+ --log-notice: #5fd9b0;
+ --log-warning: #ffcc88;
+ --log-error: #ff8a8a;
+ --log-unknown: #c4c4c4;
+ --log-divider: rgba(255, 255, 255, 0.12);
+}
+
+/* Mobile: pull the modal flush with the screen edges. */
+:global(.logmodal-mobile) {
+ top: 0 !important;
+ padding-bottom: 0 !important;
+ max-width: 100vw !important;
+}
+:global(.logmodal-mobile .ant-modal-content) {
+ border-radius: 0;
+ height: 100vh;
+}
+:global(.logmodal-mobile .ant-modal-body) {
+ padding: 12px;
}
diff --git a/frontend/src/pages/index/XrayLogModal.vue b/frontend/src/pages/index/XrayLogModal.vue
index cb128cd0..610bb39c 100644
--- a/frontend/src/pages/index/XrayLogModal.vue
+++ b/frontend/src/pages/index/XrayLogModal.vue
@@ -5,9 +5,11 @@ import { DownloadOutlined, SyncOutlined } from '@ant-design/icons-vue';
import { HttpUtil, FileManager, IntlUtil, PromiseUtil } from '@/utils';
import { useDatepicker } from '@/composables/useDatepicker.js';
+import { useMediaQuery } from '@/composables/useMediaQuery.js';
const { t } = useI18n();
const { datepicker } = useDatepicker();
+const { isMobile } = useMediaQuery();
const props = defineProps({
open: { type: Boolean, default: false },
@@ -23,43 +25,28 @@ const showProxy = ref(true);
const loading = ref(false);
const logs = ref([]);
-function escapeHtml(value) {
- if (value == null) return '';
- return String(value)
- .replace(/&/g, '&').replace(//g, '>')
- .replace(/"/g, '"').replace(/'/g, ''');
+// Newest first.
+const orderedLogs = computed(() => [...logs.value].reverse());
+
+const EVENT_LABELS = { 0: 'DIRECT', 1: 'BLOCKED', 2: 'PROXY' };
+const EVENT_COLORS = { 0: 'green', 1: 'red', 2: 'blue' };
+
+function eventLabel(ev) { return EVENT_LABELS[ev] || String(ev ?? ''); }
+function eventColor(ev) { return EVENT_COLORS[ev] || 'default'; }
+
+function fullDate(value) {
+ return IntlUtil.formatDate(value, datepicker.value);
}
-
-// Renders a `
` with one row per log entry. Event 1 = blocked
-// (red); Event 2 = proxy (blue); Event 0 = direct.
-function formatLogs(lines) {
- let out = ''
- + '| Date | From | To | Inbound | Outbound | Email | '
- + '
';
-
- // Reverse a copy — the legacy code mutated state with `.reverse()`.
- [...lines].reverse().forEach((log) => {
- let rowStyle = '';
- if (log.Event === 1) rowStyle = ' style="color: #e04141;"';
- else if (log.Event === 2) rowStyle = ' style="color: #3c89e8;"';
-
- const emailCell = log.Email ? `${escapeHtml(log.Email)} | ` : ' | ';
-
- out += ``
- + `| ${escapeHtml(IntlUtil.formatDate(log.DateTime, datepicker.value))} | `
- + `${escapeHtml(log.FromAddress)} | `
- + `${escapeHtml(log.ToAddress)} | `
- + `${escapeHtml(log.Inbound)} | `
- + `${escapeHtml(log.Outbound)} | `
- + emailCell
- + '
';
- });
-
- return out + '
';
+function shortTime(value) {
+ if (!value) return '';
+ const d = new Date(value);
+ if (isNaN(d.getTime())) return '';
+ const hh = String(d.getHours()).padStart(2, '0');
+ const mm = String(d.getMinutes()).padStart(2, '0');
+ const ss = String(d.getSeconds()).padStart(2, '0');
+ return `${hh}:${mm}:${ss}`;
}
-const formattedLogs = computed(() => (logs.value.length > 0 ? formatLogs(logs.value) : 'No Record...'));
-
async function refresh() {
loading.value = true;
try {
@@ -85,12 +72,11 @@ function download() {
FileManager.downloadTextFile('', 'x-ui.log');
return;
}
- const eventMap = { 0: 'DIRECT', 1: 'BLOCKED', 2: 'PROXY' };
const lines = logs.value.map((l) => {
try {
const dt = l.DateTime ? new Date(l.DateTime) : null;
const dateStr = dt && !isNaN(dt.getTime()) ? dt.toISOString() : '';
- const eventText = eventMap[l.Event] || String(l.Event ?? '');
+ const eventText = eventLabel(l.Event);
const emailPart = l.Email ? ` Email=${l.Email}` : '';
return `${dateStr} FROM=${l.FromAddress || ''} TO=${l.ToAddress || ''} INBOUND=${l.Inbound || ''} OUTBOUND=${l.Outbound || ''}${emailPart} EVENT=${eventText}`.trim();
} catch (_e) {
@@ -102,16 +88,19 @@ function download() {
watch(() => props.open, (next) => { if (next) refresh(); });
watch([rows, showDirect, showBlocked, showProxy], () => { if (props.open) refresh(); });
+
+const modalWidth = computed(() => (isMobile.value ? '100vw' : '80vw'));
-
+
{{ t('pages.index.logs') }}
-
+
10
@@ -121,7 +110,7 @@ watch([rows, showDirect, showBlocked, showProxy], () => { if (props.open) refres
500
-
+
@@ -129,7 +118,7 @@ watch([rows, showDirect, showBlocked, showProxy], () => { if (props.open) refres
Blocked
Proxy
-
+
@@ -138,7 +127,55 @@ watch([rows, showDirect, showBlocked, showProxy], () => { if (props.open) refres
-
+
+
No Record...
+
+
+
+
+
{{ shortTime(log.DateTime) }}
+
{{ eventLabel(log.Event) }}
+
+
+ {{ log.FromAddress }}
+ →
+ {{ log.ToAddress }}
+
+
+
+ in
+ {{ log.Inbound }}
+
+
+ out
+ {{ log.Outbound }}
+
+
+ email
+ {{ log.Email }}
+
+
+
+
+
+
+
+
+ | Date | From | To | Inbound | Outbound | Email |
+
+
+
+
+ | {{ fullDate(log.DateTime) }} |
+ {{ log.FromAddress }} |
+ {{ log.ToAddress }} |
+ {{ log.Inbound }} |
+ {{ log.Outbound }} |
+ {{ log.Email }} |
+
+
+
+
@@ -149,7 +186,24 @@ watch([rows, showDirect, showBlocked, showProxy], () => { if (props.open) refres
margin-left: 10px;
}
+.log-toolbar {
+ flex-wrap: wrap;
+ row-gap: 8px;
+}
+.log-toolbar .filter-item {
+ flex: 1 1 160px;
+}
+.log-toolbar .download-item {
+ margin-left: auto;
+}
+
.log-container {
+ /* Per-theme palette — overridden in body.dark / [data-theme="ultra-dark"]
+ below so blocked/proxy rows keep ≥4.5:1 contrast on darker surfaces. */
+ --log-blocked: #e04141;
+ --log-proxy: #3c89e8;
+ --log-divider: rgba(128, 128, 128, 0.18);
+
margin-top: 12px;
padding: 10px 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
@@ -161,22 +215,117 @@ watch([rows, showDirect, showBlocked, showProxy], () => { if (props.open) refres
border-radius: 6px;
background: rgba(0, 0, 0, 0.04);
}
+.log-container-mobile {
+ padding: 8px;
+ font-size: 12px;
+ max-height: 70vh;
+}
+
+.log-empty {
+ text-align: center;
+ opacity: 0.5;
+ padding: 20px 0;
+}
+
+.log-card {
+ border-bottom: 1px solid var(--log-divider);
+ padding: 8px 0;
+}
+.log-card:last-child { border-bottom: 0; }
+
+.log-card-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ margin-bottom: 4px;
+}
+.log-time {
+ font-weight: 600;
+ font-size: 12px;
+ letter-spacing: 0.02em;
+}
+.log-event-tag {
+ margin: 0;
+ font-size: 10px;
+ line-height: 16px;
+ padding: 0 6px;
+}
+
+.log-route {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex-wrap: wrap;
+ font-size: 12px;
+ margin-bottom: 4px;
+}
+.log-addr {
+ word-break: break-all;
+}
+.log-arrow {
+ opacity: 0.5;
+}
+
+.log-meta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px 12px;
+ font-size: 11px;
+ opacity: 0.75;
+}
+.log-meta-pair {
+ display: inline-flex;
+ align-items: baseline;
+ gap: 4px;
+ word-break: break-all;
+}
+.log-meta-key {
+ font-size: 10px;
+ text-transform: uppercase;
+ opacity: 0.6;
+ letter-spacing: 0.04em;
+}
:global(body.dark) .log-container {
background: rgba(255, 255, 255, 0.03);
border-color: rgba(255, 255, 255, 0.1);
-}
-
+ color: rgba(255, 255, 255, 0.88);
+
+ --log-blocked: #ff7575;
+ --log-proxy: #6aa6ee;
+ --log-divider: rgba(255, 255, 255, 0.1);
+}
+
+:global([data-theme="ultra-dark"]) .log-container {
+ --log-blocked: #ff8a8a;
+ --log-proxy: #7fb6f1;
+ --log-divider: rgba(255, 255, 255, 0.12);
+}
+
+/* Mobile: pull the modal flush with the screen edges. */
+:global(.xraylog-modal-mobile) {
+ top: 0 !important;
+ padding-bottom: 0 !important;
+ max-width: 100vw !important;
+}
+:global(.xraylog-modal-mobile .ant-modal-content) {
+ border-radius: 0;
+ height: 100vh;
+}
+:global(.xraylog-modal-mobile .ant-modal-body) {
+ padding: 12px;
+}
-