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:
@@ -89,6 +89,8 @@ def apply_structured_section(data, section: str, values: dict) -> None:
|
||||
data[key] = values[key]
|
||||
elif section == "users":
|
||||
data["users"] = values
|
||||
elif section == "hosts":
|
||||
data["hosts"] = values
|
||||
else:
|
||||
raise ValueError(f"Unknown structured section: {section!r}")
|
||||
|
||||
|
||||
+10
-1
@@ -1049,6 +1049,8 @@ async def start(
|
||||
title="Settings - Heartbeat",
|
||||
sections=settings_data["sections"],
|
||||
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,
|
||||
active_page="settings",
|
||||
)
|
||||
@@ -1226,10 +1228,17 @@ async def start(
|
||||
attrs.pop("client_secret", None)
|
||||
data["oauth"] = new_oauth
|
||||
|
||||
for section in ("notification_channels", "thresholds", "hosts", "dns"):
|
||||
for section in ("notification_channels", "thresholds", "dns"):
|
||||
if section in payload:
|
||||
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)
|
||||
except Exception as exc:
|
||||
logger.error("Config write failed: %s", exc)
|
||||
|
||||
+14
-2
@@ -436,7 +436,7 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
|
||||
"id": "hosts",
|
||||
"title": "Hosts",
|
||||
"description": "Host definitions loaded from the config file.",
|
||||
"section_mode": "yaml",
|
||||
"section_mode": "hosts",
|
||||
"api_section": "hosts",
|
||||
"hosts": hosts_list,
|
||||
"fields": [],
|
||||
@@ -475,4 +475,16 @@ def get_settings_data(config: dict, threshold_checker=None) -> dict:
|
||||
"""Return sections list + auxiliary data for the settings template."""
|
||||
sections = get_settings_sections(config, threshold_checker=threshold_checker)
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -554,6 +554,68 @@
|
||||
<button class="btn btn-primary" onclick="stageOAuthSection()">Stage changes</button>
|
||||
</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) ---- #}
|
||||
{% elif section.section_mode == 'channels' %}
|
||||
{% for f in section.fields %}
|
||||
@@ -666,8 +728,10 @@
|
||||
</div>{# /container #}
|
||||
|
||||
<script>
|
||||
// ---- Channel names for add-user row ----
|
||||
// ---- Lookup arrays for CRUD rows ----
|
||||
const _allChannels = {{ all_channel_names | tojson }};
|
||||
const _allUsers = {{ all_usernames | tojson }};
|
||||
const _allThresholdConfigs = {{ all_threshold_configs | tojson }};
|
||||
|
||||
// ---- Channel CRUD ----
|
||||
let _channelSchemas = {};
|
||||
@@ -905,6 +969,63 @@
|
||||
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() {
|
||||
const btn = document.querySelector('[onclick="publishAll()"]');
|
||||
btn.disabled = true;
|
||||
|
||||
@@ -24,7 +24,7 @@ def test_sections_have_section_mode():
|
||||
sections = settings_mod.get_settings_sections(CFG)
|
||||
for s in sections:
|
||||
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():
|
||||
@@ -46,14 +46,20 @@ def test_yaml_sections_have_correct_mode():
|
||||
sections = settings_mod.get_settings_sections(CFG)
|
||||
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 "hosts" in yaml_sections
|
||||
assert "hosts" not in yaml_sections # now uses "hosts" mode
|
||||
assert "thresholds" 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["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():
|
||||
sections = settings_mod.get_settings_sections(CFG)
|
||||
ch_sec = next(s for s in sections if s["id"] == "channels")
|
||||
|
||||
Reference in New Issue
Block a user