Development

This commit is contained in:
Matthew Grotke 2026-06-02 12:49:39 -04:00
parent 59ac3c5973
commit 3d0dc265ba
31 changed files with 1093 additions and 2794 deletions

View file

@ -1,15 +1,24 @@
# factory.py: JSON content-type renderer
# Converts content.json item trees into HTML strings.
# Pure type processing: no data loading, no routing, no layout.
# factory.py: HTML renderer and shared utilities
# Builds HTML from content.json item trees. Pure rendering - no data fetching.
from flask import session
from markupsafe import Markup
import json, re, sys, html as html_mod
from config_utils import config_hash
import json, re, sys, html as html_mod, os, subprocess
from config_utils import (
config_hash, load_config, CONFIGS_DIR, DATA_DIR, WWW_DIR, APP_DIR,
ACCOUNTS_FILE, HEALTH_FILE, BLOCKLISTS_DIR,
fmt_timestamp, relative_time, fmt_bytes, resolve_iface,
WEB_APP_DISPLAY_NAME,
)
from config_utils import (
get_pending_entries, get_dashboard_pending, _find_cmd_in_queues,
_apply_changes_immediately, _seconds_until_next_run, _format_timing,
_is_locked, _lock_mtime, _entry_ts_from_queue,
)
# Injected by view_page at startup ====================================
# view_page sets this after defining load_datasource so that build_table
# can load row data without creating a circular import.
load_datasource = None
PAGES_DIR = os.path.join(APP_DIR, 'pages')
NAVBAR_FILE = os.path.join(APP_DIR, 'navbar.json')
CSS_FILE = os.path.join(DATA_DIR, 'styles.css')
COMMON_JS_FILE = os.path.join(DATA_DIR, 'common.js')
# Constants ===========================================================
@ -34,6 +43,60 @@ VALIDATION_FLAGS = {
'VALIDATION_IPV4_CIDR': 1 << 13,
}
# File / shell helpers ================================================
def load_json(path):
try:
with open(path) as f:
return json.load(f)
except Exception as ex:
print(f'[factory] ERROR loading {path}: {ex}', file=sys.stderr)
return {}
def load_ddns():
return load_config().get('ddns', {})
def load_accounts():
return load_json(ACCOUNTS_FILE)
def run(cmd):
try:
r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=5)
return r.stdout.strip()
except Exception:
return ''
def load_css():
try:
with open(CSS_FILE) as f:
return f.read()
except Exception:
return ''
def load_icon(name):
try:
with open(f'{WWW_DIR}/icons/{name}.svg') as f:
return f.read().strip()
except Exception:
return ''
def inline_js(page_name=None):
big_validate_js = build_big_validate()
try:
with open(COMMON_JS_FILE) as f:
app_js = f.read()
except Exception:
app_js = ''
page_js = ''
if page_name:
page_js_path = os.path.join(PAGES_DIR, page_name, 'page.js')
try:
with open(page_js_path) as f:
page_js = f.read()
except Exception:
pass
return big_validate_js + '\n' + app_js + ('\n' + page_js if page_js else '')
# Utilities ===========================================================
def e(text):
@ -191,6 +254,16 @@ def get_worker_id(datasource):
return datasource[len(prefix):]
return ''
def table_token_key(spec):
return 'TABLE_' + re.sub(r'[^A-Z0-9]', '_', spec.upper())
def iter_table_items(items):
for item in items:
if item.get('type') == 'table':
yield item
for sub in (item.get('items') or [], (item.get('toolbar') or {}).get('items') or []):
yield from iter_table_items(sub)
# Access control ======================================================
def client_level():
@ -215,17 +288,17 @@ def passes(req, level):
# Snapshot helpers ====================================================
def _flatten_json(val, prefix):
def flatten_json(val, prefix):
"""Recursively flatten a parsed JSON value into [(path, leaf_str)] pairs."""
if isinstance(val, dict):
out = []
for k, v in val.items():
out.extend(_flatten_json(v, f'{prefix}.{k}'))
out.extend(flatten_json(v, f'{prefix}.{k}'))
return out
if isinstance(val, list):
out = []
for i, v in enumerate(val):
out.extend(_flatten_json(v, f'{prefix}[{i}]'))
out.extend(flatten_json(v, f'{prefix}[{i}]'))
return out
if val is None:
return [(prefix, None)]
@ -260,8 +333,8 @@ def snap_expand_row(changes, colspan):
bval = json.loads(before_text) if before_text is not None else None
aval = json.loads(after_text) if after_text is not None else None
if isinstance(bval, (dict, list)) or isinstance(aval, (dict, list)):
bflat = dict(_flatten_json(bval, field)) if isinstance(bval, (dict, list)) else {}
aflat = dict(_flatten_json(aval, field)) if isinstance(aval, (dict, list)) else {}
bflat = dict(flatten_json(bval, field)) if isinstance(bval, (dict, list)) else {}
aflat = dict(flatten_json(aval, field)) if isinstance(aval, (dict, list)) else {}
if bflat or aflat:
seen = set()
for k in list(aflat) + list(bflat):
@ -1007,10 +1080,9 @@ def build_table_cell(value, render_fn, col_class='', field='', row_idx=None,
# Table renderer ======================================================
def build_table(item, tokens, inherited_req=None):
def build_table(item, tokens, rows, inherited_req=None):
level = client_level()
columns = item.get('columns', [])
rows = load_datasource(item.get('datasource', ''))
empty = e(item.get('empty_message', 'No data.'))
row_actions = item.get('row_actions', [])
hash_val = config_hash()
@ -1515,9 +1587,217 @@ def build_item(item, tokens, inherited_req=None):
return f'<div class="button-row"{style_attr}>{inner}</div>'
if t == 'table':
return build_table(item, tokens, req)
ds = item.get('datasource', '')
key = table_token_key(ds)
if key in tokens:
return Markup(tokens[key])
return build_table(item, tokens, [], req)
if t == 'raw_html':
return Markup(apply_tokens(item.get('html', ''), tokens))
return ''
# Layout renderer =====================================================
def render_layout(view_id, content_html, tokens, page_name=None):
css = load_css()
level = client_level()
has_pending_alert = not _apply_changes_immediately() and bool(get_dashboard_pending())
titlebar_html = f'<div class="titlebar"><span class="titlebar-brand">{WEB_APP_DISPLAY_NAME}</span></div>'
navbar_html = build_navbar(view_id, level, tokens, pending_alert=has_pending_alert)
footer_html = f'<footer class="footer">{WEB_APP_DISPLAY_NAME}</footer>'
page_hash = config_hash()
lan_iface = e(tokens.get('GENERAL_LAN_INTERFACE', ''))
vpn_count = tokens.get('VPN_VLAN_COUNT', '0')
current_user = session.get('email_address', '')
pending = get_pending_entries()
my_uuid = next((u for u, t, c, usr in pending if usr == current_user and c != 'fix problems'), None)
secs = _seconds_until_next_run()
locked = _is_locked()
lock_mtime = _lock_mtime()
other_bars = ''
seen_other_users = set()
for o_uuid, o_ts, o_cmd, o_user in pending:
if o_user == current_user:
continue
if o_user in seen_other_users:
continue
seen_other_users.add(o_user)
display_user = 'Another user' if o_user in ('unknown', '') else e(o_user)
if locked and lock_mtime and o_ts < lock_mtime:
text = f'{display_user}\'s changes are being applied now...'
cls = 'info-bar-warning info-bar-running'
else:
timing = _format_timing(secs)
text = (
f'{display_user} has pending changes which will be applied {timing}.'
if timing else
f'{display_user} has pending changes. The processing service is not running.'
)
cls = 'info-bar-warning'
other_bars += f'<div class="info-bar {cls}" data-apply-uuid="{e(o_uuid)}" data-apply-user="{e(o_user)}"><span>{text}</span></div>\n'
problem_bars = ''
if level >= LEVEL_RANK['viewer']:
try:
st = json.load(open(HEALTH_FILE))
problems = []
for section in ('configurations', 'logs'):
for item in st.get(section, []):
if item.get('status') == 'problem':
problems.append(e(item.get('detail', item.get('name', ''))))
for item in st.get('services', []):
if item.get('status') == 'problem':
name = item.get('name', '')
utype = 'timer' if name.endswith('.timer') else 'service' if name.endswith('.service') else 'unit'
exp_parts, act_parts = [], []
if not item.get('active_ok'):
exp_parts.append(item.get('expected_active', 'active'))
act_parts.append(item.get('active', 'unknown'))
if not item.get('enabled_ok'):
exp_parts.append(item.get('expected_enabled', 'enabled'))
act_parts.append(item.get('enabled', 'unknown'))
problems.append(e(
f"The {utype} `{name}` is expected to be "
f"{' and '.join(exp_parts)} but is {' and '.join(act_parts)}."
))
has_problems = bool(problems)
fix_suffix = ''
fix_uuid = None
if has_problems:
if level < LEVEL_RANK['administrator']:
fix_suffix = 'Please contact an administrator.'
else:
fix_uuid, fix_ts = _find_cmd_in_queues('fix problems')
if _apply_changes_immediately():
if _is_locked():
mtime = _lock_mtime()
fix_suffix = (
'Fix is being applied now...'
if fix_ts and mtime and fix_ts < mtime
else 'Fix will be applied on the next run.'
)
else:
timing = _format_timing(_seconds_until_next_run())
fix_suffix = (
f'Fix will be applied {timing}.'
if timing else
'Fix pending. The processing service is not running.'
)
else:
fix_suffix = (
'Fix pending. Click <strong>Apply Now</strong> below to fix.'
if view_id == 'actions' else
'Fix pending. Visit the <strong>Actions</strong> page ASAP to apply fix.'
)
if problems:
problems_list = (
'<ul style="margin:0.25em 0;padding-left:1.25em">'
+ ''.join(f'<li>{d}</li>' for d in problems)
+ '</ul>'
)
uuid_attr = (
f' data-health-uuid="{e(fix_uuid)}"'
if fix_uuid and _entry_ts_from_queue(fix_uuid) is not None else ''
)
fix_html = (
f'<div style="margin-top:0.5em"{uuid_attr}>{fix_suffix}</div>'
if fix_suffix else ''
)
content = (
'<div style="width:100%">'
'<div style="font-weight:600;margin-bottom:0.25em">Health check - problems found:</div>'
+ problems_list + fix_html
+ '</div>'
)
problem_bars += f'<div class="info-bar info-bar-danger">{content}</div>\n'
except Exception:
pass
pending_bar = ''
if has_pending_alert and not problem_bars and view_id != 'actions':
pending_bar = (
'<div class="info-bar info-bar-warning">'
'<span>You have actions pending. Please visit the <strong>Actions</strong> page.</span>'
'</div>\n'
)
return (
'<!DOCTYPE html>\n<html lang="en">\n<head>\n'
' <meta charset="UTF-8"/>\n'
' <meta name="viewport" content="width=device-width, initial-scale=1.0"/>\n'
f' <title>{WEB_APP_DISPLAY_NAME}</title>\n'
f' <style>{css}</style>\n'
'</head>\n<body>\n'
f'{titlebar_html}\n'
f'{navbar_html}\n'
f'<main class="main-content">\n{pending_bar}{problem_bars}{other_bars}{content_html}\n</main>\n'
f'{footer_html}\n'
f'<script>var CONFIG_HASH="{page_hash}";var LAN_IFACE="{lan_iface}";var VPN_VLAN_COUNT={vpn_count};var APPLY_UUID={json.dumps(my_uuid)};</script>\n'
f'<script>{inline_js(page_name)}</script>\n'
'</body>\n</html>'
)
def build_navbar(active_view, level, tokens, pending_alert=False):
navbar_data = load_json(NAVBAR_FILE)
left, right = [], []
for item in navbar_data.get('items', []):
req = item.get('client_requirement')
align = item.get('align', 'left')
if not passes(req, level):
continue
frag = build_nav_item(item, active_view, level, in_dropdown=False, inherited_req=req, pending_alert=pending_alert)
(right if align == 'right' else left).append(frag)
return (
'<nav class="nav-bar">'
f'<div class="nav-left">{"".join(left)}</div>'
f'<div class="nav-right">{"".join(right)}</div>'
'</nav>'
)
def build_nav_item(item, active_view, level, in_dropdown=False, inherited_req=None, pending_alert=False):
req = item.get('client_requirement', inherited_req)
t = item.get('type', '')
if t in ('nav_item', 'nav_action'):
label = e(item.get('label', ''))
map_to = item.get('map_to', '')
action = item.get('action', '')
is_active = ' active' if map_to and map_to == active_view else ''
pending = ' nav-item-pending' if pending_alert and map_to == 'actions' else ''
cls = f'dropdown-item{is_active}' if in_dropdown else f'nav-item{is_active}{pending}'
if action:
return (
f'<form method="post" action="/action/{e(action)}" class="form-inline">'
f'<button type="submit" class="{cls}">{label}</button></form>'
)
if map_to:
return f'<a href="/{e(map_to)}" class="{cls}">{label}</a>'
return f'<span class="{cls}">{label}</span>'
if t == 'nav_menu':
raw_label = item.get('label', '')
if raw_label == '%MENU_LABEL%':
raw_label = 'Configure' if level >= LEVEL_RANK['administrator'] else 'View'
label = e(raw_label)
children = ''
for child in item.get('items', []):
child_req = child.get('client_requirement', req)
if not passes(child_req, level):
continue
children += build_nav_item(child, active_view, level, in_dropdown=True, inherited_req=req, pending_alert=pending_alert)
if not children:
return ''
return (
'<div class="nav-menu">'
f'<button class="nav-item nav-menu-trigger" aria-haspopup="true">{label}</button>'
f'<div class="nav-dropdown">{children}</div>'
'</div>'
)
return ''