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("")