feat: replace YAML hosts editor with form-based CRUD table

Settings > Hosts now renders a table with per-column controls
(watch, dyndns, owner, managers/monitors multi-select, threshold
config, notification channels) instead of a raw YAML textarea.
Changes stage via the existing Publish flow like other form sections.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Andreas Wrede
2026-05-11 07:57:28 -04:00
parent 12e8812070
commit 1dbe0f8e64
5 changed files with 157 additions and 7 deletions
+2
View File
@@ -89,6 +89,8 @@ def apply_structured_section(data, section: str, values: dict) -> None:
data[key] = values[key] data[key] = values[key]
elif section == "users": elif section == "users":
data["users"] = values data["users"] = values
elif section == "hosts":
data["hosts"] = values
else: else:
raise ValueError(f"Unknown structured section: {section!r}") raise ValueError(f"Unknown structured section: {section!r}")
+10 -1
View File
@@ -1049,6 +1049,8 @@ async def start(
title="Settings - Heartbeat", title="Settings - Heartbeat",
sections=settings_data["sections"], sections=settings_data["sections"],
all_channel_names=settings_data["all_channel_names"], all_channel_names=settings_data["all_channel_names"],
all_usernames=settings_data["all_usernames"],
all_threshold_configs=settings_data["all_threshold_configs"],
current_user=current_user.to_dict() if current_user else None, current_user=current_user.to_dict() if current_user else None,
active_page="settings", active_page="settings",
) )
@@ -1226,10 +1228,17 @@ async def start(
attrs.pop("client_secret", None) attrs.pop("client_secret", None)
data["oauth"] = new_oauth data["oauth"] = new_oauth
for section in ("notification_channels", "thresholds", "hosts", "dns"): for section in ("notification_channels", "thresholds", "dns"):
if section in payload: if section in payload:
configio_mod.apply_yaml_section(data, section, payload[section]) configio_mod.apply_yaml_section(data, section, payload[section])
if "hosts" in payload:
h = payload["hosts"]
if isinstance(h, dict):
configio_mod.apply_structured_section(data, "hosts", h)
else:
configio_mod.apply_yaml_section(data, "hosts", h)
configio_mod.write_config(_config_path, data) configio_mod.write_config(_config_path, data)
except Exception as exc: except Exception as exc:
logger.error("Config write failed: %s", exc) logger.error("Config write failed: %s", exc)
+14 -2
View File
@@ -436,7 +436,7 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
"id": "hosts", "id": "hosts",
"title": "Hosts", "title": "Hosts",
"description": "Host definitions loaded from the config file.", "description": "Host definitions loaded from the config file.",
"section_mode": "yaml", "section_mode": "hosts",
"api_section": "hosts", "api_section": "hosts",
"hosts": hosts_list, "hosts": hosts_list,
"fields": [], "fields": [],
@@ -475,4 +475,16 @@ def get_settings_data(config: dict, threshold_checker=None) -> dict:
"""Return sections list + auxiliary data for the settings template.""" """Return sections list + auxiliary data for the settings template."""
sections = get_settings_sections(config, threshold_checker=threshold_checker) sections = get_settings_sections(config, threshold_checker=threshold_checker)
all_channel_names = sorted((config.get("notification_channels") or {}).keys()) all_channel_names = sorted((config.get("notification_channels") or {}).keys())
return {"sections": sections, "all_channel_names": all_channel_names} all_usernames = sorted((config.get("users") or {}).keys())
# Threshold config names come from the parsed section data already in sections.
tc_section = next((s for s in sections if s["id"] == "thresholds"), None)
all_threshold_configs = (
[tc["name"] for tc in tc_section["threshold_configs"]]
if tc_section else []
)
return {
"sections": sections,
"all_channel_names": all_channel_names,
"all_usernames": all_usernames,
"all_threshold_configs": all_threshold_configs,
}
+122 -1
View File
@@ -554,6 +554,68 @@
<button class="btn btn-primary" onclick="stageOAuthSection()">Stage changes</button> <button class="btn btn-primary" onclick="stageOAuthSection()">Stage changes</button>
</div> </div>
{# ---- Hosts CRUD table ---- #}
{% elif section.section_mode == 'hosts' %}
<div style="overflow-x:auto;padding:0 20px">
<table class="crud-table" id="hosts-editor">
<thead><tr>
<th>Hostname</th>
<th>Watch</th>
<th>DynDNS</th>
<th>Owner</th>
<th style="min-width:110px">Managers</th>
<th style="min-width:110px">Monitors</th>
<th style="min-width:110px">Threshold config</th>
<th style="min-width:110px">Channels</th>
<th></th>
</tr></thead>
<tbody id="hosts-tbody">
{% for h in section.hosts %}
<tr data-host-row="true" data-hostname="{{ h.name | e }}">
<td style="font-family:monospace;font-size:.9em;white-space:nowrap">{{ h.name | e }}</td>
<td style="text-align:center"><input type="checkbox" class="host-watch" {% if h.watch %}checked{% endif %}></td>
<td style="text-align:center"><input type="checkbox" class="host-dyndns" {% if h.dyndns %}checked{% endif %}></td>
<td><input class="field-input host-owner" value="{{ h.owner | e }}" placeholder="(none)" style="min-width:90px"></td>
<td>
<select class="field-input host-managers" multiple size="{{ [all_usernames|length, 4]|min or 2 }}">
{% for u in all_usernames %}
<option value="{{ u | e }}" {% if u in h.managers %}selected{% endif %}>{{ u | e }}</option>
{% endfor %}
</select>
</td>
<td>
<select class="field-input host-monitors" multiple size="{{ [all_usernames|length, 4]|min or 2 }}">
{% for u in all_usernames %}
<option value="{{ u | e }}" {% if u in h.monitors %}selected{% endif %}>{{ u | e }}</option>
{% endfor %}
</select>
</td>
<td>
<select class="field-input host-tc">
<option value="">— none —</option>
{% for tc in all_threshold_configs %}
<option value="{{ tc | e }}" {% if h.threshold_config == tc %}selected{% endif %}>{{ tc | e }}</option>
{% endfor %}
</select>
</td>
<td>
<select class="field-input host-channels" multiple size="{{ [all_channel_names|length, 4]|min or 2 }}">
{% for ch in all_channel_names %}
<option value="{{ ch | e }}" {% if ch in h.notification_channels %}selected{% endif %}>{{ ch | e }}</option>
{% endfor %}
</select>
</td>
<td><button class="btn-danger" onclick="toggleDeleteRow(this)"></button></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="section-footer">
<button class="btn btn-secondary" onclick="addHostRow()" style="margin-right:auto">+ Add host</button>
<button class="btn btn-primary" onclick="stageHostsSection()">Stage changes</button>
</div>
{# ---- Notification channels (form-based, live CRUD) ---- #} {# ---- Notification channels (form-based, live CRUD) ---- #}
{% elif section.section_mode == 'channels' %} {% elif section.section_mode == 'channels' %}
{% for f in section.fields %} {% for f in section.fields %}
@@ -666,8 +728,10 @@
</div>{# /container #} </div>{# /container #}
<script> <script>
// ---- Channel names for add-user row ---- // ---- Lookup arrays for CRUD rows ----
const _allChannels = {{ all_channel_names | tojson }}; const _allChannels = {{ all_channel_names | tojson }};
const _allUsers = {{ all_usernames | tojson }};
const _allThresholdConfigs = {{ all_threshold_configs | tojson }};
// ---- Channel CRUD ---- // ---- Channel CRUD ----
let _channelSchemas = {}; let _channelSchemas = {};
@@ -905,6 +969,63 @@
flashStaged('oauth'); flashStaged('oauth');
} }
function stageHostsSection() {
function rowToEntry(row) {
const entry = {
watch: row.querySelector('.host-watch').checked,
dyndns: row.querySelector('.host-dyndns').checked,
};
const owner = row.querySelector('.host-owner').value.trim();
if (owner) entry.owner = owner;
const managers = [...(row.querySelector('.host-managers')?.selectedOptions || [])].map(o => o.value);
if (managers.length) entry.managers = managers;
const monitors = [...(row.querySelector('.host-monitors')?.selectedOptions || [])].map(o => o.value);
if (monitors.length) entry.monitors = monitors;
const tc = row.querySelector('.host-tc').value;
if (tc) entry.threshold_config = tc;
const chs = [...(row.querySelector('.host-channels')?.selectedOptions || [])].map(o => o.value);
if (chs.length) entry.notification_channels = chs;
return entry;
}
const hosts = {};
document.querySelectorAll('[data-host-row]').forEach(row => {
if (row.dataset.deleted === 'true') return;
hosts[row.dataset.hostname] = rowToEntry(row);
});
document.querySelectorAll('[data-new-host]').forEach(row => {
if (row.dataset.deleted === 'true') return;
const h = (row.querySelector('.new-hostname') || {value: ''}).value.trim();
if (!h) return;
hosts[h] = rowToEntry(row);
});
_staged['hosts'] = hosts;
updatePendingBanner();
flashStaged('hosts');
}
function addHostRow() {
const tbody = document.getElementById('hosts-tbody');
const sz = n => Math.min(Math.max(n, 1), 4);
const usersOpts = _allUsers.map(u => `<option value="${escHtml(u)}">${escHtml(u)}</option>`).join('');
const tcOpts = ['<option value="">— none —</option>'].concat(
_allThresholdConfigs.map(t => `<option value="${escHtml(t)}">${escHtml(t)}</option>`)
).join('');
const chOpts = _allChannels.map(c => `<option value="${escHtml(c)}">${escHtml(c)}</option>`).join('');
const row = document.createElement('tr');
row.setAttribute('data-new-host', 'true');
row.innerHTML = `
<td><input class="field-input new-hostname" placeholder="hostname" required style="min-width:120px"></td>
<td style="text-align:center"><input type="checkbox" class="host-watch" checked></td>
<td style="text-align:center"><input type="checkbox" class="host-dyndns"></td>
<td><input class="field-input host-owner" placeholder="(none)" style="min-width:90px"></td>
<td><select class="field-input host-managers" multiple size="${sz(_allUsers.length)}">${usersOpts}</select></td>
<td><select class="field-input host-monitors" multiple size="${sz(_allUsers.length)}">${usersOpts}</select></td>
<td><select class="field-input host-tc" size="${sz(_allThresholdConfigs.length)}">${tcOpts}</select></td>
<td><select class="field-input host-channels" multiple size="${sz(_allChannels.length)}">${chOpts}</select></td>
<td><button class="btn-danger" onclick="this.closest('tr').remove()"></button></td>`;
tbody.appendChild(row);
}
async function publishAll() { async function publishAll() {
const btn = document.querySelector('[onclick="publishAll()"]'); const btn = document.querySelector('[onclick="publishAll()"]');
btn.disabled = true; btn.disabled = true;
+9 -3
View File
@@ -24,7 +24,7 @@ def test_sections_have_section_mode():
sections = settings_mod.get_settings_sections(CFG) sections = settings_mod.get_settings_sections(CFG)
for s in sections: for s in sections:
assert "section_mode" in s, f"Section {s['id']} missing section_mode" assert "section_mode" in s, f"Section {s['id']} missing section_mode"
assert s["section_mode"] in ("form", "yaml", "channels") assert s["section_mode"] in ("form", "yaml", "channels", "hosts")
def test_sections_have_api_section(): def test_sections_have_api_section():
@@ -46,14 +46,20 @@ def test_yaml_sections_have_correct_mode():
sections = settings_mod.get_settings_sections(CFG) sections = settings_mod.get_settings_sections(CFG)
yaml_sections = {s["id"]: s for s in sections if s["section_mode"] == "yaml"} yaml_sections = {s["id"]: s for s in sections if s["section_mode"] == "yaml"}
assert "channels" not in yaml_sections # now uses "channels" mode assert "channels" not in yaml_sections # now uses "channels" mode
assert "hosts" in yaml_sections assert "hosts" not in yaml_sections # now uses "hosts" mode
assert "thresholds" in yaml_sections assert "thresholds" in yaml_sections
assert "dns" in yaml_sections assert "dns" in yaml_sections
assert yaml_sections["hosts"]["api_section"] == "hosts"
assert yaml_sections["thresholds"]["api_section"] == "thresholds" assert yaml_sections["thresholds"]["api_section"] == "thresholds"
assert yaml_sections["dns"]["api_section"] == "dns" assert yaml_sections["dns"]["api_section"] == "dns"
def test_hosts_section_uses_hosts_mode():
sections = settings_mod.get_settings_sections(CFG)
hosts_sec = next(s for s in sections if s["id"] == "hosts")
assert hosts_sec["section_mode"] == "hosts"
assert hosts_sec["api_section"] == "hosts"
def test_channels_section_uses_channels_mode(): def test_channels_section_uses_channels_mode():
sections = settings_mod.get_settings_sections(CFG) sections = settings_mod.get_settings_sections(CFG)
ch_sec = next(s for s in sections if s["id"] == "channels") ch_sec = next(s for s in sections if s["id"] == "channels")