Development

This commit is contained in:
Matthew Grotke 2026-05-28 22:50:00 -04:00
parent 1ed913524f
commit 1ac45768de
6 changed files with 2451 additions and 1302 deletions

View file

@ -0,0 +1,386 @@
# Developer Guide
This guide covers how to add features, write action handlers, and follow the conventions used throughout the codebase.
---
## Architecture Overview
This app is the web UI for Routlin, a Linux-based router software package. It runs as a Docker container and manages the same configuration files that Routlin's core scripts read and apply to the system. The app does not manage the system directly; it edits `config.json` and queues commands that Routlin's `core.py` executes.
The app is a Flask application in a Docker image.
```
app/ Python source (baked into image)
factory.py Converts content.json item trees into HTML strings
view_page.py Routes, data loaders, token assembly, layout rendering
navbar.json Navigation structure
pages/ One subdirectory per page
<pagename>/
content.json Declarative page layout
action.py Flask Blueprint for POST actions on this page
sanitize.py Input sanitization (strips dangerous characters)
config_utils.py Config I/O, snapshot system, command queue
authorized_accounts.json Web UI user accounts (separate from Routlin users)
data/ Live-mounted at runtime (./data:/data)
styles.css Application stylesheet
common.js Client-side interaction logic
validation.js Client-side field validation
# host directory mounted into container: $HOME/routlin -> /routlin_location
routlin_location/ Routlin install dir
config.json Main Routlin configuration (read on every request, written on save)
validation.py Shared validation module (imported via PYTHONPATH)
core.py Apply script invoked to push config changes to the system
ddns.log DDNS update log
blocklists/ Downloaded blocklist files (*.con)
.snapshots/ Config snapshot JSON files (one per saved change)
.health Health status JSON written by Routlin
.dashboard-queue Pending commands waiting to be applied
.dashboard-done Record of completed commands
.dashboard-pending Commands queued but not yet run
.dashboard-last-run Timestamp of the last apply run
.dashboard-lock Lock file indicating an apply is in progress
.ddns-last-ip-* Cached public IP files, one per DDNS provider
.ddns-last-service Timestamp of the last DDNS service check
.<iface>.pub WireGuard public key files (e.g. .wg0.pub)
```
---
## Shared Resources with Routlin
The Routlin install directory (`$HOME/routlin` on the host) is mounted into the container at `/routlin_location` and added to `PYTHONPATH`. This is the primary integration point between the dashboard and the router software.
Files and directories under `/routlin_location` that the app reads or writes:
| Path | Access | Description |
|------|--------|-------------|
| `config.json` | read/write | Main Routlin configuration. The app reads this on every request and writes it on every save action. |
| `validation.py` | import | Shared validation module imported as `import validation as validate`. Contains field validators and `validate_config()` used by all action handlers. |
| `core.py` | exec | Routlin's apply script. The app invokes it as `python3 core.py --apply` or `--update-blocklists` to push config changes to the system. |
| `ddns.log` | read | DDNS update log shown in the DDNS page. |
| `blocklists/` | read | Directory of downloaded blocklist files (`*.con`). The app reads them to count entries and report last-updated timestamps. |
| `.snapshots/` | read/write | Directory of config snapshot JSON files. One file per saved change, used for the change history and revert feature. |
| `.health` | read | JSON file written by Routlin describing service and configuration health status. The dashboard reads it to display the health panel and auto-queue fixes. |
| `.dashboard-queue` | read/write | Pending commands waiting to be applied. |
| `.dashboard-done` | read/write | Record of completed commands. |
| `.dashboard-pending` | read/write | Commands that have been queued but not yet run. |
| `.dashboard-last-run` | read | Timestamp of the last dashboard apply run. |
| `.dashboard-lock` | read | Lock file checked to determine if an apply is currently in progress. |
| `.ddns-last-ip-*` | read | One file per DDNS provider, written by Routlin with the last-known public IP. The app reads all matching files to display the current IP. |
| `.ddns-last-service` | read | Timestamp written by Routlin each time the DDNS service runs. Displayed as "Last checked" on the DDNS page. |
| `.<iface>.pub` | read | WireGuard public key files (e.g. `.wg0.pub`). Read by the VPN page to display the server public key. |
The container also mounts `/sys/class/net` and `/sys/devices` read-only to read live network interface state (link status, speed, MAC address, MTU), and `/etc/localtime` read-only for correct timezone display.
---
## Code Style
- **ASCII only in all files.** No em dashes, en dashes, curly quotes, or any non-ASCII character. If a sentence needs a pause that would normally use a dash, restructure it.
- **No comments unless the WHY is non-obvious.** Well-named functions and variables explain the what. Comments belong on hidden constraints, subtle invariants, or workarounds for specific external bugs.
- **No docstrings on obvious functions.** Reserve them for functions with non-obvious behavior or important invariants to document.
- **`import validation as validate`** - the module is aliased because `validate` is a common local variable name and shadowing it causes confusion.
- **Imports at top, no inline imports** except in rare cases where a circular import cannot otherwise be avoided (e.g. the `from flask import abort` inside `serve_view`).
---
## Naming Conventions
### Underscore prefix
Use `_` prefix only for tiny helpers used in exactly one place. Do not use it for:
- Module-level constants (`NAVBAR_FILE`, not `_NAVBAR_FILE`)
- Functions reused by more than one caller
- Imports (`import factory`, not `import factory as _factory`)
Good uses of `_`:
- A two-line inner function defined inside a larger function and called once
- A short closure that is never referenced outside its enclosing scope
### File path constants
Define all file paths as named constants at the top of the module, using `os.path.join`:
```python
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')
```
Never hardcode paths inline.
### Section headers in Python files
Use the `# Label ===...` style for major section breaks:
```python
# File loaders ======================================================
# Config data loaders ===============================================
# Routes ============================================================
```
---
## HTML Building
All HTML in `factory.py` and `view_page.py` is built by string concatenation using f-strings, not a template engine. Every value that comes from user data or config must be passed through `e()` before interpolation:
```python
from factory import e
html = f'<td class="table-cell">{e(row["description"])}</td>'
```
`e()` wraps `html.escape()`. Never skip it for user-controlled or config-sourced values.
When building multi-line HTML inside view_page.py for a token, follow the same `e()` discipline and assign the result to `tokens['MY_TOKEN']`. Mark values as `Markup()` only when they are already-safe assembled HTML that must not be double-escaped at the outer render stage.
Key principle: `factory.py` is a pure HTML builder. It has no routing, no data loading, and no Flask context other than reading the session for access level. All data loading and token assembly happens in `view_page.py`. The two modules are kept separate to avoid circular imports; `view_page.py` injects `factory.load_datasource = load_datasource` at startup.
---
## Adding a New Page
### 1. Create the page directory
```
app/pages/<pagename>/
__init__.py Empty file (makes it a Python package)
content.json Page layout
action.py POST action handlers
```
`<pagename>` becomes the URL: `/pagename`.
### 2. Write content.json
Every `content.json` starts with a `client_requirement` and an `items` array:
```json
{
"client_requirement": "client_is_viewer+",
"items": [
{
"type": "header_page_title",
"items": [
{ "type": "h1", "text": "My Page" },
{ "type": "p", "text": "Description here." }
]
}
]
}
```
See `TYPES.md` for the full set of item types and their fields. See `TYPES.md#access-control` for the full set of `client_requirement` values.
### 3. Write action.py
```python
from pathlib import Path
from flask import Blueprint, request, redirect, flash
from auth import require_level
from config_utils import load_config, save_config_with_snapshot, verify_config_hash
import sanitize
import validation as validate
_PAGE = Path(__file__).parent.name
bp = Blueprint(_PAGE, __name__)
```
`_PAGE` is the canonical page name and redirect target. Using `Path(__file__).parent.name` means it stays correct even if the directory is renamed.
### 4. Register the blueprint
In `app/main.py` (or wherever blueprints are registered), add the new action blueprint alongside the existing ones.
---
## Token System
Tokens are `%LIKE_THIS%` placeholders in `content.json` string fields. They are substituted before the page is rendered. Tokens carry dynamic data (config values, computed HTML, JSON arrays) from Python into the declarative layout without embedding Python logic in the JSON.
Tokens are assembled in `view_page.py`'s `collect_tokens()` function:
```python
tokens['VLAN_SUBNET'] = vlan.get('subnet', '')
tokens['EXISTING_VLAN_IDS_JSON'] = json.dumps([v['vlan_id'] for v in vlans])
```
String tokens that resolve to a JSON array or object are automatically parsed back into Python structures by `factory.expand_fields()`, so they can be serialized correctly into `data-fields` attributes.
Token values are not HTML-escaped by default. When a token is used in an HTML attribute or text node, `factory.py` calls `e()` on it. When a token resolves to raw HTML meant for injection (e.g. `PENDING_ACTIONS_HTML`), it is used as-is and must be safe before it enters the token map.
---
## Action Handler Pattern
Every POST action follows the same sequence: parse, validate input, check config hash, mutate config, validate config, save.
```python
@bp.route('/action/bannedips/addip_add', methods=['POST'])
@require_level('administrator')
def addip_add():
# 1. Parse and validate individual fields first, before touching config.
raw = request.form.get('ip', '').strip()
ip = validate.banned_ip(raw)
if not ip:
flash('Invalid IP address.', 'error')
return redirect(f'/{_PAGE}')
description = sanitize.text(request.form.get('description', ''))
# 2. Check config hash (stale-config detection / CSRF guard).
if not verify_config_hash(request.form.get('config_hash', '')):
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
return redirect(f'/{_PAGE}')
# 3. Load, mutate, validate the full config.
cfg = load_config()
entry = {'ip': ip, 'description': description, 'enabled': True}
cfg.setdefault('banned_ips', []).append(entry)
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(f'/{_PAGE}')
# 4. Save with a snapshot entry.
flash(save_config_with_snapshot(
cfg,
path='banned_ips', key=ip, operation='add',
before=None, after=entry,
description=f'Added banned IP: {ip}',
), 'success')
return redirect(f'/{_PAGE}')
```
Rules:
- Always redirect back to `/{_PAGE}` after a POST, never render HTML directly.
- `flash()` is the only feedback mechanism. Use `'error'` or `'success'` as the category.
- Parse all user input before checking the config hash. If input is invalid you want to bail out cheaply before acquiring anything.
- Always call `validate.validate_config(cfg)` on the mutated config before saving, even for simple operations.
- `save_config_with_snapshot` returns a human-readable success message string suitable for passing directly to `flash()`.
### Row index actions
For table row operations, extract the row index and bounds-check it:
```python
def _row_index():
try:
return int(request.form.get('row_index', ''))
except (ValueError, TypeError):
return None
idx = _row_index()
if idx is None:
flash('Invalid request.', 'error')
return redirect(f'/{_PAGE}')
items = cfg.get('my_list', [])
if idx < 0 or idx >= len(items):
flash('Entry not found.', 'error')
return redirect(f'/{_PAGE}')
```
---
## Input Handling
### Sanitize vs. validate
- `sanitize.*` functions strip or normalize raw input so it is safe to store. They do not return errors; they return a cleaned value.
- `validate.*` functions check whether a value is semantically correct for a specific field. They return a cleaned/normalized value on success or a falsy value (empty string, `None`) on failure.
```python
description = sanitize.text(request.form.get('description', '')) # always safe
ip = validate.banned_ip(raw) # returns None on failure
```
### Checkbox fields
Checkboxes are absent from the form body when unchecked. Always compare to `'on'`:
```python
enabled = request.form.get('enabled') == 'on'
```
### JSON fields from record_editor
`record_editor` submits its data as a JSON string in a hidden input. Parse it defensively:
```python
import json
raw = request.form.get('server_identities', '[]')
try:
identities = json.loads(raw)
if not isinstance(identities, list):
raise ValueError
except (ValueError, TypeError):
flash('Invalid identity data.', 'error')
return redirect(f'/{_PAGE}')
```
---
## Access Control
### In content.json
Any item can carry `"client_requirement": "client_is_administrator+"`. Items that fail the check are omitted from the rendered output entirely. See `TYPES.md#access-control` for the full table.
The page-level `client_requirement` is inherited by all child items that do not declare their own. This means you can set `client_is_viewer+` at the page level and only gate specific cards or buttons at a higher level.
### In action.py
```python
@require_level('administrator')
def my_action():
...
```
Valid levels: `nothing`, `viewer`, `administrator`, `manager`. The decorator enforces the minimum rank and handles the redirect automatically.
---
## Config Persistence
`load_config()` reads `config.json` fresh on every call. Do not cache it across a request.
`save_config_with_snapshot(cfg, path, key, operation, before, after, description)` writes the config and records a snapshot entry for the change history. Always pass `before` and `after` as the specific sub-object that changed, not the whole config.
`verify_config_hash(hash)` confirms the config has not changed since the form was rendered. Always check it before mutating the config.
---
## Client-Side Validation
Attach a `validate` field to any `field` item in `content.json` to enable live validation:
```json
{ "type": "field", "name": "subnet", "validate": "ipv4" }
```
See `TYPES.md#validation` for the full table of validate values and what each accepts.
For `record_editor` fields that need subnet-aware validation, use `valtype` instead of `validate` and wire `data-dep-subnet` / `data-dep-mask` attributes via the `attrs` field.
For data that must be passed to a JS validator (such as existing VLAN IDs for uniqueness checking), use a `data-*` HTML attribute on the input rather than a global JS variable. The `existing_ids` field on a `field` item emits `data-existing-ids` on the rendered input.
---
## JS Table Workers
Non-standard `input_type` values in `inline_edit` row actions require a table worker. `factory.py` detects any `input_type` not in `STANDARD_INPUT_TYPES = {'text', 'password', 'number', 'checkbox', 'select', 'textarea'}` and emits a `<script>` block via `build_table_worker_script()` that registers the worker using `registerTableWorker(id, impl)`.
Workers implement:
- `renderCell(fDef, td, val, row)` - returns `true` if handled, `false` to fall through to default text input
- `afterRowOpen(tr, row)` - optional; called after all cells are rendered; use it to wire cross-field listeners
The worker ID is derived from the table's `datasource` field by stripping the `config:` or `live:` prefix. The only current non-standard type is `credentials` on the DDNS accounts table.

View file

@ -0,0 +1,691 @@
# Content Item Types
All `content.json` files use an `items` array of typed objects. Every object has a `type` field; all other fields depend on the type. Optional fields are marked with `?`.
Token substitution is available in most string fields: any `%TOKEN%` placeholder is replaced with the corresponding value. Where noted, a string that resolves to a JSON array or object is automatically parsed back into the appropriate structure.
---
## Access control
Any item (and any table column or row action) can carry a `client_requirement` field. Items whose requirement is not satisfied are omitted entirely from the rendered output.
Roles are ranked from lowest to highest:
| Rank | Role | Description |
|------|------|-------------|
| 3 | `manager` | Superuser; can manage accounts and administrators |
| 2 | `administrator` | Full configuration access |
| 1 | `viewer` | Read-only authenticated user |
| 0 | `nothing` | Unauthenticated (no session) |
The value is a role name with a suffix that controls the comparison:
| Suffix | Meaning |
|--------|---------|
| `+` | Client rank must be greater than or equal to the named role |
| `=` | Client rank must equal the named role exactly |
| `-` | Client rank must be less than or equal to the named role |
Possible values for `client_requirement` field:
| Value | Meaning |
|----------|-------------|
| `client_is_manager=` | Client must be manager |
| `client_is_manager-` | Client must be manager or lower |
| `client_is_administrator+` | Client must be administrator or higher |
| `client_is_administrator=` | Client must be administrator |
| `client_is_administrator-` | Client must be administrator or lower |
| `client_is_viewer+` | Client must be viewer or higher |
| `client_is_viewer=` | Client must be viewer |
| `client_is_viewer-` | Client must be viewer or lower |
| `client_is_nothing+` | Client may be anybody (logged in or not logged in) |
| `client_is_nothing=` | Client must be nothing (not logged in) |
Example:
```json
{ "client_requirement": "client_is_administrator+", "type": "card", "items": [] }
```
---
## Layout / structure
### `h1`
```json
{ "type": "h1", "text": "Page Title" }
```
Renders as `<h1>`.
---
### `hr`
```json
{ "type": "hr" }
```
Full-width horizontal rule.
---
### `p`
```json
{
"type": "p",
"text": "Introductory text.",
"link?": { "text": "Click here", "action": "/some-path" }
}
```
Paragraph with an optional inline link appended after the text.
---
### `spacer`
```json
{ "type": "spacer" }
```
Blank vertical space (`<div class="spacer">`).
---
### `header_page_title`
```json
{
"type": "header_page_title",
"items": [ ... ]
}
```
Wraps children in the page header div. Typically contains `h1` and toolbar buttons.
---
### `section`
```json
{
"type": "section",
"items": [ ... ]
}
```
Generic section wrapper (`<div class="section">`).
---
### `auth_wrapper`
```json
{
"type": "auth_wrapper",
"items": [ ... ]
}
```
Full-page centering wrapper for login/auth pages.
---
### `auth_card`
```json
{
"type": "auth_card",
"items": [ ... ]
}
```
Contained card used on auth pages.
---
### `card`
```json
{
"type": "card",
"label?": "Card Title",
"id?": "my-card-id",
"hidden?": true,
"items": [ ... ]
}
```
General-purpose card with an optional header label. Setting `hidden: true` renders the card with the `hidden` attribute, which is used for `js_edit` reveal targets. The `id` attribute wires it up as a `js_edit` target.
---
### `grid`
```json
{
"type": "grid",
"rows": [
{ "cells": [ { "type": "grid_label", ... }, { "type": "grid_value", ... } ] }
]
}
```
Two-column info grid. Each row contains `cells` rendered inline.
---
### `grid_label`
```json
{ "type": "grid_label", "text": "Label" }
```
Label cell in an info grid row.
---
### `grid_value`
```json
{ "type": "grid_value", "text": "%TOKEN%" }
```
Value cell in an info grid row. Token substitution applies.
---
### `raw_html`
```json
{ "type": "raw_html", "html": "<strong>Some markup</strong>" }
```
Injects raw HTML without escaping. Token substitution applies. Use sparingly.
---
## Buttons
All button types share these optional fields:
| Field | Meaning |
|-------|---------|
| `text` | Button label |
| `class?` | Extra CSS classes appended to the default |
| `disabled?` | Token expression; disables button if truthy (not `"false"` / `"0"`) |
### `button_primary` / `button_secondary` / `button_danger` / `button_ghost`
```json
{
"type": "button_primary",
"text": "Save",
"action?": "/some/path",
"method?": "post",
"formaction?": "/alternate/path",
"disabled?": "%SOME_TOKEN%"
}
```
- No `action`: renders `<button type="submit">` inside an existing form.
- `action` + `method: "post"`: wraps in a standalone `<form method="post">`.
- `action` only (no method): renders `<a href="...">` styled as a button.
- `formaction`: overrides the form action on this submit button (for multi-target forms).
### `button_cancel`
```json
{ "type": "button_cancel", "text?": "Cancel", "class?": "extra-cls" }
```
Always rendered as a disabled button (`btn-secondary`). JS enables it when the form becomes dirty.
### `button_row`
```json
{
"type": "button_row",
"justify?": "flex-end",
"items": [ ... ]
}
```
Flex container for buttons. Children are typically button types. `justify` sets `justify-content`.
---
## Stat cards
### `stat_card_grid`
```json
{
"type": "stat_card_grid",
"items": [ ... ]
}
```
Responsive grid container for stat cards.
---
### `stat_card`
Three variants:
**Read-only:**
```json
{
"type": "stat_card",
"label": "Active Sessions",
"value": "%VPN_SESSION_COUNT%",
"sub?": "Secondary text",
"variant?": "wide"
}
```
**Inline editable:**
```json
{
"type": "stat_card",
"label": "Max Log Size",
"value": "%GENERAL_LOG_MAX_KB%",
"edit_action": "/action/update_log_max_kb",
"edit_field": "log_max_kb",
"edit_input_type?": "number",
"edit_value?": "%GENERAL_LOG_MAX_KB%",
"edit_suffix?": "KB",
"edit_min?": "64"
}
```
**Reveal (opens a card):**
```json
{
"type": "stat_card",
"label": "VPN Subnet",
"value": "%VPN_SUBNET%",
"reveal_card_id": "edit-vpn-subnet-card"
}
```
---
## Info / status
### `info_bar`
```json
{
"type": "info_bar",
"variant": "info",
"text": "Some note about this section."
}
```
Renders inline (not as a full-width layout bar). Variants: `info`, `warning`, `danger`.
---
### `field_status`
```json
{
"type": "field_status",
"label": "WAN Interface",
"value": "%GENERAL_WAN_STATUS%"
}
```
Form-group layout with a badge showing interface state (`UP`/`DOWN`/`INVALID`/other).
---
### `pre_block`
```json
{
"type": "pre_block",
"text": "%LOG_OUTPUT%",
"scroll_to_bottom?": true
}
```
`<pre>` block for log output. Setting `scroll_to_bottom: true` adds a `data-scroll-bottom` attribute that JS uses to auto-scroll.
---
## Forms
### `form`
```json
{
"type": "form",
"action": "/action/save_settings",
"method?": "post",
"items": [ ... ]
}
```
HTML form. Automatically injects:
- `config_hash` hidden input (CSRF / stale-config detection)
- `original_values` hidden input (JSON map of field names to original values, used for change detection)
Also generates a validation `<script>` block if any child fields have `validate` or `number` input type.
---
### `hidden`
```json
{ "type": "hidden", "name": "provider", "value": "%PROVIDER%" }
```
Hidden input. Token substitution applies.
---
### `field`
The general form field. Behavior is driven by `input_type`.
**Common fields:**
```json
{
"type": "field",
"input_type": "text",
"label": "Hostname",
"name": "hostname",
"value?": "%HOSTNAME%",
"placeholder?": "e.g. myhost.example.com",
"hint?": "Helper text shown below the input.",
"readonly?": true,
"validate?": "hostname",
"client_requirement?": "client_is_administrator+"
}
```
**`input_type` values:**
| Value | Renders | Extra fields |
|-------|---------|--------------|
| `text` *(default)* | `<input type="text">` | `validate?`, `depends?` |
| `password` | `<input type="password">` | `validate?`, `depends?` |
| `number` | `<input type="number">` | `min?`, `max?`, `validate?` (default `positive_int`), `depends?`, `layout?` (`"inline"`) |
| `checkbox` | Toggle checkbox | `checkbox_label?` (inline label next to box) |
| `checkbox_group` | Multiple checkboxes | `options`: JSON array of `{value, label}` |
| `select` | `<select>` | `options`: JSON array of `{value, label}` or token string; `validate?`, `depends?` |
| `textarea` | `<textarea>` | `rows?`, `validate?` |
| `interface_picker` | Table picker for network interfaces | `data?`: token resolving to JSON array of interface rows; `value?` |
`depends` is a list of field names whose values are sent as `data-depends` for JS-driven conditional validation.
---
### `field_row`
```json
{
"type": "field_row",
"cols?": 2,
"items": [ ... ]
}
```
Horizontal row of fields in `cols` equal columns (default 2). Children are typically `field` items.
---
### `subnet_row`
```json
{
"type": "subnet_row",
"label?": "Subnet",
"subnet_name?": "subnet",
"prefix_name?": "subnet_mask",
"subnet_value?": "%VLAN_SUBNET%",
"prefix_value?": "24",
"subnet_placeholder?": "e.g. 192.168.1.0"
}
```
Combined subnet address and prefix length inputs. Displays the dotted mask equivalent (e.g. `/24` becomes `255.255.255.0`) as a hint below. Auto-validates via JS.
---
### `select`
```json
{
"type": "select",
"name": "filter_field",
"options": "<option value=\"a\">A</option>",
"filter_col?": "status"
}
```
Bare `<select>` outside a form-group. `options` is raw HTML. `filter_col` wires it to filter a sibling table column via JS.
---
### `record_editor`
```json
{
"type": "record_editor",
"label": "Hostnames",
"name": "hostnames",
"empty_message?": "No hostnames added.",
"fields": [
{
"label": "Hostname",
"name": "hostname",
"placeholder?": "e.g. myhost.example.com",
"required?": true,
"validate?": "hostname",
"valtype?": "string",
"attrs?": { "data-custom": "value" }
}
]
}
```
JS-driven table where the user can add, edit, and remove rows. Submits as a JSON array in the named hidden input. Each entry in `fields` gets its own column and a corresponding input in the add/edit form.
---
### `readonly_select`
```json
{
"type": "readonly_select",
"label?": "Gateway",
"name?": "gateway"
}
```
Disabled `<select>` placeholder shown while dependent data is loading. JS replaces it with real options once identities are available.
---
### `overridable_textarea`
```json
{
"type": "overridable_textarea",
"label": "Custom Rules",
"name": "custom_rules",
"override_name?": "custom_rules_override",
"validate?": "ip_in_subnet"
}
```
Read-only textarea with an "Override" checkbox. Checking the box enables editing. `override_name` is the checkbox's `name` attribute and defaults to `{name}_override`.
---
### `editable_list`
```json
{
"type": "editable_list",
"name": "blocklist_urls",
"item_placeholder?": "https://...",
"add_label?": "Add URL",
"hint?": "One URL per entry.",
"items?": "%BLOCKLIST_URLS_JSON%"
}
```
Simple list where the user can add and remove text items. Submits as a JSON array.
---
### `credential_fields`
```json
{
"type": "credential_fields",
"provider_select?": "provider"
}
```
Group of hidden credential sub-groups (API token vs. No-IP username/password). JS shows the correct group based on the named provider `<select>`. `provider_select` is the `name` of that select and defaults to `"provider"`.
---
## Validation
The `validate` field attaches a named validator to an input. Validators run live as the user types, show inline hint messages, and keep the form's submit button disabled until all validated fields pass.
Three mechanisms exist depending on context:
- `field`, `editable_list`: sets `data-validate` on the input. The named classifier runs on every keystroke and marks the field valid, incomplete (warning), or invalid (error).
- `overridable_textarea`: sets `data-validate-lines` on the textarea. When the override checkbox is checked, each non-empty line is validated as an IP address. If subnet context is available (from sibling subnet/mask inputs), each line is also checked against that subnet.
- `record_editor` fields: use `valtype` instead of `validate`. The field is wired to sibling subnet/mask inputs via `data-dep-subnet` and `data-dep-mask` attributes.
### `validate` values (for `field` and `editable_list`)
| Value | Accepts |
|-------|---------|
| `ipv4` | IPv4 address. Four dot-separated octets, each 0-255. |
| `mac` | MAC address. Six colon-separated two-digit hex groups. |
| `url` | HTTP or HTTPS URL. Must begin with `http://` or `https://`, have a valid hostname, and an optional port in range 1-65535. |
| `port` | Port number. Integer 1-65535, digits only. |
| `ipv4cidr` | IPv4 address or CIDR notation. Accepts a bare IP or `IP/prefix` where prefix is 0-32. |
| `endpoint` | Network endpoint. Accepts an IPv4 address, IPv6 address, or hostname. Hostnames may include a `:port` suffix. |
| `dashname` | Dash-delimited identifier. Lowercase letters, digits, and hyphens. No leading, trailing, or consecutive hyphens. |
| `domainname` | Domain name. Letters, digits, hyphens, and dots. Each label must not start or end with a hyphen. |
| `networkname` | Network identifier. Letters, digits, hyphens, and underscores. No leading, trailing, or consecutive special characters. |
| `time_24h` | 24-hour time. Format `HH:MM`. Hours 00-23, minutes 00-59. |
| `positive_int` | Positive integer. Digits only. Respects the field's `min` and `max` bounds when present. |
| `vlan_id` | VLAN ID. Integer 1-4094. Also checks uniqueness against existing IDs read from the input's `data-existing-ids` attribute. |
### `validate` value (for `overridable_textarea`)
| Value | Behavior |
|-------|---------|
| `ip_in_subnet` | Each non-empty line must be a valid IPv4 address. When the VLAN subnet and mask are both filled in, also verifies each address falls within that subnet. |
### `valtype` values (for `record_editor` fields)
`valtype` wires subnet-aware real-time validation using sibling inputs pointed to by `data-dep-subnet` and `data-dep-mask` attributes on the field.
| Value | Validates |
|-------|---------|
| `address` | IPv4 host address. Must be a valid IP within the known subnet, and must not be the network or broadcast address. |
| `subnet` | IPv4 subnet address. All host bits must be zero for the given mask. |
| `mask` | Prefix length. Integer 1-30. |
| `format` | IPv4 address format only. No subnet membership check. |
---
## Table
### `table`
```json
{
"type": "table",
"datasource": "config:banned_ips",
"empty_message?": "No banned IPs.",
"columns": [ ... ],
"row_actions?": [ ... ],
"toolbar?": { "client_requirement?": "...", "items": [ ... ] }
}
```
**Datasource** prefixes:
| Prefix | Source |
|--------|--------|
| `config:<name>` | Reads from `config.json`. Available names: `vlans`, `banned_ips`, `host_overrides`, `port_forwardings`, `ddns_providers`, `upstream_dns_servers`, `accounts` |
| `live:<name>` | Live system data. Available names: `dhcp_leases`, `vpn_sessions` |
**Toolbar** renders content items (typically buttons) above the table, gated by `client_requirement`.
#### Column definition
```json
{
"label": "Enabled",
"field": "enabled",
"class?": "col-center",
"render?": "badge_enabled_disabled",
"render_options?": { "title_true": "...", "title_false": "..." },
"toggle_action?": "/action/toggle_vlan",
"client_requirement?": "client_is_administrator+"
}
```
**`render` values:**
| Value | Output |
|-------|--------|
| *(none)* | Escaped plain text |
| `badge_enabled_disabled` | Green `Enabled` / red `Disabled` badge |
| `badge_yes_no` | Green `Yes` / red `No` badge. `render_options.title_true` and `title_false` add tooltips |
| `badge_recording_on_off` | Green `Recording On` / red `Recording Off` badge |
| `badge_toggle` | Badge that submits a POST to `toggle_action` when clicked (if allowed by `client_requirement`) |
| `badge_active_inactive` | `active` gets a green badge, `pending` gets yellow, anything else gets grey |
| `raw_html` | Value injected as raw HTML (no escaping) |
| `tag_list` | JSON array of tag objects `{n, d?, short?, mini?}` rendered as pill tags |
| `interface_status` | `UP` gets a green badge, `DOWN` gets yellow, `INVALID` gets red |
#### Row action definition
```json
{
"method": "post",
"text": "Delete",
"action": "/action/delete_ban",
"class?": "btn-danger btn-sm",
"client_requirement?": "client_is_administrator+",
"disable_if?": { "field": "status", "value": "locked" }
}
```
**`method` values:**
**`"post"`** renders a form with `row_index` and `config_hash` hidden inputs. `disable_if` can disable the button when `row[field] == value`.
**`"js_edit"`** opens a reveal card by ID. Row data is passed as `data-row` JSON. The card element (a `card` item with matching `id`) is populated and shown by JS.
```json
{
"method": "js_edit",
"text": "Edit",
"target": "edit-vlan-form"
}
```
**`"inline_edit"`** converts the table row into editable inputs in place. Fields are defined inline:
```json
{
"method": "inline_edit",
"text": "Edit",
"action": "/action/save_host_override",
"fields": [
{
"label": "Hostname",
"name": "hostname",
"input_type": "text",
"validate?": "hostname"
},
{
"label": "Provider",
"name": "provider",
"input_type": "select",
"options": [
{ "value": "noip", "label": "No-IP" },
{ "value": "other", "label": "Other (API Token)" }
]
}
]
}
```
Non-standard `input_type` values in inline_edit fields (anything not in `text`, `password`, `number`, `checkbox`, `select`, `textarea`) require a **table worker** registered in JS. The factory emits a `<script>` block via `registerTableWorker(id, impl)` automatically when a non-standard type is detected. Currently the only non-standard inline type is:
| `input_type` | Page | What the worker renders |
|---|---|---|
| `credentials` | DDNS Accounts | Username/password or API token based on the row's `provider` field |
---
## Navigation (navbar.json only)
These types are used only in `app/navbar.json`, not in page `content.json` files.
### `nav_item`
A link or action in the navbar.
```json
{
"type": "nav_item",
"label": "Dashboard",
"map_to": "overview",
"client_requirement?": "client_is_viewer+"
}
```
### `nav_action`
Same as `nav_item` but uses an `action` field (POST) instead of a link.
```json
{ "type": "nav_action", "label": "Logout", "action": "logout" }
```
### `nav_menu`
Dropdown menu.
```json
{
"type": "nav_menu",
"label": "Configure",
"items": [ ... ]
}
```
If `label` is `"%MENU_LABEL%"`, it renders as `"Configure"` for administrators and above, or `"View"` for viewers.

File diff suppressed because it is too large Load diff

View file

@ -143,6 +143,7 @@
"min": 1, "min": 1,
"max": 4094, "max": 4094,
"validate": "vlan_id", "validate": "vlan_id",
"existing_ids": "%EXISTING_VLAN_IDS_JSON%",
"hint": "Unique integer 1-4094. Sets the 802.1Q tag and interface name." "hint": "Unique integer 1-4094. Sets the 802.1Q tag and interface name."
}, },
{ {

File diff suppressed because it is too large Load diff