Development

This commit is contained in:
Matthew Grotke 2026-05-24 01:46:48 -04:00
parent e98eb85c5a
commit 278995958a
6 changed files with 39 additions and 18 deletions

View file

@ -4,7 +4,7 @@ import re
from flask import Blueprint, make_response, redirect, flash, request from flask import Blueprint, make_response, redirect, flash, request
from auth import require_level from auth import require_level
from config_utils import load_core, save_core, verify_core_hash, queued_msg, CONFIGS_DIR, PRODUCT_DISPLAY_NAME from config_utils import load_core, save_core, verify_core_hash, queued_msg, CONFIGS_DIR, WEB_APP_DISPLAY_NAME
import sanitize import sanitize
import validation as validate import validation as validate
@ -103,7 +103,7 @@ def _build_client_conf(vlan, peer_name, peer_ip, private_key, server_pubkey):
allowed_ips = f'{subnet}/{prefix}' if split_tunnel else '0.0.0.0/0' allowed_ips = f'{subnet}/{prefix}' if split_tunnel else '0.0.0.0/0'
lines = [ lines = [
f'# Generated by {PRODUCT_DISPLAY_NAME}', f'# Generated by {WEB_APP_DISPLAY_NAME}',
'', '',
'[Interface]', '[Interface]',
f'PrivateKey = {private_key}', f'PrivateKey = {private_key}',

View file

@ -3,7 +3,7 @@ import json, os, bcrypt, secrets, smtplib
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from email.message import EmailMessage from email.message import EmailMessage
from auth import require_level from auth import require_level
from config_utils import PRODUCT_DISPLAY_NAME, ACCOUNTS_FILE from config_utils import WEB_APP_DISPLAY_NAME, ACCOUNTS_FILE
import sanitize import sanitize
bp = Blueprint('action_create_account', __name__) bp = Blueprint('action_create_account', __name__)
@ -30,7 +30,7 @@ def _send_verification_email(to_address, code):
raise RuntimeError('SMTP_HOST is not configured.') raise RuntimeError('SMTP_HOST is not configured.')
msg = EmailMessage() msg = EmailMessage()
msg['Subject'] = f'{PRODUCT_DISPLAY_NAME} - Email Verification' msg['Subject'] = f'{WEB_APP_DISPLAY_NAME} - Email Verification'
msg['From'] = from_addr msg['From'] = from_addr
msg['To'] = to_address msg['To'] = to_address
msg.set_content( msg.set_content(

View file

@ -12,8 +12,10 @@ DASHBOARD_LAST_RUN = f'{CONFIGS_DIR}/.dashboard-last-run'
DASHBOARD_LOCK = f'{CONFIGS_DIR}/.dashboard-lock' DASHBOARD_LOCK = f'{CONFIGS_DIR}/.dashboard-lock'
DASHBOARD_PENDING = f'{CONFIGS_DIR}/.dashboard-pending' DASHBOARD_PENDING = f'{CONFIGS_DIR}/.dashboard-pending'
STATUS_FILE = f'{CONFIGS_DIR}/.status' STATUS_FILE = f'{CONFIGS_DIR}/.status'
DASHB_TIMER_NAME = 'routlin-dashboard-queue' PRODUCT_NAME = os.environ.get('PRODUCT_NAME', 'routlin')
PRODUCT_DISPLAY_NAME = os.environ.get('PRODUCT_DISPLAY_NAME', 'Routlin Dashboard') DASHB_TIMER_NAME = f'{PRODUCT_NAME}-dashboard-queue'
DDNS_TIMER_NAME = f'{PRODUCT_NAME}-ddns-update'
WEB_APP_DISPLAY_NAME = os.environ.get('WEB_APP_DISPLAY_NAME', f'{PRODUCT_NAME.capitalize()} Dashboard')
DASHB_INTERVAL_SECS = 60 DASHB_INTERVAL_SECS = 60
QUEUE_MAX_LINES = 50 QUEUE_MAX_LINES = 50

View file

@ -5,7 +5,7 @@ import sanitize
import validation as validate import validation as validate
from datetime import datetime, timezone from datetime import datetime, timezone
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from config_utils import core_hash, get_pending_entries, get_dashboard_pending, _seconds_until_next_run, _format_timing, _is_locked, _lock_mtime, PRODUCT_DISPLAY_NAME, CONFIGS_DIR, DATA_DIR from config_utils import core_hash, get_pending_entries, get_dashboard_pending, _seconds_until_next_run, _format_timing, _is_locked, _lock_mtime, WEB_APP_DISPLAY_NAME, CONFIGS_DIR, DATA_DIR, DDNS_TIMER_NAME
bp = Blueprint('view_page', __name__) bp = Blueprint('view_page', __name__)
@ -540,7 +540,7 @@ def _public_ip_info(ddns_cfg):
next_interval = '-' next_interval = '-'
def _last_obtained(mtime): def _last_obtained(mtime):
return f'Last obtained: {_relative_time(mtime)}' if mtime else '' return f'Obtained: {_relative_time(mtime)}' if mtime else ''
# Path 1: timer healthy and within interval -> use cached IP # Path 1: timer healthy and within interval -> use cached IP
if interval_secs and enabled_p: if interval_secs and enabled_p:
@ -565,6 +565,22 @@ def _public_ip_info(ddns_cfg):
# Path 3: offline # Path 3: offline
return 'DDNS Offline', domains_sub, next_interval, '' return 'DDNS Offline', domains_sub, next_interval, ''
def _ddns_last_checked():
"""Return 'Last checked: X ago' based on when the DDNS timer last fired, or ''."""
try:
out = _run(f'systemctl show {DDNS_TIMER_NAME}.timer --property=LastTriggerUSec --timestamp=utc')
val = out.split('=', 1)[1].strip() if '=' in out else ''
if not val or val == '0' or val == 'n/a':
return ''
parts = val.split() # ['Mon', '2026-05-25', '04:28:00', 'UTC']
if len(parts) >= 3:
dt = datetime.strptime(f'{parts[1]} {parts[2]}', '%Y-%m-%d %H:%M:%S')
mtime = dt.replace(tzinfo=timezone.utc).timestamp()
return f'Last checked: {_relative_time(mtime)}'
except Exception:
pass
return ''
def _vpn_info(): def _vpn_info():
for vlan in _load_core().get('vlans', []): for vlan in _load_core().get('vlans', []):
if 'vpn_information' in vlan: if 'vpn_information' in vlan:
@ -715,6 +731,7 @@ def collect_tokens():
tokens['STAT_DDNS_HOSTNAME'] = sub_str tokens['STAT_DDNS_HOSTNAME'] = sub_str
tokens['STAT_DDNS_NEXT_INTERVAL'] = next_interval tokens['STAT_DDNS_NEXT_INTERVAL'] = next_interval
tokens['STAT_PUBLIC_IP_LAST_OBTAINED'] = last_obtained 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() tokens['DDNS_LOG_TAIL'], tokens['DDNS_LOG_SUMMARY'] = _ddns_log_tail()
tokens['STAT_UPTIME'] = _run('uptime -p') or '-' tokens['STAT_UPTIME'] = _run('uptime -p') or '-'
tokens['STAT_NFTABLES_STATUS'] = 'Active' if _run('nft list tables 2>/dev/null').strip() else 'Inactive' tokens['STAT_NFTABLES_STATUS'] = 'Active' if _run('nft list tables 2>/dev/null').strip() else 'Inactive'
@ -1451,9 +1468,9 @@ def _load_datasource(spec):
def render_layout(view_id, content_html, tokens): def render_layout(view_id, content_html, tokens):
css = _load_css() css = _load_css()
level = _client_level() level = _client_level()
titlebar_html = f'<div class="titlebar"><span class="titlebar-brand">{PRODUCT_DISPLAY_NAME}</span></div>' titlebar_html = f'<div class="titlebar"><span class="titlebar-brand">{WEB_APP_DISPLAY_NAME}</span></div>'
navbar_html = _render_navbar(view_id, level, tokens) navbar_html = _render_navbar(view_id, level, tokens)
footer_html = f'<footer class="footer">{PRODUCT_DISPLAY_NAME}</footer>' footer_html = f'<footer class="footer">{WEB_APP_DISPLAY_NAME}</footer>'
page_hash = core_hash() page_hash = core_hash()
lan_iface = e(tokens.get('GENERAL_LAN_INTERFACE', '')) lan_iface = e(tokens.get('GENERAL_LAN_INTERFACE', ''))
@ -1535,8 +1552,10 @@ def render_layout(view_id, content_html, tokens):
fix_items = ''.join(f'<li><code>{e(c)}</code></li>' for c in fix_cmds) fix_items = ''.join(f'<li><code>{e(c)}</code></li>' for c in fix_cmds)
fix_html = ('<div style="margin-top:0.5em">To fix:</div>' fix_html = ('<div style="margin-top:0.5em">To fix:</div>'
f'<ul style="margin:0.25em 0;padding-left:1.25em">{fix_items}</ul>') f'<ul style="margin:0.25em 0;padding-left:1.25em">{fix_items}</ul>')
content = ('Health check &mdash; problems found:' content = ('<div style="width:100%">'
+ problems_list + fix_html) '<div style="font-weight:600;margin-bottom:0.25em">Health check &mdash; problems found:</div>'
+ problems_list + fix_html
+ '</div>')
problem_bars += f'<div class="info-bar {cls}">{content}</div>\n' problem_bars += f'<div class="info-bar {cls}">{content}</div>\n'
except Exception: except Exception:
pass pass
@ -1544,7 +1563,7 @@ def render_layout(view_id, content_html, tokens):
return (f'<!DOCTYPE html>\n<html lang="en">\n<head>\n' return (f'<!DOCTYPE html>\n<html lang="en">\n<head>\n'
f' <meta charset="UTF-8"/>\n' f' <meta charset="UTF-8"/>\n'
f' <meta name="viewport" content="width=device-width, initial-scale=1.0"/>\n' f' <meta name="viewport" content="width=device-width, initial-scale=1.0"/>\n'
f' <title>{PRODUCT_DISPLAY_NAME}</title>\n' f' <title>{WEB_APP_DISPLAY_NAME}</title>\n'
f' <style>{css}</style>\n' f' <style>{css}</style>\n'
f'</head>\n<body>\n' f'</head>\n<body>\n'
f'{titlebar_html}\n' f'{titlebar_html}\n'

View file

@ -319,7 +319,7 @@
"type": "stat_card", "type": "stat_card",
"label": "Check Interval", "label": "Check Interval",
"value": "%DDNS_TIMER_INTERVAL%", "value": "%DDNS_TIMER_INTERVAL%",
"sub": "next in %STAT_DDNS_NEXT_INTERVAL%" "sub": "%STAT_PUBLIC_IP_LAST_CHECKED%"
}, },
{ {
"type": "stat_card", "type": "stat_card",

View file

@ -13,7 +13,7 @@ services:
- /sys/devices:/sys/devices:ro - /sys/devices:/sys/devices:ro
environment: environment:
- PYTHONPATH=/routlin_location - PYTHONPATH=/routlin_location
- PRODUCT_DISPLAY_NAME=Routlin Dashboard - WEB_APP_DISPLAY_NAME=Routlin Dashboard
- INITIAL_MANAGER_EMAIL=mgrotke@gmail.com - INITIAL_MANAGER_EMAIL=mgrotke@gmail.com
- SECRET_KEY=ey8hSQCCYE5kQXV8nOg1CB44LSd3AoUet2ZBc3aZlFrwBbazE7aHcxXWyuT97eAObet5jmOL0CjMg0rB1hE4d2SBVYHPfl8De55EiFv307r1QP3Mf5XgOSSCxD3TuD - SECRET_KEY=ey8hSQCCYE5kQXV8nOg1CB44LSd3AoUet2ZBc3aZlFrwBbazE7aHcxXWyuT97eAObet5jmOL0CjMg0rB1hE4d2SBVYHPfl8De55EiFv307r1QP3Mf5XgOSSCxD3TuD
- SMTP_HOST=smtp.gmail.com - SMTP_HOST=smtp.gmail.com