Development
This commit is contained in:
parent
59ac3c5973
commit
3d0dc265ba
31 changed files with 1093 additions and 2794 deletions
|
|
@ -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 ''
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue