@@ -15,6 +15,7 @@ define Package/$(PKG_NAME)/conffiles
/etc/mosdns/cache.dump
/etc/mosdns/config_custom.yaml
/etc/mosdns/rule
+/etc/mosdns/stats.dump
endef
include $(TOPDIR)/feeds/luci/luci.mk
diff --git a/luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js b/luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js
index 430b8e895..443ed1705 100644
--- a/luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js
+++ b/luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js
@@ -163,7 +163,7 @@ return view.extend({
o.value('info', _('Info'));
o.value('warn', _('Warning'));
o.value('error', _('Error'));
- o.default = 'info';
+ o.default = 'error';
o.depends('configfile', '/var/etc/mosdns.json');
o = s.taboption('basic', form.Value, 'log_file', _('Log File'));
@@ -426,6 +426,29 @@ return view.extend({
o.default = 52001;
o.depends('configfile', '/var/etc/mosdns.json');
+ o = s.taboption('api', form.Flag, 'stats_collector', _('Enable Stats Collector'));
+ o.rmempty = false;
+ o.default = o.enabled;
+ o.depends('configfile', '/var/etc/mosdns.json');
+
+ o = s.taboption('api', form.Value, 'stats_capacity', _('Ring Buffer Capacity'),
+ _('Query log ring buffer capacity (FIFO overwrite, default 2000, larger values consume more memory)'));
+ o.datatype = 'and(uinteger,min(1))';
+ o.default = 2000;
+ o.depends('stats_collector', '1');
+
+ o = s.taboption('api', form.Flag, 'stats_dump_file', _('Stats Dump'),
+ _('Save query statistics and logs locally and reload on next startup.'));
+ o.rmempty = false;
+ o.default = false;
+ o.depends('stats_collector', '1');
+
+ o = s.taboption('api', form.Value, 'stats_dump_interval',
+ _('Auto Save Stats Interval'));
+ o.datatype = 'and(uinteger,min(1))';
+ o.default = 600;
+ o.depends('stats_dump_file', '1');
+
o = s.taboption('api', form.Button, '_flush_cache', null,
_('Flushing DNS Cache will clear any IP addresses or DNS records from MosDNS cache.'));
o.title = ' ';
diff --git a/luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js b/luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js
new file mode 100644
index 000000000..9b3980941
--- /dev/null
+++ b/luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js
@@ -0,0 +1,722 @@
+'use strict';
+'require dom';
+'require poll';
+'require rpc';
+'require ui';
+'require view';
+
+const callGetStats = rpc.declare({
+ object: 'luci.mosdns',
+ method: 'get_stats',
+ expect: { '': {} }
+});
+
+const callGetHistory = rpc.declare({
+ object: 'luci.mosdns',
+ method: 'get_history',
+ params: ['points'],
+ expect: { '': {} }
+});
+
+const callGetTop = rpc.declare({
+ object: 'luci.mosdns',
+ method: 'get_top',
+ params: ['limit'],
+ expect: { '': {} }
+});
+
+const callGetLogs = rpc.declare({
+ object: 'luci.mosdns',
+ method: 'get_logs',
+ params: ['limit', 'offset', 'search', 'filter'],
+ expect: { '': {} }
+});
+
+const callClearQueryLogs = rpc.declare({
+ object: 'luci.mosdns',
+ method: 'clear_query_logs',
+ expect: { '': {} }
+});
+
+let filterVal = 'all';
+let searchVal = '';
+let pageIdx = 0;
+const PAGE_SIZE = 20;
+let isUserPaused = false;
+
+let nodeStats;
+let nodeTop;
+let nodeLogs;
+let autoStatusBadge;
+
+const cleanIP = ip => {
+ if (!ip) return '-';
+ return ip.replace(/^::ffff:/i, '');
+};
+
+const injectStyles = () => {
+ if (document.getElementById('mosdns-statistics-styles'))
+ return;
+
+ /* HTML Styles provided by DeepSeek Chat */
+ const css = [
+ '.mosdns-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1rem; margin-bottom: 1.25rem; }',
+ '.mosdns-rankings-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }',
+ '.mosdns-stat-card { background: var(--cbi-section-bg, #fff); border: 1px solid rgba(0,0,0,0.08); border-radius: 8px; padding: 1rem 1.2rem; box-shadow: 0 2px 6px rgba(0,0,0,0.03); display: flex; flex-direction: column; justify-content: space-between; position: relative; overflow: hidden; }',
+ '.mosdns-stat-card .title-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem; font-size: 0.85rem; opacity: 0.75; font-weight: 500; }',
+ '.mosdns-stat-card .metric-val { font-size: 1.85rem; font-weight: 700; line-height: 1.1; letter-spacing: -0.02em; }',
+ '.mosdns-stat-card .subtext { font-size: 0.8rem; opacity: 0.6; margin-top: 0.4rem; }',
+ '.mosdns-sparkline-wrap { margin-top: 0.5rem; height: 44px; position: relative; overflow: visible; display: flex; align-items: flex-end; }',
+ '.mosdns-sparkline { width: 100%; height: 100%; display: block; overflow: visible; }',
+ '.mosdns-sparkline-tooltip { position: absolute; pointer-events: none; z-index: 20; padding: 0.25rem 0.5rem; border-radius: 5px; background: var(--cbi-section-bg, #fff); border: 1px solid rgba(0,0,0,0.12); box-shadow: 0 3px 10px rgba(0,0,0,0.12); line-height: 1.25; text-align: center; white-space: nowrap; transition: opacity 0.15s ease; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }',
+ '.mosdns-rank-panel { background: var(--cbi-section-bg, #fff); border: 1px solid rgba(0,0,0,0.08); border-radius: 8px; padding: 1rem 1.1rem; box-shadow: 0 2px 6px rgba(0,0,0,0.03); }',
+ '.mosdns-rank-panel h4 { margin: 0 0 0.85rem 0; font-size: 0.95rem; font-weight: 600; display: flex; align-items: center; justify-content: space-between; }',
+ '.mosdns-rank-item { position: relative; overflow: hidden; border-radius: 6px; padding: 0.35rem 0.65rem; display: flex; justify-content: space-between; align-items: center; background: rgba(125,125,125,0.03); border: 1px solid rgba(125,125,125,0.08); margin-bottom: 0.35rem; }',
+ '.mosdns-rank-bar { position: absolute; left: 0; top: 0; bottom: 0; opacity: 0.15; pointer-events: none; transition: width .3s ease; }',
+ '.mosdns-badge { display: inline-block; padding: 0.15em 0.55em; font-size: 0.75rem; font-weight: 600; border-radius: 4px; line-height: 1.25; text-align: center; white-space: nowrap; box-sizing: border-box; }',
+ '.mosdns-status-badge { min-width: 68px; }',
+ '.badge-danger { background: rgba(239, 68, 68, 0.12); color: #dc2626; border: 1px solid rgba(239, 68, 68, 0.25); }',
+ '.badge-teal { background: rgba(16, 185, 129, 0.12); color: #059669; border: 1px solid rgba(16, 185, 129, 0.25); }',
+ '.badge-primary { background: rgba(59, 130, 246, 0.12); color: #2563eb; border: 1px solid rgba(59, 130, 246, 0.25); }',
+ '.badge-neutral { background: rgba(107, 114, 128, 0.12); color: #4b5563; border: 1px solid rgba(107, 114, 128, 0.25); }',
+ '.badge-qtype { font-family: monospace; font-size: 0.72rem; padding: 0.1em 0.4em; background: rgba(125,125,125,0.1); border-radius: 3px; opacity: 0.8; margin-left: 0.4rem; }',
+ '.badge-pulse { animation: pulse 2s infinite; }',
+ '.dns-latency-fastest { color: #10b981; font-weight: 600; }',
+ '.dns-latency-fast { color: #059669; font-weight: 600; }',
+ '.dns-latency-normal { color: #3b82f6; font-weight: 600; }',
+ '.dns-latency-slow { color: #d97706; font-weight: 600; }',
+ '.dns-latency-slower { color: #ea580c; font-weight: 600; }',
+ '.dns-latency-timeout { color: #dc2626; font-weight: 600; }',
+ '.mosdns-table td { vertical-align: middle !important; padding: 0.45rem 0.6rem !important; }',
+ '.mosdns-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }',
+ '.mosdns-modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.85rem; padding-bottom: 0.75rem; border-bottom: 1px solid rgba(125,125,125,0.15); flex-wrap: wrap; gap: 0.5rem; }',
+ '.mosdns-modal-domain { font-size: 1.05rem; font-weight: 700; word-break: break-all; display: flex; align-items: center; flex-wrap: wrap; gap: 0.4rem; }',
+ '.mosdns-modal-meta-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 0.6rem; margin-bottom: 1rem; }',
+ '.mosdns-modal-meta-item { background: rgba(125,125,125,0.04); border: 1px solid rgba(125,125,125,0.08); border-radius: 6px; padding: 0.5rem 0.75rem; }',
+ '.mosdns-modal-meta-item .meta-label { font-size: 0.75rem; opacity: 0.6; margin-bottom: 0.2rem; font-weight: 600; }',
+ '.mosdns-modal-meta-item .meta-val { font-size: 0.85rem; font-weight: 600; }',
+ '.mosdns-modal-section-title { font-size: 0.9rem; font-weight: 700; margin: 0.85rem 0 0.45rem 0; display: flex; align-items: center; justify-content: space-between; }',
+ '.mosdns-answers-list { display: flex; flex-direction: column; gap: 0.35rem; max-height: 240px; overflow-y: auto; }',
+ '.mosdns-answer-row { display: flex; justify-content: space-between; align-items: center; background: rgba(125,125,125,0.04); border: 1px solid rgba(125,125,125,0.08); border-radius: 6px; padding: 0.4rem 0.65rem; gap: 0.5rem; font-size: 0.82rem; }',
+ '.mosdns-answer-data { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }',
+ '.mosdns-answer-ttl { font-size: 0.75rem; opacity: 0.65; white-space: nowrap; }',
+ '@keyframes pulse { 0% { opacity: 1; } 50% { opacity: 0.4; } 100% { opacity: 1; } }',
+ '@media (prefers-color-scheme: dark) {',
+ ' .mosdns-stat-card, .mosdns-rank-panel, .mosdns-modal-meta-item, .mosdns-answer-row { background: rgba(255,255,255,0.03); border-color: rgba(255,255,255,0.08); box-shadow: none; }',
+ ' .mosdns-sparkline-tooltip { background: #1e242b; border-color: rgba(255,255,255,0.15); box-shadow: 0 4px 12px rgba(0,0,0,0.5); }',
+ ' .badge-danger { background: rgba(239, 68, 68, 0.2); color: #f87171; border-color: rgba(239, 68, 68, 0.35); }',
+ ' .badge-teal { background: rgba(16, 185, 129, 0.2); color: #34d399; border-color: rgba(16, 185, 129, 0.35); }',
+ ' .badge-primary { background: rgba(59, 130, 246, 0.2); color: #60a5fa; border-color: rgba(59, 130, 246, 0.35); }',
+ ' .badge-neutral { background: rgba(156, 163, 175, 0.2); color: #9ca3af; border-color: rgba(156, 163, 175, 0.3); }',
+ ' .dns-latency-fastest { color: #34d399; }',
+ ' .dns-latency-normal { color: #60a5fa; }',
+ ' .dns-latency-timeout { color: #f87171; }',
+ '}'
+ ].join('\n');
+
+ document.head.appendChild(E('style', { id: 'mosdns-statistics-styles' }, css));
+};
+
+const debounce = (fn, delay = 300) => {
+ let timer;
+ return (...args) => {
+ clearTimeout(timer);
+ timer = setTimeout(() => fn(...args), delay);
+ };
+};
+
+const formatTimestamp = iso => {
+ if (!iso) return '-';
+ const d = new Date(iso);
+ return isNaN(d) ? iso : d.toTimeString().slice(0, 8);
+};
+
+const getLatencyClass = elapsedMs => {
+ const val = parseFloat(elapsedMs) || 0;
+ if (val < 5) return 'dns-latency-fastest';
+ if (val < 20) return 'dns-latency-fast';
+ if (val < 50) return 'dns-latency-normal';
+ if (val < 100) return 'dns-latency-slow';
+ if (val < 300) return 'dns-latency-slower';
+ return 'dns-latency-timeout';
+};
+
+const updateLiveStatusBadge = () => {
+ if (!autoStatusBadge) return;
+ if (pageIdx === 0 && !searchVal && !isUserPaused) {
+ dom.content(autoStatusBadge, [
+ E('span', { class: 'mosdns-badge badge-teal badge-pulse' }, _('● Live Auto-refresh'))
+ ]);
+ } else {
+ dom.content(autoStatusBadge, [
+ E('span', { class: 'mosdns-badge badge-neutral' }, _('❚❚ Paused (Page %d)').format(pageIdx + 1))
+ ]);
+ }
+};
+
+const createSparklineSVG = (dataItems, strokeColor, fillGradId, maxScale) => {
+ const width = 300;
+ const height = 44;
+ const padTop = 4;
+ const padBottom = 2;
+ const drawHeight = height - padTop - padBottom;
+
+ let items = (dataItems && dataItems.length > 0) ? dataItems : [];
+ if (!items.length) {
+ items = new Array(24).fill(0).map(() => ({ time: '', val: 0 }));
+ }
+ if (items.length < 2) {
+ items = [items[0] || { time: '', val: 0 }, items[0] || { time: '', val: 0 }];
+ }
+
+ const vals = items.map(i => i.val || 0);
+ const maxVal = maxScale || Math.max(...vals, 1);
+ const len = items.length;
+
+ const coords = items.map((item, idx) => {
+ const x = (idx / (len - 1)) * width;
+ const y = height - padBottom - ((item.val || 0) / maxVal) * drawHeight;
+ return { x, y, time: item.time, val: item.val || 0 };
+ });
+
+ let pathD = 'M ' + coords[0].x.toFixed(1) + ',' + coords[0].y.toFixed(1);
+ for (let i = 0; i < coords.length - 1; i++) {
+ const p0 = coords[i === 0 ? 0 : i - 1];
+ const p1 = coords[i];
+ const p2 = coords[i + 1];
+ const p3 = coords[i + 2 < coords.length ? i + 2 : i + 1];
+
+ const cp1x = p1.x + (p2.x - p0.x) / 6;
+ const cp1y = p1.y + (p2.y - p0.y) / 6;
+ const cp2x = p2.x - (p3.x - p1.x) / 6;
+ const cp2y = p2.y - (p3.y - p1.y) / 6;
+
+ pathD += ' C ' + cp1x.toFixed(1) + ',' + cp1y.toFixed(1) + ' ' + cp2x.toFixed(1) + ',' + cp2y.toFixed(1) + ' ' + p2.x.toFixed(1) + ',' + p2.y.toFixed(1);
+ }
+
+ const areaD = pathD + ' L ' + width + ',' + height + ' L 0,' + height + ' Z';
+
+ const svgContainer = E('div', { class: 'mosdns-sparkline-wrap' });
+ svgContainer.innerHTML =
+ '' +
+ '';
+
+ const hoverGroup = svgContainer.querySelector('.spark-hover-group');
+ const hoverLine = svgContainer.querySelector('.spark-hover-line');
+ const hoverDot = svgContainer.querySelector('.spark-hover-dot');
+ const hitbox = svgContainer.querySelector('.spark-hover-hitbox');
+ const tooltip = svgContainer.querySelector('.mosdns-sparkline-tooltip');
+
+ const onMove = e => {
+ const rect = svgContainer.getBoundingClientRect();
+ const mouseX = e.clientX - rect.left;
+ const ratio = Math.max(0, Math.min(1, mouseX / rect.width));
+ const idx = Math.round(ratio * (len - 1));
+ const coord = coords[idx];
+ if (!coord) return;
+
+ hoverLine.setAttribute('x1', coord.x);
+ hoverLine.setAttribute('x2', coord.x);
+ hoverDot.setAttribute('cx', coord.x);
+ hoverDot.setAttribute('cy', coord.y);
+ hoverGroup.style.display = 'block';
+
+ let timeStr = '-';
+ if (coord.time) {
+ const d = new Date(coord.time);
+ if (!isNaN(d)) {
+ const hh = String(d.getHours()).padStart(2, '0');
+ timeStr = hh + ':00';
+ } else {
+ timeStr = coord.time.slice(11, 16) || coord.time;
+ }
+ }
+
+ tooltip.innerHTML =
+ '' + coord.val.toLocaleString() + '
' +
+ '' + timeStr + '
';
+ tooltip.style.display = 'block';
+
+ const tooltipX = (coord.x / width) * rect.width;
+ if (ratio > 0.65) {
+ tooltip.style.left = 'auto';
+ tooltip.style.right = (rect.width - tooltipX + 8) + 'px';
+ } else {
+ tooltip.style.left = (tooltipX + 8) + 'px';
+ tooltip.style.right = 'auto';
+ }
+ tooltip.style.top = '-6px';
+ };
+
+ const onLeave = () => {
+ hoverGroup.style.display = 'none';
+ tooltip.style.display = 'none';
+ };
+
+ hitbox.addEventListener('mousemove', onMove);
+ hitbox.addEventListener('mouseleave', onLeave);
+
+ return svgContainer;
+};
+
+const renderOverviewStats = (stats, historyData) => {
+ if (!stats || stats.error) {
+ return E('div', { class: 'alert-message warning' },
+ _('MosDNS API is unreachable. Please ensure MosDNS is running and stats_api plugin is enabled.'));
+ }
+
+ const {
+ total_queries: total = 0,
+ blocked_queries: blocked = 0,
+ cached_queries: cached = 0,
+ blocked_percentage: blocked_pct = 0,
+ cached_percentage: cached_pct = 0,
+ avg_latency_ms: avg_ms = 0
+ } = stats;
+
+ const points = historyData?.points || [];
+ const totalItems = points.map(p => ({ time: p.time, val: Number(p.total) || 0 }));
+ const blockedItems = points.map(p => ({ time: p.time, val: Number(p.blocked) || 0 }));
+ const cachedItems = points.map(p => ({ time: p.time, val: Number(p.cached) || 0 }));
+
+ const baseMax = Math.max(...totalItems.map(i => i.val), 1);
+
+ return E('div', { class: 'mosdns-grid' }, [
+ E('div', { class: 'mosdns-stat-card' }, [
+ E('div', {}, [
+ E('div', { class: 'title-row' }, [
+ E('span', {}, _('DNS Queries Total')),
+ E('span', { class: 'mosdns-badge badge-teal badge-pulse' }, _('● Live'))
+ ]),
+ E('div', { class: 'metric-val' }, total.toLocaleString()),
+ E('div', { class: 'subtext' }, _('Avg Processing') + ': ' + avg_ms + ' ms')
+ ]),
+ createSparklineSVG(totalItems, '#3b82f6', 'spark-grad-total', baseMax)
+ ]),
+
+ E('div', { class: 'mosdns-stat-card' }, [
+ E('div', {}, [
+ E('div', { class: 'title-row' }, [
+ E('span', {}, _('Blocked by Filters')),
+ E('span', { class: 'mosdns-badge badge-danger' }, blocked_pct + '%')
+ ]),
+ E('div', { class: 'metric-val', style: 'color: #dc2626;' }, blocked.toLocaleString())
+ ]),
+ createSparklineSVG(blockedItems, '#dc2626', 'spark-grad-blocked', baseMax)
+ ]),
+
+ E('div', { class: 'mosdns-stat-card' }, [
+ E('div', {}, [
+ E('div', { class: 'title-row' }, [
+ E('span', {}, _('Cached Queries')),
+ E('span', { class: 'mosdns-badge badge-teal' }, cached_pct + '%')
+ ]),
+ E('div', { class: 'metric-val', style: 'color: #059669;' }, cached.toLocaleString())
+ ]),
+ createSparklineSVG(cachedItems, '#059669', 'spark-grad-cached', baseMax)
+ ]),
+
+ E('div', { class: 'mosdns-stat-card' }, [
+ E('div', {}, [
+ E('div', { class: 'title-row' }, [
+ E('span', {}, _('Average Processing Time')),
+ E('span', { class: 'mosdns-badge badge-primary' }, _('Latency'))
+ ]),
+ E('div', { class: 'metric-val', style: 'color: #2563eb;' }, avg_ms + ' ms'),
+ E('div', { class: 'subtext' }, _('Per-query speed'))
+ ])
+ ])
+ ]);
+};
+
+const renderTopRankings = topData => {
+ if (!topData || topData.error) return E('div', {});
+
+ const { top_blocked = [], top_domains = [], top_clients = [] } = topData;
+
+ const renderList = (items, key, color, isClient = false) => {
+ if (!items || !items.length) {
+ return E('div', { style: 'padding: 1.5rem; text-align: center; opacity: 0.5; font-size: 0.85rem;' }, _('No data available'));
+ }
+ const maxCount = Math.max(...items.map(i => i.count || 1));
+ return E('div', { style: 'display: flex; flex-direction: column;' },
+ items.map(item => {
+ let val = item[key] || '-';
+ if (isClient) val = cleanIP(val);
+ const cnt = item.count || 0;
+ const pct = Math.round((cnt / maxCount) * 100);
+
+ return E('div', { class: 'mosdns-rank-item' }, [
+ E('div', { class: 'mosdns-rank-bar', style: 'width: ' + pct + '%; background-color: ' + color + ';' }),
+ E('span', {
+ class: 'mosdns-mono',
+ style: 'font-size: 0.82rem; z-index: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-right: 0.5rem;',
+ title: val
+ }, val),
+ E('span', { class: 'mosdns-badge badge-neutral mosdns-mono', style: 'z-index: 1;' }, cnt.toLocaleString())
+ ]);
+ })
+ );
+ };
+
+ return E('div', { class: 'mosdns-rankings-grid' }, [
+ E('div', { class: 'mosdns-rank-panel' }, [
+ E('h4', { style: 'color: #2563eb;' }, [
+ E('span', {}, _('Top Queried Domains')),
+ E('span', { class: 'mosdns-badge badge-primary' }, top_domains.length)
+ ]),
+ renderList(top_domains, 'domain', '#2563eb')
+ ]),
+ E('div', { class: 'mosdns-rank-panel' }, [
+ E('h4', { style: 'color: #dc2626;' }, [
+ E('span', {}, _('Top Blocked Domains')),
+ E('span', { class: 'mosdns-badge badge-danger' }, top_blocked.length)
+ ]),
+ renderList(top_blocked, 'domain', '#dc2626')
+ ]),
+ E('div', { class: 'mosdns-rank-panel' }, [
+ E('h4', { style: 'color: #059669;' }, [
+ E('span', {}, _('Top Clients')),
+ E('span', { class: 'mosdns-badge badge-teal' }, top_clients.length)
+ ]),
+ renderList(top_clients, 'client_ip', '#059669', true)
+ ])
+ ]);
+};
+
+const showLogDetailsModal = item => {
+ let statusBadge;
+ if (item.is_blocked) {
+ statusBadge = E('span', { class: 'mosdns-badge badge-danger mosdns-status-badge' }, 'BLOCKED');
+ } else if (item.is_cached) {
+ statusBadge = E('span', { class: 'mosdns-badge badge-teal mosdns-status-badge' }, 'CACHED');
+ } else if (item.status === 'NOERROR') {
+ statusBadge = E('span', { class: 'mosdns-badge badge-primary mosdns-status-badge' }, 'NOERROR');
+ } else {
+ statusBadge = E('span', { class: 'mosdns-badge badge-neutral mosdns-status-badge' }, item.status || 'NOERROR');
+ }
+
+ const answersCount = (item.answers && item.answers.length) || 0;
+ let answersContent;
+ if (answersCount > 0) {
+ answersContent = E('div', { class: 'mosdns-answers-list' },
+ item.answers.map(a => E('div', { class: 'mosdns-answer-row' }, [
+ E('div', { style: 'display: flex; align-items: center; gap: 0.5rem; overflow: hidden; flex: 1;' }, [
+ E('span', { class: 'badge-qtype', style: 'margin: 0;' }, a.type || 'A'),
+ E('span', { class: 'mosdns-mono mosdns-answer-data', title: a.data }, a.data)
+ ]),
+ E('span', { class: 'mosdns-badge badge-neutral mosdns-mono mosdns-answer-ttl' }, 'TTL ' + a.ttl + 's')
+ ]))
+ );
+ } else {
+ answersContent = E('div', {
+ style: 'text-align: center; padding: 1.25rem; background: rgba(125,125,125,0.03); border: 1px dashed rgba(125,125,125,0.15); border-radius: 6px; opacity: 0.6; font-size: 0.85rem;'
+ }, _('No DNS answer records returned.'));
+ }
+
+ const body = E('div', { style: 'padding: 0.25rem 0;' }, [
+ E('div', { class: 'mosdns-modal-header' }, [
+ E('div', { class: 'mosdns-modal-domain' }, [
+ E('span', { class: 'mosdns-mono' }, item.domain || '-'),
+ E('span', { class: 'badge-qtype' }, item.qtype || 'A')
+ ]),
+ E('div', { style: 'display: flex; align-items: center; gap: 0.5rem;' }, [
+ statusBadge,
+ E('span', { class: 'mosdns-mono ' + getLatencyClass(item.elapsed_ms), style: 'font-size: 0.85rem;' }, item.elapsed_ms + ' ms')
+ ])
+ ]),
+
+ E('div', { class: 'mosdns-modal-meta-grid' }, [
+ E('div', { class: 'mosdns-modal-meta-item' }, [
+ E('div', { class: 'meta-label' }, _('Client IP')),
+ E('div', { class: 'meta-val mosdns-mono' }, cleanIP(item.client_ip))
+ ]),
+ E('div', { class: 'mosdns-modal-meta-item' }, [
+ E('div', { class: 'meta-label' }, _('Time')),
+ E('div', { class: 'meta-val mosdns-mono' }, formatTimestamp(item.timestamp) + (item.timestamp ? ' (' + item.timestamp.slice(0, 10) + ')' : ''))
+ ]),
+ E('div', { class: 'mosdns-modal-meta-item' }, [
+ E('div', { class: 'meta-label' }, _('Upstream')),
+ E('div', { class: 'meta-val mosdns-mono', style: 'word-break: break-all;' }, item.upstream || '-')
+ ]),
+ E('div', { class: 'mosdns-modal-meta-item' }, [
+ E('div', { class: 'meta-label' }, _('Rule Hit')),
+ E('div', { class: 'meta-val mosdns-mono', style: 'word-break: break-all;' }, item.rule || '-')
+ ])
+ ]),
+
+ E('div', { class: 'mosdns-modal-section-title' }, [
+ E('span', {}, _('Answers')),
+ E('span', { class: 'mosdns-badge badge-neutral' }, answersCount)
+ ]),
+ answersContent
+ ]);
+
+ ui.showModal(_('Query Log Details'), [
+ body,
+ E('div', { class: 'right', style: 'margin-top: 1.25rem;' }, [
+ E('button', {
+ class: 'btn cbi-button cbi-button-action',
+ click: ui.hideModal
+ }, _('Close'))
+ ])
+ ]);
+};
+
+const renderLogsTable = logsData => {
+ const { total = 0, items = [] } = logsData || {};
+ const totalPages = Math.ceil(total / PAGE_SIZE) || 1;
+
+ const rows = items.map(item => {
+ let statusBadge;
+ if (item.is_blocked) {
+ statusBadge = E('span', { class: 'mosdns-badge badge-danger mosdns-status-badge' }, 'BLOCKED');
+ } else if (item.is_cached) {
+ statusBadge = E('span', { class: 'mosdns-badge badge-teal mosdns-status-badge' }, 'CACHED');
+ } else if (item.status === 'NOERROR') {
+ statusBadge = E('span', { class: 'mosdns-badge badge-primary mosdns-status-badge' }, 'NOERROR');
+ } else {
+ statusBadge = E('span', { class: 'mosdns-badge badge-neutral mosdns-status-badge' }, item.status || 'NOERROR');
+ }
+
+ const answersText = (item.answers && item.answers.length > 0)
+ ? item.answers.map(a => a.data + ' (' + a.type + ')').join(', ')
+ : '-';
+
+ return E('tr', { class: 'tr' }, [
+ E('td', { class: 'td', style: 'font-size: 0.82rem; opacity: 0.7; white-space: nowrap;' }, formatTimestamp(item.timestamp)),
+ E('td', { class: 'td mosdns-mono', style: 'font-size: 0.82rem; white-space: nowrap;' }, cleanIP(item.client_ip)),
+ E('td', { class: 'td', style: 'max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;', title: item.domain || '-' }, [
+ E('span', { class: 'mosdns-mono', style: 'font-weight: 600;' }, item.domain || '-'),
+ E('span', { class: 'badge-qtype' }, item.qtype || 'A')
+ ]),
+ E('td', { class: 'td' }, statusBadge),
+ E('td', {
+ class: 'td mosdns-mono',
+ style: 'font-size: 0.82rem; max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer;',
+ title: _('Click to view full details'),
+ click: () => showLogDetailsModal(item)
+ }, answersText),
+ E('td', { class: 'td mosdns-mono ' + getLatencyClass(item.elapsed_ms), style: 'text-align: right; font-size: 0.82rem;' }, item.elapsed_ms + ' ms')
+ ]);
+ });
+
+ if (!rows.length) {
+ rows.push(E('tr', { class: 'tr' }, [
+ E('td', { class: 'td', colspan: 6, style: 'text-align: center; opacity: 0.5; padding: 2rem;' }, _('No query log entries found.'))
+ ]));
+ }
+
+ return E('div', {}, [
+ E('table', { class: 'table cbi-section-table mosdns-table', style: 'margin-top: 0.25rem;' }, [
+ E('tr', { class: 'tr table-titles' }, [
+ E('th', { class: 'th', style: 'width: 85px;' }, _('Time')),
+ E('th', { class: 'th', style: 'width: 125px;' }, _('Client IP')),
+ E('th', { class: 'th' }, _('Domain & Record')),
+ E('th', { class: 'th', style: 'width: 90px;' }, _('Status')),
+ E('th', { class: 'th' }, _('Answers')),
+ E('th', { class: 'th', style: 'width: 90px; text-align: right;' }, _('Elapsed'))
+ ]),
+ ...rows
+ ]),
+
+ E('div', { style: 'display: flex; justify-content: space-between; align-items: center; margin-top: 0.75rem;' }, [
+ E('span', { style: 'font-size: 0.85rem; opacity: 0.7;' }, _('Page %d / %d (%d entries)').format(pageIdx + 1, totalPages, total)),
+ E('div', { style: 'display: flex; gap: 0.5rem;' }, [
+ E('button', {
+ class: 'btn cbi-button cbi-button-action',
+ disabled: pageIdx === 0 ? 'disabled' : null,
+ click: () => {
+ if (pageIdx > 0) {
+ pageIdx--;
+ updateLiveStatusBadge();
+ refreshLogs();
+ }
+ }
+ }, _('Previous')),
+ E('button', {
+ class: 'btn cbi-button cbi-button-action',
+ disabled: (pageIdx + 1) >= totalPages ? 'disabled' : null,
+ click: () => {
+ if ((pageIdx + 1) < totalPages) {
+ pageIdx++;
+ updateLiveStatusBadge();
+ refreshLogs();
+ }
+ }
+ }, _('Next'))
+ ])
+ ])
+ ]);
+};
+
+const pollScheduler = async () => {
+ try {
+ const promises = [
+ callGetStats(),
+ callGetTop(10),
+ callGetHistory(24)
+ ];
+
+ const shouldRefreshLogs = (pageIdx === 0 && !searchVal && !isUserPaused);
+ if (shouldRefreshLogs) {
+ promises.push(callGetLogs(PAGE_SIZE, 0, '', filterVal));
+ }
+
+ const results = await Promise.all(promises);
+
+ dom.content(nodeStats, renderOverviewStats(results[0], results[2]));
+ dom.content(nodeTop, renderTopRankings(results[1]));
+
+ if (shouldRefreshLogs && results[3]) {
+ dom.content(nodeLogs, renderLogsTable(results[3]));
+ }
+ updateLiveStatusBadge();
+ } catch (e) {
+ }
+};
+
+const refreshLogs = async () => {
+ try {
+ const logs = await callGetLogs(PAGE_SIZE, pageIdx * PAGE_SIZE, searchVal, filterVal);
+ dom.content(nodeLogs, renderLogsTable(logs));
+ updateLiveStatusBadge();
+ } catch (e) {
+ ui.addNotification(null, E('p', [_('Failed to update query logs: '), e.message]), 'error');
+ }
+};
+
+return view.extend({
+ async load() {
+ return Promise.all([
+ L.resolveDefault(callGetStats(), {}),
+ L.resolveDefault(callGetTop(10), {}),
+ L.resolveDefault(callGetLogs(PAGE_SIZE, 0, searchVal, filterVal), {}),
+ L.resolveDefault(callGetHistory(24), {})
+ ]);
+ },
+
+ render(data) {
+ injectStyles();
+
+ nodeStats = E('div', { id: 'overview-stats' });
+ nodeTop = E('div', { id: 'top-rankings' });
+ nodeLogs = E('div', { id: 'logs-table' });
+ autoStatusBadge = E('div', { style: 'display: inline-block;' });
+
+ dom.content(nodeStats, renderOverviewStats(data[0], data[3]));
+ dom.content(nodeTop, renderTopRankings(data[1]));
+ dom.content(nodeLogs, renderLogsTable(data[2]));
+ updateLiveStatusBadge();
+
+ const searchInput = E('input', {
+ type: 'text',
+ class: 'cbi-input-text',
+ placeholder: _('Search domain or client IP...'),
+ style: 'min-width: 220px;'
+ });
+ searchInput.addEventListener('input', debounce(() => {
+ searchVal = searchInput.value.trim();
+ pageIdx = 0;
+ updateLiveStatusBadge();
+ refreshLogs();
+ }, 300));
+
+ const filterSelect = E('select', { class: 'cbi-input-select' }, [
+ E('option', { value: 'all', selected: filterVal === 'all' ? 'selected' : null }, _('All Queries')),
+ E('option', { value: 'blocked', selected: filterVal === 'blocked' ? 'selected' : null }, _('Blocked Only')),
+ E('option', { value: 'cached', selected: filterVal === 'cached' ? 'selected' : null }, _('Cached Only'))
+ ]);
+ filterSelect.addEventListener('change', () => {
+ filterVal = filterSelect.value;
+ pageIdx = 0;
+ updateLiveStatusBadge();
+ refreshLogs();
+ });
+
+ const resetPageBtn = E('button', {
+ class: 'cbi-button cbi-button-apply',
+ click: () => {
+ pageIdx = 0;
+ updateLiveStatusBadge();
+ refreshLogs();
+ }
+ }, _('First Page / Resume'));
+
+ const clearBtn = E('button', {
+ class: 'btn cbi-button cbi-button-remove',
+ style: 'margin-left: auto;'
+ }, _('Clear query logs'));
+
+ clearBtn.addEventListener('click', () => {
+ ui.showModal(_('Clear query logs'), [
+ E('p', {}, _('Are you sure you want to clear all real-time query logs and top rankings?')),
+ E('div', { class: 'right', style: 'margin-top: 1rem;' }, [
+ E('button', {
+ class: 'btn cbi-button cbi-button-neutral',
+ click: ui.hideModal
+ }, _('Cancel')),
+ ' ',
+ E('button', {
+ class: 'btn cbi-button cbi-button-remove',
+ click: async () => {
+ ui.hideModal();
+ try {
+ const res = await callClearQueryLogs();
+ if (res?.success) {
+ ui.addNotification(null, E('p', _('Query logs cleared successfully.')), 'info');
+ pageIdx = 0;
+ await pollScheduler();
+ await refreshLogs();
+ } else {
+ ui.addNotification(null, E('p', [_('Failed to clear query logs: '), res?.error || '']), 'error');
+ }
+ } catch (e) {
+ ui.addNotification(null, E('p', e.message), 'error');
+ }
+ }
+ }, _('Clear'))
+ ])
+ ]);
+ });
+
+ const controlBar = E('div', { style: 'display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; margin-bottom: 0.75rem;' }, [
+ searchInput,
+ filterSelect,
+ resetPageBtn,
+ autoStatusBadge,
+ clearBtn
+ ]);
+
+ poll.add(pollScheduler);
+
+ return E('div', { class: 'cbi-map' }, [
+ E('h2', { name: 'content' }, '%s - %s'.format(_('MosDNS'), _('Statistics'))),
+ nodeStats,
+ nodeTop,
+ E('div', { class: 'cbi-section' }, [
+ E('h3', {}, _('Real-time Query Logs')),
+ controlBar,
+ nodeLogs
+ ])
+ ]);
+ },
+
+ handleSave: null,
+ handleSaveApply: null,
+ handleReset: null
+});
diff --git a/luci-app-mosdns/po/templates/mosdns.pot b/luci-app-mosdns/po/templates/mosdns.pot
index 605e12abf..a136fad46 100644
--- a/luci-app-mosdns/po/templates/mosdns.pot
+++ b/luci-app-mosdns/po/templates/mosdns.pot
@@ -74,24 +74,50 @@ msgstr ""
msgid "Aliyun Public DNS (DNS over QUIC)"
msgstr ""
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:640
+msgid "All Queries"
+msgstr ""
+
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/update.js:68
msgid "Another update is already in progress."
msgstr ""
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:463
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:531
+msgid "Answers"
+msgstr ""
+
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:203
msgid "Apple domains optimization"
msgstr ""
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:667
+msgid ""
+"Are you sure you want to clear all real-time query logs and top rankings?"
+msgstr ""
+
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:359
msgid "Auto Save Cache Interval"
msgstr ""
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:447
+msgid "Auto Save Stats Interval"
+msgstr ""
+
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/update.js:106
msgid ""
"Automatically update GeoIP and GeoSite databases as well as ad filtering "
"rules through scheduled tasks."
msgstr ""
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:336
+msgid "Average Processing Time"
+msgstr ""
+
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:306
+msgid "Avg Processing"
+msgstr ""
+
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:217
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:263
msgid "Baidu Public DNS (180.76.76.76)"
@@ -119,6 +145,14 @@ msgstr ""
msgid "Block PTR"
msgstr ""
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:641
+msgid "Blocked Only"
+msgstr ""
+
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:314
+msgid "Blocked by Filters"
+msgstr ""
+
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:255
msgid "Bootstrap DNS servers"
msgstr ""
@@ -137,6 +171,18 @@ msgstr ""
msgid "Cache Prefetching"
msgstr ""
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:642
+msgid "Cached Only"
+msgstr ""
+
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:325
+msgid "Cached Queries"
+msgstr ""
+
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:672
+msgid "Cancel"
+msgstr ""
+
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/update.js:150
msgid "Check And Update"
msgstr ""
@@ -163,10 +209,29 @@ msgstr ""
msgid "Cisco Public DNS (208.67.222.222)"
msgstr ""
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:692
+msgid "Clear"
+msgstr ""
+
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/logs.js:65
msgid "Clear logs"
msgstr ""
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:663
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:666
+msgid "Clear query logs"
+msgstr ""
+
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:511
+msgid "Click to view full details"
+msgstr ""
+
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:445
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:528
+msgid "Client IP"
+msgstr ""
+
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:475
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/update.js:35
msgid "Close"
msgstr ""
@@ -202,7 +267,7 @@ msgstr ""
msgid "Config File"
msgstr ""
-#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:455
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:478
msgid "Configuration Editor"
msgstr ""
@@ -240,6 +305,10 @@ msgstr ""
msgid "DNS Forward"
msgstr ""
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:302
+msgid "DNS Queries Total"
+msgstr ""
+
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:271
msgid ""
"DNS query request concurrency, The number of upstream DNS servers that are "
@@ -280,6 +349,14 @@ msgstr ""
msgid "DoH/TCP/DoT Connection Multiplexing idle timeout (default 30 seconds)"
msgstr ""
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:529
+msgid "Domain & Record"
+msgstr ""
+
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:532
+msgid "Elapsed"
+msgstr ""
+
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/update.js:111
msgid "Enable Auto Database Update"
msgstr ""
@@ -296,6 +373,10 @@ msgstr ""
msgid "Enable EDNS client subnet"
msgstr ""
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:429
+msgid "Enable Stats Collector"
+msgstr ""
+
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:283
msgid ""
"Enable TCP/DoT RFC 7766 new Query Pipelining connection multiplexing mode"
@@ -310,11 +391,11 @@ msgstr ""
msgid "Enabled"
msgstr ""
-#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:482
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:505
msgid "Enter the GeoIP.dat category to be exported, Allow add multiple tags"
msgstr ""
-#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:477
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:500
msgid "Enter the GeoSite.dat category to be exported, Allow add multiple tags"
msgstr ""
@@ -354,8 +435,8 @@ msgstr ""
msgid "Every Wednesday"
msgstr ""
-#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:478
-#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:483
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:501
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:506
msgid "Export directory: /var/mosdns"
msgstr ""
@@ -363,11 +444,23 @@ msgstr ""
msgid "Failed to clean logs."
msgstr ""
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:686
+msgid "Failed to clear query logs:"
+msgstr ""
+
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/update.js:89
msgid "Failed to start update."
msgstr ""
-#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:432
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:599
+msgid "Failed to update query logs:"
+msgstr ""
+
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:658
+msgid "First Page / Resume"
+msgstr ""
+
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:455
msgid "Flush DNS Cache"
msgstr ""
@@ -379,7 +472,7 @@ msgstr ""
msgid "Flushing DNS Cache Success."
msgstr ""
-#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:430
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:453
msgid ""
"Flushing DNS Cache will clear any IP addresses or DNS records from MosDNS "
"cache."
@@ -415,7 +508,7 @@ msgstr ""
msgid "GeoData Export"
msgstr ""
-#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:481
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:504
msgid "GeoIP Tags"
msgstr ""
@@ -423,7 +516,7 @@ msgstr ""
msgid "GeoIP Type"
msgstr ""
-#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:476
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:499
msgid "GeoSite Tags"
msgstr ""
@@ -496,6 +589,10 @@ msgid ""
"seconds)."
msgstr ""
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:337
+msgid "Latency"
+msgstr ""
+
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:322
msgid "Lazy Cache TTL"
msgstr ""
@@ -537,7 +634,7 @@ msgstr ""
msgid "Log Level"
msgstr ""
-#: luci-app-mosdns/root/usr/share/luci/menu.d/luci-app-mosdns.json:38
+#: luci-app-mosdns/root/usr/share/luci/menu.d/luci-app-mosdns.json:46
msgid "Logs"
msgstr ""
@@ -577,10 +674,17 @@ msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:33
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:106
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/logs.js:70
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:708
#: luci-app-mosdns/root/usr/share/luci/menu.d/luci-app-mosdns.json:3
msgid "MosDNS"
msgstr ""
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:279
+msgid ""
+"MosDNS API is unreachable. Please ensure MosDNS is running and stats_api "
+"plugin is enabled."
+msgstr ""
+
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:107
msgid "MosDNS is a plugin-based DNS forwarder/traffic splitter."
msgstr ""
@@ -593,10 +697,34 @@ msgstr ""
msgid "Netflix, Disney+, Hulu and streaming media rules list will use this DNS"
msgstr ""
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:561
+msgid "Next"
+msgstr ""
+
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:428
+msgid "No DNS answer records returned."
+msgstr ""
+
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:353
+msgid "No data available"
+msgstr ""
+
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/logs.js:27
msgid "No log data."
msgstr ""
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:520
+msgid "No query log entries found."
+msgstr ""
+
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:538
+msgid "Page %d / %d (%d entries)"
+msgstr ""
+
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:340
+msgid "Per-query speed"
+msgstr ""
+
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:301
msgid ""
"Please provide the IP address you use when accessing foreign websites. This "
@@ -628,6 +756,10 @@ msgstr ""
msgid "Prevent DNS Leaks"
msgstr ""
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:550
+msgid "Previous"
+msgstr ""
+
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:329
msgid ""
"Proactively refresh hot cache entries in the background before they expire."
@@ -643,10 +775,28 @@ msgstr ""
msgid "Quad9 Public DNS (9.9.9.9)"
msgstr ""
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:469
+msgid "Query Log Details"
+msgstr ""
+
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:435
+msgid ""
+"Query log ring buffer capacity (FIFO overwrite, default 2000, larger values "
+"consume more memory)"
+msgstr ""
+
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:681
+msgid "Query logs cleared successfully."
+msgstr ""
+
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:31
msgid "RUNNING"
msgstr ""
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:712
+msgid "Real-time Query Logs"
+msgstr ""
+
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/rules.js:23
msgid "Redirect"
msgstr ""
@@ -669,6 +819,14 @@ msgstr ""
msgid "Remote DNS server"
msgstr ""
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:434
+msgid "Ring Buffer Capacity"
+msgstr ""
+
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:457
+msgid "Rule Hit"
+msgstr ""
+
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/rules.js:11
msgid "Rule Settings"
msgstr ""
@@ -677,10 +835,18 @@ msgstr ""
msgid "Rules"
msgstr ""
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:441
+msgid "Save query statistics and logs locally and reload on next startup."
+msgstr ""
+
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:353
msgid "Save the cache locally and reload the cache dump on the next startup"
msgstr ""
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:629
+msgid "Search domain or client IP..."
+msgstr ""
+
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:175
msgid "Set the maximum size of the log file (in MB)."
msgstr ""
@@ -689,6 +855,19 @@ msgstr ""
msgid "Starting update..."
msgstr ""
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:708
+#: luci-app-mosdns/root/usr/share/luci/menu.d/luci-app-mosdns.json:38
+msgid "Statistics"
+msgstr ""
+
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:440
+msgid "Stats Dump"
+msgstr ""
+
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:530
+msgid "Status"
+msgstr ""
+
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/rules.js:25
msgid "Streaming Media"
msgstr ""
@@ -730,13 +909,30 @@ msgid ""
"server)"
msgstr ""
-#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:456
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:479
msgid ""
"This is the content of the file '/etc/mosdns/config_custom.yaml' from which "
"your MosDNS configuration will be generated. Only accepts configuration "
"content in yaml format."
msgstr ""
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:449
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:527
+msgid "Time"
+msgstr ""
+
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:386
+msgid "Top Blocked Domains"
+msgstr ""
+
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:393
+msgid "Top Clients"
+msgstr ""
+
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:379
+msgid "Top Queried Domains"
+msgstr ""
+
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:213
msgid "TrafficRoute Public DNS (180.184.1.1)"
msgstr ""
@@ -745,7 +941,7 @@ msgstr ""
msgid "TrafficRoute Public DNS (180.184.2.2)"
msgstr ""
-#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:471
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:494
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/rules.js:43
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/rules.js:48
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/rules.js:67
@@ -798,6 +994,10 @@ msgstr ""
msgid "Updating Database..."
msgstr ""
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:453
+msgid "Upstream"
+msgstr ""
+
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:164
msgid "Warning"
msgstr ""
@@ -832,3 +1032,15 @@ msgstr ""
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/update.js:143
msgid "https://gh-proxy.com"
msgstr ""
+
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:303
+msgid "● Live"
+msgstr ""
+
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:148
+msgid "● Live Auto-refresh"
+msgstr ""
+
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:152
+msgid "❚❚ Paused (Page %d)"
+msgstr ""
diff --git a/luci-app-mosdns/po/zh_Hans/mosdns.po b/luci-app-mosdns/po/zh_Hans/mosdns.po
index aa99da22b..da0ee1023 100644
--- a/luci-app-mosdns/po/zh_Hans/mosdns.po
+++ b/luci-app-mosdns/po/zh_Hans/mosdns.po
@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-20 09:23+0800\n"
-"PO-Revision-Date: 2026-05-15 14:09+0800\n"
+"PO-Revision-Date: 2026-08-30 08:26+0800\n"
"Last-Translator: sbwml \n"
"Language-Team: Chinese\n"
"Language: zh_Hans\n"
@@ -89,24 +89,50 @@ msgstr "阿里云公共 DNS(DNS over HTTPS)"
msgid "Aliyun Public DNS (DNS over QUIC)"
msgstr "阿里云公共 DNS(DNS over QUIC)"
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:640
+msgid "All Queries"
+msgstr "所有查询"
+
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/update.js:68
msgid "Another update is already in progress."
msgstr "另一个更新正在进行中。"
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:463
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:531
+msgid "Answers"
+msgstr "应答结果"
+
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:203
msgid "Apple domains optimization"
msgstr "Apple 域名解析优化"
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:667
+msgid ""
+"Are you sure you want to clear all real-time query logs and top rankings?"
+msgstr "确定要清空所有实时查询日志和排行数据吗?"
+
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:359
msgid "Auto Save Cache Interval"
msgstr "自动保存缓存间隔(秒)"
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:447
+msgid "Auto Save Stats Interval"
+msgstr "自动保存统计间隔"
+
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/update.js:106
msgid ""
"Automatically update GeoIP and GeoSite databases as well as ad filtering "
"rules through scheduled tasks."
msgstr "通过定时任务自动更新 GeoIP 和 GeoSite 数据库以及广告过滤规则。"
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:336
+msgid "Average Processing Time"
+msgstr "平均延迟"
+
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:306
+msgid "Avg Processing"
+msgstr "平均处理"
+
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:217
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:263
msgid "Baidu Public DNS (180.76.76.76)"
@@ -136,6 +162,14 @@ msgstr "黑名单"
msgid "Block PTR"
msgstr "PTR 黑名单"
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:641
+msgid "Blocked Only"
+msgstr "仅拦截"
+
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:314
+msgid "Blocked by Filters"
+msgstr "拦截总数"
+
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:255
msgid "Bootstrap DNS servers"
msgstr "Bootstrap DNS 服务器"
@@ -154,6 +188,18 @@ msgstr "自动保存缓存"
msgid "Cache Prefetching"
msgstr "缓存预取"
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:642
+msgid "Cached Only"
+msgstr "仅缓存"
+
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:325
+msgid "Cached Queries"
+msgstr "缓存命中"
+
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:672
+msgid "Cancel"
+msgstr "取消"
+
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/update.js:150
msgid "Check And Update"
msgstr "检查并更新"
@@ -180,10 +226,29 @@ msgstr "思科公共 DNS(208.67.220.220)"
msgid "Cisco Public DNS (208.67.222.222)"
msgstr "思科公共 DNS(208.67.222.222)"
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:692
+msgid "Clear"
+msgstr "清空"
+
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/logs.js:65
msgid "Clear logs"
msgstr "清空日志"
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:663
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:666
+msgid "Clear query logs"
+msgstr "清除查询日志"
+
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:511
+msgid "Click to view full details"
+msgstr "点击查看完整详情"
+
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:445
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:528
+msgid "Client IP"
+msgstr "客户端 IP"
+
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:475
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/update.js:35
msgid "Close"
msgstr "关闭"
@@ -219,7 +284,7 @@ msgstr "DNS 服务器并发数(默认 2)"
msgid "Config File"
msgstr "配置文件"
-#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:455
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:478
msgid "Configuration Editor"
msgstr "配置编辑器"
@@ -258,6 +323,10 @@ msgstr "DNS 缓存大小"
msgid "DNS Forward"
msgstr "DNS 转发"
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:302
+msgid "DNS Queries Total"
+msgstr "DNS 查询总数"
+
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:271
msgid ""
"DNS query request concurrency, The number of upstream DNS servers that are "
@@ -299,6 +368,14 @@ msgstr ""
msgid "DoH/TCP/DoT Connection Multiplexing idle timeout (default 30 seconds)"
msgstr "DoH/TCP/DoT 连接复用空闲保持时间(默认 30 秒)"
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:529
+msgid "Domain & Record"
+msgstr "域名与类型"
+
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:532
+msgid "Elapsed"
+msgstr "耗时"
+
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/update.js:111
msgid "Enable Auto Database Update"
msgstr "启用自动更新"
@@ -315,6 +392,10 @@ msgstr "启用 DNS 缓存"
msgid "Enable EDNS client subnet"
msgstr "启用 EDNS 客户端子网"
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:429
+msgid "Enable Stats Collector"
+msgstr "启用统计收集器"
+
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:283
msgid ""
"Enable TCP/DoT RFC 7766 new Query Pipelining connection multiplexing mode"
@@ -329,11 +410,11 @@ msgstr "启用此选项 fallback 策略会强制转发到远程 DNS"
msgid "Enabled"
msgstr "启用"
-#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:482
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:505
msgid "Enter the GeoIP.dat category to be exported, Allow add multiple tags"
msgstr "输入需要导出的 GeoIP.dat 类别条目,允许添加多个标签"
-#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:477
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:500
msgid "Enter the GeoSite.dat category to be exported, Allow add multiple tags"
msgstr "填写需要导出的 GeoSite.dat 类别条目,允许添加多个标签"
@@ -373,8 +454,8 @@ msgstr "每周二"
msgid "Every Wednesday"
msgstr "每周三"
-#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:478
-#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:483
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:501
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:506
msgid "Export directory: /var/mosdns"
msgstr "导出目录:/var/mosdns"
@@ -382,11 +463,23 @@ msgstr "导出目录:/var/mosdns"
msgid "Failed to clean logs."
msgstr "清理日志失败:%s"
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:686
+msgid "Failed to clear query logs:"
+msgstr "清除查询日志失败:"
+
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/update.js:89
msgid "Failed to start update."
msgstr "启动更新失败"
-#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:432
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:599
+msgid "Failed to update query logs:"
+msgstr "更新查询日志失败:"
+
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:658
+msgid "First Page / Resume"
+msgstr "重置并恢复"
+
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:455
msgid "Flush DNS Cache"
msgstr "刷新 DNS 缓存"
@@ -398,7 +491,7 @@ msgstr "刷新 DNS 缓存失败,请检查 MosDNS 状态是否在运行中。"
msgid "Flushing DNS Cache Success."
msgstr "刷新 DNS 缓存成功"
-#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:430
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:453
msgid ""
"Flushing DNS Cache will clear any IP addresses or DNS records from MosDNS "
"cache."
@@ -434,7 +527,7 @@ msgstr "全量:包含所有国家和私有 IP 地址。"
msgid "GeoData Export"
msgstr "GeoData 导出"
-#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:481
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:504
msgid "GeoIP Tags"
msgstr "GeoIP 标签"
@@ -442,7 +535,7 @@ msgstr "GeoIP 标签"
msgid "GeoIP Type"
msgstr "GeoIP 类型"
-#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:476
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:499
msgid "GeoSite Tags"
msgstr "GeoSite 标签"
@@ -475,7 +568,7 @@ msgstr "灰名单"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/rules.js:22
msgid "Hosts"
-msgstr ""
+msgstr "Hosts 列表"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:300
msgid "IP Address"
@@ -488,6 +581,10 @@ msgid ""
"www.cloudflare.com/ips-v6\" target=\"_blank\">https://www.cloudflare.com/ips-"
"v6"
msgstr ""
+"IPv4 CIDR: https://www.cloudflare.com/ips-v4
IPv6 CIDR: https://www.cloudflare.com/ips-"
+"v6"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:190
msgid ""
@@ -515,6 +612,10 @@ msgid ""
"seconds)."
msgstr "后台线程扫描缓存以进行预取的时间间隔(单位:秒)"
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:337
+msgid "Latency"
+msgstr "延迟"
+
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:322
msgid "Lazy Cache TTL"
msgstr "乐观缓存 TTL"
@@ -556,7 +657,7 @@ msgstr "日志文件大小"
msgid "Log Level"
msgstr "日志等级"
-#: luci-app-mosdns/root/usr/share/luci/menu.d/luci-app-mosdns.json:38
+#: luci-app-mosdns/root/usr/share/luci/menu.d/luci-app-mosdns.json:46
msgid "Logs"
msgstr "日志"
@@ -599,10 +700,19 @@ msgstr "修改 DNS 应答结果的最小 TTL 值 (秒),0 表示不修改"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:33
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:106
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/logs.js:70
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:708
#: luci-app-mosdns/root/usr/share/luci/menu.d/luci-app-mosdns.json:3
msgid "MosDNS"
msgstr "MosDNS"
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:279
+msgid ""
+"MosDNS API is unreachable. Please ensure MosDNS is running and stats_api "
+"plugin is enabled."
+msgstr ""
+"MosDNS API 无法连接。请确保 MosDNS 正在运行且已启用统计收集器(stats_api)插"
+"件。"
+
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:107
msgid "MosDNS is a plugin-based DNS forwarder/traffic splitter."
msgstr "MosDNS 是一个插件化的 DNS 转发/分流器。"
@@ -615,10 +725,34 @@ msgstr "未运行"
msgid "Netflix, Disney+, Hulu and streaming media rules list will use this DNS"
msgstr "自定义 Netflix、Disney+、Hulu 以及 “流媒体” 规则列表的 DNS 服务器"
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:561
+msgid "Next"
+msgstr "下一页"
+
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:428
+msgid "No DNS answer records returned."
+msgstr "无 DNS 应答记录"
+
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:353
+msgid "No data available"
+msgstr "暂无数据"
+
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/logs.js:27
msgid "No log data."
msgstr "无日志数据。"
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:520
+msgid "No query log entries found."
+msgstr "未找到查询日志记录。"
+
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:538
+msgid "Page %d / %d (%d entries)"
+msgstr "第 %d / %d 页(%d 条记录)"
+
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:340
+msgid "Per-query speed"
+msgstr "单次查询速度"
+
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:301
msgid ""
"Please provide the IP address you use when accessing foreign websites. This "
@@ -652,6 +786,10 @@ msgstr "当剩余 TTL 小于此值(秒)时触发预取"
msgid "Prevent DNS Leaks"
msgstr "防止 DNS 泄漏"
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:550
+msgid "Previous"
+msgstr "上一页"
+
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:329
msgid ""
"Proactively refresh hot cache entries in the background before they expire."
@@ -667,10 +805,28 @@ msgstr "Quad9 公共 DNS(149.112.112.112)"
msgid "Quad9 Public DNS (9.9.9.9)"
msgstr "Quad9 公共 DNS(9.9.9.9)"
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:469
+msgid "Query Log Details"
+msgstr "查询日志详情"
+
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:435
+msgid ""
+"Query log ring buffer capacity (FIFO overwrite, default 2000, larger values "
+"consume more memory)"
+msgstr "查询日志环形缓冲区容量(FIFO 覆盖,默认 2000,数值越大占用内存越多)"
+
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:681
+msgid "Query logs cleared successfully."
+msgstr "查询日志清除成功。"
+
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:31
msgid "RUNNING"
msgstr "运行中"
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:712
+msgid "Real-time Query Logs"
+msgstr "实时查询日志"
+
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/rules.js:23
msgid "Redirect"
msgstr "重定向"
@@ -695,6 +851,14 @@ msgstr "远程 DNS 首选 IPv4"
msgid "Remote DNS server"
msgstr "远程 DNS 服务器"
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:434
+msgid "Ring Buffer Capacity"
+msgstr "环形缓冲区容量"
+
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:457
+msgid "Rule Hit"
+msgstr "命中规则"
+
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/rules.js:11
msgid "Rule Settings"
msgstr "自定义规则列表"
@@ -703,17 +867,38 @@ msgstr "自定义规则列表"
msgid "Rules"
msgstr "规则列表"
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:441
+msgid "Save query statistics and logs locally and reload on next startup."
+msgstr "保存统计数据与查询日志在本地,并在下次启动时重新加载"
+
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:353
msgid "Save the cache locally and reload the cache dump on the next startup"
msgstr "保存缓存到本地文件,以供下次启动时重新载入使用"
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:629
+msgid "Search domain or client IP..."
+msgstr "搜索域名或客户端 IP..."
+
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:175
msgid "Set the maximum size of the log file (in MB)."
msgstr "设置日志文件的最大容量(单位:MB)。"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/update.js:28
msgid "Starting update..."
-msgstr ""
+msgstr "正在启动更新..."
+
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:708
+#: luci-app-mosdns/root/usr/share/luci/menu.d/luci-app-mosdns.json:38
+msgid "Statistics"
+msgstr "统计"
+
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:440
+msgid "Stats Dump"
+msgstr "自动保存统计"
+
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:530
+msgid "Status"
+msgstr "状态"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/rules.js:25
msgid "Streaming Media"
@@ -758,7 +943,7 @@ msgstr ""
"此功能通常在使用自建 DNS 服务器作为 远程 / 流媒体 DNS 上游时使用(需要上游服"
"务器的支持)"
-#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:456
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:479
msgid ""
"This is the content of the file '/etc/mosdns/config_custom.yaml' from which "
"your MosDNS configuration will be generated. Only accepts configuration "
@@ -767,6 +952,23 @@ msgstr ""
"这是文件 “/etc/mosdns/config_custom.yaml” 的内容,您的 MosDNS 配置将从此文件"
"生成。仅接受 yaml 格式的配置内容。"
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:449
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:527
+msgid "Time"
+msgstr "时间"
+
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:386
+msgid "Top Blocked Domains"
+msgstr "拦截域名 (Top 10)"
+
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:393
+msgid "Top Clients"
+msgstr "活跃客户端 (Top 10)"
+
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:379
+msgid "Top Queried Domains"
+msgstr "查询域名 (Top 10)"
+
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:213
msgid "TrafficRoute Public DNS (180.184.1.1)"
msgstr "火山引擎公共 DNS(180.184.1.1)"
@@ -775,7 +977,7 @@ msgstr "火山引擎公共 DNS(180.184.1.1)"
msgid "TrafficRoute Public DNS (180.184.2.2)"
msgstr "火山引擎公共 DNS(180.184.2.2)"
-#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:471
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:494
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/rules.js:43
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/rules.js:48
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/rules.js:67
@@ -828,6 +1030,10 @@ msgstr "更新成功"
msgid "Updating Database..."
msgstr "更新数据库..."
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:453
+msgid "Upstream"
+msgstr "上游服务器"
+
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/basic.js:164
msgid "Warning"
msgstr "警告"
@@ -865,4 +1071,16 @@ msgstr "信风公共 DNS(114.114.115.115)"
#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/update.js:143
msgid "https://gh-proxy.com"
-msgstr ""
+msgstr "https://gh-proxy.com"
+
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:303
+msgid "● Live"
+msgstr "● 实时"
+
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:148
+msgid "● Live Auto-refresh"
+msgstr "● 实时刷新"
+
+#: luci-app-mosdns/htdocs/luci-static/resources/view/mosdns/statistics.js:152
+msgid "❚❚ Paused (Page %d)"
+msgstr "❚❚ 暂停 (第 %d 页)"
diff --git a/luci-app-mosdns/root/etc/config/mosdns b/luci-app-mosdns/root/etc/config/mosdns
index 6736cd7f3..a56d1b910 100644
--- a/luci-app-mosdns/root/etc/config/mosdns
+++ b/luci-app-mosdns/root/etc/config/mosdns
@@ -8,7 +8,7 @@ config mosdns 'config'
option geo_update_day_time '2'
option geoip_type 'geoip-only-cn-private'
option configfile '/var/etc/mosdns.json'
- option log_level 'info'
+ option log_level 'error'
option log_file '/var/log/mosdns.log'
option log_size '1'
option cache '1'
@@ -30,4 +30,9 @@ config mosdns 'config'
option lazy_cache_ttl '86400'
option dump_file '0'
option reject_type65 '1'
+ option stats_collector '1'
+ option stats_capacity '2000'
+ option stats_dump_file '0'
+ option stats_dump_interval '600'
+
diff --git a/luci-app-mosdns/root/etc/init.d/mosdns b/luci-app-mosdns/root/etc/init.d/mosdns
index cf4a3bd21..441cf336f 100755
--- a/luci-app-mosdns/root/etc/init.d/mosdns
+++ b/luci-app-mosdns/root/etc/init.d/mosdns
@@ -34,7 +34,7 @@ get_config() {
config_get listen_address $1 listen_address "0.0.0.0"
config_get log_file $1 log_file "/var/log/mosdns.log"
config_get log_size $1 log_size "1"
- config_get log_level $1 log_level "info"
+ config_get log_level $1 log_level "error"
config_get minimal_ttl $1 minimal_ttl 0
config_get maximum_ttl $1 maximum_ttl 0
config_get redirect $1 redirect 0
@@ -57,6 +57,10 @@ get_config() {
config_get cloudflare $1 cloudflare 0
config_get cloudflare_ip $1 cloudflare_ip ""
config_get reject_type65 $1 reject_type65 "0"
+ config_get stats_collector $1 stats_collector 1
+ config_get stats_capacity $1 stats_capacity 2000
+ config_get stats_dump_file $1 stats_dump_file 0
+ config_get stats_dump_interval $1 stats_dump_interval 600
}
generate_config() {
@@ -79,6 +83,20 @@ generate_config() {
json_close_array
# plugins
json_add_array "plugins"
+ # plugin: stats_collector
+ [ "$stats_collector" -eq 1 ] && {
+ json_add_object
+ json_add_string "tag" "stats_collector"
+ json_add_string "type" "stats_api"
+ json_add_object "args"
+ json_add_int "capacity" "$stats_capacity"
+ [ "$stats_dump_file" -eq 1 ] && {
+ json_add_string "dump_file" "/etc/mosdns/stats.dump"
+ json_add_int "dump_interval" "$stats_dump_interval"
+ }
+ json_close_object
+ json_close_object
+ }
# plugin: geosite_cn
json_add_object
json_add_string "tag" "geosite_cn"
@@ -589,6 +607,11 @@ generate_config() {
json_add_string "tag" "main_sequence"
json_add_string "type" "sequence"
json_add_array "args"
+ [ "$stats_collector" -eq 1 ] && {
+ json_add_object
+ json_add_string "exec" "\$stats_collector"
+ json_close_object
+ }
json_add_object
json_add_string "exec" "\$hosts"
json_close_object
diff --git a/luci-app-mosdns/root/etc/mosdns/config_custom.yaml b/luci-app-mosdns/root/etc/mosdns/config_custom.yaml
index 5e521e961..32330e5d8 100644
--- a/luci-app-mosdns/root/etc/mosdns/config_custom.yaml
+++ b/luci-app-mosdns/root/etc/mosdns/config_custom.yaml
@@ -9,6 +9,14 @@ api:
include: []
plugins:
+ # 统计收集器
+ - tag: stats_collector
+ type: stats_api
+ args:
+ capacity: 2000 # 查询日志环缓冲区容量 (FIFO 覆盖)
+ dump_file: "/etc/mosdns/stats.dump" # 数据持久化转存文件路径,不设置则不转存
+ dump_interval: 600 # 周期性转储间隔(秒)
+
# 国内域名
- tag: geosite_cn
type: domain_set
@@ -120,6 +128,7 @@ plugins:
- tag: main_sequence
type: sequence
args:
+ - exec: $stats_collector # 执行全局统计控制器
- exec: $lazy_cache
- exec: jump has_resp_sequence
- exec: $query_is_local_domain
diff --git a/luci-app-mosdns/root/usr/share/luci/menu.d/luci-app-mosdns.json b/luci-app-mosdns/root/usr/share/luci/menu.d/luci-app-mosdns.json
index e866a8f17..2c58026dd 100644
--- a/luci-app-mosdns/root/usr/share/luci/menu.d/luci-app-mosdns.json
+++ b/luci-app-mosdns/root/usr/share/luci/menu.d/luci-app-mosdns.json
@@ -34,9 +34,17 @@
"path": "mosdns/update"
}
},
+ "admin/services/mosdns/statistics": {
+ "title": "Statistics",
+ "order": 25,
+ "action": {
+ "type": "view",
+ "path": "mosdns/statistics"
+ }
+ },
"admin/services/mosdns/logs": {
"title": "Logs",
- "order": 25,
+ "order": 30,
"action": {
"type": "view",
"path": "mosdns/logs"
diff --git a/luci-app-mosdns/root/usr/share/mosdns/mosdns.uc b/luci-app-mosdns/root/usr/share/mosdns/mosdns.uc
index 423c141f0..c041d1f86 100755
--- a/luci-app-mosdns/root/usr/share/mosdns/mosdns.uc
+++ b/luci-app-mosdns/root/usr/share/mosdns/mosdns.uc
@@ -149,8 +149,8 @@ function update_adlist() {
print(`Downloading ${mirror}${url}\n`);
stdout.flush();
- let curl_res = exec_sys(`curl --connect-timeout 5 -m 90 --ipv4 -kfSLo "${ad_tmpdir}/${filename}" "${mirror}${url}"`);
- if (curl_res.code !== 0) download_failed = true;
+ let dl_res = exec_sys(`wget -4 -q --no-check-certificate -T 90 -t 2 -O "${ad_tmpdir}/${filename}" "${mirror}${url}"`);
+ if (dl_res.code !== 0) download_failed = true;
}
}
@@ -192,7 +192,7 @@ function update_geodat() {
print(`Downloading ${geoip_url}.sha256sum\n`);
stdout.flush();
- if (exec_sys(`curl --connect-timeout 5 -m 20 --ipv4 -kfSLo "${tmpdir}/geoip.dat.sha256sum" "${geoip_url}.sha256sum"`).code !== 0) {
+ if (exec_sys(`wget -4 -q --no-check-certificate -T 20 -t 2 -O "${tmpdir}/geoip.dat.sha256sum" "${geoip_url}.sha256sum"`).code !== 0) {
exec_sys(`rm -rf "${tmpdir}"`);
die("Failed to download geoip.dat.sha256sum");
}
@@ -209,7 +209,7 @@ function update_geodat() {
} else {
print(`Downloading ${geoip_url}\n`);
stdout.flush();
- if (exec_sys(`curl --connect-timeout 5 -m 120 --ipv4 -kfSLo "${tmpdir}/geoip.dat" "${geoip_url}"`).code !== 0) {
+ if (exec_sys(`wget -4 -q --no-check-certificate -T 120 -t 2 -O "${tmpdir}/geoip.dat" "${geoip_url}"`).code !== 0) {
exec_sys(`rm -rf "${tmpdir}"`);
die("Failed to download geoip.dat");
}
@@ -227,7 +227,7 @@ function update_geodat() {
print(`Downloading ${geosite_url}.sha256sum\n`);
stdout.flush();
- if (exec_sys(`curl --connect-timeout 5 -m 20 --ipv4 -kfSLo "${tmpdir}/geosite.dat.sha256sum" "${geosite_url}.sha256sum"`).code !== 0) {
+ if (exec_sys(`wget -4 -q --no-check-certificate -T 20 -t 2 -O "${tmpdir}/geosite.dat.sha256sum" "${geosite_url}.sha256sum"`).code !== 0) {
exec_sys(`rm -rf "${tmpdir}"`);
die("Failed to download geosite.dat.sha256sum");
}
@@ -244,7 +244,7 @@ function update_geodat() {
} else {
print(`Downloading ${geosite_url}\n`);
stdout.flush();
- if (exec_sys(`curl --connect-timeout 5 -m 120 --ipv4 -kfSLo "${tmpdir}/geosite.dat" "${geosite_url}"`).code !== 0) {
+ if (exec_sys(`wget -4 -q --no-check-certificate -T 120 -t 2 -O "${tmpdir}/geosite.dat" "${geosite_url}"`).code !== 0) {
exec_sys(`rm -rf "${tmpdir}"`);
die("Failed to download geosite.dat");
}
diff --git a/luci-app-mosdns/root/usr/share/rpcd/acl.d/luci-app-mosdns.json b/luci-app-mosdns/root/usr/share/rpcd/acl.d/luci-app-mosdns.json
index 9c7d23ad8..ecb5c7b37 100644
--- a/luci-app-mosdns/root/usr/share/rpcd/acl.d/luci-app-mosdns.json
+++ b/luci-app-mosdns/root/usr/share/rpcd/acl.d/luci-app-mosdns.json
@@ -16,10 +16,15 @@
"service": [ "list" ],
"luci.mosdns": [
"clean_log",
+ "clear_query_logs",
"flush_cache",
+ "get_history",
+ "get_logs",
+ "get_stats",
+ "get_top",
+ "get_update_log",
"get_version",
"print_log",
- "get_update_log",
"start_update"
]
},
@@ -35,7 +40,8 @@
"/var/mosdns/*": [ "write" ]
},
"ubus": {
- "file": [ "write" ]
+ "file": [ "write" ],
+ "luci.mosdns": [ "clear_query_logs" ]
},
"uci": [ "mosdns" ]
}
diff --git a/luci-app-mosdns/root/usr/share/rpcd/ucode/luci.mosdns b/luci-app-mosdns/root/usr/share/rpcd/ucode/luci.mosdns
index 9760722f2..10d61532c 100644
--- a/luci-app-mosdns/root/usr/share/rpcd/ucode/luci.mosdns
+++ b/luci-app-mosdns/root/usr/share/rpcd/ucode/luci.mosdns
@@ -58,16 +58,85 @@ function get_logfile_path_internal() {
}
}
+function parse_json_safe(str) {
+ if (!str || str == "")
+ return null;
+
+ try {
+ return json(str);
+ } catch(e) {
+ return null;
+ }
+}
+
+function call_mosdns_api(endpoint, method) {
+ try {
+ let uci_cursor = cursor();
+ uci_cursor.load('mosdns');
+ let port = uci_cursor.get('mosdns', 'config', 'listen_port_api') || '9091';
+ let http_method = method ? method : 'GET';
+ let post_flag = (http_method === 'POST') ? '--post-data=""' : '';
+ let cmd = `wget -q -O - ${post_flag} "http://127.0.0.1:${port}/plugins/stats_collector${endpoint}"`;
+ let res = exec_sys(cmd);
+ if (res.code === 0 && res.stdout != "") {
+ let json_val = parse_json_safe(res.stdout);
+ if (json_val) return json_val;
+ }
+ return { error: "MosDNS API unreachable" };
+ } catch (e) {
+ return { error: String(e) };
+ }
+}
+
const methods = {
+ get_stats: {
+ call: function() {
+ return call_mosdns_api('/api/v1/stats', 'GET');
+ }
+ },
+
+ get_history: {
+ args: { points: 24 },
+ call: function(request) {
+ let points = request?.args?.points || 24;
+ return call_mosdns_api(`/api/v1/history?points=${points}`, 'GET');
+ }
+ },
+
+ get_top: {
+ args: { limit: 10 },
+ call: function(request) {
+ let limit = request?.args?.limit || 10;
+ return call_mosdns_api(`/api/v1/top?limit=${limit}`, 'GET');
+ }
+ },
+
+ get_logs: {
+ args: { limit: 50, offset: 0, search: '', filter: 'all' },
+ call: function(request) {
+ let limit = request?.args?.limit || 50;
+ let offset = request?.args?.offset || 0;
+ let search = request?.args?.search || '';
+ let filter = request?.args?.filter || 'all';
+ return call_mosdns_api(`/api/v1/logs?limit=${limit}&offset=${offset}&search=${search}&filter=${filter}`, 'GET');
+ }
+ },
+
+ clear_query_logs: {
+ call: function() {
+ return call_mosdns_api('/api/v1/logs/clear', 'POST');
+ }
+ },
+
flush_cache: {
call: function() {
try {
let uci_cursor = cursor();
uci_cursor.load('mosdns');
- let port = uci_cursor.get('mosdns', 'config', 'listen_port_api');
+ let port = uci_cursor.get('mosdns', 'config', 'listen_port_api') || '9091';
if (!port) return { error: "API listen port not configured." };
- let res = exec_sys(`curl -s 127.0.0.1:${port}/plugins/lazy_cache/flush`);
+ let res = exec_sys(`wget -q -O - "http://127.0.0.1:${port}/plugins/lazy_cache/flush"`);
if (res.code === 0) {
return { success: true };
} else {
diff --git a/luci-theme-footstrap/Makefile b/luci-theme-footstrap/Makefile
index dc32c4981..2ef82e479 100644
--- a/luci-theme-footstrap/Makefile
+++ b/luci-theme-footstrap/Makefile
@@ -17,7 +17,7 @@ LUCI_NAME:=luci-theme-footstrap
FOOTSTRAP_VERSION?=
ifneq ($(FOOTSTRAP_VERSION),)
PKG_VERSION:=$(FOOTSTRAP_VERSION)
-PKG_RELEASE:=43
+PKG_RELEASE:=44
endif
LUCI_TITLE:=Footstrap Theme
@@ -30,7 +30,7 @@ LUCI_DEPENDS:=+luci-base
LUCI_PKGARCH:=all
# Else luci.mk defaults these to LuCI's own, and the package claims MAINTAINER "OpenWrt LuCI
# community" / URL openwrt/luci.
-LUCI_MAINTAINER:=VizzleTF
+LUCI_MAINTAINER:=Ivan Kvashonkin
LUCI_URL:=https://github.com/VizzleTF/luci-theme-footstrap
# CSS: ship verbatim. luci.mk would run csstidy over it, which is old enough to mangle :has(),
diff --git a/luci-theme-footstrap/htdocs/luci-static/footstrap/app-icon-192.png b/luci-theme-footstrap/htdocs/luci-static/footstrap/app-icon-192.png
new file mode 100644
index 000000000..259936641
Binary files /dev/null and b/luci-theme-footstrap/htdocs/luci-static/footstrap/app-icon-192.png differ
diff --git a/luci-theme-footstrap/htdocs/luci-static/footstrap/app-icon-512.png b/luci-theme-footstrap/htdocs/luci-static/footstrap/app-icon-512.png
deleted file mode 100644
index f57ab31f9..000000000
Binary files a/luci-theme-footstrap/htdocs/luci-static/footstrap/app-icon-512.png and /dev/null differ
diff --git a/luci-theme-footstrap/htdocs/luci-static/footstrap/manifest.json b/luci-theme-footstrap/htdocs/luci-static/footstrap/manifest.json
index d6b161ef1..1694b4620 100644
--- a/luci-theme-footstrap/htdocs/luci-static/footstrap/manifest.json
+++ b/luci-theme-footstrap/htdocs/luci-static/footstrap/manifest.json
@@ -9,8 +9,8 @@
"theme_color": "#f6f8fa",
"icons": [
{
- "src": "/luci-static/footstrap/app-icon-512.png",
- "sizes": "512x512",
+ "src": "/luci-static/footstrap/app-icon-192.png",
+ "sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
}
diff --git a/luci-theme-footstrap/htdocs/luci-static/resources/fs-prefs.js b/luci-theme-footstrap/htdocs/luci-static/resources/fs-prefs.js
index ee8d7bba3..c6c65694d 100644
--- a/luci-theme-footstrap/htdocs/luci-static/resources/fs-prefs.js
+++ b/luci-theme-footstrap/htdocs/luci-static/resources/fs-prefs.js
@@ -140,6 +140,44 @@ function guardDarkStamp() {
attributeFilter: ['data-darkmode', 'data-theme', 'data-bs-theme']
});
}
+/* ---- the browser's own chrome follows the page ----
+ *
+ * `` is what colours a mobile address bar, the Android task-switcher card
+ * and an installed PWA's title bar. head.ut ships it at the default palette's page colour, which is
+ * all a static template can know; from here it tracks the live one.
+ *
+ * Read from the BODY's computed background, not from `--fs-bg`: a custom property returns its token
+ * stream, so a palette whose page colour is a color-mix() would put `color-mix(in srgb, …)` in the
+ * attribute, and 21 of the axes reach the canvas through one mix or another.
+ *
+ * One observer rather than a call in each applier: the colour is a function of :root's attributes
+ * and inline properties — mode, palette, tint, its strength — and every axis already writes exactly
+ * those. Adding an axis therefore needs nothing here. Coalesced into a frame because the tint and
+ * strength sliders write on every input event, and getComputedStyle forces style resolution. */
+function paintThemeColor() {
+ const meta = document.querySelector('meta[name="theme-color"]');
+ if (!meta || !document.body) return;
+ const bg = getComputedStyle(document.body).backgroundColor;
+ /* a transparent body means the sheet has not applied yet; keep the server's value */
+ if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') meta.setAttribute('content', bg);
+}
+
+function watchThemeColor() {
+ let queued = false;
+ const paint = () => {
+ queued = false;
+ paintThemeColor();
+ };
+ const schedule = () => {
+ if (queued) return;
+ queued = true;
+ window.requestAnimationFrame(paint);
+ };
+ paintThemeColor();
+ new MutationObserver(schedule).observe(document.documentElement,
+ { attributes: true, attributeFilter: [ 'style', 'class', 'data-darkmode', 'data-palette', 'data-wallpaper' ] });
+}
+
/* "Auto" means follow the OS continuously, not only at page load. Only while the effective mode is
* auto: an explicit browser choice, or an explicit router default with no browser override. */
_mqDark.addEventListener('change', () => {
@@ -316,7 +354,7 @@ return baseclass.extend({
/* the two axis shapes, so the nineteen axes in fs-axes.js can be built from them */
listAxis, enumAxis,
- currentMode, applyMode, modeDefault, guardDarkStamp,
+ currentMode, applyMode, modeDefault, guardDarkStamp, watchThemeColor,
currentDensity, applyDensity, densityDefault,
currentLayout, applyLayout, isTopLayout,
currentAutoCollapse, applyAutoCollapse, autoCollapseDefault,
diff --git a/luci-theme-footstrap/htdocs/luci-static/resources/menu-footstrap-common.js b/luci-theme-footstrap/htdocs/luci-static/resources/menu-footstrap-common.js
index e91b87934..60e9579b9 100644
--- a/luci-theme-footstrap/htdocs/luci-static/resources/menu-footstrap-common.js
+++ b/luci-theme-footstrap/htdocs/luci-static/resources/menu-footstrap-common.js
@@ -205,6 +205,7 @@ return baseclass.extend({
* below swallows a menu failure. */
sheets.watchViewSheets();
prefs.guardDarkStamp(); /* same, for a third party stamping :root */
+ prefs.watchThemeColor(); /* the mobile address bar, from the live page colour */
ui.menu.load().then((menu) => {
tree.setTree(menu);
diff --git a/luci-theme-footstrap/styles/04-nocolormix.css b/luci-theme-footstrap/styles/04-nocolormix.css
new file mode 100644
index 000000000..ff5fb26c2
--- /dev/null
+++ b/luci-theme-footstrap/styles/04-nocolormix.css
@@ -0,0 +1,79 @@
+@layer tokens {
+ /* Static twins for every token whose value is a color-mix(), for a browser that has none.
+ *
+ * Last in the tokens layer on purpose: it has to outrank 02-tokens.css AND the per-palette
+ * definitions in 03-palettes.css, and directory order is source order (build-css.sh).
+ *
+ * Why a whole file rather than a fallback declaration in front of each color-mix(): a custom
+ * property keeps whatever token stream it is given, so the failure is deferred to the point of USE
+ * — `background: var(--fs-good-soft)` becomes invalid at computed-value time and computes to
+ * `unset`, which is `initial` for a non-inherited property. IACVT does not fall back to an earlier
+ * declaration in the same block, so the two-declaration trick that works for `background:
+ * color-mix(…)` written literally does nothing for a token. 38 tokens are affected; without this
+ * block a browser below the floor paints borders in currentcolor and loses every soft surface.
+ *
+ * color-mix() lands in Chrome 111, Firefox 113 and Safari 16.2 (all 2022-2023). See docs/css.md
+ * for the theme's browser floor and which feature sets it.
+ *
+ * The rules: a tint over transparent degrades to `transparent` — an absent surface never lowers
+ * contrast, while a solid version of the same colour under its own text does. A hairline degrades
+ * to the solid colour it is a fraction of. A focus ring degrades to the -solo ring, which is
+ * already color-mix-free, because an invisible focus ring is an accessibility regression rather
+ * than a cosmetic one. */
+ @supports not (color: color-mix(in srgb, red 50%, transparent)) {
+ :root {
+ /* tints: absent, not solid */
+ --fs-accent-soft: transparent;
+ --fs-good-soft: transparent;
+ --fs-warn-soft: transparent;
+ --fs-danger-soft: transparent;
+ --fs-accent-fill: transparent;
+ --fs-good-fill: transparent;
+ --fs-warn-fill: transparent;
+ --fs-danger-fill: transparent;
+
+ /* hairlines: the solid colour they are a fraction of */
+ --fs-accent-line: var(--fs-accent);
+ --fs-good-line: var(--fs-good);
+ --fs-warn-line: var(--fs-warn);
+ --fs-danger-line: var(--fs-danger);
+ --fs-accent-line-hi: var(--fs-accent);
+ --fs-good-line-hi: var(--fs-good);
+ --fs-warn-line-hi: var(--fs-warn);
+ --fs-danger-line-hi: var(--fs-danger);
+
+ /* the two rings, restated: -soft and -fill are transparent above, and a ring nobody can
+ * see is worse than a heavy one. -solo is already color-mix-free and measured. */
+ --fs-focus-ring: var(--fs-focus-ring-solo);
+ --fs-focus-ring-invalid: 0 0 0 2px var(--fs-panel), 0 0 0 5px var(--fs-danger);
+
+ /* surfaces that are a near-opaque panel anyway */
+ --fs-glass: var(--fs-panel);
+ --fs-bar-bg: var(--fs-panel);
+ --background-color-medium: var(--fs-panel2);
+
+ /* depth cues: dropped rather than approximated — each is a 1px highlight whose whole
+ * purpose is subtlety, and an opaque one reads as a stray line. */
+ --fs-emboss: none;
+ --fs-text-emboss: none;
+ --fs-shadow-bar: 0 1px 0 var(--fs-border);
+
+ /* the scrim over an uploaded photo: --fs-photo-dim is a percentage this cannot apply, so
+ * take the fixed dialog scrim. Losing it entirely would leave login text over a photo. */
+ --fs-photo-scrim: var(--fs-scrim);
+
+ /* export tier — third-party apps read these, so they must resolve to something.
+ * -medium keeps the hue, -low degrades to the dim text colour it was mixed toward. */
+ --border-color-high: var(--fs-border);
+ --border-color-low: var(--fs-border);
+ --primary-color-medium: var(--fs-accent);
+ --primary-color-low: var(--fs-dim);
+ --error-color-medium: var(--fs-danger);
+ --error-color-low: var(--fs-dim);
+ --success-color-medium: var(--fs-good);
+ --success-color-low: var(--fs-dim);
+ --warn-color-medium: var(--fs-warn);
+ --warn-color-low: var(--fs-dim);
+ }
+ }
+}
diff --git a/luci-theme-footstrap/styles/theme/30-tables.css b/luci-theme-footstrap/styles/theme/30-tables.css
index 6de382823..7f8fbcbfb 100644
--- a/luci-theme-footstrap/styles/theme/30-tables.css
+++ b/luci-theme-footstrap/styles/theme/30-tables.css
@@ -519,9 +519,14 @@
* mid-card (measured). Stretched, both cells take the flex line's height and the
* separators meet; content still sits at the top (the cells are blocks). */
/* A `` with no `.tr` inside it IS the row — see the footer block below for which view
- * writes which shape. Here rather than in a rule of its own: `css-dup` compares bodies of three
- * declarations or more, so a second copy of these two would sit unflagged until they diverged. */
- .table.fs-stacked .tr,
+ * writes which shape.
+ *
+ * TWO rules, not one list: a selector list is not forgiving, so a browser without `:has()`
+ * (Firefox before 121) throws away the WHOLE list, and `.table.fs-stacked .tr` — every card on
+ * every stacked table — loses `display: flex` with it. Split, the `:has()` half degrades alone.
+ * The duplicated body is under `css-dup`'s three-declaration floor, so it will not be flagged;
+ * the two must change together. */
+ .table.fs-stacked .tr { display: flex; flex-wrap: wrap; }
.table.fs-stacked tfoot:not(:has(> .tr)),
.table.fs-stacked .tfoot:not(:has(> .tr)) { display: flex; flex-wrap: wrap; }
.table.fs-stacked .td {
diff --git a/luci-theme-footstrap/ucode/template/themes/footstrap/partials/head.ut b/luci-theme-footstrap/ucode/template/themes/footstrap/partials/head.ut
index 477a88d7a..f832eb4fb 100644
--- a/luci-theme-footstrap/ucode/template/themes/footstrap/partials/head.ut
+++ b/luci-theme-footstrap/ucode/template/themes/footstrap/partials/head.ut
@@ -148,10 +148,16 @@
localStorage. See the sanitiser above for the unset encodings. #}
-
-
-
+ {# The colour a mobile browser paints its own chrome with — address bar, task switcher, the
+ installed app's title bar. Without it a dark page keeps a white bar above it.
+
+ The literal is the default palette's page colour and the SAME value manifest.json carries,
+ for the same reason: neither file can be rendered per request. It is a starting value
+ only — fs-prefs.js repaints this tag from the live body background, so it follows palette,
+ mode and every tint axis. tests/theme-color.test.mjs holds the two literals equal.
+
+ One tag, no `media` pair: a media-qualified tag would win over this one for a viewer whose
+ OS is dark but who chose light HERE, and the choice is the theme's to honour. #}
+
{# The sheet link below is emitted only when the TOKEN says the sheet exists — set the option
after the file, never before. A preload is not in the stylesheet, so dropping the
@font-face rules alone left the pair behind and every page went on asking the router for
@@ -388,9 +402,9 @@
that extension, so the same bytes come back `application/octet-stream` there and with a
JSON type only as `.json`. The map is compiled into uhttpd. #}
- {# iOS reads this link and never the manifest's icons array, and it scales the 512 square the
- manifest also uses. One raster serves both — tools/build-icons.mjs. #}
-
+ {# iOS reads this link and never the manifest's icons array, and it scales the 192 square the
+ manifest also uses down to its own 180. One raster serves both — tools/build-icons.mjs. #}
+
{% if (dispatched?.css): %}
{# The `css` property of the dispatched menu.d node: an app naming a stylesheet the server
should link for its page, instead of the view injecting one at module eval.
diff --git a/mosdns/Makefile b/mosdns/Makefile
index 5f9852232..bab8a1b76 100644
--- a/mosdns/Makefile
+++ b/mosdns/Makefile
@@ -6,7 +6,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=mosdns
PKG_VERSION:=5.3.4
-PKG_RELEASE:=18
+PKG_RELEASE:=19
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/IrineSistiana/mosdns/tar.gz/v$(PKG_VERSION)?
diff --git a/mosdns/patches/216-plugin-add-stats_api-plugin.patch b/mosdns/patches/216-plugin-add-stats_api-plugin.patch
new file mode 100644
index 000000000..500f64085
--- /dev/null
+++ b/mosdns/patches/216-plugin-add-stats_api-plugin.patch
@@ -0,0 +1,1021 @@
+From 1c53fecde36c06f87d62860e11aee92b9c570d75 Mon Sep 17 00:00:00 2001
+From: sbwml
+Date: Fri, 28 Aug 2026 19:06:24 +0800
+Subject: [PATCH 1/5] plugin: add stats_api plugin
+
+Signed-off-by: sbwml
+---
+ plugin/enabled_plugins.go | 1 +
+ plugin/executable/stats_api/stats_api.go | 726 ++++++++++++++++++
+ plugin/executable/stats_api/stats_api_test.go | 265 +++++++
+ 3 files changed, 992 insertions(+)
+ create mode 100644 plugin/executable/stats_api/stats_api.go
+ create mode 100644 plugin/executable/stats_api/stats_api_test.go
+
+--- a/plugin/enabled_plugins.go
++++ b/plugin/enabled_plugins.go
+@@ -62,6 +62,7 @@ import (
+ _ "github.com/IrineSistiana/mosdns/v5/plugin/executable/sequence"
+ _ "github.com/IrineSistiana/mosdns/v5/plugin/executable/sequence/fallback"
+ _ "github.com/IrineSistiana/mosdns/v5/plugin/executable/sleep"
++ _ "github.com/IrineSistiana/mosdns/v5/plugin/executable/stats_api"
+ _ "github.com/IrineSistiana/mosdns/v5/plugin/executable/ttl"
+
+ // executable and matcher
+--- /dev/null
++++ b/plugin/executable/stats_api/stats_api.go
+@@ -0,0 +1,726 @@
++/*
++ * Copyright (C) 2020-2026, IrineSistiana
++ *
++ * This file is part of mosdns.
++ *
++ * mosdns is free software: you can redistribute it and/or modify
++ * it under the terms of the GNU General Public License as published by
++ * the Free Software Foundation, either version 3 of the License, or
++ * (at your option) any later version.
++ *
++ * mosdns is distributed in the hope that it will be useful,
++ * but WITHOUT ANY WARRANTY; without even the implied warranty of
++ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
++ * GNU General Public License for more details.
++ *
++ * You should have received a copy of the GNU General Public License
++ * along with this program. If not, see .
++ */
++
++package stats_api
++
++import (
++ "context"
++ "encoding/json"
++ "errors"
++ "fmt"
++ "math"
++ "net/http"
++ "sort"
++ "strconv"
++ "strings"
++ "sync"
++ "sync/atomic"
++ "time"
++
++ "github.com/IrineSistiana/mosdns/v5/coremain"
++ "github.com/IrineSistiana/mosdns/v5/pkg/query_context"
++ "github.com/IrineSistiana/mosdns/v5/plugin/executable/sequence"
++ "github.com/go-chi/chi/v5"
++ "github.com/miekg/dns"
++ "go.uber.org/zap"
++)
++
++const PluginType = "stats_api"
++
++func init() {
++ coremain.RegNewPluginFunc(PluginType, Init, func() any { return new(Args) })
++ sequence.MustRegExecQuickSetup(PluginType, QuickSetup)
++}
++
++var _ sequence.RecursiveExecutable = (*StatsAPI)(nil)
++
++type Args struct {
++ Listen string `yaml:"listen"`
++ Capacity int `yaml:"capacity"`
++}
++
++func (a *Args) init() {
++ if a.Capacity <= 0 {
++ a.Capacity = 2000
++ }
++}
++
++type AnswerDTO struct {
++ Type string `json:"type"`
++ Data string `json:"data"`
++ TTL uint32 `json:"ttl"`
++}
++
++type LogEntry struct {
++ ID string `json:"id"`
++ Timestamp string `json:"timestamp"`
++ ClientIP string `json:"client_ip"`
++ Domain string `json:"domain"`
++ QType string `json:"qtype"`
++ Status string `json:"status"`
++ IsBlocked bool `json:"is_blocked"`
++ IsCached bool `json:"is_cached"`
++ ElapsedMS float64 `json:"elapsed_ms"`
++ Upstream string `json:"upstream,omitempty"`
++ Rule string `json:"rule,omitempty"`
++ Answers []AnswerDTO `json:"answers"`
++}
++
++type RingBuffer struct {
++ mu sync.RWMutex
++ buf []LogEntry
++ capacity int
++ head int
++ count int
++ seqID uint64
++}
++
++func NewRingBuffer(capacity int) *RingBuffer {
++ if capacity <= 0 {
++ capacity = 2000
++ }
++ return &RingBuffer{
++ buf: make([]LogEntry, capacity),
++ capacity: capacity,
++ }
++}
++
++func (r *RingBuffer) Push(entry LogEntry) {
++ r.mu.Lock()
++ defer r.mu.Unlock()
++
++ r.seqID++
++ nowSec := time.Now().Unix()
++ entry.ID = fmt.Sprintf("%d-%d", nowSec, r.seqID)
++
++ r.buf[r.head] = entry
++ r.head = (r.head + 1) % r.capacity
++ if r.count < r.capacity {
++ r.count++
++ }
++}
++
++func (r *RingBuffer) Clear() {
++ r.mu.Lock()
++ defer r.mu.Unlock()
++
++ r.buf = make([]LogEntry, r.capacity)
++ r.head = 0
++ r.count = 0
++ r.seqID = 0
++}
++
++func (r *RingBuffer) QueryLogs(limit, offset int, search, filter string) (int, []LogEntry) {
++ r.mu.RLock()
++ defer r.mu.RUnlock()
++
++ all := make([]LogEntry, 0, r.count)
++ for i := 0; i < r.count; i++ {
++ idx := (r.head - 1 - i + r.capacity) % r.capacity
++ all = append(all, r.buf[idx])
++ }
++
++ searchLower := strings.ToLower(strings.TrimSpace(search))
++ filterLower := strings.ToLower(strings.TrimSpace(filter))
++
++ filtered := make([]LogEntry, 0, len(all))
++ for _, entry := range all {
++ if filterLower == "blocked" && !entry.IsBlocked {
++ continue
++ }
++ if filterLower == "cached" && !entry.IsCached {
++ continue
++ }
++
++ if searchLower != "" {
++ if !strings.Contains(strings.ToLower(entry.Domain), searchLower) &&
++ !strings.Contains(strings.ToLower(entry.ClientIP), searchLower) {
++ continue
++ }
++ }
++
++ filtered = append(filtered, entry)
++ }
++
++ total := len(filtered)
++ if offset < 0 {
++ offset = 0
++ }
++ if offset >= total {
++ return total, []LogEntry{}
++ }
++
++ end := offset + limit
++ if end > total {
++ end = total
++ }
++
++ return total, filtered[offset:end]
++}
++
++type TopItem struct {
++ Domain string `json:"domain,omitempty"`
++ ClientIP string `json:"client_ip,omitempty"`
++ Count uint64 `json:"count"`
++}
++
++type TopStats struct {
++ mu sync.RWMutex
++ topDomains map[string]uint64
++ topClients map[string]uint64
++ topBlocked map[string]uint64
++}
++
++func NewTopStats() *TopStats {
++ return &TopStats{
++ topDomains: make(map[string]uint64),
++ topClients: make(map[string]uint64),
++ topBlocked: make(map[string]uint64),
++ }
++}
++
++func (t *TopStats) Record(domain, clientIP string, isBlocked bool) {
++ t.mu.Lock()
++ defer t.mu.Unlock()
++
++ if domain != "" {
++ t.topDomains[domain]++
++ }
++ if clientIP != "" {
++ t.topClients[clientIP]++
++ }
++ if isBlocked && domain != "" {
++ t.topBlocked[domain]++
++ }
++}
++
++func (t *TopStats) Clear() {
++ t.mu.Lock()
++ defer t.mu.Unlock()
++
++ t.topDomains = make(map[string]uint64)
++ t.topClients = make(map[string]uint64)
++ t.topBlocked = make(map[string]uint64)
++}
++
++func getSortedTop(m map[string]uint64, isClient bool, limit int) []TopItem {
++ type pair struct {
++ key string
++ count uint64
++ }
++ pairs := make([]pair, 0, len(m))
++ for k, v := range m {
++ pairs = append(pairs, pair{key: k, count: v})
++ }
++ sort.Slice(pairs, func(i, j int) bool {
++ if pairs[i].count == pairs[j].count {
++ return pairs[i].key < pairs[j].key
++ }
++ return pairs[i].count > pairs[j].count
++ })
++
++ if limit > len(pairs) {
++ limit = len(pairs)
++ }
++ res := make([]TopItem, 0, limit)
++ for i := 0; i < limit; i++ {
++ item := TopItem{Count: pairs[i].count}
++ if isClient {
++ item.ClientIP = pairs[i].key
++ } else {
++ item.Domain = pairs[i].key
++ }
++ res = append(res, item)
++ }
++ return res
++}
++
++func (t *TopStats) GetTop(limit int) ([]TopItem, []TopItem, []TopItem) {
++ t.mu.RLock()
++ defer t.mu.RUnlock()
++
++ if limit <= 0 {
++ limit = 10
++ }
++ topDomains := getSortedTop(t.topDomains, false, limit)
++ topClients := getSortedTop(t.topClients, true, limit)
++ topBlocked := getSortedTop(t.topBlocked, false, limit)
++ return topDomains, topClients, topBlocked
++}
++
++type HistoryPoint struct {
++ Time string `json:"time"`
++ Total uint64 `json:"total"`
++ Blocked uint64 `json:"blocked"`
++ Cached uint64 `json:"cached"`
++}
++
++type HistoryBucket struct {
++ Total atomic.Uint64
++ Blocked atomic.Uint64
++ Cached atomic.Uint64
++}
++
++type HistoryStats struct {
++ mu sync.RWMutex
++ points map[int64]*HistoryBucket
++}
++
++func NewHistoryStats() *HistoryStats {
++ return &HistoryStats{
++ points: make(map[int64]*HistoryBucket),
++ }
++}
++
++func (h *HistoryStats) Record(t time.Time, isBlocked, isCached bool) {
++ tHour := t.UTC().Truncate(time.Hour).Unix()
++
++ h.mu.RLock()
++ bucket, ok := h.points[tHour]
++ h.mu.RUnlock()
++
++ if !ok {
++ h.mu.Lock()
++ bucket, ok = h.points[tHour]
++ if !ok {
++ bucket = &HistoryBucket{}
++ h.points[tHour] = bucket
++
++ // Clean up old buckets beyond 48 hours
++ cutoff := t.UTC().Add(-48 * time.Hour).Unix()
++ for k := range h.points {
++ if k < cutoff {
++ delete(h.points, k)
++ }
++ }
++ }
++ h.mu.Unlock()
++ }
++
++ bucket.Total.Add(1)
++ if isBlocked {
++ bucket.Blocked.Add(1)
++ }
++ if isCached {
++ bucket.Cached.Add(1)
++ }
++}
++
++func (h *HistoryStats) GetHistory(numPoints int) []HistoryPoint {
++ if numPoints <= 0 {
++ numPoints = 24
++ }
++ now := time.Now().UTC().Truncate(time.Hour)
++ res := make([]HistoryPoint, 0, numPoints)
++
++ h.mu.RLock()
++ defer h.mu.RUnlock()
++
++ for i := numPoints - 1; i >= 0; i-- {
++ slotTime := now.Add(time.Duration(-i) * time.Hour)
++ slotUnix := slotTime.Unix()
++
++ var total, blocked, cached uint64
++ if bucket, ok := h.points[slotUnix]; ok {
++ total = bucket.Total.Load()
++ blocked = bucket.Blocked.Load()
++ cached = bucket.Cached.Load()
++ }
++
++ res = append(res, HistoryPoint{
++ Time: slotTime.Format(time.RFC3339),
++ Total: total,
++ Blocked: blocked,
++ Cached: cached,
++ })
++ }
++ return res
++}
++
++type StatsAPI struct {
++ args *Args
++ logger *zap.Logger
++
++ ringBuffer *RingBuffer
++ topStats *TopStats
++ historyStats *HistoryStats
++
++ totalQueries atomic.Uint64
++ blockedQueries atomic.Uint64
++ cachedQueries atomic.Uint64
++ totalLatencyUs atomic.Uint64
++
++ httpServer *http.Server
++ closeOnce sync.Once
++}
++
++func Init(bp *coremain.BP, args any) (any, error) {
++ a := args.(*Args)
++ s := NewStatsAPI(a, bp.L())
++ bp.RegAPI(s.Router())
++ return s, nil
++}
++
++func QuickSetup(bq sequence.BQ, s string) (any, error) {
++ fields := strings.Fields(s)
++ listen := ""
++ capacity := 2000
++ if len(fields) > 0 {
++ listen = fields[0]
++ }
++ if len(fields) > 1 {
++ if c, err := strconv.Atoi(fields[1]); err == nil && c > 0 {
++ capacity = c
++ }
++ }
++ return NewStatsAPI(&Args{Listen: listen, Capacity: capacity}, bq.L()), nil
++}
++
++func NewStatsAPI(args *Args, logger *zap.Logger) *StatsAPI {
++ args.init()
++ if logger == nil {
++ logger = zap.NewNop()
++ }
++ s := &StatsAPI{
++ args: args,
++ logger: logger,
++ ringBuffer: NewRingBuffer(args.Capacity),
++ topStats: NewTopStats(),
++ historyStats: NewHistoryStats(),
++ }
++
++ if len(args.Listen) > 0 {
++ srv := &http.Server{
++ Addr: args.Listen,
++ Handler: s.Router(),
++ }
++ s.httpServer = srv
++ go func() {
++ logger.Info("starting stats api http server", zap.String("addr", args.Listen))
++ if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
++ logger.Error("stats api http server error", zap.Error(err))
++ }
++ }()
++ }
++ return s
++}
++
++func (s *StatsAPI) Router() *chi.Mux {
++ r := chi.NewRouter()
++
++ r.Use(func(next http.Handler) http.Handler {
++ return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
++ w.Header().Set("Access-Control-Allow-Origin", "*")
++ w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
++ w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
++ if req.Method == http.MethodOptions {
++ w.WriteHeader(http.StatusNoContent)
++ return
++ }
++ next.ServeHTTP(w, req)
++ })
++ })
++
++ r.Get("/api/v1/stats", s.handleStats)
++ r.Get("/api/v1/logs", s.handleLogs)
++ r.Get("/api/v1/top", s.handleTop)
++ r.Get("/api/v1/history", s.handleHistory)
++ r.Post("/api/v1/logs/clear", s.handleClearLogs)
++ r.Post("/api/v1/cache/clear", s.handleClearCache)
++
++ return r
++}
++
++func (s *StatsAPI) handleStats(w http.ResponseWriter, req *http.Request) {
++ total := s.totalQueries.Load()
++ blocked := s.blockedQueries.Load()
++ cached := s.cachedQueries.Load()
++ latUs := s.totalLatencyUs.Load()
++
++ var blockedPct, cachedPct, avgLat float64
++ if total > 0 {
++ blockedPct = float64(blocked) / float64(total) * 100.0
++ cachedPct = float64(cached) / float64(total) * 100.0
++ avgLat = (float64(latUs) / float64(total)) / 1000.0
++ }
++
++ blockedPct = math.Round(blockedPct*100) / 100
++ cachedPct = math.Round(cachedPct*100) / 100
++ avgLat = math.Round(avgLat*100) / 100
++
++ resp := map[string]any{
++ "total_queries": total,
++ "blocked_queries": blocked,
++ "cached_queries": cached,
++ "blocked_percentage": blockedPct,
++ "cached_percentage": cachedPct,
++ "avg_latency_ms": avgLat,
++ }
++
++ w.Header().Set("Content-Type", "application/json")
++ _ = json.NewEncoder(w).Encode(resp)
++}
++
++func (s *StatsAPI) handleLogs(w http.ResponseWriter, req *http.Request) {
++ q := req.URL.Query()
++ limitStr := q.Get("limit")
++ offsetStr := q.Get("offset")
++ search := q.Get("search")
++ filter := q.Get("filter")
++
++ limit := 50
++ if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
++ limit = l
++ }
++ if limit > 500 {
++ limit = 500
++ }
++ offset := 0
++ if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 {
++ offset = o
++ }
++
++ total, items := s.ringBuffer.QueryLogs(limit, offset, search, filter)
++ if items == nil {
++ items = []LogEntry{}
++ }
++
++ w.Header().Set("Content-Type", "application/json")
++ _ = json.NewEncoder(w).Encode(map[string]any{
++ "total": total,
++ "items": items,
++ })
++}
++
++func (s *StatsAPI) handleTop(w http.ResponseWriter, req *http.Request) {
++ limitStr := req.URL.Query().Get("limit")
++ limit := 10
++ if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
++ limit = l
++ }
++
++ topDomains, topClients, topBlocked := s.topStats.GetTop(limit)
++ if topDomains == nil {
++ topDomains = []TopItem{}
++ }
++ if topClients == nil {
++ topClients = []TopItem{}
++ }
++ if topBlocked == nil {
++ topBlocked = []TopItem{}
++ }
++
++ w.Header().Set("Content-Type", "application/json")
++ _ = json.NewEncoder(w).Encode(map[string]any{
++ "top_domains": topDomains,
++ "top_clients": topClients,
++ "top_blocked": topBlocked,
++ })
++}
++
++func (s *StatsAPI) handleHistory(w http.ResponseWriter, req *http.Request) {
++ pointsStr := req.URL.Query().Get("points")
++ numPoints := 24
++ if p, err := strconv.Atoi(pointsStr); err == nil && p > 0 {
++ numPoints = p
++ }
++ if numPoints > 168 { // Max 7 days of hourly points
++ numPoints = 168
++ }
++
++ points := s.historyStats.GetHistory(numPoints)
++ w.Header().Set("Content-Type", "application/json")
++ _ = json.NewEncoder(w).Encode(map[string]any{
++ "points": points,
++ })
++}
++
++func (s *StatsAPI) handleClearLogs(w http.ResponseWriter, req *http.Request) {
++ s.ringBuffer.Clear()
++ s.topStats.Clear()
++
++ w.Header().Set("Content-Type", "application/json")
++ _ = json.NewEncoder(w).Encode(map[string]any{
++ "success": true,
++ "message": "Logs cleared successfully",
++ })
++}
++
++func (s *StatsAPI) handleClearCache(w http.ResponseWriter, req *http.Request) {
++ w.Header().Set("Content-Type", "application/json")
++ _ = json.NewEncoder(w).Encode(map[string]any{
++ "success": true,
++ "message": "Cache clear request processed",
++ })
++}
++
++func (s *StatsAPI) Exec(ctx context.Context, qCtx *query_context.Context, next sequence.ChainWalker) error {
++ start := time.Now()
++ err := next.ExecNext(ctx, qCtx)
++ elapsed := time.Since(start)
++
++ s.totalQueries.Add(1)
++ s.totalLatencyUs.Add(uint64(elapsed.Microseconds()))
++
++ var clientIP string
++ if clientAddr := qCtx.ServerMeta.ClientAddr; clientAddr.IsValid() {
++ clientIP = clientAddr.String()
++ }
++ if clientIP == "" {
++ clientIP = "127.0.0.1"
++ }
++
++ qQuestion := qCtx.QQuestion()
++ domain := qQuestion.Name
++ qtypeStr := dns.TypeToString[qQuestion.Qtype]
++ if qtypeStr == "" {
++ qtypeStr = fmt.Sprintf("TYPE%d", qQuestion.Qtype)
++ }
++
++ isCached := qCtx.CacheState.Hit || qCtx.CacheState.LazyHit
++ if isCached {
++ s.cachedQueries.Add(1)
++ }
++
++ r := qCtx.R()
++ var status string
++ var answers []AnswerDTO
++ isBlocked := false
++
++ if r == nil {
++ status = "DROPPED"
++ isBlocked = true
++ } else {
++ if rcodeStr, ok := dns.RcodeToString[r.Rcode]; ok {
++ status = rcodeStr
++ } else {
++ status = fmt.Sprintf("RCODE%d", r.Rcode)
++ }
++
++ if r.Rcode == dns.RcodeNameError || r.Rcode == dns.RcodeRefused {
++ isBlocked = true
++ }
++
++ for _, rr := range r.Answer {
++ hdr := rr.Header()
++ rrTypeStr := dns.TypeToString[hdr.Rrtype]
++ if rrTypeStr == "" {
++ rrTypeStr = fmt.Sprintf("TYPE%d", hdr.Rrtype)
++ }
++ var dataStr string
++ switch record := rr.(type) {
++ case *dns.A:
++ dataStr = record.A.String()
++ if dataStr == "0.0.0.0" || dataStr == "127.0.0.1" {
++ isBlocked = true
++ }
++ case *dns.AAAA:
++ dataStr = record.AAAA.String()
++ if dataStr == "::" || dataStr == "::1" {
++ isBlocked = true
++ }
++ case *dns.CNAME:
++ dataStr = record.Target
++ case *dns.TXT:
++ dataStr = strings.Join(record.Txt, " ")
++ case *dns.PTR:
++ dataStr = record.Ptr
++ case *dns.MX:
++ dataStr = record.Mx
++ default:
++ dataStr = rr.String()
++ }
++ answers = append(answers, AnswerDTO{
++ Type: rrTypeStr,
++ Data: dataStr,
++ TTL: hdr.Ttl,
++ })
++ }
++ }
++
++ if isBlocked {
++ s.blockedQueries.Add(1)
++ }
++
++ // Extract Upstream information
++ var upstream string
++ if isCached {
++ upstream = "cache"
++ } else if u := qCtx.UpstreamSelected; u != nil {
++ if u.Protocol != "" && u.Addr != "" {
++ upstream = fmt.Sprintf("%s://%s", u.Protocol, u.Addr)
++ } else if u.Addr != "" {
++ upstream = u.Addr
++ } else if u.Tag != "" {
++ upstream = u.Tag
++ }
++ }
++
++ // Extract Rule information
++ var rule string
++ if len(qCtx.RuleHits) > 0 {
++ for i := len(qCtx.RuleHits) - 1; i >= 0; i-- {
++ hit := qCtx.RuleHits[i]
++ if len(hit.Matches) > 0 {
++ rule = strings.Join(hit.Matches, ",")
++ break
++ } else if hit.Exec != "" {
++ rule = hit.Exec
++ break
++ } else if hit.Sequence != "" {
++ rule = hit.Sequence
++ break
++ }
++ }
++ }
++
++ s.topStats.Record(domain, clientIP, isBlocked)
++ s.historyStats.Record(start, isBlocked, isCached)
++
++ elapsedMS := math.Round(float64(elapsed.Microseconds())/10.0) / 100.0
++
++ entry := LogEntry{
++ Timestamp: start.UTC().Format(time.RFC3339),
++ ClientIP: clientIP,
++ Domain: domain,
++ QType: qtypeStr,
++ Status: status,
++ IsBlocked: isBlocked,
++ IsCached: isCached,
++ ElapsedMS: elapsedMS,
++ Upstream: upstream,
++ Rule: rule,
++ Answers: answers,
++ }
++
++ s.ringBuffer.Push(entry)
++
++ return err
++}
++
++func (s *StatsAPI) Close() error {
++ s.closeOnce.Do(func() {
++ if s.httpServer != nil {
++ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
++ defer cancel()
++ _ = s.httpServer.Shutdown(ctx)
++ }
++ })
++ return nil
++}
+--- /dev/null
++++ b/plugin/executable/stats_api/stats_api_test.go
+@@ -0,0 +1,265 @@
++/*
++ * Copyright (C) 2020-2026, IrineSistiana
++ *
++ * This file is part of mosdns.
++ *
++ * mosdns is free software: you can redistribute it and/or modify
++ * it under the terms of the GNU General Public License as published by
++ * the Free Software Foundation, either version 3 of the License, or
++ * (at your option) any later version.
++ *
++ * mosdns is distributed in the hope that it will be useful,
++ * but WITHOUT ANY WARRANTY; without even the implied warranty of
++ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
++ * GNU General Public License for more details.
++ *
++ * You should have received a copy of the GNU General Public License
++ * along with this program. If not, see .
++ */
++
++package stats_api
++
++import (
++ "context"
++ "encoding/json"
++ "fmt"
++ "net/http"
++ "net/http/httptest"
++ "testing"
++ "time"
++
++ "github.com/IrineSistiana/mosdns/v5/pkg/query_context"
++ "github.com/IrineSistiana/mosdns/v5/plugin/executable/sequence"
++ "github.com/miekg/dns"
++ "go.uber.org/zap"
++)
++
++func TestRingBuffer(t *testing.T) {
++ rb := NewRingBuffer(5)
++ for i := 1; i <= 10; i++ {
++ rb.Push(LogEntry{
++ Domain: fmt.Sprintf("example%d.com.", i),
++ ClientIP: "192.168.1.1",
++ IsBlocked: i%2 == 0,
++ IsCached: i%3 == 0,
++ })
++ }
++
++ total, logs := rb.QueryLogs(10, 0, "", "all")
++ if total != 5 {
++ t.Fatalf("expected total 5, got %d", total)
++ }
++ if len(logs) != 5 {
++ t.Fatalf("expected 5 logs, got %d", len(logs))
++ }
++
++ if logs[0].Domain != "example10.com." {
++ t.Errorf("expected newest domain example10.com., got %s", logs[0].Domain)
++ }
++
++ totalSearch, searchLogs := rb.QueryLogs(10, 0, "example9", "all")
++ if totalSearch != 1 || searchLogs[0].Domain != "example9.com." {
++ t.Errorf("search failed, total: %d", totalSearch)
++ }
++
++ totalBlocked, blockedLogs := rb.QueryLogs(10, 0, "", "blocked")
++ if totalBlocked != 3 {
++ t.Errorf("expected 3 blocked logs, got %d", totalBlocked)
++ }
++ for _, l := range blockedLogs {
++ if !l.IsBlocked {
++ t.Errorf("expected blocked log, got unblocked: %s", l.Domain)
++ }
++ }
++
++ _, pageLogs := rb.QueryLogs(2, 1, "", "all")
++ if len(pageLogs) != 2 {
++ t.Fatalf("expected 2 page items, got %d", len(pageLogs))
++ }
++ if pageLogs[0].Domain != logs[1].Domain {
++ t.Errorf("pagination offset mismatch: got %s, expected %s", pageLogs[0].Domain, logs[1].Domain)
++ }
++
++ // Test Clear
++ rb.Clear()
++ totalClear, logsClear := rb.QueryLogs(10, 0, "", "all")
++ if totalClear != 0 || len(logsClear) != 0 {
++ t.Errorf("expected 0 logs after clear, got %d", totalClear)
++ }
++}
++
++func TestTopStats(t *testing.T) {
++ top := NewTopStats()
++
++ top.Record("a.com.", "192.168.1.1", false)
++ top.Record("a.com.", "192.168.1.1", true)
++ top.Record("b.com.", "192.168.1.2", true)
++ top.Record("a.com.", "192.168.1.2", false)
++
++ domains, clients, blocked := top.GetTop(10)
++
++ if len(domains) == 0 || domains[0].Domain != "a.com." || domains[0].Count != 3 {
++ t.Errorf("top domains mismatch: %+v", domains)
++ }
++
++ if len(clients) < 2 {
++ t.Fatalf("expected at least 2 clients, got %d", len(clients))
++ }
++
++ if len(blocked) < 2 {
++ t.Fatalf("expected at least 2 blocked domains, got %d", len(blocked))
++ }
++
++ // Test Clear
++ top.Clear()
++ dClear, _, _ := top.GetTop(10)
++ if len(dClear) != 0 {
++ t.Errorf("expected 0 top domains after clear, got %d", len(dClear))
++ }
++}
++
++func TestHistoryStats(t *testing.T) {
++ h := NewHistoryStats()
++ now := time.Now()
++
++ h.Record(now, false, false)
++ h.Record(now, true, false)
++ h.Record(now, false, true)
++
++ points := h.GetHistory(24)
++ if len(points) != 24 {
++ t.Fatalf("expected 24 history points, got %d", len(points))
++ }
++
++ lastPoint := points[len(points)-1]
++ if lastPoint.Total != 3 || lastPoint.Blocked != 1 || lastPoint.Cached != 1 {
++ t.Errorf("history point mismatch: %+v", lastPoint)
++ }
++}
++
++func TestStatsAPIHTTPEndpoints(t *testing.T) {
++ s := NewStatsAPI(&Args{Capacity: 100}, zap.NewNop())
++ router := s.Router()
++
++ s.ringBuffer.Push(LogEntry{
++ Domain: "test.com.",
++ ClientIP: "192.168.1.50",
++ IsBlocked: true,
++ IsCached: false,
++ ElapsedMS: 15.5,
++ Upstream: "UDP://8.8.8.8:53",
++ Rule: "qname test.com.",
++ })
++ s.totalQueries.Add(1)
++ s.blockedQueries.Add(1)
++ s.totalLatencyUs.Add(15500)
++ s.topStats.Record("test.com.", "192.168.1.50", true)
++ s.historyStats.Record(time.Now(), true, false)
++
++ // Test GET /api/v1/stats
++ reqStats := httptest.NewRequest(http.MethodGet, "/api/v1/stats", nil)
++ wStats := httptest.NewRecorder()
++ router.ServeHTTP(wStats, reqStats)
++
++ if wStats.Code != http.StatusOK {
++ t.Fatalf("expected HTTP 200, got %d", wStats.Code)
++ }
++
++ var statsResp map[string]any
++ if err := json.Unmarshal(wStats.Body.Bytes(), &statsResp); err != nil {
++ t.Fatalf("failed to unmarshal stats response: %v", err)
++ }
++ if statsResp["total_queries"].(float64) != 1 {
++ t.Errorf("expected total_queries 1, got %v", statsResp["total_queries"])
++ }
++
++ // Test GET /api/v1/history
++ reqHist := httptest.NewRequest(http.MethodGet, "/api/v1/history?points=24", nil)
++ wHist := httptest.NewRecorder()
++ router.ServeHTTP(wHist, reqHist)
++
++ if wHist.Code != http.StatusOK {
++ t.Fatalf("expected HTTP 200 for history, got %d", wHist.Code)
++ }
++ var histResp map[string]any
++ if err := json.Unmarshal(wHist.Body.Bytes(), &histResp); err != nil {
++ t.Fatalf("failed to unmarshal history response: %v", err)
++ }
++ histPoints := histResp["points"].([]any)
++ if len(histPoints) != 24 {
++ t.Errorf("expected 24 history points, got %d", len(histPoints))
++ }
++
++ // Test POST /api/v1/logs/clear
++ reqClearLogs := httptest.NewRequest(http.MethodPost, "/api/v1/logs/clear", nil)
++ wClearLogs := httptest.NewRecorder()
++ router.ServeHTTP(wClearLogs, reqClearLogs)
++
++ if wClearLogs.Code != http.StatusOK {
++ t.Fatalf("expected HTTP 200 for clear logs, got %d", wClearLogs.Code)
++ }
++
++ // Verify logs ring buffer is empty
++ totalLogs, logs := s.ringBuffer.QueryLogs(10, 0, "", "all")
++ if totalLogs != 0 || len(logs) != 0 {
++ t.Errorf("expected 0 logs after clear, got %d", totalLogs)
++ }
++
++ // Test POST /api/v1/cache/clear
++ reqClearCache := httptest.NewRequest(http.MethodPost, "/api/v1/cache/clear", nil)
++ wClearCache := httptest.NewRecorder()
++ router.ServeHTTP(wClearCache, reqClearCache)
++
++ if wClearCache.Code != http.StatusOK {
++ t.Fatalf("expected HTTP 200 for clear cache, got %d", wClearCache.Code)
++ }
++}
++
++func TestStatsAPIExec(t *testing.T) {
++ s := NewStatsAPI(&Args{Capacity: 100}, zap.NewNop())
++
++ q := new(dns.Msg)
++ q.SetQuestion("google.com.", dns.TypeA)
++ qCtx := query_context.NewContext(q)
++ qCtx.SetUpstreamSelected("8.8.8.8:53", "UDP", "remote", "forward_remote")
++ qCtx.AddRuleHit("main_sequence", []string{"qname google.com."}, "forward_remote")
++
++ execFunc := sequence.ExecutableFunc(func(ctx context.Context, qCtx *query_context.Context) error {
++ time.Sleep(10 * time.Millisecond)
++ resp := new(dns.Msg)
++ resp.SetReply(qCtx.Q())
++ resp.Answer = append(resp.Answer, &dns.A{
++ Hdr: dns.RR_Header{Name: "google.com.", Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 300},
++ A: []byte{142, 250, 190, 46},
++ })
++ qCtx.SetResponse(resp)
++ return nil
++ })
++
++ walker := sequence.NewChainWalker([]*sequence.ChainNode{
++ {E: execFunc},
++ }, nil)
++
++ err := s.Exec(context.Background(), qCtx, walker)
++ if err != nil {
++ t.Fatalf("Exec returned error: %v", err)
++ }
++
++ if s.totalQueries.Load() != 1 {
++ t.Errorf("expected 1 total query, got %d", s.totalQueries.Load())
++ }
++
++ totalLogs, logs := s.ringBuffer.QueryLogs(10, 0, "", "all")
++ if totalLogs != 1 {
++ t.Fatalf("expected 1 log entry, got %d", totalLogs)
++ }
++ if logs[0].Domain != "google.com." {
++ t.Errorf("expected domain google.com., got %s", logs[0].Domain)
++ }
++ if logs[0].Upstream != "UDP://8.8.8.8:53" {
++ t.Errorf("expected upstream UDP://8.8.8.8:53, got %s", logs[0].Upstream)
++ }
++ if logs[0].Rule != "qname google.com." {
++ t.Errorf("expected rule qname google.com., got %s", logs[0].Rule)
++ }
++}
diff --git a/mosdns/patches/217-fix-stats_api-separate-blocked-domain-counting-in-to.patch b/mosdns/patches/217-fix-stats_api-separate-blocked-domain-counting-in-to.patch
new file mode 100644
index 000000000..76993dbff
--- /dev/null
+++ b/mosdns/patches/217-fix-stats_api-separate-blocked-domain-counting-in-to.patch
@@ -0,0 +1,47 @@
+From 7a0bf53ad7219ab07693f93be4700551802bde5d Mon Sep 17 00:00:00 2001
+From: sbwml
+Date: Fri, 28 Aug 2026 21:41:05 +0800
+Subject: [PATCH 2/5] fix(stats_api): separate blocked domain counting in top
+ stats
+
+Avoid incrementing non-blocked top domain counter when a query is blocked.
+
+Signed-off-by: sbwml
+---
+ plugin/executable/stats_api/stats_api.go | 9 +++++----
+ plugin/executable/stats_api/stats_api_test.go | 2 +-
+ 2 files changed, 6 insertions(+), 5 deletions(-)
+
+--- a/plugin/executable/stats_api/stats_api.go
++++ b/plugin/executable/stats_api/stats_api.go
+@@ -200,14 +200,15 @@ func (t *TopStats) Record(domain, client
+ defer t.mu.Unlock()
+
+ if domain != "" {
+- t.topDomains[domain]++
++ if isBlocked {
++ t.topBlocked[domain]++
++ } else {
++ t.topDomains[domain]++
++ }
+ }
+ if clientIP != "" {
+ t.topClients[clientIP]++
+ }
+- if isBlocked && domain != "" {
+- t.topBlocked[domain]++
+- }
+ }
+
+ func (t *TopStats) Clear() {
+--- a/plugin/executable/stats_api/stats_api_test.go
++++ b/plugin/executable/stats_api/stats_api_test.go
+@@ -98,7 +98,7 @@ func TestTopStats(t *testing.T) {
+
+ domains, clients, blocked := top.GetTop(10)
+
+- if len(domains) == 0 || domains[0].Domain != "a.com." || domains[0].Count != 3 {
++ if len(domains) == 0 || domains[0].Domain != "a.com." || domains[0].Count != 2 {
+ t.Errorf("top domains mismatch: %+v", domains)
+ }
+
diff --git a/mosdns/patches/218-fix-stats_api-filter-blocked-domains-in-top-list-and.patch b/mosdns/patches/218-fix-stats_api-filter-blocked-domains-in-top-list-and.patch
new file mode 100644
index 000000000..bace155cb
--- /dev/null
+++ b/mosdns/patches/218-fix-stats_api-filter-blocked-domains-in-top-list-and.patch
@@ -0,0 +1,72 @@
+From 1fba45727126115b09c7f73d944e960643b21d24 Mon Sep 17 00:00:00 2001
+From: sbwml
+Date: Fri, 28 Aug 2026 22:01:35 +0800
+Subject: [PATCH 3/5] fix(stats_api): filter blocked domains in top list and
+ ignore HTTPS queries
+
+- Filter out blocked domains from top domains list
+- Do not mark HTTPS type queries as blocked on NXDOMAIN/REFUSED
+
+Signed-off-by: sbwml
+---
+ plugin/executable/stats_api/stats_api.go | 12 ++++++++++--
+ plugin/executable/stats_api/stats_api_test.go | 13 ++++---------
+ 2 files changed, 14 insertions(+), 11 deletions(-)
+
+--- a/plugin/executable/stats_api/stats_api.go
++++ b/plugin/executable/stats_api/stats_api.go
+@@ -259,7 +259,15 @@ func (t *TopStats) GetTop(limit int) ([]
+ if limit <= 0 {
+ limit = 10
+ }
+- topDomains := getSortedTop(t.topDomains, false, limit)
++
++ cleanTopDomains := make(map[string]uint64, len(t.topDomains))
++ for domain, count := range t.topDomains {
++ if _, isBlocked := t.topBlocked[domain]; !isBlocked {
++ cleanTopDomains[domain] = count
++ }
++ }
++
++ topDomains := getSortedTop(cleanTopDomains, false, limit)
+ topClients := getSortedTop(t.topClients, true, limit)
+ topBlocked := getSortedTop(t.topBlocked, false, limit)
+ return topDomains, topClients, topBlocked
+@@ -614,7 +622,7 @@ func (s *StatsAPI) Exec(ctx context.Cont
+ status = fmt.Sprintf("RCODE%d", r.Rcode)
+ }
+
+- if r.Rcode == dns.RcodeNameError || r.Rcode == dns.RcodeRefused {
++ if (r.Rcode == dns.RcodeNameError || r.Rcode == dns.RcodeRefused) && qQuestion.Qtype != dns.TypeHTTPS {
+ isBlocked = true
+ }
+
+--- a/plugin/executable/stats_api/stats_api_test.go
++++ b/plugin/executable/stats_api/stats_api_test.go
+@@ -92,22 +92,17 @@ func TestTopStats(t *testing.T) {
+ top := NewTopStats()
+
+ top.Record("a.com.", "192.168.1.1", false)
+- top.Record("a.com.", "192.168.1.1", true)
++ top.Record("a.com.", "192.168.1.1", false)
+ top.Record("b.com.", "192.168.1.2", true)
+- top.Record("a.com.", "192.168.1.2", false)
+
+- domains, clients, blocked := top.GetTop(10)
++ domains, _, blocked := top.GetTop(10)
+
+ if len(domains) == 0 || domains[0].Domain != "a.com." || domains[0].Count != 2 {
+ t.Errorf("top domains mismatch: %+v", domains)
+ }
+
+- if len(clients) < 2 {
+- t.Fatalf("expected at least 2 clients, got %d", len(clients))
+- }
+-
+- if len(blocked) < 2 {
+- t.Fatalf("expected at least 2 blocked domains, got %d", len(blocked))
++ if len(blocked) == 0 || blocked[0].Domain != "b.com." {
++ t.Errorf("top blocked mismatch: %+v", blocked)
+ }
+
+ // Test Clear
diff --git a/mosdns/patches/219-feat-stats_api-add-data-persistence-support.patch b/mosdns/patches/219-feat-stats_api-add-data-persistence-support.patch
new file mode 100644
index 000000000..bdea11e19
--- /dev/null
+++ b/mosdns/patches/219-feat-stats_api-add-data-persistence-support.patch
@@ -0,0 +1,756 @@
+From 7279f1514e11b8a5bf6717a0127d73ecf36014a3 Mon Sep 17 00:00:00 2001
+From: sbwml
+Date: Sat, 29 Aug 2026 12:59:42 +0800
+Subject: [PATCH 4/5] feat(stats_api): add data persistence support
+
+Add dump_file and dump_interval configuration to support periodically
+persisting and restoring metrics, top stats, and query logs across restarts.
+
+Signed-off-by: sbwml
+---
+ plugin/executable/stats_api/stats_api.go | 314 +++++++++++++++++-
+ plugin/executable/stats_api/stats_api_test.go | 244 ++++++++++++++
+ 2 files changed, 552 insertions(+), 6 deletions(-)
+
+--- a/plugin/executable/stats_api/stats_api.go
++++ b/plugin/executable/stats_api/stats_api.go
+@@ -24,8 +24,10 @@ import (
+ "encoding/json"
+ "errors"
+ "fmt"
++ "io"
+ "math"
+ "net/http"
++ "os"
+ "sort"
+ "strconv"
+ "strings"
+@@ -35,13 +37,18 @@ import (
+
+ "github.com/IrineSistiana/mosdns/v5/coremain"
+ "github.com/IrineSistiana/mosdns/v5/pkg/query_context"
++ "github.com/IrineSistiana/mosdns/v5/pkg/utils"
+ "github.com/IrineSistiana/mosdns/v5/plugin/executable/sequence"
+ "github.com/go-chi/chi/v5"
++ "github.com/klauspost/compress/gzip"
+ "github.com/miekg/dns"
+ "go.uber.org/zap"
+ )
+
+-const PluginType = "stats_api"
++const (
++ PluginType = "stats_api"
++ statsDumpHeader = "mosdns_stats_v1"
++)
+
+ func init() {
+ coremain.RegNewPluginFunc(PluginType, Init, func() any { return new(Args) })
+@@ -51,14 +58,17 @@ func init() {
+ var _ sequence.RecursiveExecutable = (*StatsAPI)(nil)
+
+ type Args struct {
+- Listen string `yaml:"listen"`
+- Capacity int `yaml:"capacity"`
++ Listen string `yaml:"listen"`
++ Capacity int `yaml:"capacity"`
++ DumpFile string `yaml:"dump_file"`
++ DumpInterval int `yaml:"dump_interval"`
+ }
+
+ func (a *Args) init() {
+ if a.Capacity <= 0 {
+ a.Capacity = 2000
+ }
++ utils.SetDefaultUnsignNum(&a.DumpInterval, 600)
+ }
+
+ type AnswerDTO struct {
+@@ -174,6 +184,42 @@ func (r *RingBuffer) QueryLogs(limit, of
+ return total, filtered[offset:end]
+ }
+
++// Export returns logs in chronological order (oldest first) and current seqID.
++func (r *RingBuffer) Export() ([]LogEntry, uint64) {
++ r.mu.RLock()
++ defer r.mu.RUnlock()
++
++ entries := make([]LogEntry, 0, r.count)
++ for i := 0; i < r.count; i++ {
++ idx := (r.head - r.count + i + r.capacity) % r.capacity
++ entries = append(entries, r.buf[idx])
++ }
++ return entries, r.seqID
++}
++
++// Import restores logs into ring buffer adapting to current capacity.
++func (r *RingBuffer) Import(entries []LogEntry, seqID uint64) {
++ r.mu.Lock()
++ defer r.mu.Unlock()
++
++ r.buf = make([]LogEntry, r.capacity)
++ r.head = 0
++ r.count = 0
++ r.seqID = seqID
++
++ start := 0
++ if len(entries) > r.capacity {
++ start = len(entries) - r.capacity
++ }
++ for i := start; i < len(entries); i++ {
++ r.buf[r.head] = entries[i]
++ r.head = (r.head + 1) % r.capacity
++ if r.count < r.capacity {
++ r.count++
++ }
++ }
++}
++
+ type TopItem struct {
+ Domain string `json:"domain,omitempty"`
+ ClientIP string `json:"client_ip,omitempty"`
+@@ -220,6 +266,43 @@ func (t *TopStats) Clear() {
+ t.topBlocked = make(map[string]uint64)
+ }
+
++func (t *TopStats) Export() (map[string]uint64, map[string]uint64, map[string]uint64) {
++ t.mu.RLock()
++ defer t.mu.RUnlock()
++
++ domains := make(map[string]uint64, len(t.topDomains))
++ for k, v := range t.topDomains {
++ domains[k] = v
++ }
++ clients := make(map[string]uint64, len(t.topClients))
++ for k, v := range t.topClients {
++ clients[k] = v
++ }
++ blocked := make(map[string]uint64, len(t.topBlocked))
++ for k, v := range t.topBlocked {
++ blocked[k] = v
++ }
++ return domains, clients, blocked
++}
++
++func (t *TopStats) Import(domains, clients, blocked map[string]uint64) {
++ t.mu.Lock()
++ defer t.mu.Unlock()
++
++ t.topDomains = make(map[string]uint64, len(domains))
++ for k, v := range domains {
++ t.topDomains[k] = v
++ }
++ t.topClients = make(map[string]uint64, len(clients))
++ for k, v := range clients {
++ t.topClients[k] = v
++ }
++ t.topBlocked = make(map[string]uint64, len(blocked))
++ for k, v := range blocked {
++ t.topBlocked[k] = v
++ }
++}
++
+ func getSortedTop(m map[string]uint64, isClient bool, limit int) []TopItem {
+ type pair struct {
+ key string
+@@ -286,6 +369,12 @@ type HistoryBucket struct {
+ Cached atomic.Uint64
+ }
+
++type HistoryBucketData struct {
++ Total uint64 `json:"total"`
++ Blocked uint64 `json:"blocked"`
++ Cached uint64 `json:"cached"`
++}
++
+ type HistoryStats struct {
+ mu sync.RWMutex
+ points map[int64]*HistoryBucket
+@@ -362,6 +451,55 @@ func (h *HistoryStats) GetHistory(numPoi
+ return res
+ }
+
++func (h *HistoryStats) Export() map[int64]HistoryBucketData {
++ h.mu.RLock()
++ defer h.mu.RUnlock()
++
++ res := make(map[int64]HistoryBucketData, len(h.points))
++ for k, v := range h.points {
++ if v != nil {
++ res[k] = HistoryBucketData{
++ Total: v.Total.Load(),
++ Blocked: v.Blocked.Load(),
++ Cached: v.Cached.Load(),
++ }
++ }
++ }
++ return res
++}
++
++func (h *HistoryStats) Import(points map[int64]HistoryBucketData) {
++ h.mu.Lock()
++ defer h.mu.Unlock()
++
++ h.points = make(map[int64]*HistoryBucket, len(points))
++ cutoff := time.Now().UTC().Add(-48 * time.Hour).Unix()
++ for k, v := range points {
++ if k >= cutoff {
++ bucket := &HistoryBucket{}
++ bucket.Total.Store(v.Total)
++ bucket.Blocked.Store(v.Blocked)
++ bucket.Cached.Store(v.Cached)
++ h.points[k] = bucket
++ }
++ }
++}
++
++type StatsDumpData struct {
++ Version int `json:"version"`
++ Timestamp int64 `json:"timestamp"`
++ TotalQueries uint64 `json:"total_queries"`
++ BlockedQueries uint64 `json:"blocked_queries"`
++ CachedQueries uint64 `json:"cached_queries"`
++ TotalLatencyUs uint64 `json:"total_latency_us"`
++ TopDomains map[string]uint64 `json:"top_domains,omitempty"`
++ TopClients map[string]uint64 `json:"top_clients,omitempty"`
++ TopBlocked map[string]uint64 `json:"top_blocked,omitempty"`
++ History map[int64]HistoryBucketData `json:"history,omitempty"`
++ Logs []LogEntry `json:"logs,omitempty"`
++ SeqID uint64 `json:"seq_id,omitempty"`
++}
++
+ type StatsAPI struct {
+ args *Args
+ logger *zap.Logger
+@@ -375,8 +513,10 @@ type StatsAPI struct {
+ cachedQueries atomic.Uint64
+ totalLatencyUs atomic.Uint64
+
+- httpServer *http.Server
+- closeOnce sync.Once
++ updatedCount atomic.Uint64
++ closeNotify chan struct{}
++ httpServer *http.Server
++ closeOnce sync.Once
+ }
+
+ func Init(bp *coremain.BP, args any) (any, error) {
+@@ -390,6 +530,8 @@ func QuickSetup(bq sequence.BQ, s string
+ fields := strings.Fields(s)
+ listen := ""
+ capacity := 2000
++ dumpFile := ""
++ dumpInterval := 600
+ if len(fields) > 0 {
+ listen = fields[0]
+ }
+@@ -398,7 +540,20 @@ func QuickSetup(bq sequence.BQ, s string
+ capacity = c
+ }
+ }
+- return NewStatsAPI(&Args{Listen: listen, Capacity: capacity}, bq.L()), nil
++ if len(fields) > 2 {
++ dumpFile = fields[2]
++ }
++ if len(fields) > 3 {
++ if d, err := strconv.Atoi(fields[3]); err == nil && d > 0 {
++ dumpInterval = d
++ }
++ }
++ return NewStatsAPI(&Args{
++ Listen: listen,
++ Capacity: capacity,
++ DumpFile: dumpFile,
++ DumpInterval: dumpInterval,
++ }, bq.L()), nil
+ }
+
+ func NewStatsAPI(args *Args, logger *zap.Logger) *StatsAPI {
+@@ -412,8 +567,14 @@ func NewStatsAPI(args *Args, logger *zap
+ ringBuffer: NewRingBuffer(args.Capacity),
+ topStats: NewTopStats(),
+ historyStats: NewHistoryStats(),
++ closeNotify: make(chan struct{}),
+ }
+
++ if err := s.loadDump(); err != nil {
++ s.logger.Error("failed to load stats dump", zap.Error(err))
++ }
++ s.startDumpLoop()
++
+ if len(args.Listen) > 0 {
+ srv := &http.Server{
+ Addr: args.Listen,
+@@ -450,6 +611,8 @@ func (s *StatsAPI) Router() *chi.Mux {
+ r.Get("/api/v1/logs", s.handleLogs)
+ r.Get("/api/v1/top", s.handleTop)
+ r.Get("/api/v1/history", s.handleHistory)
++ r.Get("/api/v1/dump", s.handleDump)
++ r.Post("/api/v1/load_dump", s.handleLoadDump)
+ r.Post("/api/v1/logs/clear", s.handleClearLogs)
+ r.Post("/api/v1/cache/clear", s.handleClearCache)
+
+@@ -560,9 +723,27 @@ func (s *StatsAPI) handleHistory(w http.
+ })
+ }
+
++func (s *StatsAPI) handleDump(w http.ResponseWriter, req *http.Request) {
++ w.Header().Set("Content-Type", "application/octet-stream")
++ if err := s.writeDump(w); err != nil {
++ http.Error(w, err.Error(), http.StatusInternalServerError)
++ return
++ }
++}
++
++func (s *StatsAPI) handleLoadDump(w http.ResponseWriter, req *http.Request) {
++ if err := s.readDump(req.Body); err != nil {
++ http.Error(w, err.Error(), http.StatusBadRequest)
++ return
++ }
++ s.updatedCount.Add(1)
++ w.WriteHeader(http.StatusOK)
++}
++
+ func (s *StatsAPI) handleClearLogs(w http.ResponseWriter, req *http.Request) {
+ s.ringBuffer.Clear()
+ s.topStats.Clear()
++ s.updatedCount.Add(1)
+
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+@@ -579,6 +760,122 @@ func (s *StatsAPI) handleClearCache(w ht
+ })
+ }
+
++func (s *StatsAPI) writeDump(w io.Writer) error {
++ gw, err := gzip.NewWriterLevel(w, gzip.BestSpeed)
++ if err != nil {
++ return err
++ }
++ gw.Name = statsDumpHeader
++
++ data := StatsDumpData{
++ Version: 1,
++ Timestamp: time.Now().Unix(),
++ TotalQueries: s.totalQueries.Load(),
++ BlockedQueries: s.blockedQueries.Load(),
++ CachedQueries: s.cachedQueries.Load(),
++ TotalLatencyUs: s.totalLatencyUs.Load(),
++ }
++
++ data.TopDomains, data.TopClients, data.TopBlocked = s.topStats.Export()
++ data.History = s.historyStats.Export()
++ data.Logs, data.SeqID = s.ringBuffer.Export()
++
++ if err := json.NewEncoder(gw).Encode(&data); err != nil {
++ _ = gw.Close()
++ return fmt.Errorf("failed to encode stats dump: %w", err)
++ }
++
++ return gw.Close()
++}
++
++func (s *StatsAPI) readDump(r io.Reader) error {
++ gr, err := gzip.NewReader(r)
++ if err != nil {
++ return fmt.Errorf("failed to create gzip reader: %w", err)
++ }
++ defer gr.Close()
++
++ if gr.Name != statsDumpHeader {
++ return fmt.Errorf("invalid stats dump header: got %s, want %s", gr.Name, statsDumpHeader)
++ }
++
++ var data StatsDumpData
++ if err := json.NewDecoder(gr).Decode(&data); err != nil {
++ return fmt.Errorf("failed to decode stats dump: %w", err)
++ }
++
++ s.totalQueries.Store(data.TotalQueries)
++ s.blockedQueries.Store(data.BlockedQueries)
++ s.cachedQueries.Store(data.CachedQueries)
++ s.totalLatencyUs.Store(data.TotalLatencyUs)
++
++ s.topStats.Import(data.TopDomains, data.TopClients, data.TopBlocked)
++ s.historyStats.Import(data.History)
++ s.ringBuffer.Import(data.Logs, data.SeqID)
++
++ return nil
++}
++
++func (s *StatsAPI) loadDump() error {
++ if len(s.args.DumpFile) == 0 {
++ return nil
++ }
++ f, err := os.Open(s.args.DumpFile)
++ if err != nil {
++ if errors.Is(err, os.ErrNotExist) {
++ s.logger.Info("stats dump file does not exist, starting with empty stats", zap.String("file", s.args.DumpFile))
++ return nil
++ }
++ return err
++ }
++ defer f.Close()
++
++ if err := s.readDump(f); err != nil {
++ return err
++ }
++ s.logger.Info("stats dump loaded successfully", zap.String("file", s.args.DumpFile))
++ return nil
++}
++
++func (s *StatsAPI) dumpStats() error {
++ if len(s.args.DumpFile) == 0 {
++ return nil
++ }
++ f, err := os.Create(s.args.DumpFile)
++ if err != nil {
++ return err
++ }
++ defer f.Close()
++
++ if err := s.writeDump(f); err != nil {
++ return fmt.Errorf("failed to write stats dump, %w", err)
++ }
++ s.logger.Info("stats dumped successfully", zap.String("file", s.args.DumpFile))
++ return nil
++}
++
++func (s *StatsAPI) startDumpLoop() {
++ if len(s.args.DumpFile) == 0 {
++ return
++ }
++ go func() {
++ ticker := time.NewTicker(time.Duration(s.args.DumpInterval) * time.Second)
++ defer ticker.Stop()
++ for {
++ select {
++ case <-ticker.C:
++ if s.updatedCount.Swap(0) > 0 {
++ if err := s.dumpStats(); err != nil {
++ s.logger.Error("failed to dump stats", zap.Error(err))
++ }
++ }
++ case <-s.closeNotify:
++ return
++ }
++ }
++ }()
++}
++
+ func (s *StatsAPI) Exec(ctx context.Context, qCtx *query_context.Context, next sequence.ChainWalker) error {
+ start := time.Now()
+ err := next.ExecNext(ctx, qCtx)
+@@ -586,6 +883,7 @@ func (s *StatsAPI) Exec(ctx context.Cont
+
+ s.totalQueries.Add(1)
+ s.totalLatencyUs.Add(uint64(elapsed.Microseconds()))
++ s.updatedCount.Add(1)
+
+ var clientIP string
+ if clientAddr := qCtx.ServerMeta.ClientAddr; clientAddr.IsValid() {
+@@ -725,6 +1023,10 @@ func (s *StatsAPI) Exec(ctx context.Cont
+
+ func (s *StatsAPI) Close() error {
+ s.closeOnce.Do(func() {
++ close(s.closeNotify)
++ if err := s.dumpStats(); err != nil {
++ s.logger.Error("failed to dump stats on close", zap.Error(err))
++ }
+ if s.httpServer != nil {
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+--- a/plugin/executable/stats_api/stats_api_test.go
++++ b/plugin/executable/stats_api/stats_api_test.go
+@@ -20,16 +20,20 @@
+ package stats_api
+
+ import (
++ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
++ "os"
++ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/IrineSistiana/mosdns/v5/pkg/query_context"
+ "github.com/IrineSistiana/mosdns/v5/plugin/executable/sequence"
++ "github.com/klauspost/compress/gzip"
+ "github.com/miekg/dns"
+ "go.uber.org/zap"
+ )
+@@ -88,6 +92,65 @@ func TestRingBuffer(t *testing.T) {
+ }
+ }
+
++func TestRingBufferExportImport(t *testing.T) {
++ rb1 := NewRingBuffer(5)
++ for i := 1; i <= 8; i++ {
++ rb1.Push(LogEntry{
++ Domain: fmt.Sprintf("domain%d.com.", i),
++ ClientIP: "10.0.0.1",
++ })
++ }
++
++ exported, seqID := rb1.Export()
++ if len(exported) != 5 {
++ t.Fatalf("expected 5 exported entries, got %d", len(exported))
++ }
++ if seqID != 8 {
++ t.Errorf("expected seqID 8, got %d", seqID)
++ }
++ if exported[0].Domain != "domain4.com." {
++ t.Errorf("expected oldest in exported to be domain4.com., got %s", exported[0].Domain)
++ }
++ if exported[4].Domain != "domain8.com." {
++ t.Errorf("expected newest in exported to be domain8.com., got %s", exported[4].Domain)
++ }
++
++ // Import into same capacity
++ rb2 := NewRingBuffer(5)
++ rb2.Import(exported, seqID)
++ total, logs2 := rb2.QueryLogs(10, 0, "", "all")
++ if total != 5 || len(logs2) != 5 {
++ t.Fatalf("expected 5 logs in rb2, got %d", total)
++ }
++ if logs2[0].Domain != "domain8.com." {
++ t.Errorf("expected newest log to be domain8.com., got %s", logs2[0].Domain)
++ }
++ if logs2[4].Domain != "domain4.com." {
++ t.Errorf("expected oldest log to be domain4.com., got %s", logs2[4].Domain)
++ }
++
++ // Test pushing another entry to rb2 to verify seqID continues
++ rb2.Push(LogEntry{Domain: "domain9.com."})
++ _, logsAfterPush := rb2.QueryLogs(1, 0, "", "all")
++ if logsAfterPush[0].Domain != "domain9.com." {
++ t.Errorf("expected newest log domain9.com., got %s", logsAfterPush[0].Domain)
++ }
++
++ // Import into smaller capacity (3)
++ rb3 := NewRingBuffer(3)
++ rb3.Import(exported, seqID)
++ total3, logs3 := rb3.QueryLogs(10, 0, "", "all")
++ if total3 != 3 || len(logs3) != 3 {
++ t.Fatalf("expected 3 logs in rb3, got %d", total3)
++ }
++ if logs3[0].Domain != "domain8.com." {
++ t.Errorf("expected newest log domain8.com., got %s", logs3[0].Domain)
++ }
++ if logs3[2].Domain != "domain6.com." {
++ t.Errorf("expected oldest log domain6.com., got %s", logs3[2].Domain)
++ }
++}
++
+ func TestTopStats(t *testing.T) {
+ top := NewTopStats()
+
+@@ -113,6 +176,28 @@ func TestTopStats(t *testing.T) {
+ }
+ }
+
++func TestTopStatsExportImport(t *testing.T) {
++ top1 := NewTopStats()
++ top1.Record("a.com.", "192.168.1.1", false)
++ top1.Record("a.com.", "192.168.1.1", false)
++ top1.Record("b.com.", "192.168.1.2", true)
++
++ d, c, b := top1.Export()
++ top2 := NewTopStats()
++ top2.Import(d, c, b)
++
++ domains, clients, blocked := top2.GetTop(10)
++ if len(domains) != 1 || domains[0].Domain != "a.com." || domains[0].Count != 2 {
++ t.Errorf("top domains export/import mismatch: %+v", domains)
++ }
++ if len(clients) != 2 {
++ t.Errorf("top clients count mismatch: %+v", clients)
++ }
++ if len(blocked) != 1 || blocked[0].Domain != "b.com." {
++ t.Errorf("top blocked mismatch: %+v", blocked)
++ }
++}
++
+ func TestHistoryStats(t *testing.T) {
+ h := NewHistoryStats()
+ now := time.Now()
+@@ -132,6 +217,26 @@ func TestHistoryStats(t *testing.T) {
+ }
+ }
+
++func TestHistoryStatsExportImport(t *testing.T) {
++ h1 := NewHistoryStats()
++ now := time.Now()
++ h1.Record(now, false, false)
++ h1.Record(now, true, true)
++
++ exported := h1.Export()
++ if len(exported) == 0 {
++ t.Fatalf("expected exported history points")
++ }
++
++ h2 := NewHistoryStats()
++ h2.Import(exported)
++ points := h2.GetHistory(24)
++ lastPoint := points[len(points)-1]
++ if lastPoint.Total != 2 || lastPoint.Blocked != 1 || lastPoint.Cached != 1 {
++ t.Errorf("history stats import mismatch: %+v", lastPoint)
++ }
++}
++
+ func TestStatsAPIHTTPEndpoints(t *testing.T) {
+ s := NewStatsAPI(&Args{Capacity: 100}, zap.NewNop())
+ router := s.Router()
+@@ -185,6 +290,31 @@ func TestStatsAPIHTTPEndpoints(t *testin
+ t.Errorf("expected 24 history points, got %d", len(histPoints))
+ }
+
++ // Test GET /api/v1/dump
++ reqDump := httptest.NewRequest(http.MethodGet, "/api/v1/dump", nil)
++ wDump := httptest.NewRecorder()
++ router.ServeHTTP(wDump, reqDump)
++ if wDump.Code != http.StatusOK {
++ t.Fatalf("expected HTTP 200 for dump, got %d", wDump.Code)
++ }
++ dumpBytes := wDump.Body.Bytes()
++ if len(dumpBytes) == 0 {
++ t.Fatalf("expected non-empty dump body")
++ }
++
++ // Test POST /api/v1/load_dump
++ s2 := NewStatsAPI(&Args{Capacity: 100}, zap.NewNop())
++ router2 := s2.Router()
++ reqLoadDump := httptest.NewRequest(http.MethodPost, "/api/v1/load_dump", bytes.NewReader(dumpBytes))
++ wLoadDump := httptest.NewRecorder()
++ router2.ServeHTTP(wLoadDump, reqLoadDump)
++ if wLoadDump.Code != http.StatusOK {
++ t.Fatalf("expected HTTP 200 for load_dump, got %d", wLoadDump.Code)
++ }
++ if s2.totalQueries.Load() != 1 {
++ t.Errorf("expected s2 total queries to be 1 after load_dump, got %d", s2.totalQueries.Load())
++ }
++
+ // Test POST /api/v1/logs/clear
+ reqClearLogs := httptest.NewRequest(http.MethodPost, "/api/v1/logs/clear", nil)
+ wClearLogs := httptest.NewRecorder()
+@@ -258,3 +388,117 @@ func TestStatsAPIExec(t *testing.T) {
+ t.Errorf("expected rule qname google.com., got %s", logs[0].Rule)
+ }
+ }
++
++func TestStatsAPIPersistence(t *testing.T) {
++ tempDir := t.TempDir()
++ dumpFilePath := filepath.Join(tempDir, "stats.dump")
++
++ // Phase 1: Start stats_api with dump_file configured
++ s1 := NewStatsAPI(&Args{
++ Capacity: 50,
++ DumpFile: dumpFilePath,
++ DumpInterval: 600,
++ }, zap.NewNop())
++
++ // Push test logs and stats
++ for i := 1; i <= 10; i++ {
++ s1.ringBuffer.Push(LogEntry{
++ Domain: fmt.Sprintf("test%d.com.", i),
++ ClientIP: "192.168.1.100",
++ IsBlocked: i%2 == 0,
++ IsCached: i%3 == 0,
++ ElapsedMS: float64(i * 5),
++ })
++ s1.totalQueries.Add(1)
++ if i%2 == 0 {
++ s1.blockedQueries.Add(1)
++ }
++ if i%3 == 0 {
++ s1.cachedQueries.Add(1)
++ }
++ s1.totalLatencyUs.Add(uint64(i * 5000))
++ s1.topStats.Record(fmt.Sprintf("test%d.com.", i), "192.168.1.100", i%2 == 0)
++ s1.historyStats.Record(time.Now(), i%2 == 0, i%3 == 0)
++ }
++
++ // Close s1 -> triggers dumpStats()
++ if err := s1.Close(); err != nil {
++ t.Fatalf("Close failed: %v", err)
++ }
++
++ // Verify file exists
++ fi, err := os.Stat(dumpFilePath)
++ if err != nil {
++ t.Fatalf("dump file was not created: %v", err)
++ }
++ if fi.Size() == 0 {
++ t.Fatalf("dump file is empty")
++ }
++
++ // Verify gzip header and compression
++ f, err := os.Open(dumpFilePath)
++ if err != nil {
++ t.Fatalf("failed to open dump file: %v", err)
++ }
++ gr, err := gzip.NewReader(f)
++ if err != nil {
++ t.Fatalf("failed to create gzip reader on dump file: %v", err)
++ }
++ if gr.Name != statsDumpHeader {
++ t.Errorf("expected gzip header %s, got %s", statsDumpHeader, gr.Name)
++ }
++ _ = gr.Close()
++ _ = f.Close()
++
++ // Phase 2: Start new instance s2 with same dump_file -> loads dump automatically
++ s2 := NewStatsAPI(&Args{
++ Capacity: 50,
++ DumpFile: dumpFilePath,
++ DumpInterval: 600,
++ }, zap.NewNop())
++ defer s2.Close()
++
++ if s2.totalQueries.Load() != 10 {
++ t.Errorf("expected 10 total queries after load, got %d", s2.totalQueries.Load())
++ }
++ if s2.blockedQueries.Load() != 5 {
++ t.Errorf("expected 5 blocked queries after load, got %d", s2.blockedQueries.Load())
++ }
++ if s2.cachedQueries.Load() != 3 {
++ t.Errorf("expected 3 cached queries after load, got %d", s2.cachedQueries.Load())
++ }
++
++ totalLogs, logs := s2.ringBuffer.QueryLogs(10, 0, "", "all")
++ if totalLogs != 10 || len(logs) != 10 {
++ t.Fatalf("expected 10 logs in s2, got total=%d len=%d", totalLogs, len(logs))
++ }
++ if logs[0].Domain != "test10.com." {
++ t.Errorf("expected newest log test10.com., got %s", logs[0].Domain)
++ }
++
++ topDomains, topClients, topBlocked := s2.topStats.GetTop(10)
++ if len(topDomains) == 0 {
++ t.Errorf("expected top domains to be restored")
++ }
++ if len(topClients) == 0 || topClients[0].ClientIP != "192.168.1.100" {
++ t.Errorf("expected top clients to be restored")
++ }
++ if len(topBlocked) == 0 {
++ t.Errorf("expected top blocked to be restored")
++ }
++}
++
++func TestStatsAPINonExistentDumpFile(t *testing.T) {
++ tempDir := t.TempDir()
++ dumpFilePath := filepath.Join(tempDir, "non_existent_stats.dump")
++
++ // Should not error or panic
++ s := NewStatsAPI(&Args{
++ DumpFile: dumpFilePath,
++ }, zap.NewNop())
++ defer s.Close()
++
++ if s.totalQueries.Load() != 0 {
++ t.Errorf("expected 0 total queries, got %d", s.totalQueries.Load())
++ }
++}
diff --git a/mosdns/patches/220-fix-stats_api-improve-rule-hit-extraction-and-upstre.patch b/mosdns/patches/220-fix-stats_api-improve-rule-hit-extraction-and-upstre.patch
new file mode 100644
index 000000000..df5368a4e
--- /dev/null
+++ b/mosdns/patches/220-fix-stats_api-improve-rule-hit-extraction-and-upstre.patch
@@ -0,0 +1,126 @@
+From 4a7034b471ea68aea2703b2c2dd89bcf195944dc Mon Sep 17 00:00:00 2001
+From: sbwml
+Date: Sat, 29 Aug 2026 22:59:19 +0800
+Subject: [PATCH 5/5] fix(stats_api): improve rule hit extraction and upstream
+ formatting
+
+- Preserve configured upstream address in forward plugin
+- Format DoH/DoT/DoQ upstream URLs with proper protocol scheme
+- Filter out control rules and internal sequences when extracting rule hits
+- Update unit test assertions for upstream formatting
+
+Signed-off-by: sbwml
+---
+ plugin/executable/forward/forward.go | 2 +-
+ plugin/executable/stats_api/stats_api.go | 66 ++++++++++++++-----
+ plugin/executable/stats_api/stats_api_test.go | 4 +-
+ 3 files changed, 53 insertions(+), 19 deletions(-)
+
+--- a/plugin/executable/forward/forward.go
++++ b/plugin/executable/forward/forward.go
+@@ -330,7 +330,7 @@ func (f *Forward) exchange(ctx context.C
+ } else if proto == "QUIC" || proto == "DOQ" {
+ proto = "DoQ"
+ }
+- qCtx.SetUpstreamSelected(addr, proto, chosenUpstream.cfg.Tag, f.pluginTag)
++ qCtx.SetUpstreamSelected(chosenUpstream.cfg.Addr, proto, chosenUpstream.cfg.Tag, f.pluginTag)
+ }
+
+ return r, nil
+--- a/plugin/executable/stats_api/stats_api.go
++++ b/plugin/executable/stats_api/stats_api.go
+@@ -970,10 +970,20 @@ func (s *StatsAPI) Exec(ctx context.Cont
+ if isCached {
+ upstream = "cache"
+ } else if u := qCtx.UpstreamSelected; u != nil {
+- if u.Protocol != "" && u.Addr != "" {
+- upstream = fmt.Sprintf("%s://%s", u.Protocol, u.Addr)
+- } else if u.Addr != "" {
+- upstream = u.Addr
++ if u.Addr != "" {
++ if !strings.Contains(u.Addr, "://") {
++ if u.Protocol == "DoH" {
++ upstream = "https://" + u.Addr
++ } else if u.Protocol == "DoT" {
++ upstream = "tls://" + u.Addr
++ } else if u.Protocol == "DoQ" {
++ upstream = "quic://" + u.Addr
++ } else {
++ upstream = u.Addr
++ }
++ } else {
++ upstream = u.Addr
++ }
+ } else if u.Tag != "" {
+ upstream = u.Tag
+ }
+@@ -981,20 +991,44 @@ func (s *StatsAPI) Exec(ctx context.Cont
+
+ // Extract Rule information
+ var rule string
+- if len(qCtx.RuleHits) > 0 {
+- for i := len(qCtx.RuleHits) - 1; i >= 0; i-- {
+- hit := qCtx.RuleHits[i]
+- if len(hit.Matches) > 0 {
+- rule = strings.Join(hit.Matches, ",")
+- break
+- } else if hit.Exec != "" {
+- rule = hit.Exec
+- break
+- } else if hit.Sequence != "" {
+- rule = hit.Sequence
+- break
++ for i := len(qCtx.RuleHits) - 1; i >= 0; i-- {
++ hit := qCtx.RuleHits[i]
++ exec := strings.TrimSpace(hit.Exec)
++
++ if exec == "accept" || exec == "return" || strings.HasPrefix(exec, "jump ") || strings.HasPrefix(exec, "ttl ") {
++ continue
++ }
++
++ var positiveMatches []string
++ for _, m := range hit.Matches {
++ m = strings.TrimSpace(m)
++ if m != "" && m != "has_resp" && !strings.HasPrefix(m, "!") {
++ positiveMatches = append(positiveMatches, m)
+ }
+ }
++
++ if len(positiveMatches) > 0 {
++ rule = strings.Join(positiveMatches, ",")
++ break
++ }
++
++ if exec != "" && exec != "$stats_collector" {
++ rule = exec
++ break
++ }
++
++ if hit.Sequence != "" && hit.Sequence != "has_resp_sequence" && hit.Sequence != "main_sequence" {
++ rule = hit.Sequence
++ break
++ }
++ }
++
++ if rule == "" {
++ if isCached {
++ rule = "cache"
++ } else {
++ rule = "-"
++ }
+ }
+
+ s.topStats.Record(domain, clientIP, isBlocked)
+--- a/plugin/executable/stats_api/stats_api_test.go
++++ b/plugin/executable/stats_api/stats_api_test.go
+@@ -381,8 +381,8 @@ func TestStatsAPIExec(t *testing.T) {
+ if logs[0].Domain != "google.com." {
+ t.Errorf("expected domain google.com., got %s", logs[0].Domain)
+ }
+- if logs[0].Upstream != "UDP://8.8.8.8:53" {
+- t.Errorf("expected upstream UDP://8.8.8.8:53, got %s", logs[0].Upstream)
++ if logs[0].Upstream != "8.8.8.8:53" {
++ t.Errorf("expected upstream 8.8.8.8:53, got %s", logs[0].Upstream)
+ }
+ if logs[0].Rule != "qname google.com." {
+ t.Errorf("expected rule qname google.com., got %s", logs[0].Rule)
diff --git a/mosdns/patches/221-fix-stats_api-use-system-local-timezone-for-history-.patch b/mosdns/patches/221-fix-stats_api-use-system-local-timezone-for-history-.patch
new file mode 100644
index 000000000..e23f2b662
--- /dev/null
+++ b/mosdns/patches/221-fix-stats_api-use-system-local-timezone-for-history-.patch
@@ -0,0 +1,86 @@
+From 006d70d6910080c7468ec8fa16fb67227b208036 Mon Sep 17 00:00:00 2001
+From: sbwml
+Date: Sun, 30 Aug 2026 07:12:23 +0800
+Subject: [PATCH] fix(stats_api): use system local timezone for history and log
+ timestamps
+
+Signed-off-by: sbwml
+---
+ plugin/executable/stats_api/stats_api.go | 13 +++++++------
+ plugin/executable/stats_api/stats_api_test.go | 9 +++++++++
+ 2 files changed, 16 insertions(+), 6 deletions(-)
+
+--- a/plugin/executable/stats_api/stats_api.go
++++ b/plugin/executable/stats_api/stats_api.go
+@@ -387,7 +387,7 @@ func NewHistoryStats() *HistoryStats {
+ }
+
+ func (h *HistoryStats) Record(t time.Time, isBlocked, isCached bool) {
+- tHour := t.UTC().Truncate(time.Hour).Unix()
++ tHour := time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), 0, 0, 0, t.Location()).Unix()
+
+ h.mu.RLock()
+ bucket, ok := h.points[tHour]
+@@ -401,7 +401,7 @@ func (h *HistoryStats) Record(t time.Tim
+ h.points[tHour] = bucket
+
+ // Clean up old buckets beyond 48 hours
+- cutoff := t.UTC().Add(-48 * time.Hour).Unix()
++ cutoff := t.Add(-48 * time.Hour).Unix()
+ for k := range h.points {
+ if k < cutoff {
+ delete(h.points, k)
+@@ -424,14 +424,15 @@ func (h *HistoryStats) GetHistory(numPoi
+ if numPoints <= 0 {
+ numPoints = 24
+ }
+- now := time.Now().UTC().Truncate(time.Hour)
++ now := time.Now()
++ nowHour := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, now.Location())
+ res := make([]HistoryPoint, 0, numPoints)
+
+ h.mu.RLock()
+ defer h.mu.RUnlock()
+
+ for i := numPoints - 1; i >= 0; i-- {
+- slotTime := now.Add(time.Duration(-i) * time.Hour)
++ slotTime := nowHour.Add(time.Duration(-i) * time.Hour)
+ slotUnix := slotTime.Unix()
+
+ var total, blocked, cached uint64
+@@ -473,7 +474,7 @@ func (h *HistoryStats) Import(points map
+ defer h.mu.Unlock()
+
+ h.points = make(map[int64]*HistoryBucket, len(points))
+- cutoff := time.Now().UTC().Add(-48 * time.Hour).Unix()
++ cutoff := time.Now().Add(-48 * time.Hour).Unix()
+ for k, v := range points {
+ if k >= cutoff {
+ bucket := &HistoryBucket{}
+@@ -1037,7 +1038,7 @@ func (s *StatsAPI) Exec(ctx context.Cont
+ elapsedMS := math.Round(float64(elapsed.Microseconds())/10.0) / 100.0
+
+ entry := LogEntry{
+- Timestamp: start.UTC().Format(time.RFC3339),
++ Timestamp: start.Format(time.RFC3339),
+ ClientIP: clientIP,
+ Domain: domain,
+ QType: qtypeStr,
+--- a/plugin/executable/stats_api/stats_api_test.go
++++ b/plugin/executable/stats_api/stats_api_test.go
+@@ -215,6 +215,15 @@ func TestHistoryStats(t *testing.T) {
+ if lastPoint.Total != 3 || lastPoint.Blocked != 1 || lastPoint.Cached != 1 {
+ t.Errorf("history point mismatch: %+v", lastPoint)
+ }
++
++ parsedTime, err := time.Parse(time.RFC3339, lastPoint.Time)
++ if err != nil {
++ t.Fatalf("failed to parse history point time: %v", err)
++ }
++ expectedHour := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, now.Location())
++ if parsedTime.Unix() != expectedHour.Unix() {
++ t.Errorf("expected history point time unix %d, got %d", expectedHour.Unix(), parsedTime.Unix())
++ }
+ }
+
+ func TestHistoryStatsExportImport(t *testing.T) {
diff --git a/natflow/Makefile b/natflow/Makefile
index 3fc0b5fd7..8bd6abeb5 100644
--- a/natflow/Makefile
+++ b/natflow/Makefile
@@ -10,12 +10,12 @@ include $(INCLUDE_DIR)/kernel.mk
PKG_NAME:=natflow
PKG_VERSION:=20260531
-PKG_RELEASE:=75
+PKG_RELEASE:=76
PKG_SOURCE:=$(PKG_VERSION).tar.xz
PKG_SOURCE_URL:=https://github.com/ptpt52/natflow.git
PKG_SOURCE_PROTO:=git
-PKG_SOURCE_VERSION:=e89fb0a015be44a2ca2071a5cff6803347759215
+PKG_SOURCE_VERSION:=f330072aed90336bb594da2a671475ee6499d726
PKG_SOURCE_SUBDIR:=$(PKG_NAME)-$(PKG_VERSION)
PKG_MAINTAINER:=Chen Minqiang
PKG_LICENSE:=GPL-2.0
diff --git a/wwand/Makefile b/wwand/Makefile
index de8e5c17c..fa3c1deee 100644
--- a/wwand/Makefile
+++ b/wwand/Makefile
@@ -43,12 +43,12 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=wwand
-PKG_RELEASE:=12
+PKG_RELEASE:=13
PKG_SOURCE_PROTO:=git
PKG_SOURCE_URL:=https://github.com/ddimension/wwand.git
-PKG_SOURCE_VERSION:=2311728191b2b325de54946b0767000baa245366
-PKG_SOURCE_DATE:=2026-08-27
+PKG_SOURCE_VERSION:=810b851ea2657dca1049e6a8f8395dba35f10141
+PKG_SOURCE_DATE:=2026-08-30
PKG_MIRROR_HASH:=skip
PKG_LICENSE:=GPL-2.0-only
@@ -172,9 +172,10 @@ define Package/wwand/install
$(INSTALL_DIR) $(1)/usr/bin
$(INSTALL_BIN) $(WWAND_UCODE)/wwandctl.uc $(1)/usr/bin/wwandctl
$(INSTALL_DIR) $(1)/lib/netifd/proto
- # the shim registers `wwand`; the legacy `qmi` alias only when the global
- # `option takeover` is set (default off, so uqmi keeps `proto qmi`). Install
- # under the current name — the historical qmi.sh belonged to uqmi
+ # the shim registers `wwand` and nothing else — netifd sources every handler
+ # in this directory, so two of them claiming `qmi` would be settled by load
+ # order. `proto qmi` therefore stays uqmi's qmi.sh; migrating an interface is
+ # the user's explicit act.
$(INSTALL_BIN) $(PKG_BUILD_DIR)/files/wwand-proto.sh $(1)/lib/netifd/proto/wwand.sh
$(INSTALL_DIR) $(1)/usr/libexec/wwand
$(INSTALL_BIN) $(PKG_BUILD_DIR)/files/wwand-migrate $(1)/usr/libexec/wwand/migrate
@@ -214,11 +215,15 @@ define Package/wwand-qmi
CATEGORY:=Network
SUBMENU:=WWAN
TITLE:=QMI backend for wwand
- DEPENDS:=+wwand +kmod-usb-net-qmi-wwan +kmod-rmnet
- # coexists with the stock OpenWrt QMI stack (uqmi):
- # by default wwand claims only `proto wwand` interfaces, so both can be
- # installed. Set the global `option takeover '1'` (or migrate interfaces from
- # the LuCI modem list) to hand `proto qmi` interfaces to wwand.
+ # kmod-rmnet is the QMAP demuxer and is needed on BOTH transports (an MHI
+ # modem multiplexes through it too); only the USB glue is conditional, so a
+ # target built without USB support can still select this backend and drive a
+ # PCIe/MHI modem. On a USB target nothing changes.
+ DEPENDS:=+wwand +USB_SUPPORT:kmod-usb-net-qmi-wwan +kmod-rmnet
+ # coexists with the stock OpenWrt QMI stack (uqmi): wwand claims only
+ # `proto wwand` interfaces, so both can be installed. To hand an existing
+ # `proto qmi` interface to wwand, migrate it — from the LuCI modem list or
+ # with `/usr/libexec/wwand/migrate --apply` — which rewrites it in place.
endef
define Package/wwand-qmi/description
@@ -246,7 +251,10 @@ define Package/wwand-mbim
CATEGORY:=Network
SUBMENU:=WWAN
TITLE:=MBIM backend for wwand
- DEPENDS:=+wwand-qmi +kmod-usb-net-cdc-mbim
+ # conditional for the same reason as wwand-qmi: the ucode side is
+ # transport-neutral, and on MHI the transport is kmod-mhi-wwan-mbim, which
+ # wwand-mhi already pulls
+ DEPENDS:=+wwand-qmi +USB_SUPPORT:kmod-usb-net-cdc-mbim
# coexists with the stock OpenWrt MBIM stack (netifd `mbim` proto, package
# umbim): wwand manages a cdc_mbim modem only once its interface has been
# migrated to `proto wwand` (LuCI modem list / migrate CLI), so both stacks can
@@ -285,7 +293,8 @@ define Package/wwand-ncm
TITLE:=NCM/ECM backend for wwand
# +kmod-usb-net-rndis: the RNDIS datapath (rndis_host) used by e.g. the
# Fibocom FM350-GL (MediaTek T700) — same AT-driven backend, RNDIS netdev.
- DEPENDS:=+wwand +kmod-usb-net-cdc-ncm +kmod-usb-net-cdc-ether +kmod-usb-net-rndis
+ DEPENDS:=+wwand +USB_SUPPORT:kmod-usb-net-cdc-ncm \
+ +USB_SUPPORT:kmod-usb-net-cdc-ether +USB_SUPPORT:kmod-usb-net-rndis
# coexists with the stock OpenWrt NCM stack (netifd `ncm` proto, package
# comgt-ncm): wwand drives an AT/NCM modem only once its interface has been
# migrated to `proto wwand` (LuCI modem list / migrate CLI), so both stacks can
@@ -329,12 +338,14 @@ define Package/wwand-mhi/description
wwand binds these modems on boot. Install together with a control backend
(wwand-qmi or wwand-mbim).
- Adding wwand-mbim is worthwhile even on a QMI-driven MHI modem: many of them
+ Adding wwand-mbim is worth weighing on a QMI-driven MHI modem: many of them
expose no DUN channel and therefore no AT port at all, and wwand can then
- carry AT over the modem's MBIM channel instead (Quectel QDU). Without that
- package the capability is simply absent — vendor AT commands, the protocol
- switch and AT telemetry are unavailable, which is a limitation rather than a
- failure.
+ carry AT over the modem's MBIM channel instead (Quectel QDU). The cost is
+ that wwand-mbim currently pulls the USB MBIM transport with it, which this
+ path does not use — and on a target built without USB support it cannot be
+ selected at all. Without it the capability is simply absent: vendor AT
+ commands, the protocol switch and AT telemetry are unavailable, which is a
+ limitation rather than a failure.
endef
define Package/wwand-mhi/install
@@ -387,9 +398,93 @@ define Package/wwand-esim/install
$(INSTALL_DATA) $(WWAND_UCODE)/esim_bridge.uc $(1)$(UCDIR)/
endef
+# ---------------------------------------------------------------------------
+# wwand-datapath-rmnet_nss: Qualcomm NSS offload behind the VENDOR qmi_wwan_q.
+# A datapath add-on, not a control backend — it plugs into wwand's datapath
+# interface (`option mux 'rmnet_nss'`, or picked up on its own under 'auto').
+# Underscore in the name on purpose: the datapath name doubles as the ucode
+# module name (wwand.datapath_rmnet_nss), and the daemon's "package not
+# installed" note is built from it, so the two must read alike.
+# ---------------------------------------------------------------------------
+define Package/wwand-datapath-rmnet_nss
+ SECTION:=net
+ CATEGORY:=Network
+ SUBMENU:=WWAN
+ TITLE:=NSS-offloaded QMAP datapath (vendor qmi_wwan_q) for wwand
+ # +wwand-qmi: it is a QMI datapath. The vendor driver and the NSS shim are
+ # NOT dependencies — they come from the board's own kernel tree (QSDK/NSS
+ # builds), and this package must stay installable next to them rather than
+ # try to name them.
+ DEPENDS:=+wwand-qmi
+endef
+
+define Package/wwand-datapath-rmnet_nss/description
+ Datapath add-on for Qualcomm NSS builds (ipq807x and friends), where the modem
+ datapath is offloaded to the NSS cores. The attach point is a global callback
+ contract: rmnet_nss publishes rmnet_nss_callbacks and the vendor qmi_wwan_q
+ driver calls nss_create() on each QMAP netdev it registers. Mainline rmnet
+ makes no such call, so wwand's built-in rmnet/qmimux datapaths produce children
+ that never reach the NSS shim — traffic forwards, but on the CPU.
+
+ This datapath therefore creates nothing: qmi_wwan_q registers the children in
+ its USB probe, one per its qmap_mode module parameter, and wwand adopts them,
+ drives the per-channel link_state gate and binds each WDS session to the QMAP
+ id the driver expects (0x81 upwards, not the config channel number).
+
+ It claims any parent carrying those vendor children, with or without the NSS
+ shim: they need adopting either way, and leaving a non-NSS vendor box to
+ mainline rmnet makes it build a second set of children on a parent that
+ already demuxes QMAP itself. For the offload, rmnet_nss must be LOADED BEFORE
+ the modem's driver binds — qmi_wwan_q captures whether NSS is available at the
+ moment it creates each child, so a module loaded afterwards leaves them
+ without an NSS context; that case is logged and shown on the status page.
+
+ Install it on a box with the vendor qmi_wwan_q driver; anywhere else it probes
+ false and changes nothing.
+endef
+
+define Package/wwand-datapath-rmnet_nss/install
+ $(INSTALL_DIR) $(1)$(UCDIR)
+ $(INSTALL_DATA) $(WWAND_UCODE)/datapath_rmnet_nss.uc $(1)$(UCDIR)/
+endef
+
$(eval $(call BuildPackage,wwand))
$(eval $(call BuildPackage,wwand-qmi))
$(eval $(call BuildPackage,wwand-mbim))
$(eval $(call BuildPackage,wwand-ncm))
$(eval $(call BuildPackage,wwand-mhi))
$(eval $(call BuildPackage,wwand-esim))
+define Package/wwand-datapath-rmnet_nss_mhi
+ SECTION:=net
+ CATEGORY:=Network
+ SUBMENU:=WWAN
+ TITLE:=NSS-offloaded QMAP datapath (vendor PCIe/MHI) for wwand
+ # +wwand: it serves both control protocols, so it names neither backend
+ # package. The vendor pcie_mhi driver and the NSS shim come from the board's
+ # own kernel tree and are deliberately not named here.
+ DEPENDS:=+wwand
+endef
+
+define Package/wwand-datapath-rmnet_nss_mhi/description
+ The PCIe/MHI sibling of wwand-datapath-rmnet_nss, for Quectel's vendor
+ pcie_mhi driver (mhi_netdev_quectel). That driver registers the QMAP children
+ itself and calls the rmnet_nss callbacks on each, so wwand adopts them rather
+ than creating any -- mainline rmnet would build a second set on a parent that
+ already demuxes.
+
+ It serves QMI and MBIM alike, because the driver does. The wire id differs:
+ QMAP framing uses 0x81 upwards, MBIM framing uses the MBIM session id, which
+ equals wwand's channel number on ordinary hardware and is offset by 112 on an
+ SDX7x (PCI 17cb:0309).
+
+ NOT hardware-verified: written from the driver sources. On any board without
+ that driver it probes false and changes nothing.
+endef
+
+define Package/wwand-datapath-rmnet_nss_mhi/install
+ $(INSTALL_DIR) $(1)$(UCDIR)
+ $(INSTALL_DATA) $(WWAND_UCODE)/datapath_rmnet_nss_mhi.uc $(1)$(UCDIR)/
+endef
+
+$(eval $(call BuildPackage,wwand-datapath-rmnet_nss))
+$(eval $(call BuildPackage,wwand-datapath-rmnet_nss_mhi))