Development
This commit is contained in:
parent
6d9aac0460
commit
59ac3c5973
20 changed files with 1466 additions and 1 deletions
9
docker/routlin-dash/app/pages/accountcreate/view.py
Normal file
9
docker/routlin-dash/app/pages/accountcreate/view.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import json
|
||||
import sanitize
|
||||
|
||||
|
||||
def collect_tokens(cfg):
|
||||
blank = [{'value': '', 'label': '-- Select timezone --'}]
|
||||
return {
|
||||
'TIMEZONE_OPTIONS': json.dumps(blank + [{'value': tz, 'label': tz} for tz in sanitize.VALID_TIMEZONES]),
|
||||
}
|
||||
11
docker/routlin-dash/app/pages/accountmanage/view.py
Normal file
11
docker/routlin-dash/app/pages/accountmanage/view.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import json
|
||||
|
||||
|
||||
def collect_tokens(cfg):
|
||||
return {
|
||||
'ACCOUNT_LEVEL_OPTIONS': json.dumps([
|
||||
{'value': 'viewer', 'label': 'Viewer (read-only access to live data)'},
|
||||
{'value': 'administrator', 'label': 'Administrator (can modify configuration)'},
|
||||
{'value': 'manager', 'label': 'Manager (full access including account management)'},
|
||||
]),
|
||||
}
|
||||
135
docker/routlin-dash/app/pages/actions/view.py
Normal file
135
docker/routlin-dash/app/pages/actions/view.py
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import json
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
from flask import session
|
||||
from config_utils import (
|
||||
get_dashboard_pending, load_all_groups, get_done_timestamps,
|
||||
_apply_changes_immediately, _find_cmd_in_queues, WEB_APP_DISPLAY_NAME,
|
||||
)
|
||||
from factory import LEVEL_RANK, e, client_level, build_snap_val, snap_expand_row
|
||||
from view_common import _load_icon
|
||||
|
||||
|
||||
def collect_tokens(cfg):
|
||||
tokens = {}
|
||||
tokens['GENERAL_APPLY_ON_SAVE'] = 'true' if session.get('apply_changes_immediately', False) else 'false'
|
||||
|
||||
all_groups = load_all_groups()
|
||||
group_uuid_set = {g['uuid'] for g, _ in all_groups}
|
||||
pending_items = get_dashboard_pending()
|
||||
|
||||
if pending_items:
|
||||
pgroups = defaultdict(list)
|
||||
for uuid, ts, cmd, user in pending_items:
|
||||
pgroups[cmd].append((uuid, user))
|
||||
rows = ''
|
||||
for cmd, entries in pgroups.items():
|
||||
users = ', '.join(sorted({u for _, u in entries if u and u != 'unknown'}))
|
||||
snap_uuids = [uuid for uuid, _ in entries if uuid in group_uuid_set]
|
||||
if snap_uuids:
|
||||
req_tags = ''.join(
|
||||
f'<span class="tag" data-tooltip="{uuid}" data-uuid="{uuid}">'
|
||||
f'<span class="tl-full">{uuid[:8]}</span>'
|
||||
f'<span class="tl-short">{uuid[:8]}</span>'
|
||||
f'<span class="tl-min">{uuid[:8]}</span>'
|
||||
'</span>'
|
||||
for uuid in snap_uuids
|
||||
)
|
||||
req_cell = f'<td class="table-cell"><div class="tag-list">{req_tags}</div></td>'
|
||||
else:
|
||||
req_cell = '<td class="table-cell">-</td>'
|
||||
rows += (
|
||||
'<tr>'
|
||||
f'<td class="table-cell">{e(cmd)}</td>'
|
||||
f'<td class="table-cell">{e(users)}</td>'
|
||||
f'{req_cell}'
|
||||
'</tr>'
|
||||
)
|
||||
pending_html = (
|
||||
'<table class="data-table"><thead><tr>'
|
||||
'<th class="table-header">Command</th>'
|
||||
'<th class="table-header">User</th>'
|
||||
'<th class="table-header">Required By</th>'
|
||||
'</tr></thead>'
|
||||
f'<tbody>{rows}</tbody></table>'
|
||||
)
|
||||
else:
|
||||
pending_html = '<p class="text-muted">No pending actions.</p>'
|
||||
|
||||
tokens['PENDING_ACTIONS_HTML'] = pending_html
|
||||
tokens['NO_PENDING'] = 'true' if not pending_items else ''
|
||||
tokens['NO_DISMISSIBLE_PENDING'] = 'true' if not any(c != 'fix problems' for _, _, c, _ in pending_items) else ''
|
||||
tokens['APPLY_WARNING'] = (
|
||||
f'<span style="color:var(--warning)"><p>{_load_icon("arrow-left")} <strong>Applying actions will briefly disrupt connections as network services are restarted.</strong></p></span>'
|
||||
if pending_items else ''
|
||||
)
|
||||
|
||||
done_ts_map = get_done_timestamps()
|
||||
if all_groups:
|
||||
no_revert = set()
|
||||
for g, _ in all_groups:
|
||||
if g['reverts_group']:
|
||||
no_revert.add(g['uuid'])
|
||||
no_revert.add(g['reverts_group'])
|
||||
hist_rows = ''
|
||||
hist_onclick = (
|
||||
'onclick="if(event.target.type!==\'checkbox\')'
|
||||
'this.nextElementSibling.hidden=!this.nextElementSibling.hidden"'
|
||||
)
|
||||
for g, changes in all_groups:
|
||||
uuid = g['uuid']
|
||||
applied_ts = done_ts_map.get(uuid)
|
||||
dt_str = datetime.fromtimestamp(applied_ts).strftime('%Y-%m-%d %H:%M') if applied_ts else '-'
|
||||
all_before_null = all(c['before'] is None for c in changes)
|
||||
all_after_null = all(c['after'] is None for c in changes)
|
||||
if g['reverts_group']:
|
||||
verb = 'Reverted'
|
||||
elif all_before_null:
|
||||
verb = 'Added'
|
||||
elif all_after_null:
|
||||
verb = 'Deleted'
|
||||
else:
|
||||
verb = 'Edited'
|
||||
item = g.get('item_value') or ''
|
||||
summary = f'{verb} {g["parent_path"]}: {item}' if item else f'{verb} {g["parent_path"]}'
|
||||
snap_tag = (
|
||||
f'<div class="tag-list"><span class="tag" data-tooltip="{e(uuid)}" data-uuid="{e(uuid)}">'
|
||||
f'<span class="tl-full">{e(uuid[:8])}</span>'
|
||||
f'<span class="tl-short">{e(uuid[:8])}</span>'
|
||||
f'<span class="tl-min">{e(uuid[:8])}</span>'
|
||||
'</span></div>'
|
||||
)
|
||||
snap_user = e(g.get('user', ''))
|
||||
cb_attrs = 'disabled title="Cannot revert"' if uuid in no_revert else ''
|
||||
hist_rows += (
|
||||
f'<tr class="row-expandable" data-uuid="{e(uuid)}" {hist_onclick}>'
|
||||
f'<td class="table-cell"><input type="checkbox" name="selected_uuids" value="{e(uuid)}" {cb_attrs}/></td>'
|
||||
f'<td class="table-cell">{e(dt_str)}</td>'
|
||||
f'<td class="table-cell">{e(summary)}</td>'
|
||||
f'<td class="table-cell">{build_snap_val(changes)}</td>'
|
||||
f'<td class="table-cell">{snap_tag}</td>'
|
||||
f'<td class="table-cell">{snap_user}</td>'
|
||||
'</tr>'
|
||||
f'{snap_expand_row(changes, 6)}'
|
||||
)
|
||||
select_all = (
|
||||
'<input type="checkbox" '
|
||||
'onchange="document.querySelectorAll(\'[name=selected_uuids]:not(:disabled)\').forEach(c=>c.checked=this.checked)"/>'
|
||||
)
|
||||
history_html = (
|
||||
'<table class="data-table"><thead><tr>'
|
||||
f'<th class="table-header">{select_all}</th>'
|
||||
'<th class="table-header">Applied</th>'
|
||||
'<th class="table-header">Change</th>'
|
||||
'<th class="table-header">Fields</th>'
|
||||
'<th class="table-header">Change ID</th>'
|
||||
'<th class="table-header">User</th>'
|
||||
'</tr></thead>'
|
||||
f'<tbody>{hist_rows}</tbody></table>'
|
||||
)
|
||||
else:
|
||||
history_html = '<p class="text-muted">No change history.</p>'
|
||||
|
||||
tokens['CHANGE_HISTORY_HTML'] = history_html
|
||||
tokens['NO_HISTORY'] = 'true' if not all_groups else ''
|
||||
return tokens
|
||||
71
docker/routlin-dash/app/pages/ddns/view.py
Normal file
71
docker/routlin-dash/app/pages/ddns/view.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import json
|
||||
import re
|
||||
import os
|
||||
from view_common import load_ddns, public_ip_info, ddns_last_checked, CONFIGS_DIR
|
||||
import validation as validate
|
||||
|
||||
DDNS_LOG_MAX = 50
|
||||
|
||||
|
||||
def _parse_interval_to_seconds(s):
|
||||
m = re.match(r'^(\d+)([mhd])$', str(s).strip())
|
||||
if not m:
|
||||
return None
|
||||
val, unit = int(m.group(1)), m.group(2)
|
||||
return val * {'m': 60, 'h': 3600, 'd': 86400}[unit]
|
||||
|
||||
|
||||
def _ddns_log_tail():
|
||||
log_path = f'{CONFIGS_DIR}/ddns.log'
|
||||
try:
|
||||
log_max_kb = load_ddns().get('general', {}).get('log_max_kb', 1024)
|
||||
size_kb = os.path.getsize(log_path) / 1024
|
||||
with open(log_path) as f:
|
||||
lines = f.readlines()
|
||||
if not lines:
|
||||
return '(log is empty)', ''
|
||||
total = len(lines)
|
||||
tail = lines[-DDNS_LOG_MAX:]
|
||||
shown = len(tail)
|
||||
hidden = total - shown
|
||||
pct = min(100, round(size_kb / log_max_kb * 100)) if log_max_kb else 0
|
||||
left = f'Showing {shown} of {total} lines ({hidden} not shown)' if hidden > 0 else f'Showing {shown} of {total} lines'
|
||||
right = f'Log file size: {size_kb:.1f} KB ({pct}% of max)'
|
||||
summary = (
|
||||
'<div class="text-muted" style="display:flex;justify-content:space-between;margin-top:0.5em;">'
|
||||
f'<span>{left}</span><span>{right}</span></div>'
|
||||
)
|
||||
return ''.join(tail).strip(), summary
|
||||
except FileNotFoundError:
|
||||
return '(log file not found)', ''
|
||||
except Exception:
|
||||
return '(error reading log)', ''
|
||||
|
||||
|
||||
def collect_tokens(cfg):
|
||||
tokens = {}
|
||||
ddns = load_ddns()
|
||||
ddns_gen = ddns.get('general', {})
|
||||
tokens['DDNS_TIMER_INTERVAL'] = ddns_gen.get('timer_interval', '-')
|
||||
interval_secs = _parse_interval_to_seconds(ddns_gen.get('timer_interval', '')) or 600
|
||||
tokens['DDNS_TIMER_INTERVAL_MINS'] = str(interval_secs // 60)
|
||||
tokens['DDNS_GEN_LOG_MAX_KB'] = str(ddns_gen.get('log_max_kb', 1024))
|
||||
tokens['DDNS_GEN_LOG_ERRORS_ONLY'] = 'true' if ddns_gen.get('log_errors_only') else 'false'
|
||||
ip_check = ddns.get('ip_check_services', [])
|
||||
http_svc = [s['url'] for s in ip_check if s.get('type') == 'http']
|
||||
dig_svc = [s['url'] for s in ip_check if s.get('type') == 'dig']
|
||||
tokens['STAT_IP_CHECK_TOTAL'] = str(len(ip_check))
|
||||
tokens['STAT_IP_CHECK_SUB'] = f'{len(http_svc)} http and {len(dig_svc)} dig'
|
||||
tokens['IP_CHECK_HTTP_JSON'] = json.dumps(http_svc)
|
||||
tokens['IP_CHECK_DIG_JSON'] = json.dumps(dig_svc)
|
||||
ddns_labels = {'noip': 'No-IP', 'cloudflare': 'Cloudflare', 'duckdns': 'DuckDNS'}
|
||||
tokens['DDNS_PROVIDER_OPTIONS'] = json.dumps([
|
||||
{'value': p, 'label': ddns_labels.get(p, p.title())}
|
||||
for p in validate.VALID_DDNS_PROVIDERS
|
||||
])
|
||||
ip_str, domains_sub, last_obtained = public_ip_info(ddns)
|
||||
tokens['STAT_PUBLIC_IP'] = ip_str
|
||||
tokens['STAT_PUBLIC_IP_LAST_OBTAINED'] = last_obtained
|
||||
tokens['STAT_PUBLIC_IP_LAST_CHECKED'] = ddns_last_checked()
|
||||
tokens['DDNS_LOG_TAIL'], tokens['DDNS_LOG_SUMMARY'] = _ddns_log_tail()
|
||||
return tokens
|
||||
27
docker/routlin-dash/app/pages/dhcp/view.py
Normal file
27
docker/routlin-dash/app/pages/dhcp/view.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import json
|
||||
|
||||
|
||||
def collect_tokens(cfg):
|
||||
vlans = cfg.get('vlans', [])
|
||||
vlan_names = [v.get('name', '') for v in vlans]
|
||||
res_ips_by_vlan, res_hosts_by_vlan = {}, {}
|
||||
for v in vlans:
|
||||
vn = v.get('name', '')
|
||||
if not vn:
|
||||
continue
|
||||
vlan_res = [r for r in cfg.get('dhcp_reservations', []) if r.get('vlan') == vn]
|
||||
res_ips_by_vlan[vn] = [r['ip'] for r in vlan_res if r.get('ip') and r['ip'] != 'dynamic']
|
||||
res_hosts_by_vlan[vn] = [r['hostname'] for r in vlan_res if r.get('hostname')]
|
||||
filter_opts = '<option value="all">All VLANs</option>' + ''.join(
|
||||
f'<option value="{n}">{n}</option>' for n in vlan_names
|
||||
)
|
||||
return {
|
||||
'VLAN_FILTER_OPTIONS': filter_opts,
|
||||
'VLAN_NAMES_AS_OPTIONS': json.dumps([{'value': n, 'label': n} for n in vlan_names]),
|
||||
'VLAN_SUBNET_INFO_JSON': json.dumps({
|
||||
v.get('name', ''): {'subnet': v.get('subnet', ''), 'prefix': v.get('subnet_mask', 0)}
|
||||
for v in vlans if v.get('name') and v.get('subnet')
|
||||
}),
|
||||
'RESERVATION_IPS_BY_VLAN_JSON': json.dumps(res_ips_by_vlan),
|
||||
'RESERVATION_HOSTNAMES_BY_VLAN_JSON': json.dumps(res_hosts_by_vlan),
|
||||
}
|
||||
7
docker/routlin-dash/app/pages/dhcpleases/view.py
Normal file
7
docker/routlin-dash/app/pages/dhcpleases/view.py
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
def collect_tokens(cfg):
|
||||
vlans = cfg.get('vlans', [])
|
||||
vlan_names = [v.get('name', '') for v in vlans]
|
||||
filter_opts = '<option value="all">All VLANs</option>' + ''.join(
|
||||
f'<option value="{n}">{n}</option>' for n in vlan_names
|
||||
)
|
||||
return {'VLAN_FILTER_OPTIONS': filter_opts}
|
||||
27
docker/routlin-dash/app/pages/dhcpreservations/view.py
Normal file
27
docker/routlin-dash/app/pages/dhcpreservations/view.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import json
|
||||
|
||||
|
||||
def collect_tokens(cfg):
|
||||
vlans = cfg.get('vlans', [])
|
||||
vlan_names = [v.get('name', '') for v in vlans]
|
||||
res_ips_by_vlan, res_hosts_by_vlan = {}, {}
|
||||
for v in vlans:
|
||||
vn = v.get('name', '')
|
||||
if not vn:
|
||||
continue
|
||||
vlan_res = [r for r in cfg.get('dhcp_reservations', []) if r.get('vlan') == vn]
|
||||
res_ips_by_vlan[vn] = [r['ip'] for r in vlan_res if r.get('ip') and r['ip'] != 'dynamic']
|
||||
res_hosts_by_vlan[vn] = [r['hostname'] for r in vlan_res if r.get('hostname')]
|
||||
filter_opts = '<option value="all">All VLANs</option>' + ''.join(
|
||||
f'<option value="{n}">{n}</option>' for n in vlan_names
|
||||
)
|
||||
return {
|
||||
'VLAN_FILTER_OPTIONS': filter_opts,
|
||||
'VLAN_NAMES_AS_OPTIONS': json.dumps([{'value': n, 'label': n} for n in vlan_names]),
|
||||
'VLAN_SUBNET_INFO_JSON': json.dumps({
|
||||
v.get('name', ''): {'subnet': v.get('subnet', ''), 'prefix': v.get('subnet_mask', 0)}
|
||||
for v in vlans if v.get('name') and v.get('subnet')
|
||||
}),
|
||||
'RESERVATION_IPS_BY_VLAN_JSON': json.dumps(res_ips_by_vlan),
|
||||
'RESERVATION_HOSTNAMES_BY_VLAN_JSON': json.dumps(res_hosts_by_vlan),
|
||||
}
|
||||
57
docker/routlin-dash/app/pages/dnsblocking/view.py
Normal file
57
docker/routlin-dash/app/pages/dnsblocking/view.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import json
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from view_common import fmt_bytes, relative_time, BLOCKLISTS_DIR
|
||||
from factory import e
|
||||
|
||||
|
||||
def _blocklist_stats_html(cfg):
|
||||
rows = ''
|
||||
for bl in cfg.get('dns_blocking', {}).get('blocklists', []):
|
||||
name = e(bl.get('name', ''))
|
||||
save_as = bl.get('save_as', '')
|
||||
bl_path = f'{BLOCKLISTS_DIR}/{save_as}' if save_as else ''
|
||||
try:
|
||||
with open(bl_path) as f:
|
||||
entries = sum(1 for _ in f)
|
||||
mtime = int(os.path.getmtime(bl_path))
|
||||
size_str = fmt_bytes(os.path.getsize(bl_path))
|
||||
last_refreshed = (
|
||||
f'{datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M")}'
|
||||
f' ({relative_time(mtime, datetime.now(tz=timezone.utc).timestamp())} ago)'
|
||||
)
|
||||
except Exception:
|
||||
entries, size_str, last_refreshed = '-', '-', 'Never'
|
||||
rows += (
|
||||
'<tr>'
|
||||
f'<td class="table-cell">{name}</td>'
|
||||
f'<td class="table-cell">{entries}</td>'
|
||||
f'<td class="table-cell">{size_str}</td>'
|
||||
f'<td class="table-cell">{e(last_refreshed)}</td>'
|
||||
'</tr>'
|
||||
)
|
||||
if not rows:
|
||||
return ''
|
||||
return (
|
||||
'<table class="data-table"><thead><tr>'
|
||||
'<th class="table-header">Blocklist</th>'
|
||||
'<th class="table-header">Entries</th>'
|
||||
'<th class="table-header">Size</th>'
|
||||
'<th class="table-header">Last Refreshed</th>'
|
||||
'</tr></thead>'
|
||||
f'<tbody>{rows}</tbody></table>'
|
||||
)
|
||||
|
||||
|
||||
def collect_tokens(cfg):
|
||||
dns_blk_gen = cfg.get('dns_blocking', {}).get('general', {})
|
||||
return {
|
||||
'GENERAL_LOG_MAX_KB': str(dns_blk_gen.get('log_max_kb', '-')),
|
||||
'GENERAL_LOG_ERRORS_ONLY': 'true' if dns_blk_gen.get('log_errors_only') else 'false',
|
||||
'GENERAL_DAILY_EXECUTE_TIME': str(dns_blk_gen.get('daily_execute_time_24hr_local', '-')),
|
||||
'BLOCKLIST_STATS_HTML': _blocklist_stats_html(cfg),
|
||||
'BLOCKLIST_FORMAT_OPTIONS': json.dumps([
|
||||
{'value': 'hosts', 'label': 'hosts (hosts file format)'},
|
||||
{'value': 'dnsmasq', 'label': 'dnsmasq (local=/ syntax)'},
|
||||
]),
|
||||
}
|
||||
11
docker/routlin-dash/app/pages/dnsserver/view.py
Normal file
11
docker/routlin-dash/app/pages/dnsserver/view.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import json
|
||||
|
||||
|
||||
def collect_tokens(cfg):
|
||||
dns = cfg.get('upstream_dns', {})
|
||||
servers = dns.get('upstream_servers', [])
|
||||
return {
|
||||
'DNS_STRICT_ORDER': 'true' if dns.get('strict_order') else 'false',
|
||||
'DNS_CACHE_SIZE': str(dns.get('cache_size', '-')),
|
||||
'DNS_UPSTREAM_SERVERS_JSON': json.dumps(servers),
|
||||
}
|
||||
11
docker/routlin-dash/app/pages/intervlan/view.py
Normal file
11
docker/routlin-dash/app/pages/intervlan/view.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import json
|
||||
|
||||
|
||||
def collect_tokens(cfg):
|
||||
return {
|
||||
'PROTOCOL_OPTIONS': json.dumps([
|
||||
{'value': 'tcp', 'label': 'TCP'},
|
||||
{'value': 'udp', 'label': 'UDP'},
|
||||
{'value': 'both', 'label': 'TCP/UDP'},
|
||||
]),
|
||||
}
|
||||
15
docker/routlin-dash/app/pages/networklayout/view.py
Normal file
15
docker/routlin-dash/app/pages/networklayout/view.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import json
|
||||
|
||||
|
||||
def collect_tokens(cfg):
|
||||
vlans = cfg.get('vlans', [])
|
||||
dv = next((v for v in vlans if v.get('radius_default')), None)
|
||||
return {
|
||||
'EXISTING_VLAN_IDS_JSON': json.dumps([v.get('vlan_id') for v in vlans]),
|
||||
'EXISTING_VLAN_NAMES_JSON': json.dumps([v.get('name') for v in vlans]),
|
||||
'RADIUS_DEFAULT_VLAN': f'"{dv["name"]}" (VLAN {dv["vlan_id"]})' if dv else 'none set',
|
||||
'BLOCKLIST_NAME_OPTIONS': json.dumps([
|
||||
{'value': bl.get('name', ''), 'label': bl.get('description', bl.get('name', ''))}
|
||||
for bl in cfg.get('dns_blocking', {}).get('blocklists', [])
|
||||
]),
|
||||
}
|
||||
94
docker/routlin-dash/app/pages/overview/view.py
Normal file
94
docker/routlin-dash/app/pages/overview/view.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import re
|
||||
import os
|
||||
from view_common import run, load_ddns, public_ip_info, live_dhcp_leases, fmt_timestamp, BLOCKLISTS_DIR
|
||||
|
||||
|
||||
def get_dnsmasq_stats():
|
||||
stats = {'queries': '-', 'hits': '-', 'hit_rate': '-', 'forwarded': '-', 'auth': '-', 'tcp_peak': '-'}
|
||||
out = run('journalctl -u dnsmasq -n 200 --no-pager 2>/dev/null')
|
||||
for line in reversed(out.splitlines()):
|
||||
if 'queries forwarded' in line:
|
||||
m = re.search(r'queries forwarded (\d+)', line)
|
||||
if m:
|
||||
stats['forwarded'] = m.group(1)
|
||||
m = re.search(r'queries answered locally (\d+)', line)
|
||||
if m:
|
||||
stats['hits'] = m.group(1)
|
||||
fwd = int(stats['forwarded']) if stats['forwarded'] != '-' else 0
|
||||
hit = int(stats['hits']) if stats['hits'] != '-' else 0
|
||||
total = fwd + hit
|
||||
stats['queries'] = str(total) if total else '-'
|
||||
if total > 0:
|
||||
stats['hit_rate'] = f'{hit / total * 100:.0f}%'
|
||||
break
|
||||
if 'auth answered' in line:
|
||||
m = re.search(r'auth answered (\d+)', line)
|
||||
if m and stats['auth'] == '-':
|
||||
stats['auth'] = m.group(1)
|
||||
if 'max TCP connections' in line:
|
||||
m = re.search(r'max TCP connections (\d+)', line)
|
||||
if m and stats['tcp_peak'] == '-':
|
||||
stats['tcp_peak'] = m.group(1)
|
||||
return stats
|
||||
|
||||
|
||||
def _count_blocked_today():
|
||||
out = run("journalctl -u dnsmasq --since today --no-pager 2>/dev/null | grep -c 'is NXDOMAIN'")
|
||||
return out or '0'
|
||||
|
||||
|
||||
def _count_blocked_domains():
|
||||
try:
|
||||
total = sum(
|
||||
int(run(f'wc -l < "{BLOCKLISTS_DIR}/{f}"') or 0)
|
||||
for f in os.listdir(BLOCKLISTS_DIR) if f.endswith('.con')
|
||||
)
|
||||
return str(total)
|
||||
except Exception:
|
||||
return '-'
|
||||
|
||||
|
||||
def _bl_last_update():
|
||||
try:
|
||||
mtime = max(
|
||||
os.path.getmtime(f'{BLOCKLISTS_DIR}/{f}')
|
||||
for f in os.listdir(BLOCKLISTS_DIR) if f.endswith('.con')
|
||||
)
|
||||
return fmt_timestamp(int(mtime))
|
||||
except Exception:
|
||||
return '-'
|
||||
|
||||
|
||||
def collect_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', {})
|
||||
dns_stats = get_dnsmasq_stats()
|
||||
ddns = load_ddns()
|
||||
ip_str, domains_sub, last_obtained = public_ip_info(ddns)
|
||||
|
||||
return {
|
||||
'GENERAL_WAN_INTERFACE': str(net.get('wan_interface', '-')),
|
||||
'OVERVIEW_VLAN_NAMES': ', '.join(vlan_names) or '-',
|
||||
'STAT_VLAN_COUNT': str(len(non_vpn_vlans)),
|
||||
'STAT_LEASE_COUNT': str(len(live_dhcp_leases())),
|
||||
'STAT_BANNED_IP_COUNT': str(sum(1 for b in cfg.get('banned_ips', []) if b.get('enabled', True))),
|
||||
'STAT_BLOCKLIST_COUNT': str(len(cfg.get('dns_blocking', {}).get('blocklists', []))),
|
||||
'STAT_BLOCKED_TODAY': _count_blocked_today(),
|
||||
'STAT_BLOCKED_DOMAINS': _count_blocked_domains(),
|
||||
'STAT_BL_LAST_UPDATE': _bl_last_update(),
|
||||
'STAT_UPTIME': run('uptime -p') or '-',
|
||||
'STAT_NFTABLES_STATUS': 'Active' if run('nft list tables 2>/dev/null').strip() else 'Inactive',
|
||||
'STAT_PUBLIC_IP': ip_str,
|
||||
'STAT_DDNS_HOSTNAME': domains_sub,
|
||||
'DNS_CACHE_SIZE': str(dns.get('cache_size', '-')),
|
||||
'OVERVIEW_UPSTREAM_SERVERS': ', '.join(dns.get('upstream_servers', [])) or '-',
|
||||
'DNS_STAT_QUERIES': dns_stats['queries'],
|
||||
'DNS_STAT_HITS': dns_stats['hits'],
|
||||
'DNS_STAT_HIT_RATE': dns_stats['hit_rate'],
|
||||
'DNS_STAT_FORWARDED': dns_stats['forwarded'],
|
||||
'DNS_STAT_AUTH': dns_stats['auth'],
|
||||
'DNS_STAT_TCP_PEAK': dns_stats['tcp_peak'],
|
||||
}
|
||||
19
docker/routlin-dash/app/pages/physicalinterfaces/view.py
Normal file
19
docker/routlin-dash/app/pages/physicalinterfaces/view.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import json
|
||||
from view_common import get_system_interfaces, iface_info
|
||||
|
||||
|
||||
def collect_tokens(cfg):
|
||||
net = cfg.get('network_interfaces', {})
|
||||
wan = net.get('wan_interface', '')
|
||||
lan = net.get('lan_interface', '')
|
||||
sys_ifaces = get_system_interfaces()
|
||||
for configured in [wan, lan]:
|
||||
if configured and configured not in sys_ifaces:
|
||||
sys_ifaces.append(configured)
|
||||
sys_ifaces.sort()
|
||||
iface_data = [iface_info(i) for i in sys_ifaces]
|
||||
return {
|
||||
'GENERAL_WAN_INTERFACE': str(wan or '-'),
|
||||
'GENERAL_LAN_INTERFACE': str(lan or '-'),
|
||||
'NETWORK_INTERFACE_DATA_JSON': json.dumps(iface_data),
|
||||
}
|
||||
11
docker/routlin-dash/app/pages/portforwarding/view.py
Normal file
11
docker/routlin-dash/app/pages/portforwarding/view.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import json
|
||||
|
||||
|
||||
def collect_tokens(cfg):
|
||||
return {
|
||||
'PROTOCOL_OPTIONS': json.dumps([
|
||||
{'value': 'tcp', 'label': 'TCP'},
|
||||
{'value': 'udp', 'label': 'UDP'},
|
||||
{'value': 'both', 'label': 'TCP/UDP'},
|
||||
]),
|
||||
}
|
||||
22
docker/routlin-dash/app/pages/portwrangling/view.py
Normal file
22
docker/routlin-dash/app/pages/portwrangling/view.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import json
|
||||
|
||||
|
||||
def collect_tokens(cfg):
|
||||
vlans = cfg.get('vlans', [])
|
||||
vlan_names = [v.get('name', '') for v in vlans]
|
||||
filter_opts = '<option value="all">All VLANs</option>' + ''.join(
|
||||
f'<option value="{n}">{n}</option>' for n in vlan_names
|
||||
)
|
||||
return {
|
||||
'PROTOCOL_OPTIONS': json.dumps([
|
||||
{'value': 'tcp', 'label': 'TCP'},
|
||||
{'value': 'udp', 'label': 'UDP'},
|
||||
{'value': 'both', 'label': 'TCP/UDP'},
|
||||
]),
|
||||
'VLAN_FILTER_OPTIONS': filter_opts,
|
||||
'VLAN_NAMES_AS_OPTIONS': json.dumps([{'value': n, 'label': n} for n in vlan_names]),
|
||||
'VLAN_SUBNET_INFO_JSON': json.dumps({
|
||||
v.get('name', ''): {'subnet': v.get('subnet', ''), 'prefix': v.get('subnet_mask', 0)}
|
||||
for v in vlans if v.get('name') and v.get('subnet')
|
||||
}),
|
||||
}
|
||||
12
docker/routlin-dash/app/pages/preferences/view.py
Normal file
12
docker/routlin-dash/app/pages/preferences/view.py
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import json
|
||||
from flask import session
|
||||
import sanitize
|
||||
|
||||
|
||||
def collect_tokens(cfg):
|
||||
blank = [{'value': '', 'label': '-- Select timezone --'}]
|
||||
return {
|
||||
'PREF_EMAIL': session.get('email_address', ''),
|
||||
'PREF_TIMEZONE': session.get('timezone', ''),
|
||||
'TIMEZONE_OPTIONS': json.dumps(blank + [{'value': tz, 'label': tz} for tz in sanitize.VALID_TIMEZONES]),
|
||||
}
|
||||
48
docker/routlin-dash/app/pages/radius/view.py
Normal file
48
docker/routlin-dash/app/pages/radius/view.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import os
|
||||
from view_common import CONFIGS_DIR
|
||||
|
||||
RADIUS_LOG_MAX = 50
|
||||
RADIUS_LOG_FILE = '/var/log/freeradius/radius.log'
|
||||
|
||||
|
||||
def _radius_log_tail(cfg):
|
||||
try:
|
||||
log_max_kb = cfg.get('radius', {}).get('general', {}).get('log_max_kb', 1024)
|
||||
size_kb = os.path.getsize(RADIUS_LOG_FILE) / 1024
|
||||
with open(RADIUS_LOG_FILE) as f:
|
||||
lines = f.readlines()
|
||||
if not lines:
|
||||
return '(log is empty)', ''
|
||||
total = len(lines)
|
||||
tail = lines[-RADIUS_LOG_MAX:]
|
||||
shown = len(tail)
|
||||
hidden = total - shown
|
||||
pct = min(100, round(size_kb / log_max_kb * 100)) if log_max_kb else 0
|
||||
left = f'Showing {shown} of {total} lines ({hidden} not shown)' if hidden > 0 else f'Showing {shown} of {total} lines'
|
||||
right = f'Log file size: {size_kb:.1f} KB ({pct}% of max)'
|
||||
summary = (
|
||||
'<div id="radius-log-summary" class="text-muted" style="display:flex;justify-content:space-between;margin-top:0.5em;">'
|
||||
f'<span>{left}</span><span>{right}</span></div>'
|
||||
)
|
||||
return ''.join(tail).strip(), summary
|
||||
except FileNotFoundError:
|
||||
return '(log file not found)', ''
|
||||
except Exception:
|
||||
return '(error reading log)', ''
|
||||
|
||||
|
||||
def collect_tokens(cfg):
|
||||
tokens = {}
|
||||
try:
|
||||
tokens['RADIUS_SECRET'] = open(f'{CONFIGS_DIR}/.radius-secret').read().strip()
|
||||
except OSError:
|
||||
tokens['RADIUS_SECRET'] = '(Generation is pending - visit Actions to apply generation command)'
|
||||
fr = cfg.get('radius', {})
|
||||
fr_opts = fr.get('options', {})
|
||||
fr_gen = fr.get('general', {})
|
||||
tokens['RADIUS_MAC_FORMAT'] = fr_opts.get('mac_format', 'aabbccddeeff')
|
||||
tokens['RADIUS_APPLY_TO'] = fr_opts.get('apply_to', 'all')
|
||||
tokens['RADIUS_LOGGING'] = 'true' if fr_gen.get('logging', False) else ''
|
||||
tokens['RADIUS_GEN_LOG_MAX_KB'] = str(fr_gen.get('log_max_kb', 1024))
|
||||
tokens['RADIUS_LOG_TAIL'], tokens['RADIUS_LOG_SUMMARY'] = _radius_log_tail(cfg)
|
||||
return tokens
|
||||
37
docker/routlin-dash/app/pages/vpn/view.py
Normal file
37
docker/routlin-dash/app/pages/vpn/view.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import json
|
||||
|
||||
|
||||
def collect_tokens(cfg):
|
||||
vlans = cfg.get('vlans', [])
|
||||
wg_vlans_list = sorted(
|
||||
[v for v in vlans if v.get('is_vpn')],
|
||||
key=lambda v: (v.get('vlan_id') is None, v.get('vlan_id') or 0)
|
||||
)
|
||||
wg_vlan = wg_vlans_list[0] if wg_vlans_list else {}
|
||||
vpn = wg_vlan.get('vpn_information', {})
|
||||
overrides = vpn.get('explicit_overrides', {})
|
||||
|
||||
try:
|
||||
import ipaddress
|
||||
ident_ips = [s['ip'] for s in wg_vlan.get('server_identities', []) if s.get('ip')]
|
||||
if ident_ips:
|
||||
default_gw = str(min((ipaddress.IPv4Address(ip) for ip in ident_ips), key=lambda x: x.packed[-1]))
|
||||
else:
|
||||
wg_net = ipaddress.IPv4Network(f"{wg_vlan['subnet']}/{wg_vlan['subnet_mask']}", strict=False)
|
||||
default_gw = str(next(wg_net.hosts()))
|
||||
vpn_gateway = overrides.get('gateway') or default_gw
|
||||
except Exception:
|
||||
vpn_gateway = ''
|
||||
|
||||
return {
|
||||
'VPN_VLAN_OPTIONS': json.dumps([
|
||||
{'value': v.get('name', ''), 'label': f'wg{i} (VLAN {v.get("vlan_id") or "?"})'}
|
||||
for i, v in enumerate(wg_vlans_list)
|
||||
]),
|
||||
'VPN_LISTEN_PORT': str(vpn.get('listen_port', '')),
|
||||
'VPN_SERVER_ENDPOINT': str(vpn.get('server_endpoint', '')),
|
||||
'VPN_DOMAIN': str(vpn.get('domain', '')),
|
||||
'VPN_DNS_SERVER': str(overrides.get('dns_servers', '')),
|
||||
'VPN_MTU': str(overrides.get('mtu', '')),
|
||||
'VPN_GATEWAY': vpn_gateway,
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue