diff --git a/docker/routlin-dash/app/config_utils.py b/docker/routlin-dash/app/config_utils.py
index 9706468..8e95cbf 100644
--- a/docker/routlin-dash/app/config_utils.py
+++ b/docker/routlin-dash/app/config_utils.py
@@ -747,7 +747,7 @@ def config_datasource(name):
for v in sorted(vlans, key=lambda x: x.get('vlan_id') or 0):
row = {k: v.get(k) for k in (
'name', 'subnet', 'subnet_mask', 'radius_default',
- 'mdns_reflection', 'is_vpn', 'dnsmasq_log_queries'
+ 'mdns_reflection', 'is_vpn', 'dnsmasq_log_queries_days'
)}
row['vlan_id'] = v.get('vlan_id')
row['interface'] = resolve_iface(v, cfg)
diff --git a/docker/routlin-dash/app/factory.py b/docker/routlin-dash/app/factory.py
index e744d21..56f52e5 100644
--- a/docker/routlin-dash/app/factory.py
+++ b/docker/routlin-dash/app/factory.py
@@ -936,12 +936,16 @@ def build_table_cell(value, render_fn, col_class='', field='', row_idx=None,
return f'{td_open}{inner}'
if render_fn == 'badge_yes_no':
- opts = render_options or {}
- if str(value).lower() in ('true', '1', 'yes', 'enabled'):
- tip = f' data-tooltip="{e(opts["title_true"])}"' if opts.get('title_true') else ''
+ opts = render_options or {}
+ sv = str(value)
+ is_true = sv.lower() in ('true', '1', 'yes', 'enabled') or (sv.lstrip('-').isdigit() and int(sv) > 0)
+ if is_true:
+ title = opts.get('title_true', '').replace('{value}', sv)
+ tip = f' data-tooltip="{e(title)}"' if title else ''
inner = f'Yes'
else:
- tip = f' data-tooltip="{e(opts["title_false"])}"' if opts.get('title_false') else ''
+ title = opts.get('title_false', '').replace('{value}', sv)
+ tip = f' data-tooltip="{e(title)}"' if title else ''
inner = f'No'
return f'{td_open}{inner}'
diff --git a/docker/routlin-dash/app/pages/networklayout/action.py b/docker/routlin-dash/app/pages/networklayout/action.py
index 2d77d5b..458752a 100644
--- a/docker/routlin-dash/app/pages/networklayout/action.py
+++ b/docker/routlin-dash/app/pages/networklayout/action.py
@@ -48,7 +48,7 @@ def vlans_addedit():
subnet_mask = sanitize.subnet_mask(request.form.get('subnet_mask', ''))
radius_default = 'radius_default' in request.form
mdns_reflection = 'mdns_reflection' in request.form
- dnsmasq_log_queries = 'dnsmasq_log_queries' in request.form
+ dnsmasq_log_queries_days = max(0, int(request.form.get('dnsmasq_log_queries_days', 0) or 0))
restricted_vlan_raw = request.form.get('restricted_vlan', '').strip()
restricted_vlan = restricted_vlan_raw if restricted_vlan_raw in ('q', 'c') else ''
use_blocklists = sanitize.filterlist(
@@ -264,7 +264,7 @@ def vlans_addedit():
'vlan_id': vlan_id,
'subnet': subnet,
'subnet_mask': subnet_mask,
- 'dnsmasq_log_queries': dnsmasq_log_queries,
+ 'dnsmasq_log_queries_days': dnsmasq_log_queries_days,
'radius_default': radius_default,
'mdns_reflection': mdns_reflection,
'use_blocklists': use_blocklists,
@@ -324,7 +324,7 @@ def vlans_addedit():
'is_vpn': is_vpn,
'subnet': subnet,
'subnet_mask': subnet_mask,
- 'dnsmasq_log_queries': dnsmasq_log_queries,
+ 'dnsmasq_log_queries_days': dnsmasq_log_queries_days,
'use_blocklists': use_blocklists,
'radius_default': radius_default,
'mdns_reflection': mdns_reflection,
diff --git a/docker/routlin-dash/app/pages/networklayout/content.json b/docker/routlin-dash/app/pages/networklayout/content.json
index 8707f4e..12c213b 100644
--- a/docker/routlin-dash/app/pages/networklayout/content.json
+++ b/docker/routlin-dash/app/pages/networklayout/content.json
@@ -84,12 +84,12 @@
}
},
{
- "label": "Recorded",
- "field": "dnsmasq_log_queries",
+ "label": "Query Log",
+ "field": "dnsmasq_log_queries_days",
"class": "col-narrow",
"render": "badge_yes_no",
"render_options": {
- "title_true": "DNS Queries Recorded",
+ "title_true": "DNS Queries Recorded for {value} Days",
"title_false": "DNS Queries Not Recorded"
}
},
@@ -339,10 +339,11 @@
},
{
"type": "field",
- "label": "Record DNS Queries",
- "name": "dnsmasq_log_queries",
- "input_type": "checkbox",
- "hint": "Log every DNS query. High volume - enable for debugging only."
+ "label": "Record DNS Queries (Days)",
+ "name": "dnsmasq_log_queries_days",
+ "input_type": "number",
+ "min": 0,
+ "hint": "Number of days to retain DNS query logs. 0 = recording disabled."
},
{
"type": "field",
diff --git a/docker/routlin-dash/app/pages/overview/view.py b/docker/routlin-dash/app/pages/overview/view.py
index 50b0c23..7f18eba 100644
--- a/docker/routlin-dash/app/pages/overview/view.py
+++ b/docker/routlin-dash/app/pages/overview/view.py
@@ -143,7 +143,7 @@ def load_dns_metrics(period=0):
def has_query_logging(cfg):
- return any(v.get('dnsmasq_log_queries') for v in cfg.get('vlans', []))
+ return any(v.get('dnsmasq_log_queries_days', 0) > 0 for v in cfg.get('vlans', []))
def blocked_domains_table():
diff --git a/routlin/config.json b/routlin/config.json
index 0504d28..dfddf78 100644
--- a/routlin/config.json
+++ b/routlin/config.json
@@ -283,7 +283,7 @@
"subnet": "192.168.1.0",
"subnet_mask": 24,
"is_vpn": false,
- "dnsmasq_log_queries": true,
+ "dnsmasq_log_queries_days": 30,
"radius_default": false,
"mdns_reflection": false,
"use_blocklists": [
@@ -324,7 +324,7 @@
"subnet": "192.168.10.0",
"subnet_mask": 24,
"is_vpn": false,
- "dnsmasq_log_queries": true,
+ "dnsmasq_log_queries_days": 30,
"radius_default": false,
"mdns_reflection": true,
"use_blocklists": [
@@ -355,7 +355,7 @@
"subnet": "192.168.20.0",
"subnet_mask": 24,
"is_vpn": false,
- "dnsmasq_log_queries": true,
+ "dnsmasq_log_queries_days": 30,
"radius_default": true,
"mdns_reflection": true,
"use_blocklists": [
@@ -386,7 +386,7 @@
"subnet": "192.168.30.0",
"subnet_mask": 24,
"is_vpn": false,
- "dnsmasq_log_queries": true,
+ "dnsmasq_log_queries_days": 30,
"radius_default": false,
"mdns_reflection": true,
"use_blocklists": [
@@ -418,7 +418,7 @@
"subnet": "192.168.40.0",
"subnet_mask": 24,
"is_vpn": true,
- "dnsmasq_log_queries": true,
+ "dnsmasq_log_queries_days": 30,
"radius_default": false,
"mdns_reflection": false,
"use_blocklists": [
diff --git a/routlin/maintenance.py b/routlin/maintenance.py
index 4911360..1bf1e87 100644
--- a/routlin/maintenance.py
+++ b/routlin/maintenance.py
@@ -668,6 +668,9 @@ def main():
inserted = dns_queries.collect(full_cfg)
if inserted:
log.info(f"DNS query collector: inserted {inserted} new rows.")
+ pruned = dns_queries.prune(full_cfg)
+ if pruned:
+ log.info(f"DNS query collector: pruned {pruned} old rows.")
except Exception as e:
log.warning(f"DNS query collection failed: {e}")
diff --git a/routlin/mod_dns_queries.py b/routlin/mod_dns_queries.py
index bc2a036..99ac853 100644
--- a/routlin/mod_dns_queries.py
+++ b/routlin/mod_dns_queries.py
@@ -8,7 +8,7 @@ Called by:
- maintenance.py on each timer tick
- routlin-dash overview page on each page load (background thread)
-Only VLANs with dnsmasq_log_queries=true are collected.
+Only VLANs with dnsmasq_log_queries_days > 0 are collected.
"""
import json
@@ -84,7 +84,7 @@ def collect(data):
"""
unit_to_vlan = {}
for vlan in data.get('vlans', []):
- if not vlan.get('dnsmasq_log_queries'):
+ if not vlan.get('dnsmasq_log_queries_days', 0):
continue
iface = validation.derive_interface(vlan, data)
svc = shared.vlan_service_name(vlan, iface)
@@ -180,3 +180,26 @@ def collect(data):
shared.chown_to_script_dir_owner(DB_FILE)
con.close()
return len(rows)
+
+
+def prune(data):
+ """
+ Delete dns_queries rows older than the retention period configured per VLAN.
+ Uses the maximum retention days across all logging-enabled VLANs.
+ Returns the number of rows deleted.
+ """
+ days = max(
+ (v.get('dnsmasq_log_queries_days', 0) for v in data.get('vlans', [])),
+ default=0
+ )
+ if not days:
+ return 0
+ if not DB_FILE.exists():
+ return 0
+ cutoff = int(__import__('time').time()) - days * 86400
+ con = open_db()
+ cur = con.execute('DELETE FROM dns_queries WHERE ts < ?', (cutoff,))
+ deleted = cur.rowcount
+ con.commit()
+ con.close()
+ return deleted
diff --git a/routlin/mod_dnsmasq.py b/routlin/mod_dnsmasq.py
index 599b977..83ad5c0 100644
--- a/routlin/mod_dnsmasq.py
+++ b/routlin/mod_dnsmasq.py
@@ -478,7 +478,7 @@ def build_vlan_dnsmasq_conf(vlan, data, iface):
continue # skip IPv6 upstream -- WAN has no IPv6 address
L.append(f"server={srv}")
L.append(f"cache-size={dns_cfg.get('cache_size', 1000)}")
- if vlan.get("dnsmasq_log_queries", False):
+ if vlan.get("dnsmasq_log_queries_days", 0):
L.append("log-queries")
L.append("")