// SPDX-License-Identifier: Apache-2.0 'use strict'; 'require fs'; 'require poll'; 'require ui'; 'require view'; 'require view.daede.backend as backend'; 'require view.daede.styles as styles'; const MAX_LINES = 5000; function execChecked(command, args) { return fs.exec(command, args || []).then(function(res) { if (res && res.code !== 0) throw new Error((res.stderr || res.stdout || ('exit ' + res.code)).trim()); return res; }); } const CSS = [ '.dd-log-wrap{padding:4px 0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",sans-serif}', '.dd-log-card{border:1px solid rgba(0,0,0,.06);border-radius:10px;padding:10px 14px;margin-bottom:10px;box-shadow:0 2px 8px rgba(0,0,0,.03);background:rgba(255,255,255,.02)}', '.dd-log-card-title{font-size:11px;font-weight:600;opacity:.55;margin:0 0 8px;letter-spacing:.3px;text-transform:uppercase}', '.dd-log-toolbar{display:flex;flex-wrap:wrap;align-items:center;gap:8px;padding:0 0 8px;margin-bottom:8px;border-bottom:1px dashed rgba(128,128,128,.2)}', '.dd-log-toolbar label{display:inline-flex;align-items:center;gap:5px;font-size:11.5px;cursor:pointer;margin:0;opacity:.85}', /* Argon shifts label>checkbox down by top:.4rem; reset so flex centers it with the text */ '.dd-log-toolbar input[type="checkbox"]{position:static;top:auto;right:auto;margin:0}', '.dd-log-toolbar input[type="text"]{font-size:11.5px;padding:4px 8px;border-radius:5px;border:1px solid rgba(128,128,128,.28);background:transparent;color:inherit;min-width:160px}', '.dd-log-toolbar .dd-log-btn{font-size:11.5px;line-height:1.4;min-height:0;height:auto;padding:4px 12px;border-radius:5px;border:1px solid rgba(128,128,128,.28);background:transparent;color:inherit;cursor:pointer}', '.dd-log-toolbar .dd-log-btn:hover{background:rgba(128,128,128,.1)}', '.dd-log-toolbar .dd-log-stop{color:#d9534f;border-color:rgba(217,83,79,.45)}', '.dd-log-toolbar .dd-log-meta{margin-left:auto;font-size:10.5px;opacity:.55;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace}', '.dd-log-pane{height:60vh;min-height:360px;overflow:auto;padding:8px 10px;border:1px solid rgba(0,0,0,.08);border-radius:7px;background:#1a1d21;color:#d8dde6;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono",monospace;font-size:11.5px;line-height:1.55;white-space:pre-wrap;word-break:break-all}', '.dd-log-pane .dd-line{padding:1px 4px;border-radius:3px;display:block}', '.dd-log-pane .dd-line.dd-info{color:#cfd6df}', '.dd-log-pane .dd-line.dd-warn{color:#e8b95a}', '.dd-log-pane .dd-line.dd-error{color:#ea7878;background:rgba(234,120,120,.06)}', '.dd-log-pane .dd-line.dd-debug{color:#7a8290;opacity:.7}', '.dd-log-pane .dd-line.dd-hidden{display:none}', '.dd-log-pane .dd-empty{opacity:.5;font-style:italic}', /* 简化后的字段视觉 */ '.dd-log-pane .dd-ts{color:#6b7480;margin-right:8px;white-space:nowrap}', '.dd-log-pane .dd-lvl{display:inline-block;min-width:38px;padding:0 5px;margin-right:8px;border-radius:3px;font-size:10px;font-weight:700;letter-spacing:.4px;text-align:center;vertical-align:1px}', '.dd-log-pane .dd-lvl-info{color:#7fc7a8;background:rgba(127,199,168,.08)}', '.dd-log-pane .dd-lvl-warn{color:#e8b95a;background:rgba(232,185,90,.10)}', '.dd-log-pane .dd-lvl-error{color:#ea7878;background:rgba(234,120,120,.10)}', '.dd-log-pane .dd-lvl-debug{color:#7a8290;background:rgba(122,130,144,.10)}', '.dd-log-pane .dd-msg{color:inherit}', '.dd-log-pane .dd-kv{margin-left:6px;color:#6b7480;font-size:11px;opacity:.85}', 'body.dark .dd-log-card,html[data-theme="dark"] .dd-log-card,html[data-bs-theme="dark"] .dd-log-card{border-color:rgba(255,255,255,.08);background:rgba(255,255,255,.02)}', 'body.dark .dd-log-pane,html[data-theme="dark"] .dd-log-pane,html[data-bs-theme="dark"] .dd-log-pane{border-color:rgba(255,255,255,.1)}' ].join(''); /* 拆字段:time="May 25 07:04:59" level=info msg="..." key=val key="val with space" ... */ const RE_LINE = /^time="([^"]*)"\s+level=(\w+)\s+msg=(?:"((?:[^"\\]|\\.)*)"|(\S+))\s*(.*)$/; const RE_PREFIXED_LINE = /^\[([^\]]+)\]\s+(DEBUG|INFO|WARN(?:ING)?|ERROR|FATAL|PANIC)\s*(.*)$/i; const RE_PLAIN_LEVEL_LINE = /^\s*(DEBUG|INFO|WARN(?:ING)?|ERROR|FATAL|PANIC)\s+(.*)$/i; function detectLevel(line) { // daed/dae logs use lvl=info / [INFO] / level=warning style const m = line.match(/\b(DEBUG|INFO|WARN(?:ING)?|ERROR|FATAL|PANIC)\b/i); if (!m) return ''; const lvl = m[1].toUpperCase(); if (lvl === 'DEBUG') return 'dd-debug'; if (lvl === 'INFO') return 'dd-info'; if (lvl.startsWith('WARN')) return 'dd-warn'; return 'dd-error'; } /* 格式化时间戳并按北京时间显示: * - daed 默认输出 UTC ISO 8601 (e.g. "2026-05-28T19:07:54Z"),转成北京时间 * - 新格式和旧 logrus 短格式无时区信息,保留原始完整日期时间 */ function formatTs(raw) { if (!raw) return raw; if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})$/.test(raw)) { const d = new Date(raw); if (!isNaN(d.getTime())) { const beijing = new Date(d.getTime() + 8 * 60 * 60 * 1000); const pad = n => String(n).padStart(2, '0'); return [ beijing.getUTCFullYear(), pad(beijing.getUTCMonth() + 1), pad(beijing.getUTCDate()) ].join('-') + ' ' + [ pad(beijing.getUTCHours()), pad(beijing.getUTCMinutes()), pad(beijing.getUTCSeconds()) ].join(':'); } } return raw; } function lvlShort(level) { const u = (level || '').toUpperCase(); if (u === 'WARNING') return 'WARN'; return u || '-'; } function lvlClass(level) { const u = (level || '').toUpperCase(); if (u === 'DEBUG') return 'dd-lvl-debug'; if (u === 'INFO') return 'dd-lvl-info'; if (u === 'WARN' || u === 'WARNING') return 'dd-lvl-warn'; return 'dd-lvl-error'; } function parseLine(line) { let m = line.match(RE_LINE); if (m) { return { ts: formatTs(m[1]), lvl: m[2], msg: m[3] !== undefined ? m[3] : (m[4] || ''), kv: (m[5] || '').trim() }; } m = line.match(RE_PREFIXED_LINE); let ts = '', lvl = '', body = ''; if (m) { ts = formatTs(m[1]); lvl = m[2]; body = m[3] || ''; } else { m = line.match(RE_PLAIN_LEVEL_LINE); if (!m) return null; lvl = m[1]; body = m[2] || ''; } const kvStart = body.search(/(?:^|\s)(?=[A-Za-z_][\w.-]*=)/); return { ts: ts, lvl: lvl, msg: kvStart === -1 ? body : body.slice(0, kvStart).trimEnd(), kv: kvStart === -1 ? '' : body.slice(kvStart).trim() }; } function buildLine(ln) { const cls = detectLevel(ln); const parsed = parseLine(ln); if (!parsed) { return E('div', { 'class': 'dd-line ' + cls }, ln); } const parts = []; if (parsed.ts) parts.push(E('span', { 'class': 'dd-ts' }, parsed.ts)); parts.push(E('span', { 'class': 'dd-lvl ' + lvlClass(parsed.lvl) }, lvlShort(parsed.lvl))); parts.push(E('span', { 'class': 'dd-msg' }, parsed.msg)); if (parsed.kv) parts.push(E('span', { 'class': 'dd-kv' }, parsed.kv)); return E('div', { 'class': 'dd-line ' + cls }, parts); } return view.extend({ load: function() { return backend.detectBackend(); }, render(ctx) { const self = this; const LOG_PATH = ctx.backend.log; const state = { lastContent: '', lastSize: -1, paused: false, autoScroll: true, filter: '', userScrolled: false, reading: false }; const pane = E('div', { 'class': 'dd-log-pane', 'id': 'dd-log-pane' }, [ E('div', { 'class': 'dd-empty' }, _('Loading…')) ]); // detect manual scroll → auto-pause auto-scroll pane.addEventListener('scroll', function() { const atBottom = pane.scrollHeight - pane.scrollTop - pane.clientHeight < 4; state.userScrolled = !atBottom; }); const meta = E('span', { 'class': 'dd-log-meta' }, ''); const btnAuto = E('button', { 'type': 'button', 'class': 'dd-log-btn dd-log-toggle', 'id': 'dd-log-auto' }); const btnPause = E('button', { 'type': 'button', 'class': 'dd-log-btn dd-log-toggle', 'id': 'dd-log-pause' }); const syncControls = function() { btnAuto.textContent = state.autoScroll ? '✓ ' + _('Auto-scroll: On') : '○ ' + _('Auto-scroll: Off'); btnAuto.setAttribute('aria-pressed', String(state.autoScroll)); btnPause.textContent = state.paused ? '▶ ' + _('Resume') : 'Ⅱ ' + _('Pause'); btnPause.setAttribute('aria-pressed', String(state.paused)); meta.textContent = state.paused ? _('Paused') : ''; }; btnAuto.addEventListener('click', function() { state.autoScroll = !state.autoScroll; if (state.autoScroll) { pane.scrollTop = pane.scrollHeight; state.userScrolled = false; } syncControls(); }); btnPause.addEventListener('click', function() { state.paused = !state.paused; syncControls(); if (!state.paused) tick(); }); syncControls(); const selFilter = E('select', { 'class': 'dd-log-btn' }, [ E('option', { 'value': '' }, _('All')), E('option', { 'value': 'info' }, 'INFO'), E('option', { 'value': 'warn' }, 'WARN'), E('option', { 'value': 'error' }, 'ERROR'), E('option', { 'value': 'alive' }, _('Node Status')), E('option', { 'value': 'my_group' }, _('Proxy Traffic')) ]); selFilter.addEventListener('change', function() { state.filter = selFilter.value; applyFilter(); }); const btnClear = E('button', { 'class': 'dd-log-btn' }, _('Clear View')); btnClear.addEventListener('click', function() { while (pane.firstChild) pane.removeChild(pane.firstChild); state.lastContent = ''; }); const btnDownload = E('button', { 'class': 'dd-log-btn' }, _('Download')); btnDownload.addEventListener('click', function() { const blob = new Blob([state.lastContent || ''], { type: 'text/plain' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = ctx.name + '-' + (new Date()).toISOString().replace(/[:.]/g, '-') + '.log'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); }); const btnTruncate = E('button', { 'class': 'dd-log-btn' }, _('Clear File')); btnTruncate.addEventListener('click', function() { if (!confirm(_('Truncate %s log on the router? This cannot be undone.').format(ctx.name))) return; fs.write(LOG_PATH, '').then(function() { state.lastContent = ''; state.lastSize = 0; }).catch(function() {}); }); let btnStop = null; if (ctx.name === 'daed') { btnStop = E('button', { 'class': 'dd-log-btn dd-log-stop' }, _('Stop daed')); btnStop.addEventListener('click', function() { if (!confirm(_('Stop daed now?'))) return; btnStop.disabled = true; execChecked('/sbin/uci', ['set', 'daed.config.enabled=0']) .then(function() { return execChecked('/sbin/uci', ['commit', 'daed']); }) .then(function() { return execChecked('/etc/init.d/daed', ['disable']); }) .then(function() { return execChecked('/etc/init.d/daed', ['stop']); }) .then(function() { btnStop.textContent = _('daed stopped'); }) .catch(function(e) { btnStop.disabled = false; ui.addNotification(null, E('p', {}, _('Failed to stop daed: %s').format(e.message || e)), 'error'); }); }); } function applyFilter() { const f = state.filter; pane.querySelectorAll('.dd-line').forEach(function(el) { if (!f || el.textContent.toLowerCase().indexOf(f) !== -1) el.classList.remove('dd-hidden'); else el.classList.add('dd-hidden'); }); } function appendLines(text) { const lines = text.split('\n'); const frag = document.createDocumentFragment(); for (let i = 0; i < lines.length; i++) { const ln = lines[i]; if (!ln) continue; const el = buildLine(ln); if (state.filter && ln.toLowerCase().indexOf(state.filter) === -1) el.classList.add('dd-hidden'); frag.appendChild(el); } // drop empty-state placeholder if present const empty = pane.querySelector('.dd-empty'); if (empty) pane.removeChild(empty); pane.appendChild(frag); // cap rendered rows const overflow = pane.children.length - MAX_LINES; if (overflow > 0) { for (let i = 0; i < overflow; i++) pane.removeChild(pane.firstChild); } } function renderEmpty(msg) { while (pane.firstChild) pane.removeChild(pane.firstChild); pane.appendChild(E('div', { 'class': 'dd-empty' }, msg)); } function tick() { if (state.paused || state.reading) return Promise.resolve(); state.reading = true; return fs.stat(LOG_PATH).then(function(st) { if (state.paused) return; const size = st.size || 0; if (size === state.lastSize) { meta.textContent = '%d bytes · live'.format(size); return; } // File rotated/truncated → full reload const rotated = size < state.lastSize; return fs.read_direct(LOG_PATH, 'text').then(function(content) { if (state.paused) return; content = content || ''; let delta; if (rotated || state.lastContent === '') { // full replace while (pane.firstChild) pane.removeChild(pane.firstChild); delta = content; } else if (content.indexOf(state.lastContent) === 0) { // pure append delta = content.slice(state.lastContent.length); } else { // content changed mid-stream → full replace while (pane.firstChild) pane.removeChild(pane.firstChild); delta = content; } if (delta) appendLines(delta); state.lastContent = content; state.lastSize = size; meta.textContent = '%d bytes · live'.format(size); if (state.autoScroll && !state.userScrolled) pane.scrollTop = pane.scrollHeight; }); }).catch(function(e) { if (state.paused) return; const msg = String(e); if (msg.indexOf('NotFoundError') !== -1 || msg.indexOf('No such') !== -1) renderEmpty(_('Log file does not exist yet.')); else renderEmpty(_('Error reading log: %s').format(msg)); state.lastSize = -1; state.lastContent = ''; meta.textContent = ''; }).finally(function() { state.reading = false; }); } poll.add(tick); tick(); const toolbarItems = [ btnAuto, btnPause, selFilter, btnClear, btnDownload, btnTruncate ]; if (btnStop) toolbarItems.push(btnStop); toolbarItems.push(meta); const toolbar = E('div', { 'class': 'dd-log-toolbar' }, toolbarItems); return E('div', { 'class': 'dd-log-wrap' }, [ E('style', {}, CSS + styles.CSS), E('div', { 'class': 'dd-log-card' }, [ E('h4', { 'class': 'dd-log-card-title' }, _('%s Realtime Log').format(ctx.name)), toolbar, pane ]) ]); }, handleSaveApply: null, handleSave: null, handleReset: null });