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]
|
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
@@ -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
@@ -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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user