Forráskód Böngészése

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 19 órája
szülő
commit
113a29733e
2 módosított fájl, 394 hozzáadás és 90 törlés
  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>