diff --git a/docker/routlin-dash/app/factory.py b/docker/routlin-dash/app/factory.py
index 7524eda..e744d21 100644
--- a/docker/routlin-dash/app/factory.py
+++ b/docker/routlin-dash/app/factory.py
@@ -1131,6 +1131,9 @@ def build_items(items, tokens, inherited_req=None):
req = item.get('client_requirement', inherited_req)
if not passes(req, level):
continue
+ data_req = item.get('data_requirement')
+ if data_req and not tokens.get(data_req):
+ continue
parts.append(build_item(item, tokens, req))
return ''.join(parts)
@@ -1209,7 +1212,8 @@ def build_item(item, tokens, inherited_req=None):
label = e(apply_tokens(item.get('label', ''), tokens))
raw_value = apply_tokens(item.get('value', ''), tokens)
value = e(raw_value)
- sub = e(apply_tokens(item.get('sub', ''), tokens))
+ sub_raw = apply_tokens(item.get('sub', ''), tokens)
+ sub = sub_raw if sub_raw.startswith('<') else e(sub_raw)
variant = item.get('variant', '')
cls = f'stat-card{(" stat-card-" + variant) if variant else ""}'
edit_action = item.get('edit_action', '')
diff --git a/docker/routlin-dash/app/pages/overview/content.json b/docker/routlin-dash/app/pages/overview/content.json
index 16a163e..4029ccd 100644
--- a/docker/routlin-dash/app/pages/overview/content.json
+++ b/docker/routlin-dash/app/pages/overview/content.json
@@ -50,15 +50,28 @@
"type": "stat_card",
"label": "DHCP Leases",
"value": "%STAT_LEASE_COUNT%",
- "sub": "active leases",
+ "sub": "%STAT_LEASES_LINK%",
"variant": "accent"
},
+ {
+ "type": "stat_card",
+ "label": "DNS Queries",
+ "value": "%DNS_STAT_QUERIES%",
+ "sub": "since %DNS_METRICS_SINCE%"
+ },
+ {
+ "type": "stat_card",
+ "label": "DNS Cache",
+ "value": "%DNS_STAT_HITS% (%DNS_STAT_HIT_RATE% hit rate)",
+ "sub": "cache size: %DNS_CACHE_SIZE%, evictions: %DNS_STAT_CACHE_EVICTIONS%"
+ },
{
"type": "stat_card",
"label": "Queries Blocked",
- "value": "%STAT_BLOCKED_TODAY%",
- "sub": "in last 24h",
- "variant": "warning"
+ "value": "%STAT_BLOCKED_ALLTIME%",
+ "sub": "all time",
+ "variant": "warning",
+ "data_requirement": "HAS_QUERY_LOGGING"
},
{
"type": "stat_card",
@@ -68,126 +81,6 @@
}
]
},
- {
- "type": "card",
- "label": "Network",
- "client_requirement": "client_is_viewer+",
- "items": [
- {
- "type": "grid",
- "rows": [
- {
- "cells": [
- {
- "type": "grid_label",
- "text": "WAN Interface"
- },
- {
- "type": "grid_value",
- "text": "%GENERAL_WAN_INTERFACE%"
- }
- ]
- },
- {
- "cells": [
- {
- "type": "grid_label",
- "text": "VLANs"
- },
- {
- "type": "grid_value",
- "text": "%OVERVIEW_VLAN_NAMES%"
- }
- ]
- },
- {
- "cells": [
- {
- "type": "grid_label",
- "text": "Firewall"
- },
- {
- "type": "grid_value",
- "text": "%STAT_NFTABLES_STATUS%"
- }
- ]
- },
- {
- "cells": [
- {
- "type": "grid_label",
- "text": "System Uptime"
- },
- {
- "type": "grid_value",
- "text": "%STAT_UPTIME%"
- }
- ]
- }
- ]
- }
- ]
- },
- {
- "type": "card",
- "label": "DNS Blocking",
- "client_requirement": "client_is_viewer+",
- "items": [
- {
- "type": "grid",
- "rows": [
- {
- "cells": [
- {
- "type": "grid_label",
- "text": "Blocked Domains"
- },
- {
- "type": "grid_value",
- "text": "%STAT_BLOCKED_DOMAINS%"
- }
- ]
- },
- {
- "cells": [
- {
- "type": "grid_label",
- "text": "Active Blocklists"
- },
- {
- "type": "grid_value",
- "text": "%STAT_BLOCKLIST_COUNT% lists"
- }
- ]
- },
- {
- "cells": [
- {
- "type": "grid_label",
- "text": "Last Refreshed"
- },
- {
- "type": "grid_value",
- "text": "%STAT_BL_LAST_UPDATE%"
- }
- ]
- },
- {
- "cells": [
- {
- "type": "grid_label",
- "text": "Active IP Bans"
- },
- {
- "type": "grid_value",
- "text": "%STAT_BANNED_IP_COUNT% rules"
- }
- ]
- }
- ]
- }
- ]
- },
{
"type": "card",
"label": "DNS Statistics",
@@ -245,6 +138,30 @@
"html": "%DNS_PROVIDERS_TABLE%"
}
]
+ },
+ {
+ "type": "card",
+ "label": "Blocked Domains",
+ "client_requirement": "client_is_viewer+",
+ "data_requirement": "HAS_QUERY_LOGGING",
+ "items": [
+ {
+ "type": "raw_html",
+ "html": "%BLOCKED_DOMAINS_TABLE%"
+ }
+ ]
+ },
+ {
+ "type": "card",
+ "label": "Client Activity",
+ "client_requirement": "client_is_viewer+",
+ "data_requirement": "HAS_QUERY_LOGGING",
+ "items": [
+ {
+ "type": "raw_html",
+ "html": "%CLIENT_ACTIVITY_TABLE%"
+ }
+ ]
}
]
-}
\ No newline at end of file
+}
diff --git a/docker/routlin-dash/app/pages/overview/view.py b/docker/routlin-dash/app/pages/overview/view.py
index a7c963e..57708c3 100644
--- a/docker/routlin-dash/app/pages/overview/view.py
+++ b/docker/routlin-dash/app/pages/overview/view.py
@@ -49,7 +49,7 @@ def _dns_providers_table(servers):
)
return (
'
'
- ''
+ ''
''
''
''
@@ -89,9 +89,92 @@ def load_dns_metrics():
return empty
-def count_blocked_today():
- out = factory.run("journalctl -u 'dnsmasq-routlin-*' --since '24 hours ago' --no-pager 2>/dev/null | grep -c ' is 0\\.0\\.0\\.0'")
- return out.strip() or '0'
+DNS_QUERIES_DB = f'{config_utils.CONFIGS_DIR}/dns-queries.db'
+
+
+def has_query_logging(cfg):
+ return any(v.get('dnsmasq_log_queries') for v in cfg.get('vlans', []))
+
+
+def blocked_domains_table():
+ no_data = 'No query data collected yet.
'
+ try:
+ import sqlite3
+ if not os.path.exists(DNS_QUERIES_DB):
+ return no_data
+ con = sqlite3.connect(DNS_QUERIES_DB)
+ rows = con.execute(
+ 'SELECT domain, COUNT(*) as cnt FROM dns_queries WHERE blocked=1 '
+ 'GROUP BY domain ORDER BY cnt DESC LIMIT 10'
+ ).fetchall()
+ con.close()
+ if not rows:
+ return no_data
+ trs = ''.join(
+ f'
| {factory.e(r[0])} | '
+ f'{r[1]:,} |
'
+ for r in rows
+ )
+ return (
+ ''
+ ''
+ ''
+ '
' + trs + '
'
+ )
+ except Exception:
+ return no_data
+
+
+def client_activity_table():
+ no_data = 'No query data collected yet.
'
+ try:
+ import sqlite3
+ if not os.path.exists(DNS_QUERIES_DB):
+ return no_data
+ con = sqlite3.connect(DNS_QUERIES_DB)
+ rows = con.execute(
+ 'SELECT client_ip, COUNT(*) as total, SUM(blocked) as blocked '
+ 'FROM dns_queries GROUP BY client_ip ORDER BY total DESC LIMIT 10'
+ ).fetchall()
+ con.close()
+ if not rows:
+ return no_data
+ trs = []
+ for client_ip, total, blocked in rows:
+ pct = f'{blocked / total * 100:.0f}%' if total else '0%'
+ trs.append(
+ f'| {factory.e(client_ip)} | '
+ f'{total:,} | '
+ f'{int(blocked):,} ({pct}) |
'
+ )
+ return (
+ ''
+ ''
+ ''
+ ''
+ '
' + ''.join(trs) + '
'
+ )
+ except Exception:
+ return no_data
+
+
+def all_time_blocked_display():
+ try:
+ import sqlite3
+ if not os.path.exists(DNS_QUERIES_DB):
+ return '-'
+ con = sqlite3.connect(DNS_QUERIES_DB)
+ row = con.execute(
+ 'SELECT SUM(blocked), COUNT(*) FROM dns_queries'
+ ).fetchone()
+ con.close()
+ blocked, total = row
+ if not blocked:
+ return '-'
+ pct = f'{blocked / total * 100:.0f}' if total else '0'
+ return f'{int(blocked):,} blocked ({pct}%)'
+ except Exception:
+ return '-'
def count_blocked_domains():
@@ -118,35 +201,31 @@ def bl_last_update():
def collect_tokens(cfg):
tokens = config_utils.collect_layout_tokens(cfg)
- vlans = cfg.get('vlans', [])
- non_vpn_vlans = [v for v in vlans if not v.get('is_vpn')]
- vlan_names = [v.get('name', '') for v in vlans]
- net = cfg.get('network_interfaces', {})
- dns = cfg.get('upstream_dns', {})
+ non_vpn_vlans = [v for v in cfg.get('vlans', []) if not v.get('is_vpn')]
+ dns = cfg.get('upstream_dns', {})
dns_stats = load_dns_metrics()
- ddns = factory.load_ddns()
- ip_str, domains_sub, last_obtained = public_ip_info(ddns)
+ ddns = factory.load_ddns()
+ ip_str, domains_sub, _ = public_ip_info(ddns)
- tokens['GENERAL_WAN_INTERFACE'] = str(net.get('wan_interface', '-'))
- tokens['OVERVIEW_VLAN_NAMES'] = ', '.join(vlan_names) or '-'
- tokens['STAT_VLAN_COUNT'] = str(len(non_vpn_vlans))
- tokens['STAT_LEASE_COUNT'] = str(len(live_dhcp_leases()))
- tokens['STAT_BANNED_IP_COUNT'] = str(sum(1 for b in cfg.get('banned_ips', []) if b.get('enabled', True)))
- tokens['STAT_BLOCKLIST_COUNT'] = str(len(cfg.get('dns_blocking', {}).get('blocklists', [])))
- tokens['STAT_BLOCKED_TODAY'] = count_blocked_today()
- tokens['STAT_BLOCKED_DOMAINS'] = count_blocked_domains()
- tokens['STAT_BL_LAST_UPDATE'] = bl_last_update()
- tokens['STAT_UPTIME'] = factory.run('uptime -p') or '-'
- tokens['STAT_NFTABLES_STATUS'] = 'Active' if factory.run('nft list tables 2>/dev/null').strip() else 'Inactive'
- tokens['STAT_PUBLIC_IP'] = ip_str
- tokens['STAT_DDNS_HOSTNAME'] = domains_sub
- tokens['DNS_CACHE_SIZE'] = str(dns.get('cache_size', '-'))
- tokens['DNS_STAT_QUERIES'] = dns_stats['queries']
- tokens['DNS_STAT_HITS'] = dns_stats['hits']
- tokens['DNS_STAT_HIT_RATE'] = dns_stats['hit_rate']
- tokens['DNS_STAT_FORWARDED'] = dns_stats['forwarded']
- tokens['DNS_STAT_CACHE_EVICTIONS'] = dns_stats['cache_evictions']
- tokens['DNS_METRICS_UPDATED'] = dns_stats['updated']
- tokens['DNS_METRICS_SINCE'] = dns_stats['since']
- tokens['DNS_PROVIDERS_TABLE'] = _dns_providers_table(dns_stats['servers'])
+ lease_count = len(live_dhcp_leases())
+ tokens['STAT_LEASE_COUNT'] = str(lease_count)
+ tokens['STAT_LEASES_LINK'] = f'{lease_count} active lease{"s" if lease_count != 1 else ""}'
+ tokens['STAT_VLAN_COUNT'] = str(len(non_vpn_vlans))
+ tokens['STAT_PUBLIC_IP'] = ip_str
+ tokens['STAT_DDNS_HOSTNAME'] = domains_sub
+
+ tokens['DNS_CACHE_SIZE'] = str(dns.get('cache_size', '-'))
+ tokens['DNS_STAT_QUERIES'] = dns_stats['queries']
+ tokens['DNS_STAT_HITS'] = dns_stats['hits']
+ tokens['DNS_STAT_HIT_RATE'] = dns_stats['hit_rate']
+ tokens['DNS_STAT_FORWARDED'] = dns_stats['forwarded']
+ tokens['DNS_STAT_CACHE_EVICTIONS'] = dns_stats['cache_evictions']
+ tokens['DNS_METRICS_UPDATED'] = dns_stats['updated']
+ tokens['DNS_METRICS_SINCE'] = dns_stats['since']
+ tokens['DNS_PROVIDERS_TABLE'] = _dns_providers_table(dns_stats['servers'])
+
+ tokens['STAT_BLOCKED_ALLTIME'] = all_time_blocked_display()
+ tokens['HAS_QUERY_LOGGING'] = '1' if has_query_logging(cfg) else ''
+ tokens['BLOCKED_DOMAINS_TABLE'] = blocked_domains_table()
+ tokens['CLIENT_ACTIVITY_TABLE'] = client_activity_table()
return tokens