Development

This commit is contained in:
Matthew Grotke 2026-05-25 21:31:20 -04:00
parent 59d3d65d18
commit ac0aa4de22
4 changed files with 98 additions and 113 deletions

View file

@ -1,7 +1,7 @@
from flask import Blueprint, request, redirect, flash, session
from auth import require_level
from config_utils import (flush_pending_to_queue, flush_selected_to_queue,
delete_pending_by_uuids, get_dashboard_pending,
from config_utils import (flush_pending_to_queue, get_dashboard_pending,
revert_snapshot_to_core,
_is_locked, _format_timing, _seconds_until_next_run)
bp = Blueprint('action_actions', __name__)
@ -36,13 +36,22 @@ def actions_cardpending_applynow():
return redirect(_VIEW)
@bp.route('/action/actions_cardpending_revertselected', methods=['POST'])
@bp.route('/action/actions_cardhistory_revertselected', methods=['POST'])
@require_level('administrator')
def actions_cardpending_revertselected():
def actions_cardhistory_revertselected():
selected_uuids = request.form.getlist('selected_uuids')
if not selected_uuids:
flash('No items selected.', 'info')
return redirect(_VIEW)
delete_pending_by_uuids(selected_uuids)
flash('Selected changes reverted.', 'success')
succeeded, failed = 0, 0
for uuid in selected_uuids:
msg, ok = revert_snapshot_to_core(uuid)
if ok:
succeeded += 1
else:
flash(msg, 'error')
failed += 1
if succeeded:
plural = 's' if succeeded != 1 else ''
flash(f'{succeeded} change{plural} reverted.', 'success')
return redirect(_VIEW)

View file

@ -181,35 +181,6 @@ def flush_pending_to_queue():
_trim_if_needed()
def _remove_pending_by_uuids(uuid_set):
try:
lines = open(DASHBOARD_PENDING).read().splitlines()
except Exception:
return
kept = [l for l in lines if l.strip() and l.split(None, 1)[0] not in uuid_set]
with open(DASHBOARD_PENDING, 'w') as f:
f.write('\n'.join(kept) + ('\n' if kept else ''))
def flush_selected_to_queue(selected_uuids):
if not selected_uuids:
return
selected_set = set(selected_uuids)
items = _read_dashboard_pending()
done_set = _load_done_set()
existing_ids = {uu for uu, *_ in _read_pending(done_set)}
with open(DASHBOARD_QUEUE, 'a') as f:
for entry_uuid, entry_ts, entry_cmd, entry_user in items:
if entry_uuid in selected_set and entry_uuid not in existing_ids:
f.write(f'{entry_uuid} {entry_ts} [{entry_cmd}] ({entry_user})\n')
_remove_pending_by_uuids(selected_set)
_trim_if_needed()
def delete_pending_by_uuids(selected_uuids):
if not selected_uuids:
return
_remove_pending_by_uuids(set(selected_uuids))
def _queue_pending_command(cmd):

View file

@ -442,7 +442,7 @@ def _blocklist_stats_html(cfg):
if not rows:
return ''
return (
'<table class="data-table" style="margin-bottom:1rem">'
'<table class="data-table">'
'<thead><tr>'
'<th class="table-header">Blocklist</th>'
'<th class="table-header">Entries</th>'
@ -593,55 +593,46 @@ def collect_tokens():
pending_items = get_dashboard_pending()
if pending_items:
# Group by command; each group = one row in the Pending Actions table.
from collections import defaultdict
groups = defaultdict(list)
for _uuid, _ts, cmd, user in pending_items:
groups[cmd].append((_uuid, user))
rows = ''
_tr_onclick = (
'onclick="if(event.target.type!==\'checkbox\')'
'this.nextElementSibling.hidden=!this.nextElementSibling.hidden"'
)
for _uuid, ts, _cmd, user in pending_items:
snap = load_snapshot_for_uuid(_uuid)
dt_str = datetime.fromtimestamp(ts).strftime('%Y-%m-%d %H:%M')
snap_desc = e(snap['description']) if snap else ''
before_val = snap.get('before') if snap else None
after_val = snap.get('after') if snap else None
snap_id = e(_uuid[:8]) if snap else ''
rows += (f'<tr style="cursor:pointer" {_tr_onclick}>'
f'<td class="table-cell"><input type="checkbox" name="selected_uuids" value="{e(_uuid)}"/></td>'
f'<td class="table-cell">{e(dt_str)}</td>'
f'<td class="table-cell">{snap_desc}</td>'
f'<td class="table-cell">{_render_snap_val(before_val)}</td>'
f'<td class="table-cell">{_render_snap_val(after_val)}</td>'
f'<td class="table-cell">{snap_id}</td>'
f'<td class="table-cell">{e(user)}</td>'
f'</tr>'
f'{_snap_expand_row(before_val, after_val, 7)}')
select_all = (
'<input type="checkbox" '
'onchange="document.querySelectorAll(\'[name=selected_uuids]\').forEach(c=>c.checked=this.checked)"/>'
)
for cmd, entries in groups.items():
users = ', '.join(sorted({u for _, u in entries}))
required_by_parts = []
for _uuid, _ in entries:
snap = load_snapshot_for_uuid(_uuid)
required_by_parts.append(snap.get('description', _uuid[:8]) if snap else _uuid[:8])
required_by = ', '.join(required_by_parts)
rows += (f'<tr>'
f'<td class="table-cell">{e(cmd)}</td>'
f'<td class="table-cell">{e(users)}</td>'
f'<td class="table-cell">{e(required_by)}</td>'
f'</tr>')
pending_html = (
'<table class="data-table" style="margin-bottom:1rem">'
'<table class="data-table">'
'<thead><tr>'
f'<th class="table-header">{select_all}</th>'
'<th class="table-header">Time</th>'
'<th class="table-header">Description</th>'
'<th class="table-header">Before</th>'
'<th class="table-header">After</th>'
'<th class="table-header">Snapshot</th>'
'<th class="table-header">Command</th>'
'<th class="table-header">User</th>'
'<th class="table-header">Required By</th>'
'</tr></thead>'
f'<tbody>{rows}</tbody>'
'</table>'
)
else:
pending_html = '<p class="text-muted">No pending changes.</p>'
tokens['PENDING_CHANGES_HTML'] = pending_html
pending_html = '<p class="text-muted">No pending actions.</p>'
tokens['PENDING_ACTIONS_HTML'] = pending_html
tokens['NO_PENDING'] = 'true' if not pending_items else ''
done_items = get_dashboard_done()
if done_items:
hist_rows = ''
_hist_onclick = 'onclick="this.nextElementSibling.hidden=!this.nextElementSibling.hidden"'
_hist_onclick = (
'onclick="if(event.target.type!==\'checkbox\')'
'this.nextElementSibling.hidden=!this.nextElementSibling.hidden"'
)
for _uuid, applied_ts in done_items:
snap = load_snapshot_for_uuid(_uuid)
if applied_ts:
@ -653,7 +644,8 @@ def collect_tokens():
after_val = snap.get('after') if snap else None
snap_id = e(_uuid[:8]) if snap else ''
snap_user = e(snap['user']) if snap else ''
hist_rows += (f'<tr style="cursor:pointer" {_hist_onclick}>'
hist_rows += (f'<tr class="row-expandable" {_hist_onclick}>'
f'<td class="table-cell"><input type="checkbox" name="selected_uuids" value="{e(_uuid)}"/></td>'
f'<td class="table-cell">{e(dt_str)}</td>'
f'<td class="table-cell">{snap_desc}</td>'
f'<td class="table-cell">{_render_snap_val(before_val)}</td>'
@ -661,10 +653,15 @@ def collect_tokens():
f'<td class="table-cell">{snap_id}</td>'
f'<td class="table-cell">{snap_user}</td>'
f'</tr>'
f'{_snap_expand_row(before_val, after_val, 6)}')
f'{_snap_expand_row(before_val, after_val, 7)}')
select_all = (
'<input type="checkbox" '
'onchange="document.querySelectorAll(\'[name=selected_uuids]\').forEach(c=>c.checked=this.checked)"/>'
)
history_html = (
'<table class="data-table" style="margin-bottom:1rem">'
'<table class="data-table">'
'<thead><tr>'
f'<th class="table-header">{select_all}</th>'
'<th class="table-header">Applied</th>'
'<th class="table-header">Description</th>'
'<th class="table-header">Before</th>'
@ -678,6 +675,7 @@ def collect_tokens():
else:
history_html = '<p class="text-muted">No change history.</p>'
tokens['CHANGE_HISTORY_HTML'] = history_html
tokens['NO_HISTORY'] = 'true' if not done_items else ''
servers = dns.get('upstream_servers', [])
tokens['DNS_STRICT_ORDER'] = 'true' if dns.get('strict_order') else 'false'
@ -843,19 +841,16 @@ def _render_snap_val(val):
def _snap_expand_row(before_val, after_val, colspan):
"""Return a hidden <tr> that expands with full before/after content."""
pre = ('max-height:200px;overflow-y:auto;white-space:pre-wrap;'
'font-size:0.85em;background:#fff;border:1px solid #ddd;'
'padding:0.5rem;margin:0.25rem 0')
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>(none)</em>'
return f'<div style="flex:1;min-width:0"><strong>{label}</strong><pre style="{pre}">{body}</pre></div>'
inner = f'<div style="display:flex;gap:1rem">{box("Before", before_val)}{box("After", after_val)}</div>'
return (f'<tr hidden>'
f'<td colspan="{colspan}" style="padding:0.5rem 1rem;background:#f8f8f8">'
f'{inner}</td></tr>')
body = e(text) if text else '<em class="snap-expand-none">(none)</em>'
return (f'<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>'
def apply_tokens(text, tokens):
@ -944,7 +939,7 @@ def _render_item(item, tokens, inherited_req=None):
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}" style="display:inline">'
return (f'<form method="post" action="{action}" class="form-inline">'
f'<button type="submit" class="btn {e(cls)}"{disabled}>{text}</button></form>')
return f'<a href="{action}" class="btn {e(cls)}">{text}</a>'
@ -985,7 +980,7 @@ def _render_item(item, tokens, inherited_req=None):
return (
f'<div class="{cls}">'
f'<div class="stat-card-label">{label}</div>'
f'<div style="display:flex;align-items:center;gap:0.5em">'
f'<div class="stat-card-value-row">'
f'<span class="stat-card-value">{value}</span>'
f'<button type="button" class="btn btn-ghost btn-sm"'
f' data-reveal-card="{e(reveal_card_id)}">Edit</button>'
@ -996,9 +991,9 @@ def _render_item(item, tokens, inherited_req=None):
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 = (f'<div style="display:flex;align-items:center;gap:0.5em">'
input_wrap = (f'<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"{min_attr} style="width:5rem"/>'
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">'
@ -1007,10 +1002,10 @@ def _render_item(item, tokens, inherited_req=None):
f'<span class="stat-card-value">{value}</span>'
f'<button type="button" class="btn btn-ghost btn-sm stat-card-edit-btn">Edit</button>'
f'</div>'
f'<form class="stat-card-edit-form" style="display:none" action="{e(edit_action)}" method="post">'
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}'
f'<div style="margin-top:0.5em;display:flex;gap:0.5em">'
f'<div class="stat-card-edit-actions">'
f'<button type="submit" class="btn btn-primary btn-sm" disabled>Save</button>'
f'<button type="button" class="btn btn-secondary btn-sm stat-card-cancel-btn">Cancel</button>'
f'</div>'
@ -1027,10 +1022,10 @@ def _render_item(item, tokens, inherited_req=None):
if t == 'card':
label = item.get('label', '')
id_attr = f' id="{e(item["id"])}"' if item.get('id') else ''
style = ' style="display:none"' if item.get('hidden') 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 = render_items(item.get('items', []), tokens, req)
return f'<div class="card"{id_attr}{style}>{header}<div class="card-body">{body}</div></div>'
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', ''))
@ -1060,11 +1055,11 @@ def _render_item(item, tokens, inherited_req=None):
psel = e(item.get('provider_select', 'provider'))
return (
f'<div class="credential-fields" data-provider-select="{psel}">'
f'<div class="cred-group-token" style="display:none">'
f'<div class="cred-group-token hidden">'
f'<div class="form-group"><label class="form-label">API Token</label>'
f'<input type="text" name="api_token" class="form-input"/></div>'
f'</div>'
f'<div class="cred-group-noip" style="display:none">'
f'<div class="cred-group-noip hidden">'
f'<div class="form-group"><label class="form-label">Username</label>'
f'<input type="text" name="username" class="form-input"/></div>'
f'<div class="form-group"><label class="form-label">Password</label>'
@ -1131,7 +1126,7 @@ def _render_item(item, tokens, inherited_req=None):
f'<input type="number" name="{prefix_name}" value="{pf}" min="1" max="30" class="form-input subnet-prefix-input"/>'
f'<span class="subnet-dotted">{e(dotted)}</span>'
f'</div>'
f'<p class="form-hint field-dyn-hint" style="display:none"></p>'
f'<p class="form-hint field-dyn-hint hidden"></p>'
f'</div>'
)
@ -1228,7 +1223,7 @@ def _render_field(item, tokens):
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 ''
dyn_hint_html = '<p class="form-hint field-dyn-hint" style="display:none"></p>'
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{extra_cls}"{readonly}'
f' data-validate="positive_int" />')
@ -1349,7 +1344,7 @@ def _render_field(item, tokens):
validate = item.get('validate', '')
validate_attr = f' data-validate="{e(validate)}"' if validate else ''
dyn_hint = '<p class="form-hint field-dyn-hint" style="display:none"></p>' if (item.get('readonly') or item.get('dyn_hint') or validate) else ''
dyn_hint = '<p class="form-hint field-dyn-hint hidden"></p>' if (item.get('readonly') or item.get('dyn_hint') or validate) else ''
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{extra_cls}"{readonly}{validate_attr}/>{hint_html}{dyn_hint}</div>')
@ -1484,7 +1479,7 @@ def _render_table(item, tokens, inherited_req=None):
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}" style="display:inline">'
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>')
@ -1545,7 +1540,7 @@ def _render_table_cell(value, render_fn, col_class='', field='', row_idx=None,
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)}" style="display:inline">'
inner = (f'<form method="post" action="{e(toggle_action)}" class="form-inline">'
f'<input type="hidden" name="row_index" value="{row_idx}"/>'
f'<button type="submit" class="btn-badge">'
f'<span class="badge {badge_cls}">{label}</span></button></form>')
@ -1749,7 +1744,7 @@ def _render_nav_item(item, active_view, level, in_dropdown=False, inherited_req=
is_active = ' active' if map_to and map_to == active_view else ''
cls = f'dropdown-item{is_active}' if in_dropdown else f'nav-item{is_active}'
if action:
return (f'<form method="post" action="/action/{e(action)}" style="display:inline">'
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="/view/{e(map_to)}" class="{cls}">{label}</a>'
@ -2222,7 +2217,7 @@ document.addEventListener('click', function(e) {
var maxAttr = fDef.max !== undefined ? ' max="' + esc(String(fDef.max)) + '"' : '';
td.innerHTML = '<input type="number" name="' + field + '" value="' + esc(String(val)) +
'"' + minAttr + maxAttr + ' class="form-input inline-edit-input" data-validate="positive_int"/>' +
'<p class="form-hint field-dyn-hint" style="display:none"></p>';
'<p class="form-hint field-dyn-hint hidden"></p>';
if (typeof validateEl === 'function') validateEl(td.querySelector('input'));
} else if (inputType === 'textarea') {
var textVal;
@ -2234,7 +2229,7 @@ document.addEventListener('click', function(e) {
td.innerHTML = buildCredentialsHtml(rowData.provider || 'noip', rowData);
} else {
var validateAttr = fDef.validate ? ' data-validate="' + esc(fDef.validate) + '"' : '';
var hintHtml = fDef.validate ? '<p class="form-hint field-dyn-hint" style="display:none"></p>' : '';
var hintHtml = fDef.validate ? '<p class="form-hint field-dyn-hint hidden"></p>' : '';
td.innerHTML = '<input type="' + inputType + '" name="' + field +
'" value="' + esc(String(val)) + '" class="form-input inline-edit-input"' + validateAttr + '/>' + hintHtml;
if (fDef.validate && typeof validateEl === 'function') validateEl(td.querySelector('input'));

View file

@ -603,7 +603,7 @@
},
{
"type": "card",
"label": "Pending Changes",
"label": "Pending Actions",
"client_requirement": "client_is_administrator+",
"items": [
{
@ -613,22 +613,15 @@
"items": [
{
"type": "raw_html",
"html": "%PENDING_CHANGES_HTML%"
"html": "%PENDING_ACTIONS_HTML%"
},
{
"type": "button_row",
"items": [
{
"type": "button_primary",
"formaction": "/action/actions_cardpending_applynow",
"text": "Apply Now",
"disabled": "%NO_PENDING%"
},
{
"type": "button_secondary",
"formaction": "/action/actions_cardpending_revertselected",
"text": "Revert Selected",
"disabled": "%NO_PENDING%"
}
]
}
@ -648,7 +641,7 @@
"name": "apply_changes_immediately",
"input_type": "checkbox",
"value": "%GENERAL_APPLY_ON_SAVE%",
"hint": "When enabled, saved changes are queued immediately. When disabled, changes accumulate in Pending Changes until you click Apply Now."
"hint": "When enabled, saved changes are queued immediately. When disabled, changes accumulate in Pending Actions until you click Apply Now."
},
{
"type": "button_row",
@ -675,8 +668,25 @@
"client_requirement": "client_is_administrator+",
"items": [
{
"type": "raw_html",
"html": "%CHANGE_HISTORY_HTML%"
"type": "form",
"action": "/action/actions_cardhistory_revertselected",
"method": "post",
"items": [
{
"type": "raw_html",
"html": "%CHANGE_HISTORY_HTML%"
},
{
"type": "button_row",
"items": [
{
"type": "button_danger",
"text": "Revert Selected",
"disabled": "%NO_HISTORY%"
}
]
}
]
}
]
}