Browse Source

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.
MHSanaei 21 giờ trước cách đây
mục cha
commit
113a29733e
2 tập tin đã thay đổi với 394 bổ sung90 xóa
  1. 201 46
      frontend/src/pages/index/LogModal.vue
  2. 193 44
      frontend/src/pages/index/XrayLogModal.vue

+ 201 - 46
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'];
-
-function escapeHtml(value) {
-  if (value == null) return '';
-  return String(value)
-    .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
-    .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
-}
-
-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 += '<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 {
-      const li = LEVELS.indexOf(data);
-      const levelIndex = li >= 0 ? li : 5;
-      out += `<span style="color: ${LEVEL_COLORS[levelIndex]}">${escapeHtml(data)}</span>`;
-    }
+const LEVEL_CLASSES = ['level-debug', 'level-info', 'level-notice', 'level-warning', 'level-error'];
 
-    if (message) {
-      const prefix = message.startsWith('XRAY:') ? '<b>XRAY: </b>' : '<b>X-UI: </b>';
-      const tail = message.startsWith('XRAY:') ? message.substring(5) : message;
-      out += ' - ' + prefix + escapeHtml(tail);
-    }
-  });
-  return out;
+// 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 };
 }
 
-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'));
 </script>
 
 <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>
       {{ t('pages.index.logs') }}
       <SyncOutlined :spin="loading" class="reload-icon" @click="refresh" />
     </template>
 
-    <a-form layout="inline">
+    <a-form layout="inline" class="log-toolbar">
       <a-form-item>
         <a-input-group compact>
           <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-checkbox v-model:checked="syslog">SysLog</a-checkbox>
       </a-form-item>
-      <a-form-item style="margin-left: auto">
+      <a-form-item class="download-item">
         <a-button type="primary" @click="download">
           <template #icon>
             <DownloadOutlined />
@@ -132,7 +127,43 @@ watch([rows, level, syslog], () => { if (props.open) refresh(); });
       </a-form-item>
     </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>
 </template>
 
@@ -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;
 }
 </style>

+ 193 - 44
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,42 +25,27 @@ const showProxy = ref(true);
 const loading = ref(false);
 const logs = ref([]);
 
-function escapeHtml(value) {
-  if (value == null) return '';
-  return String(value)
-    .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
-    .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
-}
-
-// Renders a `<table>` with one row per log entry. Event 1 = blocked
-// (red); Event 2 = proxy (blue); Event 0 = direct.
-function formatLogs(lines) {
-  let out = '<table class="xraylog-table"><tr>'
-    + '<th>Date</th><th>From</th><th>To</th><th>Inbound</th><th>Outbound</th><th>Email</th>'
-    + '</tr>';
-
-  // 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;"';
+// Newest first.
+const orderedLogs = computed(() => [...logs.value].reverse());
 
-    const emailCell = log.Email ? `<td>${escapeHtml(log.Email)}</td>` : '<td></td>';
+const EVENT_LABELS = { 0: 'DIRECT', 1: 'BLOCKED', 2: 'PROXY' };
+const EVENT_COLORS = { 0: 'green', 1: 'red', 2: 'blue' };
 
-    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>';
-  });
+function eventLabel(ev) { return EVENT_LABELS[ev] || String(ev ?? ''); }
+function eventColor(ev) { return EVENT_COLORS[ev] || 'default'; }
 
-  return out + '</table>';
+function fullDate(value) {
+  return IntlUtil.formatDate(value, datepicker.value);
+}
+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;
@@ -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'));
 </script>
 
 <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>
       {{ t('pages.index.logs') }}
       <SyncOutlined :spin="loading" class="reload-icon" @click="refresh" />
     </template>
 
-    <a-form layout="inline">
+    <a-form layout="inline" class="log-toolbar">
       <a-form-item>
         <a-select v-model:value="rows" size="small" :style="{ width: '70px' }">
           <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>
       </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-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="showProxy">Proxy</a-checkbox>
       </a-form-item>
-      <a-form-item style="margin-left: auto">
+      <a-form-item class="download-item">
         <a-button type="primary" @click="download">
           <template #icon>
             <DownloadOutlined />
@@ -138,7 +127,55 @@ watch([rows, showDirect, showBlocked, showProxy], () => { if (props.open) refres
       </a-form-item>
     </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>
 </template>
 
@@ -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;
 }
-</style>
 
-<style>
-/* Global so the v-html'd table picks up these styles. */
 .xraylog-table {
   border-collapse: collapse;
-  width: auto;
+  width: 100%;
 }
-
 .xraylog-table td,
 .xraylog-table th {
   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>