From a55d44f480eb7bcca5729c1d7b771edea0cb0f65 Mon Sep 17 00:00:00 2001 From: Matthew Grotke Date: Wed, 27 May 2026 23:28:55 -0400 Subject: [PATCH] Development --- .../app/pages/networklayout/action.py | 35 +++++ .../app/pages/networklayout/content.json | 30 ++++- docker/routlin-dash/app/view_page.py | 125 ++++++++++-------- 3 files changed, 131 insertions(+), 59 deletions(-) diff --git a/docker/routlin-dash/app/pages/networklayout/action.py b/docker/routlin-dash/app/pages/networklayout/action.py index 1d4e698..8084ad5 100644 --- a/docker/routlin-dash/app/pages/networklayout/action.py +++ b/docker/routlin-dash/app/pages/networklayout/action.py @@ -1,6 +1,7 @@ from pathlib import Path import copy import ipaddress +import json from flask import Blueprint, request, redirect, flash from auth import require_level @@ -62,6 +63,39 @@ def addvlan_add(): if not _hash_ok(): return redirect(f'/{_PAGE}') + server_identities_raw = request.form.get('server_identities', '[]') + try: + raw_identities = json.loads(server_identities_raw) + if not isinstance(raw_identities, list): + raise ValueError + except (ValueError, TypeError): + flash('Invalid identity data.', 'error') + return redirect(f'/{_PAGE}') + + new_identities = [] + if raw_identities: + _vlan_net = ipaddress.IPv4Network(f'{subnet}/{subnet_mask}', strict=False) + for raw in raw_identities: + ip_clean = sanitize.ip(str(raw.get('ip', ''))) + if not ip_clean: + flash('Invalid IP address in identity.', 'error') + return redirect(f'/{_PAGE}') + if ipaddress.IPv4Address(ip_clean) not in _vlan_net: + flash(f"Identity IP '{ip_clean}' is not in the VLAN subnet ({subnet}/{subnet_mask}).", 'error') + return redirect(f'/{_PAGE}') + ident = {'ip': ip_clean} + desc = str(raw.get('description', '')).strip() + if desc: + ident['description'] = desc + hostname_raw = str(raw.get('hostname', '')).strip() + if hostname_raw: + clean_hostname = sanitize.hostname(hostname_raw) + if clean_hostname is None: + flash(f"'{hostname_raw}' is not a valid hostname.", 'error') + return redirect(f'/{_PAGE}') + ident['hostname'] = clean_hostname + new_identities.append(ident) + cfg = load_config() vlans = cfg.setdefault('vlans', []) @@ -82,6 +116,7 @@ def addvlan_add(): 'use_blocklists': use_blocklists, 'radius_default': radius_default, 'mdns_reflection': mdns_reflection, + 'server_identities': new_identities, } if is_vpn: entry['peers'] = [] diff --git a/docker/routlin-dash/app/pages/networklayout/content.json b/docker/routlin-dash/app/pages/networklayout/content.json index 2f294e9..4c91170 100644 --- a/docker/routlin-dash/app/pages/networklayout/content.json +++ b/docker/routlin-dash/app/pages/networklayout/content.json @@ -217,8 +217,34 @@ "type": "hr" }, { - "type": "identity_builder", - "label": "Router Identities on this VLAN:" + "type": "record_editor", + "label": "Router Identities on this VLAN:", + "name": "server_identities", + "empty_message": "No identities added.", + "fields": [ + { + "label": "IP Address", + "name": "ip", + "valtype": "address", + "attrs": { + "data-dep-subnet": "[name='subnet']", + "data-dep-mask": ".subnet-prefix-input" + }, + "placeholder": "x.x.x.x", + "required": true + }, + { + "label": "Description", + "name": "description", + "placeholder": "Optional label" + }, + { + "label": "Hostname", + "name": "hostname", + "validate": "networkname", + "placeholder": "Optional" + } + ] }, { "type": "hr" diff --git a/docker/routlin-dash/app/view_page.py b/docker/routlin-dash/app/view_page.py index 5a965e5..840ecfd 100644 --- a/docker/routlin-dash/app/view_page.py +++ b/docker/routlin-dash/app/view_page.py @@ -1205,65 +1205,76 @@ def _render_item(item, tokens, inherited_req=None): value = e(apply_tokens(item.get('value', ''), tokens)) return f'' - if t == 'identity_builder': - label = e(item.get('label', 'Self Ident(s):')) + 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'{e(f.get("label",""))}' for f in fields) + '' + + 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'' + if f_validate or f_valtype: + field_inner = ( + '
' + + inp + + '' + '
' + ) + else: + field_inner = inp + + form_rows += ( + f'
' + f'' + f'{field_inner}' + f'
' + ) + + n = len(fields) + grid_class = f'form-row-{n}' if n in (2, 3, 4) else 'form-row-3' + return ( - '
' + f'
' f'' - '
' - '
' - '' - '' - '' - '' - '' - '' - '' - '
IP AddressDescriptionHostname
No identities added.
' - '
' - '
' - '' - '' - '' - '' - '' - '' - '' - '' - '' - '' - '' - '' - '' - '' - '' - '' - '' - '' - '' - '' - '
IP Address:' - '
' - '' - '' - '
' - '
Description (Opt):
Hostname (Opt):' - '
' - '' - '' - '
' - '
' - '
' - '
' - '' - '' - '' - '
' + f'
' + f'' + f'{ths}' + f'' + f'' + f'' + f'' + f'' + f'
{empty}
' + f'
' + f'
{form_rows}
' + f'
' + f'' + f'' + f'
' + f'
' + f'
' + f'' + f'
' ) if t == 'field':