linuxrouter/docker/routlin-dash/app/factory.py
2026-05-29 02:33:07 -04:00

1278 lines
56 KiB
Python

# factory.py — JSON content-type renderer
# Converts content.json item trees into HTML strings.
# Pure type processing: no data loading, no routing, no layout.
from flask import session
from markupsafe import Markup
import json, re, sys, html as html_mod
from config_utils import config_hash
# 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
# Constants ===========================================================
LEVEL_RANK = {'nothing': 0, 'viewer': 1, 'administrator': 2, 'manager': 3}
STANDARD_INPUT_TYPES = {'text', 'password', 'number', 'checkbox', 'select', 'textarea'}
# Utilities ===========================================================
def e(text):
return html_mod.escape(str(text))
def _prefix_to_dotted(n):
mask = (0xFFFFFFFF << (32 - n)) & 0xFFFFFFFF
return '.'.join(str((mask >> (8 * i)) & 0xFF) for i in (3, 2, 1, 0))
def apply_tokens(text, tokens):
"""Substitute %TOKEN% placeholders. Values are NOT auto-escaped — callers
that use results in HTML attribute or text context must call e() themselves."""
return re.sub(r'%([A-Z_]+)%', lambda m: str(tokens.get(m.group(1), m.group(0))), text)
def expand_fields(obj, tokens):
"""Recursively apply token substitution to a field-definition object.
String values that resolve to a JSON array or object are parsed back into
Python structures so they serialize correctly into data-fields JSON."""
if isinstance(obj, list):
return [expand_fields(item, tokens) for item in obj]
if isinstance(obj, dict):
out = {}
for k, v in obj.items():
if isinstance(v, str):
s = apply_tokens(v, tokens)
if s != v and s[:1] in ('[', '{'):
try:
out[k] = json.loads(s)
continue
except Exception:
pass
out[k] = s
else:
out[k] = expand_fields(v, tokens)
return out
return obj
def js_str(value):
return json.dumps(str(value))
def get_worker_id(datasource):
for prefix in ('config:', 'live:'):
if datasource.startswith(prefix):
return datasource[len(prefix):]
return ''
# Access control ======================================================
def client_level():
return LEVEL_RANK.get(session.get('access_level', 'nothing'), 0)
def passes(req, level):
if not req:
return False
for suffix, check in (('+', lambda n, l: l >= n),
('-', lambda n, l: l <= n),
('=', lambda n, l: l == n)):
if req.endswith(suffix):
role = req[:-1].replace('client_is_', '', 1)
needed = LEVEL_RANK.get(role)
if needed is None:
print(f'[factory] WARNING: unknown role "{role}" in client_requirement "{req}"', file=sys.stderr)
return False
return check(needed, level)
print(f'[factory] WARNING: client_requirement "{req}" has no valid suffix (+, -, =)', file=sys.stderr)
return False
# Snapshot helpers ====================================================
def snap_text(val):
"""Return the plain-text representation of a snapshot before/after value."""
if val is None:
return ''
if isinstance(val, dict) and len(val) == 1:
k, v = next(iter(val.items()))
return f'{k}: {v}'
if isinstance(val, (dict, list)):
return json.dumps(val, separators=(',', ':'))
return str(val)
def build_snap_val(val):
"""Return truncated escaped HTML for a snapshot before/after table cell."""
text = snap_text(val)
if not text:
return ''
trunc = (text[:23] + '') if len(text) > 24 else text
return e(trunc)
def snap_expand_row(before_val, after_val, colspan):
"""Return a hidden <tr> that expands with full before/after content."""
def box(label, val):
text = snap_text(val) if val is not None else ''
if isinstance(val, (dict, list)):
text = json.dumps(val, indent=2)
body = e(text) if text else '<em class="snap-expand-none">(none)</em>'
return (
'<div class="snap-expand-col">'
f'<span class="snap-expand-label">{label}</span>'
f'<pre class="snap-expand-pre">{body}</pre></div>'
)
inner = f'<div class="snap-expand-cols">{box("Before", before_val)}{box("After", after_val)}</div>'
return f'<tr hidden><td colspan="{colspan}" class="snap-expand-cell">{inner}</td></tr>'
# Form helpers ========================================================
def collect_form_specs(items):
"""Walk form items; return (field_specs, submit_sel) for form script generation."""
fields = []
submit_sel = None
for item in items:
t = item.get('type', '')
if t == 'field':
itype = item.get('input_type', 'text')
if item.get('validate') or itype == 'checkbox' or itype == 'number':
fields.append(item)
elif t == 'subnet_row':
fields.append(item)
elif t == 'button_primary' and item.get('class'):
first_cls = item['class'].split()[0]
submit_sel = submit_sel or ('.' + first_cls)
elif t in ('field_row', 'button_row', 'section', 'form'):
sub, sub_btn = collect_form_specs(item.get('items', []))
fields.extend(sub)
submit_sel = submit_sel or sub_btn
return fields, submit_sel
def build_form_script(field_specs, submit_sel):
"""Generate an inline <script> for a form's validation and submit-gate wiring."""
safe = re.compile(r'^[a-zA-Z0-9_-]+$')
lines = ['(function() {']
lines.append(" var _prev = document.currentScript.previousElementSibling;")
lines.append(" var _card = _prev.closest('.card') || _prev.parentElement;")
lines.append(f" var _submit = _card ? _card.querySelector('{submit_sel}') : null;")
lines.append('')
subnet_items = []
validate_items = []
checkbox_only = []
gate_vars = []
for spec in field_specs:
t = spec.get('type', '')
if t == 'subnet_row':
sn = spec.get('subnet_name', 'subnet')
pn = spec.get('prefix_name', 'subnet_mask')
if not (safe.match(sn) and safe.match(pn)):
continue
sv = '_' + sn.replace('-', '_')
pv = '_' + pn.replace('-', '_')
lines.append(f" var {sv} = _card.querySelector('[name=\"{sn}\"]');")
lines.append(f" var {pv} = _card.querySelector('[name=\"{pn}\"]');")
subnet_items.append((sv, pv))
gate_vars.append(f'{sv} && {sv}._valid')
elif t == 'field':
nm = spec.get('name', '')
itype = spec.get('input_type', 'text')
if not nm or not safe.match(nm):
continue
vn = '_' + nm.replace('-', '_')
lines.append(f" var {vn} = _card.querySelector('[name=\"{nm}\"]');")
if itype == 'checkbox':
if spec.get('validate'):
validate_items.append((vn, nm))
gate_vars.append(f'{vn} && {vn}._valid')
else:
checkbox_only.append(vn)
else:
validate_items.append((vn, nm))
gate_vars.append(f'{vn} && {vn}._valid')
lines.append('')
gate_expr = ' && '.join(gate_vars) if gate_vars else 'true'
lines.append(' function _upd() {')
lines.append(' if (!_submit) return;')
lines.append(f' _submit.disabled = !({gate_expr});')
lines.append(' }')
lines.append('')
for vn, _ in validate_items:
lines.append(f" if ({vn}) {vn}.addEventListener('input', function() {{ validateEl({vn}); _upd(); }});")
for sv, pv in subnet_items:
lines.append(f' function _chkSubnet() {{')
lines.append(f' if (!{sv} || !{pv}) return;')
lines.append(f" var res = ipv4SubnetValid({sv}.value.trim(), {pv}.value.trim());")
lines.append(f" setFieldHint({sv}, res.ok ? '' : (res.msg||''), res.ok ? 'ok' : (res.partial ? 'warning' : 'error'));")
lines.append(f' {sv}._valid = res.ok;')
lines.append(f" var dot = {pv}.closest('.form-group').querySelector('.subnet-dotted');")
lines.append(f' var n = parseInt({pv}.value, 10);')
lines.append(f" if (dot) dot.textContent = (!isNaN(n) && n >= 1 && n <= 30) ? prefixToDotted(n) : '';")
lines.append(f' _upd();')
lines.append(f' }}')
lines.append(f" if ({sv}) {sv}.addEventListener('input', _chkSubnet);")
lines.append(f" if ({pv}) {pv}.addEventListener('input', _chkSubnet);")
for vn in checkbox_only:
lines.append(f" if ({vn}) {vn}.addEventListener('change', _upd);")
lines.append('}());')
return '<script>' + '\n'.join(lines) + '</script>'
def collect_form_originals(items, tokens):
"""Walk form items and return {name: value} for all fields (used for original_values)."""
result = {}
for item in items:
t = item.get('type', '')
if t == 'field':
name = item.get('name', '')
input_type = item.get('input_type', 'text')
if not name or input_type == 'hidden':
continue
value = apply_tokens(item.get('value', ''), tokens)
if input_type == 'checkbox':
result[name] = '1' if value.lower() in ('true', '1', 'yes') else '0'
elif input_type == 'select' and not value:
try:
opts = json.loads(apply_tokens(item.get('options', '[]'), tokens))
value = opts[0]['value'] if opts else ''
except Exception:
pass
result[name] = value
else:
result[name] = value
elif t == 'editable_list':
name = item.get('name', '')
if not name:
continue
try:
vals = json.loads(apply_tokens(item.get('items', '[]'), tokens))
vals = [str(v) for v in vals]
except Exception:
vals = []
result[name] = vals
elif t == 'subnet_row':
result[item.get('subnet_name', 'subnet')] = apply_tokens(item.get('subnet_value', ''), tokens)
result[item.get('prefix_name', 'subnet_mask')] = apply_tokens(item.get('prefix_value', '24'), tokens)
elif t == 'field_row':
result.update(collect_form_originals(item.get('items', []), tokens))
return result
# Table-picker component ==============================================
def build_table_picker(name, label, value, rows, headers, summary_config, action_btn_html=''):
"""Generic table-picker dropdown component.
rows: list of dicts, each with:
key - str: value stored in hidden input and used to identify the row
label - str: text shown in the trigger button
badge_class - str: CSS class for the badge (optional)
badge_label - str: badge text (optional)
cells - list[str]: fully-formed <td>...</td> HTML strings, one per header column
summary - dict[str, str]: field→display-value for the button mini-table (optional)
extra_data - dict[str, str]: additional data-* attrs on the <tr> (optional)
headers: list[str]: column header labels for the dropdown table
summary_config: list of {field, label, mono?} defining the button mini-table columns
action_btn_html: optional extra HTML placed in the picker header (e.g. a Configure button)
"""
rows_html = ''
cur_row = None
for row in rows:
sel_cls = ' selected' if row['key'] == value else ''
if row['key'] == value:
cur_row = row
attrs = f'data-key="{e(row["key"])}" data-label="{e(row["label"])}"'
if row.get('badge_class'):
attrs += f' data-badge-class="{e(row["badge_class"])}" data-badge-label="{e(row.get("badge_label", ""))}"'
for field, val in (row.get('summary') or {}).items():
attrs += f' data-{e(field)}="{e(str(val))}"'
for attr, val in (row.get('extra_data') or {}).items():
attrs += f' data-{e(attr)}="{e(str(val))}"'
cells_html = ''.join(row.get('cells', []))
rows_html += f'<tr class="table-picker-row{sel_cls}" {attrs}>{cells_html}</tr>'
thead = ''.join(f'<th>{e(h)}</th>' for h in headers)
table_html = (
'<div class="table-wrapper">'
'<table class="data-table table-picker-table">'
f'<thead><tr>{thead}</tr></thead>'
f'<tbody>{rows_html}</tbody>'
'</table></div>'
)
btn_label = f'<span class="table-picker-name">{e(value) if value else "Select..."}</span>'
btn_badge = ''
if cur_row and cur_row.get('badge_class'):
btn_badge = (
f'<span class="badge {e(cur_row["badge_class"])} table-picker-badge">'
f'{e(cur_row.get("badge_label", ""))}</span>'
)
ext_meta = ''
if cur_row and summary_config:
summary = cur_row.get('summary') or {}
if any(summary.get(c['field']) for c in summary_config):
hcells = ''.join(f'<th>{e(c["label"])}</th>' for c in summary_config)
def _dcell(c):
cls = ' class="col-mono"' if c.get('mono') else ''
return f'<td{cls}>{e(str(summary.get(c["field"]) or "-"))}</td>'
dcells = ''.join(_dcell(c) for c in summary_config)
ext_meta = (
'<table class="table-picker-stats">'
f'<thead><tr>{hcells}</tr></thead>'
f'<tbody><tr>{dcells}</tr></tbody>'
'</table>'
)
summary_attr = ''
if summary_config:
summary_json = json.dumps([
{k: v for k, v in c.items() if k in ('field', 'label', 'mono')}
for c in summary_config
])
summary_attr = f' data-summary="{e(summary_json)}"'
return (
'<div class="form-group">'
f'<label class="form-label">{e(label)}</label>'
f'<div class="table-picker"{summary_attr}>'
f'<input type="hidden" name="{e(name)}" value="{e(value)}"/>'
'<div class="table-picker-header">'
f'<button type="button" class="table-picker-btn">{btn_label}{btn_badge}</button>'
f'{ext_meta}'
f'{action_btn_html}'
'</div>'
f'<div class="table-picker-dropdown">{table_html}</div>'
'</div>'
'</div>'
)
# Field renderer ======================================================
def build_field(item, tokens):
label = e(item.get('label', ''))
name = e(item.get('name', ''))
input_type = item.get('input_type', 'text')
value = apply_tokens(item.get('value', ''), tokens)
placeholder = e(apply_tokens(item.get('placeholder', ''), tokens))
hint = e(apply_tokens(item.get('hint', ''), tokens))
hint_html = f'<p class="form-hint">{hint}</p>' if hint else ''
readonly = ' readonly' if item.get('readonly') else ''
if input_type == 'hidden':
return f'<input type="hidden" name="{name}" value="{e(value)}"/>'
if input_type == 'checkbox':
checked = 'checked' if value.lower() in ('true', '1', 'yes') else ''
cb_label = item.get('checkbox_label')
if cb_label:
label_html = f'<label class="form-label">{label}</label>' if label else ''
return (
'<div class="form-group">'
f'{label_html}'
'<label class="form-checkbox-row">'
f'<input type="checkbox" name="{name}" {checked} class="form-checkbox"/>'
f' <span class="form-checkbox-label">{e(cb_label)}</span>'
f'</label>{hint_html}</div>'
)
return (
'<div class="form-group">'
'<label class="form-label">'
f'<input type="checkbox" name="{name}" {checked} class="form-checkbox"/> {label}'
f'</label>{hint_html}</div>'
)
if input_type == 'checkbox_group':
try:
opts = json.loads(apply_tokens(item.get('options', '[]'), tokens))
selected = json.loads(value) if value else []
except Exception:
opts, selected = [], []
boxes = ''.join(
'<label class="checkbox-group-item">'
f'<input type="checkbox" name="{name}" value="{e(o.get("value",""))}"'
f'{"checked" if o.get("value") in selected else ""}/> {e(o.get("label",""))}'
'</label>'
for o in opts
)
return (
f'<div class="form-group"><label class="form-label">{label}</label>'
f'<div class="checkbox-group">{boxes}</div>{hint_html}</div>'
)
if input_type == 'select':
options = item.get('options', [])
if isinstance(options, str):
try:
options = json.loads(apply_tokens(options, tokens))
except Exception:
options = []
current = apply_tokens(item.get('value', ''), tokens)
opts_html = ''.join(
f'<option value="{e(o["value"])}"{" selected" if o["value"] == current else ""}>{e(o["label"])}</option>'
for o in options
)
validate = item.get('validate', '')
depends = item.get('depends', [])
validate_attr = f' data-validate="{e(validate)}"' if validate else ''
depends_attr = f' data-depends="{e(",".join(depends))}"' if depends else ''
if validate:
return (
f'<div class="form-group"><label class="form-label">{label}</label>'
f'<div class="field-wrap"><select name="{name}" class="form-select"{validate_attr}{depends_attr}>{opts_html}</select>'
f'<p class="form-hint field-dyn-hint hidden"></p></div>'
f'{hint_html}</div>'
)
return (
f'<div class="form-group"><label class="form-label">{label}</label>'
f'<select name="{name}" class="form-select"{validate_attr}{depends_attr}>{opts_html}</select>'
f'{hint_html}</div>'
)
if input_type == 'number':
min_attr = f' min="{item["min"]}"' if 'min' in item else ''
max_attr = f' max="{item["max"]}"' if 'max' in item else ''
validate = item.get('validate', 'positive_int')
depends = item.get('depends', [])
existing_ids = apply_tokens(item.get('existing_ids', ''), tokens)
validate_attr = f' data-validate="{e(validate)}"'
depends_attr = f' data-depends="{e(",".join(depends))}"' if depends else ''
existing_attr = f' data-existing-ids="{e(existing_ids)}"' if existing_ids else ''
dyn_hint_html = '<p class="form-hint field-dyn-hint hidden"></p>'
inp = (
f'<input type="number" name="{name}" value="{e(value)}"{min_attr}{max_attr}'
f' class="form-input form-input-mono"{readonly}{validate_attr}{depends_attr}{existing_attr}/>'
)
if item.get('layout') == 'inline':
return (
'<div class="form-group" style="display:flex;align-items:center;gap:0.75em">'
f'<label class="form-label" style="margin:0;white-space:nowrap">{label}</label>'
f'<div class="field-wrap" style="width:6rem">{inp}{dyn_hint_html}</div>'
f'{hint_html}</div>'
)
return (
f'<div class="form-group"><label class="form-label">{label}</label>'
f'<div class="field-wrap">{inp}{dyn_hint_html}</div>{hint_html}</div>'
)
if input_type == 'textarea':
rows = item.get('rows', 4)
return (
f'<div class="form-group"><label class="form-label">{label}</label>'
f'<textarea name="{name}" rows="{rows}" placeholder="{placeholder}"'
f' class="form-input">{e(value)}</textarea>'
f'{hint_html}</div>'
)
if input_type == 'interface_picker':
current = apply_tokens(item.get('value', ''), tokens)
try:
ifaces = json.loads(apply_tokens(item.get('data', '[]'), tokens))
except Exception:
ifaces = []
state_map = {
'UP': ('badge-enabled', 'Up'),
'DOWN': ('badge-warning', 'Down'),
'INVALID': ('badge-danger', 'Invalid'),
}
try:
speed_pad = int(tokens.get('NETWORK_INTERFACE_STATS_SPEED_PAD', '0'))
except Exception:
speed_pad = 0
def _pad(val, width):
s = str(val) if val else '-'
return ' ' * max(0, width - len(s)) + s
picker_rows = []
action_btn_html = ''
for ifc in ifaces:
iname = ifc.get('name', '')
wireless = ifc.get('wireless', False)
state = ifc.get('state', 'UNKNOWN')
carrier = ifc.get('carrier')
raw_speed = ifc.get('speed')
raw_mtu = ifc.get('mtu')
raw_mac = ifc.get('mac')
perm_mac = ifc.get('perm_mac', '')
min_mtu = ifc.get('min_mtu')
max_mtu = ifc.get('max_mtu')
sc, st = state_map.get(state, ('badge-disabled', state.title()))
type_txt = 'Wireless' if wireless else 'Wired'
carrier_txt = '-' if wireless else ('Yes' if carrier else ('No' if carrier is False else '-'))
disp_speed = _pad(raw_speed, speed_pad)
disp_mtu = _pad(raw_mtu, 4)
picker_rows.append({
'key': iname,
'label': iname,
'badge_class': sc,
'badge_label': st,
'cells': [
f'<td class="col-mono">{e(iname)}</td>',
f'<td>{e(type_txt)}</td>',
f'<td><span class="badge {sc}">{st}</span></td>',
f'<td>{e(carrier_txt)}</td>',
f'<td>{e(disp_speed)}</td>',
f'<td>{e(disp_mtu)}</td>',
f'<td class="col-mono">{e(raw_mac or "-")}</td>',
],
'summary': {
'speed': disp_speed,
'mtu': disp_mtu,
'mac': raw_mac or '-',
},
'extra_data': {
'perm-mac': perm_mac,
'min-mtu': str(min_mtu) if min_mtu is not None else '',
'max-mtu': str(max_mtu) if max_mtu is not None else '',
},
})
if iname == current:
action_btn_html = (
'<button type="button" class="btn btn-secondary iface-configure-btn"'
f' data-iface="{e(iname)}" data-mtu="{e(raw_mtu or "")}"'
f' data-mac="{e(raw_mac or "")}" data-perm-mac="{e(perm_mac)}"'
f' data-min-mtu="{str(min_mtu) if min_mtu is not None else ""}"'
f' data-max-mtu="{str(max_mtu) if max_mtu is not None else ""}">'
'Configure</button>'
)
headers = ['Interface', 'Type', 'State', 'Carrier', 'Speed', 'MTU', 'MAC']
summary_config = [
{'field': 'speed', 'label': 'Speed'},
{'field': 'mtu', 'label': 'MTU'},
{'field': 'mac', 'label': 'MAC', 'mono': True},
]
return build_table_picker(name, label, current, picker_rows, headers, summary_config, action_btn_html)
validate = item.get('validate', '')
depends = item.get('depends', [])
validate_attr = f' data-validate="{e(validate)}"' if validate else ''
depends_attr = f' data-depends="{e(",".join(depends))}"' if depends else ''
if validate:
return (
f'<div class="form-group"><label class="form-label">{label}</label>'
f'<div class="field-wrap"><input type="{e(input_type)}" name="{name}" value="{e(value)}"'
f' placeholder="{placeholder}" class="form-input"{readonly}{validate_attr}{depends_attr}/>'
f'<p class="form-hint field-dyn-hint hidden"></p></div>'
f'{hint_html}</div>'
)
return (
f'<div class="form-group"><label class="form-label">{label}</label>'
f'<input type="{e(input_type)}" name="{name}" value="{e(value)}"'
f' placeholder="{placeholder}" class="form-input"{readonly}{validate_attr}{depends_attr}/>'
f'{hint_html}</div>'
)
# Editable list renderer ==============================================
def build_editable_list(item, tokens):
label = e(item.get('label', ''))
name = e(item.get('name', ''))
ph = e(apply_tokens(item.get('item_placeholder', ''), tokens))
add_lbl = e(apply_tokens(item.get('add_label', 'Add'), tokens))
hint = e(apply_tokens(item.get('hint', ''), tokens))
hint_html = f'<p class="form-hint">{hint}</p>' if hint else ''
validate = e(item.get('validate', ''))
try:
items_list = json.loads(apply_tokens(item.get('items', '[]'), tokens))
except Exception:
items_list = []
rows = ''.join(
'<div class="editable-list-item">'
f'<input type="text" name="{name}" value="{e(v)}" class="form-input"/>'
'<button type="button" class="btn btn-ghost btn-sm editable-list-remove">Remove</button>'
'</div>'
for v in items_list
)
validate_attr = f' data-validate="{validate}"' if validate else ''
return (
f'<div class="form-group"><label class="form-label">{label}</label>'
f'<div class="editable-list" data-name="{name}" data-placeholder="{ph}"{validate_attr}>'
f'{rows}'
f'<button type="button" class="btn btn-ghost btn-sm editable-list-add">+ {add_lbl}</button>'
f'</div>{hint_html}</div>'
)
# Table worker script =================================================
def build_table_worker_script(item, expanded_ra_fields):
"""Emit a <script> registering a table worker for any non-standard inline_edit field types.
Returns empty string when all fields are standard types."""
if not expanded_ra_fields:
return ''
worker_id = get_worker_id(item.get('datasource', ''))
if not worker_id:
return ''
nonstandard = set()
for fields in expanded_ra_fields.values():
for f in fields:
it = f.get('input_type', 'text')
if it not in STANDARD_INPUT_TYPES:
nonstandard.add(it)
if not nonstandard:
return ''
if nonstandard == {'credentials'}:
return (
f'<script>registerTableWorker({js_str(worker_id)}, (function() {{\n'
' function _buildCreds(provider, data) {\n'
' if (provider === \'noip\') {\n'
' return \'<div class="cred-field"><span class="cred-label">U:</span>\' +\n'
' \'<input type="text" name="username" value="\' + htmlEsc(data.username||\'\')'
' + \'" class="form-input inline-edit-input"/></div>\' +\n'
' \'<div class="cred-field"><span class="cred-label">P:</span>\' +\n'
' \'<input type="password" name="password" value="\' + htmlEsc(data.password||\'\')'
' + \'" class="form-input inline-edit-input"/></div>\';\n'
' }\n'
' return \'<input type="text" name="api_token" value="\' + htmlEsc(data.api_token||\'\')'
' + \'" class="form-input inline-edit-input" placeholder="API Token"/>\';\n'
' }\n'
' return {\n'
' renderCell: function(fDef, td, val, row) {\n'
' if (fDef.input_type !== \'credentials\') return false;\n'
' td.innerHTML = _buildCreds(row.provider || \'noip\', row);\n'
' return true;\n'
' },\n'
' afterRowOpen: function(tr, row) {\n'
' var provSel = tr.querySelector(\'td[data-field="provider"] select\');\n'
' var credTd = tr.querySelector(\'td[data-field="credentials"]\');\n'
' if (!provSel || !credTd) return;\n'
' provSel.addEventListener(\'change\', function() {\n'
' credTd.innerHTML = _buildCreds(this.value, row);\n'
' });\n'
' }\n'
' };\n'
'}()));</script>\n'
)
return f'<script>registerTableWorker({js_str(worker_id)}, {{}});</script>\n'
# Table cell renderer =================================================
def build_table_cell(value, render_fn, col_class='', field='', row_idx=None,
toggle_action=None, toggle_allowed=True, render_options=None):
parts = []
if col_class:
parts.append(f'class="{e(col_class)}"')
if field:
parts.append(f'data-field="{e(field)}"')
td_open = f'<td {" ".join(parts)}>' if parts else '<td>'
if not render_fn:
return f'{td_open}{e(value)}</td>'
if render_fn == 'badge_enabled_disabled':
if str(value).lower() in ('true', '1', 'yes', 'enabled'):
inner = '<span class="badge badge-enabled">Enabled</span>'
else:
inner = '<span class="badge badge-disabled">Disabled</span>'
return f'{td_open}{inner}</td>'
if render_fn == 'badge_yes_no':
opts = render_options or {}
if str(value).lower() in ('true', '1', 'yes', 'enabled'):
tip = f' data-tooltip="{e(opts["title_true"])}"' if opts.get('title_true') else ''
inner = f'<span class="badge badge-enabled"{tip}>Yes</span>'
else:
tip = f' data-tooltip="{e(opts["title_false"])}"' if opts.get('title_false') else ''
inner = f'<span class="badge badge-disabled"{tip}>No</span>'
return f'{td_open}{inner}</td>'
if render_fn == 'badge_recording_on_off':
if str(value).lower() in ('true', '1', 'yes'):
inner = '<span class="badge badge-enabled">Recording On</span>'
else:
inner = '<span class="badge badge-disabled">Recording Off</span>'
return f'{td_open}{inner}</td>'
if render_fn == 'badge_toggle':
if str(value).lower() in ('true', '1', 'yes', 'enabled'):
label = 'Enabled'; badge_cls = 'badge-enabled'
else:
label = 'Disabled'; badge_cls = 'badge-disabled'
if toggle_action and row_idx is not None and toggle_allowed:
inner = (
f'<form method="post" action="{e(toggle_action)}" class="form-inline">'
f'<input type="hidden" name="row_index" value="{row_idx}"/>'
'<button type="submit" class="btn-badge">'
f'<span class="badge {badge_cls}">{label}</span></button></form>'
)
else:
inner = f'<span class="badge {badge_cls}">{label}</span>'
return f'{td_open}{inner}</td>'
if render_fn == 'badge_active_inactive':
badges = {'active': 'badge-enabled', 'pending': 'badge-warning'}
cls = badges.get(value.lower(), 'badge-disabled')
return f'{td_open}<span class="badge {cls}">{e(value.title())}</span></td>'
if render_fn == 'raw_html':
return f'{td_open}{value}</td>'
if render_fn == 'tag_list':
try:
items = json.loads(value) if value.startswith('[') else [s.strip() for s in value.split(',')]
except Exception:
items = [value]
def _tag(t):
if isinstance(t, dict):
s, tooltip = str(t.get('n', '')).strip(), str(t.get('d', t.get('n', ''))).strip()
short = str(t['short']).strip() if 'short' in t else s.split('-')[0]
mini = str(t['mini']).strip() if 'mini' in t else (s[0] if s else '')
else:
s = tooltip = str(t).strip()
short = s.split('-')[0]
mini = s[0] if s else ''
if not s:
return ''
return (
f'<span class="tag" data-tooltip="{e(tooltip)}">'
f'<span class="tl-full">{e(s)}</span>'
f'<span class="tl-short">{e(short)}</span>'
f'<span class="tl-min">{e(mini)}</span>'
'</span>'
)
tags = ''.join(_tag(t) for t in items)
return f'{td_open}<div class="tag-list">{tags}</div></td>'
if render_fn == 'interface_status':
v = value.upper()
if v == 'INVALID':
inner = '<span class="badge badge-danger">Invalid</span>'
elif v == 'UP':
inner = '<span class="badge badge-enabled">Up</span>'
elif v == 'DOWN':
inner = '<span class="badge badge-warning">Down</span>'
else:
inner = f'<span class="badge badge-disabled">{e(value.title())}</span>'
return f'{td_open}{inner}</td>'
return f'{td_open}{e(value)}</td>'
# Table renderer ======================================================
def build_table(item, tokens, 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()
toolbar_html = ''
toolbar = item.get('toolbar')
if toolbar:
req = toolbar.get('client_requirement', inherited_req)
if passes(req, level):
t_inner = build_items(toolbar.get('items', []), tokens, req)
toolbar_html = f'<div class="table-toolbar">{t_inner}</div>'
thead = ''.join(
f'<th class="{e(c["class"])}">{e(c.get("label",""))}</th>' if c.get("class") else f'<th>{e(c.get("label",""))}</th>'
for c in columns
)
if row_actions:
thead += '<th></th>'
expanded_ra_fields = {
i: expand_fields(ra.get('fields', []), tokens)
for i, ra in enumerate(row_actions)
if ra.get('method', 'post').lower() == 'inline_edit'
}
if not rows:
colspan = len(columns) + (1 if row_actions else 0)
tbody = f'<tr><td colspan="{colspan}" class="table-empty">{empty}</td></tr>'
else:
tbody = ''
for idx, row in enumerate(rows):
cells = ''
for col in columns:
val = row
for part in col.get('field', '').split('.'):
val = val.get(part, '') if isinstance(val, dict) else ''
col_req = col.get('client_requirement', inherited_req)
toggle_allowed = passes(col_req, level) if col_req else True
cells += build_table_cell(
str(val) if val != '' else '-',
col.get('render', ''),
col.get('class', ''),
field=col.get('field', ''),
row_idx=idx,
toggle_action=col.get('toggle_action'),
toggle_allowed=toggle_allowed,
render_options=col.get('render_options', {}),
)
if row_actions:
btns = ''
for ra_i, ra in enumerate(row_actions):
req = ra.get('client_requirement', inherited_req)
if not passes(req, level):
continue
text = e(ra.get('text', ''))
cls = e(ra.get('class', 'btn-ghost btn-sm'))
action = e(apply_tokens(ra.get('action', '#'), tokens))
method = ra.get('method', 'post').lower()
if method == 'post':
disable_if = ra.get('disable_if')
if disable_if and row.get(disable_if.get('field')) == disable_if.get('value'):
btns += f'<button type="button" class="btn {cls}" disabled>{text}</button>'
continue
btns += (
f'<form method="post" action="{action}" class="form-inline">'
f'<input type="hidden" name="row_index" value="{idx}"/>'
f'<input type="hidden" name="config_hash" value="{e(hash_val)}"/>'
f'<button type="submit" class="btn {cls}">{text}</button></form>'
)
elif method == 'js_edit':
target = e(ra.get('target', 'edit-form'))
row_json = e(json.dumps(row))
btns += (
f'<button type="button" class="btn {cls} row-edit-btn"'
f' data-edit-mode="reveal"'
f' data-row-index="{idx}" data-row="{row_json}"'
f' data-target="{target}">{text}</button>'
)
elif method == 'inline_edit':
expanded = expanded_ra_fields.get(ra_i, [])
fields_json = e(json.dumps(expanded))
row_json = e(json.dumps(row))
worker_id = get_worker_id(item.get('datasource', ''))
has_nonstandard = any(
f.get('input_type', 'text') not in STANDARD_INPUT_TYPES
for f in expanded
)
worker_attr = f' data-worker-id="{e(worker_id)}"' if has_nonstandard and worker_id else ''
btns += (
f'<button type="button" class="btn {cls} row-edit-btn"'
f' data-edit-mode="inline"'
f' data-row-index="{idx}" data-row="{row_json}"'
f' data-action="{action}" data-fields="{fields_json}"{worker_attr}>{text}</button>'
)
else:
btns += f'<a href="{action}?row_index={idx}" class="btn {cls}">{text}</a>'
cells += f'<td class="col-actions">{btns}</td>'
tbody += f'<tr>{cells}</tr>'
worker_script = build_table_worker_script(item, expanded_ra_fields)
return (
f'{toolbar_html}'
'<div class="table-wrapper">'
'<table class="data-table">'
f'<thead><tr>{thead}</tr></thead>'
f'<tbody>{tbody}</tbody>'
f'</table></div>{worker_script}'
)
# Main dispatcher =====================================================
def build_items(items, tokens, inherited_req=None):
level = client_level()
parts = []
for item in items:
req = item.get('client_requirement', inherited_req)
if not passes(req, level):
continue
parts.append(build_item(item, tokens, req))
return ''.join(parts)
def build_item(item, tokens, inherited_req=None):
t = item.get('type', '')
req = item.get('client_requirement', inherited_req)
if t == 'h1':
return f'<h1>{e(apply_tokens(item.get("text", ""), tokens))}</h1>'
if t == 'hr':
return '<hr class="divider"/>'
if t == 'p':
text = e(apply_tokens(item.get('text', ''), tokens))
link = item.get('link')
if link:
href = e(apply_tokens(link.get('action', '#'), tokens))
ltext = e(apply_tokens(link.get('text', ''), tokens))
return f'<p>{text} <a href="{href}" class="auth-link">{ltext}</a></p>'
return f'<p>{text}</p>'
if t == 'spacer':
return '<div class="spacer"></div>'
if t in ('button_primary', 'button_secondary', 'button_danger', 'button_ghost'):
cls_map = {
'button_primary': 'btn-primary',
'button_secondary': 'btn-secondary',
'button_danger': 'btn-danger',
'button_ghost': 'btn-ghost',
}
cls = cls_map[t]
extra = item.get('class', '')
if extra:
cls = f'{cls} {extra}'
text = e(apply_tokens(item.get('text', ''), tokens))
action_raw = item.get('action', '')
action = e(apply_tokens(action_raw, tokens))
disabled_val = apply_tokens(str(item.get('disabled', '')), tokens)
disabled = ' disabled' if disabled_val and disabled_val not in ('false', '0') else ''
formaction = item.get('formaction', '')
if formaction:
formaction = e(apply_tokens(formaction, tokens))
return f'<button type="submit" class="btn {e(cls)}" formaction="{formaction}"{disabled}>{text}</button>'
if item.get('method', '').lower() == 'post':
return (
f'<form method="post" action="{action}" class="form-inline">'
f'<button type="submit" class="btn {e(cls)}"{disabled}>{text}</button></form>'
)
if action_raw:
return f'<a href="{action}" class="btn {e(cls)}">{text}</a>'
return f'<button type="submit" class="btn {e(cls)}"{disabled}>{text}</button>'
if t == 'button_cancel':
text = e(apply_tokens(item.get('text', 'Cancel'), tokens))
extra_cls = (' ' + item['class']) if item.get('class') else ''
return f'<button type="button" class="btn btn-secondary btn-cancel{extra_cls}" disabled>{text}</button>'
if t == 'header_page_title':
return f'<div class="page-header">{build_items(item.get("items", []), tokens, req)}</div>'
if t in ('section', 'auth_wrapper'):
tag = 'div'
cls = 'auth-wrapper' if t == 'auth_wrapper' else 'section'
return f'<{tag} class="{cls}">{build_items(item.get("items", []), tokens, req)}</{tag}>'
if t == 'auth_card':
return f'<div class="auth-card">{build_items(item.get("items", []), tokens, req)}</div>'
if t == 'stat_card_grid':
return f'<div class="stat-card-grid">{build_items(item.get("items", []), tokens, req)}</div>'
if t == 'stat_card':
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))
variant = item.get('variant', '')
cls = f'stat-card{(" stat-card-" + variant) if variant else ""}'
edit_action = item.get('edit_action', '')
edit_field = item.get('edit_field', '')
edit_input_type = item.get('edit_input_type', 'text')
edit_suffix = item.get('edit_suffix', '')
edit_min = item.get('edit_min', '')
edit_raw = apply_tokens(item.get('edit_value', item.get('value', '')), tokens)
reveal_card_id = item.get('reveal_card_id', '')
if reveal_card_id:
return (
f'<div class="{cls}">'
f'<div class="stat-card-label">{label}</div>'
'<div class="stat-card-value-row">'
f'<span class="stat-card-value">{value}</span>'
'<button type="button" class="btn btn-ghost btn-sm"'
f' data-reveal-card="{e(reveal_card_id)}">Edit</button>'
'</div>'
f'<div class="stat-card-sub">{sub}</div>'
'</div>'
)
if edit_action and edit_field:
min_attr = f' min="{e(edit_min)}"' if edit_min else ''
suffix_html = f'<span>{e(edit_suffix)}</span>' if edit_suffix else ''
input_wrap = (
'<div class="stat-card-value-row">'
f'<input type="{e(edit_input_type)}" name="{e(edit_field)}" value="{e(edit_raw)}"'
f' data-original="{e(edit_raw)}" class="form-input stat-card-edit-input"{min_attr}/>'
f'{suffix_html}</div>'
)
return (
f'<div class="{cls} stat-card-editable">'
f'<div class="stat-card-label">{label}</div>'
'<div class="stat-card-view">'
f'<span class="stat-card-value">{value}</span>'
'<button type="button" class="btn btn-ghost btn-sm stat-card-edit-btn">Edit</button>'
'</div>'
f'<form class="stat-card-edit-form hidden" action="{e(edit_action)}" method="post">'
f'<input type="hidden" name="config_hash" value="{e(config_hash())}"/>'
f'{input_wrap}'
'<div class="stat-card-edit-actions">'
'<button type="submit" class="btn btn-primary btn-sm" disabled>Save</button>'
'<button type="button" class="btn btn-secondary btn-sm stat-card-cancel-btn">Cancel</button>'
'</div>'
'</form>'
f'<div class="stat-card-sub">{sub}</div>'
'</div>'
)
return (
f'<div class="{cls}">'
f'<div class="stat-card-label">{label}</div>'
f'<div class="stat-card-value">{value}</div>'
f'<div class="stat-card-sub">{sub}</div>'
'</div>'
)
if t == 'card':
label = item.get('label', '')
id_attr = f' id="{e(item["id"])}"' if item.get('id') else ''
cls_hidden = ' hidden' if item.get('hidden') else ''
header = f'<div class="card-header"><h2 class="card-title">{e(label)}</h2></div>' if label else ''
body = build_items(item.get('items', []), tokens, req)
return f'<div class="card{cls_hidden}"{id_attr}>{header}<div class="card-body">{body}</div></div>'
if t == 'field_status':
label = e(item.get('label', ''))
raw = apply_tokens(item.get('value', ''), tokens).upper()
badge_map = {
'UP': ('badge-enabled', 'Up'),
'DOWN': ('badge-warning', 'Down'),
'INVALID': ('badge-danger', 'Invalid'),
}
badge_cls, badge_text = badge_map.get(raw, ('badge-disabled', raw.title() or 'Unknown'))
return (
'<div class="form-group">'
f'<label class="form-label">{label}</label>'
f'<div class="field-status-badge"><span class="badge {badge_cls}">{badge_text}</span></div>'
'</div>'
)
if t == 'info_bar':
variant = item.get('variant', 'info')
text = e(apply_tokens(item.get('text', ''), tokens))
return f'<div class="info-bar info-bar-inline info-bar-{e(variant)}">{text}</div>'
if t == 'pre_block':
text = e(apply_tokens(item.get('text', ''), tokens))
extra = ' data-scroll-bottom' if item.get('scroll_to_bottom') else ''
return f'<pre class="pre-block"{extra}>{text}</pre>'
if t == 'credential_fields':
psel = e(item.get('provider_select', 'provider'))
return (
f'<div class="credential-fields" data-provider-select="{psel}">'
'<div class="cred-group-token hidden">'
'<div class="form-group"><label class="form-label">API Token</label>'
'<input type="text" name="api_token" class="form-input"/></div>'
'</div>'
'<div class="cred-group-noip hidden">'
'<div class="form-group"><label class="form-label">Username</label>'
'<input type="text" name="username" class="form-input"/></div>'
'<div class="form-group"><label class="form-label">Password</label>'
'<input type="password" name="password" class="form-input"/></div>'
'</div>'
'</div>'
)
if t == 'grid':
rows_html = ''
for row in item.get('rows', []):
cells = ''.join(build_item(c, tokens, req) for c in row.get('cells', []))
rows_html += f'<div class="info-grid-row">{cells}</div>'
return f'<div class="info-grid">{rows_html}</div>'
if t == 'grid_label':
return f'<div class="info-grid-label">{e(apply_tokens(item.get("text", ""), tokens))}</div>'
if t == 'grid_value':
return f'<div class="info-grid-value">{e(apply_tokens(item.get("text", ""), tokens))}</div>'
if t == 'form':
action = e(apply_tokens(item.get('action', ''), tokens))
method = e(item.get('method', 'post'))
inner = build_items(item.get('items', []), tokens, req)
hash_field = f'<input type="hidden" name="config_hash" value="{e(config_hash())}"/>'
originals = collect_form_originals(item.get('items', []), tokens)
orig_field = (
f'<input type="hidden" name="original_values" value="{e(json.dumps(originals))}"/>'
if originals else ''
)
field_specs, submit_sel = collect_form_specs(item.get('items', []))
script = build_form_script(field_specs, submit_sel) if (field_specs and submit_sel) else ''
return f'<form action="{action}" method="{method}">{hash_field}{orig_field}{inner}</form>{script}'
if t == 'hidden':
name = e(item.get('name', ''))
value = e(apply_tokens(item.get('value', ''), tokens))
return f'<input type="hidden" name="{name}" value="{value}"/>'
if t == 'record_editor':
label = e(item.get('label', ''))
name = e(item.get('name', ''))
empty = e(item.get('empty_message', 'No records added.'))
fields = item.get('fields', [])
col_count = len(fields) + 1
ths = ''.join(f'<th>{e(f.get("label",""))}</th>' for f in fields) + '<th></th>'
form_rows = ''
for f in fields:
f_label = e(f.get('label', ''))
f_name = e(f.get('name', ''))
f_placeholder = e(f.get('placeholder', ''))
f_required = 'true' if f.get('required') else 'false'
f_validate = f.get('validate', '')
f_valtype = f.get('valtype', '')
f_attrs = f.get('attrs', {})
attr_str = f' data-field="{f_name}" data-required="{f_required}"'
if f_validate:
attr_str += f' data-validate="{e(f_validate)}"'
if f_valtype:
attr_str += f' data-valtype="{e(f_valtype)}"'
for ak, av in f_attrs.items():
attr_str += f' {e(ak)}="{e(str(av))}"'
inp = f'<input type="text" class="form-input"{attr_str} placeholder="{f_placeholder}"/>'
if f_validate or f_valtype:
field_inner = (
'<div class="field-wrap">'
+ inp +
'<p class="form-hint field-dyn-hint hidden"></p>'
'</div>'
)
else:
field_inner = inp
form_rows += (
f'<tr>'
f'<td class="record-editor-field-label">{f_label}:</td>'
f'<td>{field_inner}</td>'
f'</tr>'
)
return (
f'<div class="form-group record-editor" data-name="{name}" data-empty-message="{empty}">'
f'<div class="record-editor-body">'
f'<div class="record-editor-table-wrap">'
f'<label class="form-label record-editor-label">{label}</label>'
f'<table class="data-table record-editor-table">'
f'<thead><tr>{ths}</tr></thead>'
f'<tbody class="record-editor-rows">'
f'<tr class="record-editor-empty-row">'
f'<td colspan="{col_count}" class="table-empty">{empty}</td>'
f'</tr>'
f'</tbody>'
f'</table>'
f'</div>'
f'<div class="record-editor-form">'
f'<table class="record-editor-fields-table"><tbody>{form_rows}</tbody></table>'
f'<div style="margin-top:0.5rem">'
f'<button type="button" class="btn btn-secondary btn-sm record-editor-add-btn">Add</button>'
f'<button type="button" class="btn btn-ghost btn-sm record-editor-cancel-btn hidden" style="margin-left:0.5rem">Cancel</button>'
f'</div>'
f'</div>'
f'</div>'
f'<input type="hidden" name="{name}" class="record-editor-hidden" value="[]"/>'
f'</div>'
)
if t == 'readonly_select':
label = e(item.get('label', 'Gateway'))
name = e(item.get('name', 'gateway'))
hint = e(apply_tokens(item.get('hint', ''), tokens))
hint_html = f'<p class="form-hint">{hint}</p>' if hint else ''
return (
f'<div class="form-group">'
f'<label class="form-label">{label}</label>'
f'<select name="{name}" class="form-select readonly-select" disabled>'
f'<option value="">(add identities first)</option>'
f'</select>'
f'{hint_html}'
f'</div>'
)
if t == 'overridable_textarea':
label = e(item.get('label', ''))
name = e(item.get('name', ''))
override_name = e(item.get('override_name', name + '_override'))
validate = e(item.get('validate', ''))
validate_attr = f' data-validate-lines="{validate}"' if validate else ''
dyn_hint_html = '<p class="form-hint field-dyn-hint hidden"></p>' if validate else ''
wrap_open = '<div class="field-wrap">' if validate else ''
wrap_close = '</div>' if validate else ''
hint = e(apply_tokens(item.get('hint', ''), tokens))
hint_html = f'<p class="form-hint">{hint}</p>' if hint else ''
return (
f'<div class="form-group">'
f'<label class="form-label override-header">'
f'<span>{label}</span>'
f'<label class="override-toggle">'
f'<input type="checkbox" name="{override_name}" class="form-checkbox override-check"/> Override'
f'</label>'
f'</label>'
f'{wrap_open}'
f'<textarea name="{name}" class="form-input auto-textarea" rows="2" readonly{validate_attr}></textarea>'
f'{dyn_hint_html}'
f'{wrap_close}'
f'{hint_html}'
f'</div>'
)
if t == 'field':
return build_field(item, tokens)
if t == 'field_row':
inner = build_items(item.get('items', []), tokens, req)
cols = item.get('cols', 2)
return f'<div class="form-row-{cols}">{inner}</div>'
if t == 'subnet_row':
subnet_label = e(item.get('label', 'Subnet'))
subnet_name = e(item.get('subnet_name', 'subnet'))
prefix_name = e(item.get('prefix_name', 'subnet_mask'))
subnet_val = apply_tokens(item.get('subnet_value', ''), tokens)
prefix_raw = apply_tokens(item.get('prefix_value', '24'), tokens)
subnet_ph = e(apply_tokens(item.get('subnet_placeholder', ''), tokens))
try:
pf = max(1, min(30, int(prefix_raw)))
except (ValueError, TypeError):
pf = 24
dotted = _prefix_to_dotted(pf)
return (
'<div class="form-group">'
f'<label class="form-label">{subnet_label}</label>'
'<div class="field-wrap">'
'<div class="subnet-row-wrap">'
f'<input type="text" name="{subnet_name}" value="{e(subnet_val)}" placeholder="{subnet_ph}" class="form-input"/>'
'<span class="subnet-sep">/</span>'
f'<input type="number" name="{prefix_name}" value="{pf}" min="1" max="30" class="form-input subnet-prefix-input"/>'
'</div>'
f'<span class="subnet-dotted">{e(dotted)}</span>'
'<p class="form-hint field-dyn-hint hidden"></p>'
'</div>'
'</div>'
)
if t == 'editable_list':
return build_editable_list(item, tokens)
if t == 'select':
name = e(item.get('name', ''))
options = apply_tokens(item.get('options', ''), tokens)
filter_col = item.get('filter_col', '')
extra = f' data-filter-col="{e(filter_col)}"' if filter_col else ''
return f'<select name="{name}" class="form-select"{extra}>{options}</select>'
if t == 'spacer':
return '<span style="margin-left:auto"></span>'
if t == 'button_row':
justify = item.get('justify', '')
style_attr = f' style="justify-content:{e(justify)}"' if justify else ''
inner = build_items(item.get('items', []), tokens, req)
return f'<div class="button-row"{style_attr}>{inner}</div>'
if t == 'table':
return build_table(item, tokens, req)
if t == 'raw_html':
return Markup(apply_tokens(item.get('html', ''), tokens))
return ''