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
+122 -1
View File
@@ -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;