feat(logs): mobile-friendly log modals with theme-aware colors

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.
This commit is contained in:
MHSanaei 2026-05-10 00:13:20 +02:00
parent 3505430e57
commit 113a29733e
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
2 changed files with 397 additions and 93 deletions

View file

@ -4,8 +4,10 @@ import { useI18n } from 'vue-i18n';
import { DownloadOutlined, SyncOutlined } from '@ant-design/icons-vue'; import { DownloadOutlined, SyncOutlined } from '@ant-design/icons-vue';
import { HttpUtil, FileManager, PromiseUtil } from '@/utils'; import { HttpUtil, FileManager, PromiseUtil } from '@/utils';
import { useMediaQuery } from '@/composables/useMediaQuery.js';
const { t } = useI18n(); const { t } = useI18n();
const { isMobile } = useMediaQuery();
const props = defineProps({ const props = defineProps({
open: { type: Boolean, default: false }, open: { type: Boolean, default: false },
@ -20,48 +22,41 @@ const loading = ref(false);
const logs = ref([]); const logs = ref([]);
const LEVELS = ['DEBUG', 'INFO', 'NOTICE', 'WARNING', 'ERROR']; 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) { // Parses "YYYY-MM-DD HH:MM:SS LEVEL - message". Lines without the
if (value == null) return ''; // 3-token header degrade gracefully: the unparsed head becomes the
return String(value) // level so it still gets color-coded.
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') function parseLogLine(line) {
.replace(/"/g, '&quot;').replace(/'/g, '&#39;'); const [head, ...rest] = (line || '').split(' - ');
} const message = rest.join(' - ');
const parts = head.split(' ');
function formatLogs(lines) { let date = '';
// Each line: "YYYY-MM-DD HH:MM:SS LEVEL - message" let time = '';
// Color the timestamp + level prefix and bold the originating service. let levelText;
let out = ''; if (parts.length >= 3) {
lines.forEach((log, idx) => { [date, time, levelText] = parts;
const [data, message] = log.split(' - ', 2);
const parts = data.split(' ');
if (idx > 0) out += '<br>';
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 += `<span style="color: ${LEVEL_COLORS[0]};">${d} ${t}</span> `;
out += `<span style="color: ${LEVEL_COLORS[levelIndex]}">${escapeHtml(levelRaw)}</span>`;
} else { } else {
const li = LEVELS.indexOf(data); levelText = head;
const levelIndex = li >= 0 ? li : 5;
out += `<span style="color: ${LEVEL_COLORS[levelIndex]}">${escapeHtml(data)}</span>`;
} }
if (message) { const li = LEVELS.indexOf(levelText);
const prefix = message.startsWith('XRAY:') ? '<b>XRAY: </b>' : '<b>X-UI: </b>'; const levelClass = li >= 0 ? LEVEL_CLASSES[li] : 'level-unknown';
const tail = message.startsWith('XRAY:') ? message.substring(5) : message;
out += ' - ' + prefix + escapeHtml(tail); let service = '';
let body = message || '';
if (body.startsWith('XRAY:')) {
service = 'XRAY:';
body = body.slice('XRAY:'.length).trimStart();
} else if (body) {
service = 'X-UI:';
} }
});
return out; return { date, time, levelText, levelClass, service, body };
} }
const formattedLogs = computed(() => (logs.value.length > 0 ? formatLogs(logs.value) : 'No Record...')); const parsedLogs = computed(() => logs.value.map(parseLogLine));
async function refresh() { async function refresh() {
loading.value = true; loading.value = true;
@ -73,8 +68,6 @@ async function refresh() {
if (msg?.success) { if (msg?.success) {
logs.value = msg.obj || []; logs.value = msg.obj || [];
} }
// Keep the spinner visible long enough that rapid filter changes
// feel intentional rather than flickery.
await PromiseUtil.sleep(300); await PromiseUtil.sleep(300);
} finally { } finally {
loading.value = false; loading.value = false;
@ -89,19 +82,21 @@ function download() {
FileManager.downloadTextFile(logs.value.join('\n'), 'x-ui.log'); 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(() => props.open, (next) => { if (next) refresh(); });
watch([rows, level, syslog], () => { if (props.open) refresh(); }); watch([rows, level, syslog], () => { if (props.open) refresh(); });
const modalWidth = computed(() => (isMobile.value ? '100vw' : '800px'));
</script> </script>
<template> <template>
<a-modal :open="open" :closable="true" :footer="null" width="800px" @cancel="close"> <a-modal :open="open" :closable="true" :footer="null" :width="modalWidth"
:class="{ 'logmodal-mobile': isMobile }" @cancel="close">
<template #title> <template #title>
{{ t('pages.index.logs') }} {{ t('pages.index.logs') }}
<SyncOutlined :spin="loading" class="reload-icon" @click="refresh" /> <SyncOutlined :spin="loading" class="reload-icon" @click="refresh" />
</template> </template>
<a-form layout="inline"> <a-form layout="inline" class="log-toolbar">
<a-form-item> <a-form-item>
<a-input-group compact> <a-input-group compact>
<a-select v-model:value="rows" size="small" :style="{ width: '70px' }"> <a-select v-model:value="rows" size="small" :style="{ width: '70px' }">
@ -123,7 +118,7 @@ watch([rows, level, syslog], () => { if (props.open) refresh(); });
<a-form-item> <a-form-item>
<a-checkbox v-model:checked="syslog">SysLog</a-checkbox> <a-checkbox v-model:checked="syslog">SysLog</a-checkbox>
</a-form-item> </a-form-item>
<a-form-item style="margin-left: auto"> <a-form-item class="download-item">
<a-button type="primary" @click="download"> <a-button type="primary" @click="download">
<template #icon> <template #icon>
<DownloadOutlined /> <DownloadOutlined />
@ -132,7 +127,43 @@ watch([rows, level, syslog], () => { if (props.open) refresh(); });
</a-form-item> </a-form-item>
</a-form> </a-form>
<div class="log-container" v-html="formattedLogs" /> <div class="log-container" :class="{ 'log-container-mobile': isMobile }">
<div v-if="parsedLogs.length === 0" class="log-empty">No Record...</div>
<template v-else-if="isMobile">
<div v-for="(log, idx) in parsedLogs" :key="idx" class="log-card">
<div class="log-card-head">
<span v-if="log.date || log.time" class="log-time">
<span v-if="log.time">{{ log.time }}</span>
<span v-if="log.date" class="log-date">{{ log.date }}</span>
</span>
<span v-if="log.levelText" class="log-level-badge" :class="log.levelClass">
{{ log.levelText }}
</span>
</div>
<div v-if="log.body || log.service" class="log-body">
<b v-if="log.service">{{ log.service }}</b>
<span v-if="log.body" class="log-body-text">{{ log.body }}</span>
</div>
</div>
</template>
<template v-else>
<div v-for="(log, idx) in parsedLogs" :key="idx" class="log-line">
<span v-if="log.date || log.time" class="log-stamp">
{{ log.date }}<template v-if="log.date && log.time"> </template>{{ log.time }}
</span>
<span v-if="log.levelText" class="log-level" :class="log.levelClass">
{{ log.levelText }}
</span>
<template v-if="log.body || log.service">
<span> - </span>
<b v-if="log.service">{{ log.service }} </b>
<span>{{ log.body }}</span>
</template>
</div>
</template>
</div>
</a-modal> </a-modal>
</template> </template>
@ -143,7 +174,26 @@ watch([rows, level, syslog], () => { if (props.open) refresh(); });
margin-left: 10px; margin-left: 10px;
} }
.log-toolbar {
flex-wrap: wrap;
row-gap: 8px;
}
.log-toolbar .download-item {
margin-left: auto;
}
.log-container { .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; margin-top: 12px;
padding: 10px 12px; padding: 10px 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; 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); 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 { :global(body.dark) .log-container {
background: rgba(255, 255, 255, 0.03); background: rgba(255, 255, 255, 0.03);
border-color: rgba(255, 255, 255, 0.1); 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;
} }
</style> </style>

View file

@ -5,9 +5,11 @@ import { DownloadOutlined, SyncOutlined } from '@ant-design/icons-vue';
import { HttpUtil, FileManager, IntlUtil, PromiseUtil } from '@/utils'; import { HttpUtil, FileManager, IntlUtil, PromiseUtil } from '@/utils';
import { useDatepicker } from '@/composables/useDatepicker.js'; import { useDatepicker } from '@/composables/useDatepicker.js';
import { useMediaQuery } from '@/composables/useMediaQuery.js';
const { t } = useI18n(); const { t } = useI18n();
const { datepicker } = useDatepicker(); const { datepicker } = useDatepicker();
const { isMobile } = useMediaQuery();
const props = defineProps({ const props = defineProps({
open: { type: Boolean, default: false }, open: { type: Boolean, default: false },
@ -23,43 +25,28 @@ const showProxy = ref(true);
const loading = ref(false); const loading = ref(false);
const logs = ref([]); const logs = ref([]);
function escapeHtml(value) { // Newest first.
if (value == null) return ''; const orderedLogs = computed(() => [...logs.value].reverse());
return String(value)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') const EVENT_LABELS = { 0: 'DIRECT', 1: 'BLOCKED', 2: 'PROXY' };
.replace(/"/g, '&quot;').replace(/'/g, '&#39;'); 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);
} }
function shortTime(value) {
// Renders a `<table>` with one row per log entry. Event 1 = blocked if (!value) return '';
// (red); Event 2 = proxy (blue); Event 0 = direct. const d = new Date(value);
function formatLogs(lines) { if (isNaN(d.getTime())) return '';
let out = '<table class="xraylog-table"><tr>' const hh = String(d.getHours()).padStart(2, '0');
+ '<th>Date</th><th>From</th><th>To</th><th>Inbound</th><th>Outbound</th><th>Email</th>' const mm = String(d.getMinutes()).padStart(2, '0');
+ '</tr>'; const ss = String(d.getSeconds()).padStart(2, '0');
return `${hh}:${mm}:${ss}`;
// 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 ? `<td>${escapeHtml(log.Email)}</td>` : '<td></td>';
out += `<tr${rowStyle}>`
+ `<td><b>${escapeHtml(IntlUtil.formatDate(log.DateTime, datepicker.value))}</b></td>`
+ `<td>${escapeHtml(log.FromAddress)}</td>`
+ `<td>${escapeHtml(log.ToAddress)}</td>`
+ `<td>${escapeHtml(log.Inbound)}</td>`
+ `<td>${escapeHtml(log.Outbound)}</td>`
+ emailCell
+ '</tr>';
});
return out + '</table>';
} }
const formattedLogs = computed(() => (logs.value.length > 0 ? formatLogs(logs.value) : 'No Record...'));
async function refresh() { async function refresh() {
loading.value = true; loading.value = true;
try { try {
@ -85,12 +72,11 @@ function download() {
FileManager.downloadTextFile('', 'x-ui.log'); FileManager.downloadTextFile('', 'x-ui.log');
return; return;
} }
const eventMap = { 0: 'DIRECT', 1: 'BLOCKED', 2: 'PROXY' };
const lines = logs.value.map((l) => { const lines = logs.value.map((l) => {
try { try {
const dt = l.DateTime ? new Date(l.DateTime) : null; const dt = l.DateTime ? new Date(l.DateTime) : null;
const dateStr = dt && !isNaN(dt.getTime()) ? dt.toISOString() : ''; 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}` : ''; 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(); return `${dateStr} FROM=${l.FromAddress || ''} TO=${l.ToAddress || ''} INBOUND=${l.Inbound || ''} OUTBOUND=${l.Outbound || ''}${emailPart} EVENT=${eventText}`.trim();
} catch (_e) { } catch (_e) {
@ -102,16 +88,19 @@ function download() {
watch(() => props.open, (next) => { if (next) refresh(); }); watch(() => props.open, (next) => { if (next) refresh(); });
watch([rows, showDirect, showBlocked, showProxy], () => { if (props.open) refresh(); }); watch([rows, showDirect, showBlocked, showProxy], () => { if (props.open) refresh(); });
const modalWidth = computed(() => (isMobile.value ? '100vw' : '80vw'));
</script> </script>
<template> <template>
<a-modal :open="open" :closable="true" :footer="null" width="80vw" @cancel="close"> <a-modal :open="open" :closable="true" :footer="null" :width="modalWidth"
:class="{ 'xraylog-modal-mobile': isMobile }" @cancel="close">
<template #title> <template #title>
{{ t('pages.index.logs') }} {{ t('pages.index.logs') }}
<SyncOutlined :spin="loading" class="reload-icon" @click="refresh" /> <SyncOutlined :spin="loading" class="reload-icon" @click="refresh" />
</template> </template>
<a-form layout="inline"> <a-form layout="inline" class="log-toolbar">
<a-form-item> <a-form-item>
<a-select v-model:value="rows" size="small" :style="{ width: '70px' }"> <a-select v-model:value="rows" size="small" :style="{ width: '70px' }">
<a-select-option value="10">10</a-select-option> <a-select-option value="10">10</a-select-option>
@ -121,7 +110,7 @@ watch([rows, showDirect, showBlocked, showProxy], () => { if (props.open) refres
<a-select-option value="500">500</a-select-option> <a-select-option value="500">500</a-select-option>
</a-select> </a-select>
</a-form-item> </a-form-item>
<a-form-item :label="t('filter')"> <a-form-item :label="t('filter')" class="filter-item">
<a-input v-model:value="filter" size="small" @keyup.enter="refresh" /> <a-input v-model:value="filter" size="small" @keyup.enter="refresh" />
</a-form-item> </a-form-item>
<a-form-item> <a-form-item>
@ -129,7 +118,7 @@ watch([rows, showDirect, showBlocked, showProxy], () => { if (props.open) refres
<a-checkbox v-model:checked="showBlocked">Blocked</a-checkbox> <a-checkbox v-model:checked="showBlocked">Blocked</a-checkbox>
<a-checkbox v-model:checked="showProxy">Proxy</a-checkbox> <a-checkbox v-model:checked="showProxy">Proxy</a-checkbox>
</a-form-item> </a-form-item>
<a-form-item style="margin-left: auto"> <a-form-item class="download-item">
<a-button type="primary" @click="download"> <a-button type="primary" @click="download">
<template #icon> <template #icon>
<DownloadOutlined /> <DownloadOutlined />
@ -138,7 +127,55 @@ watch([rows, showDirect, showBlocked, showProxy], () => { if (props.open) refres
</a-form-item> </a-form-item>
</a-form> </a-form>
<div class="log-container" v-html="formattedLogs" /> <div class="log-container" :class="{ 'log-container-mobile': isMobile }">
<div v-if="orderedLogs.length === 0" class="log-empty">No Record...</div>
<template v-else-if="isMobile">
<div v-for="(log, idx) in orderedLogs" :key="idx" class="log-card">
<div class="log-card-head">
<span class="log-time" :title="fullDate(log.DateTime)">{{ shortTime(log.DateTime) }}</span>
<a-tag :color="eventColor(log.Event)" class="log-event-tag">{{ eventLabel(log.Event) }}</a-tag>
</div>
<div class="log-route">
<span class="log-addr">{{ log.FromAddress }}</span>
<span class="log-arrow"></span>
<span class="log-addr">{{ log.ToAddress }}</span>
</div>
<div class="log-meta">
<span v-if="log.Inbound" class="log-meta-pair">
<span class="log-meta-key">in</span>
<span class="log-meta-val">{{ log.Inbound }}</span>
</span>
<span v-if="log.Outbound" class="log-meta-pair">
<span class="log-meta-key">out</span>
<span class="log-meta-val">{{ log.Outbound }}</span>
</span>
<span v-if="log.Email" class="log-meta-pair">
<span class="log-meta-key">email</span>
<span class="log-meta-val">{{ log.Email }}</span>
</span>
</div>
</div>
</template>
<table v-else class="xraylog-table">
<thead>
<tr>
<th>Date</th><th>From</th><th>To</th><th>Inbound</th><th>Outbound</th><th>Email</th>
</tr>
</thead>
<tbody>
<tr v-for="(log, idx) in orderedLogs" :key="idx" :class="`log-row-${log.Event}`">
<td><b>{{ fullDate(log.DateTime) }}</b></td>
<td>{{ log.FromAddress }}</td>
<td>{{ log.ToAddress }}</td>
<td>{{ log.Inbound }}</td>
<td>{{ log.Outbound }}</td>
<td>{{ log.Email }}</td>
</tr>
</tbody>
</table>
</div>
</a-modal> </a-modal>
</template> </template>
@ -149,7 +186,24 @@ watch([rows, showDirect, showBlocked, showProxy], () => { if (props.open) refres
margin-left: 10px; 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 { .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; margin-top: 12px;
padding: 10px 12px; padding: 10px 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; 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; border-radius: 6px;
background: rgba(0, 0, 0, 0.04); 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 { :global(body.dark) .log-container {
background: rgba(255, 255, 255, 0.03); background: rgba(255, 255, 255, 0.03);
border-color: rgba(255, 255, 255, 0.1); border-color: rgba(255, 255, 255, 0.1);
} color: rgba(255, 255, 255, 0.88);
</style>
--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;
}
<style>
/* Global so the v-html'd table picks up these styles. */
.xraylog-table { .xraylog-table {
border-collapse: collapse; border-collapse: collapse;
width: auto; width: 100%;
} }
.xraylog-table td, .xraylog-table td,
.xraylog-table th { .xraylog-table th {
padding: 2px 15px; padding: 2px 15px;
text-align: left;
} }
.xraylog-table .log-row-1 { color: var(--log-blocked); }
.xraylog-table .log-row-2 { color: var(--log-proxy); }
</style> </style>