Files
heartbeat/hbd/server/templates/settings.html
T
andreasandClaude Fable 5 8eeae48964 feat: unified record-row redesign of the settings page
One idiom for every section: mono identifier, badges, fact chips, inline
accordion editor. Scalar config sections merge into one grouped 'server'
section. Sidebar becomes YAML-key nav (mobile: horizontal chips); sticky
toolbar carries pending-changes state and Publish. Staging, publish,
channel CRUD, and permission gating are unchanged — same payload shapes
and endpoints.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NfPpSpccTWBfZg1FTveyaU
2026-07-10 08:08:58 -04:00

1450 lines
76 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html>
{% include 'head.html' %}
<style>
/* ── Settings page tokens (green-biased neutrals, teal accent) ── */
:root {
--st-paper: #f4f6f4;
--st-surface: #ffffff;
--st-surface-2: #eef1ee;
--st-ink: #1d2420;
--st-muted: #5f6a63;
--st-faint: #8a948d;
--st-line: #dde3de;
--st-line-soft: #e8ece8;
--st-accent: #0e7280;
--st-accent-ink: #ffffff;
--st-accent-soft: #e0eef0;
--st-ok: #3f9c5a;
--st-warn: #d9982a;
--st-crit: #d64545;
--st-badge-owner: #ede7f6; --st-badge-owner-ink: #5e35b1;
--st-badge-mgr: #e3f2fd; --st-badge-mgr-ink: #1565c0;
--st-mono: ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace;
}
html[data-theme="dark"] {
--st-paper: #131816;
--st-surface: #1b211e;
--st-surface-2: #222925;
--st-ink: #e4eae6;
--st-muted: #9aa69e;
--st-faint: #6e7a72;
--st-line: #2c342f;
--st-line-soft: #252c28;
--st-accent: #4cc4d4;
--st-accent-ink: #0d2326;
--st-accent-soft: #143b40;
--st-ok: #5cba77;
--st-warn: #e0ad4e;
--st-crit: #e06c6c;
--st-badge-owner: #33294a; --st-badge-owner-ink: #b39ddb;
--st-badge-mgr: #1b3350; --st-badge-mgr-ink: #90caf9;
}
body { background: var(--st-paper); padding: 60px 0 0; }
/* ── sticky toolbar under the site nav ── */
.st-toolbar {
position: sticky; top: 0; z-index: 90;
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
padding: 9px 20px;
background: var(--st-surface); border-bottom: 1px solid var(--st-line);
}
.st-toolbar .brand { font-family: var(--st-mono); font-size: 15px; font-weight: 700; letter-spacing: -.02em; color: var(--st-ink); }
.st-toolbar .brand .tld { color: var(--st-accent); }
.st-toolbar .hintline { font-size: 12px; color: var(--st-faint); }
.st-toolbar .spacer { flex: 1; }
.pending-pill {
display: none; align-items: center; gap: 8px;
font-size: 12.5px; color: var(--st-warn); font-weight: 600;
}
.pending-pill::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--st-warn); }
.btn {
font: 600 13px/1 inherit; border: 1px solid var(--st-line); border-radius: 6px;
background: var(--st-surface); color: var(--st-ink); padding: 7px 14px; cursor: pointer;
}
.btn:focus-visible, a:focus-visible { outline: 2px solid var(--st-accent); outline-offset: 2px; }
.btn.primary { background: var(--st-accent); border-color: var(--st-accent); color: var(--st-accent-ink); }
.btn.quiet { border-color: transparent; background: transparent; color: var(--st-muted); }
.btn.small { padding: 4px 10px; font-size: 12px; }
.btn.danger { color: var(--st-crit); }
.btn:disabled { opacity: .6; cursor: default; }
.pulse { display: block; width: 100%; height: 14px; }
.pulse polyline { fill: none; stroke: var(--st-accent); stroke-width: 1.5; opacity: .55; }
/* ── shell: rail + main ── */
.shell { display: grid; grid-template-columns: 210px minmax(0, 1fr); gap: 0 28px; max-width: 1100px; margin: 0 auto; padding: 0 20px 90px; }
.rail { position: sticky; top: 56px; align-self: start; padding-top: 18px; max-height: calc(100vh - 70px); overflow-y: auto; }
.rail a {
display: flex; justify-content: space-between; gap: 8px;
padding: 6px 10px; border-radius: 6px; text-decoration: none;
font-family: var(--st-mono); font-size: 12.5px; color: var(--st-muted);
}
.rail a .n { color: var(--st-faint); font-size: 11px; }
.rail a:hover { background: var(--st-surface-2); color: var(--st-ink); }
.rail a.active { background: var(--st-accent-soft); color: var(--st-accent); font-weight: 600; }
.rail .as { margin-top: 16px; padding: 8px 10px; font-size: 11.5px; color: var(--st-faint); border-top: 1px solid var(--st-line-soft); }
.chips-nav { display: none; }
/* ── sections ── */
section.st { margin-top: 26px; scroll-margin-top: 96px; }
.sec-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 8px; flex-wrap: wrap; }
.sec-head h2 { margin: 0; font-family: var(--st-mono); font-size: 15px; font-weight: 700; color: var(--st-ink); }
.sec-head h2 .colon { color: var(--st-accent); }
.sec-head .count { font-family: var(--st-mono); font-size: 11.5px; color: var(--st-faint); }
.sec-head .sub { font-size: 12px; color: var(--st-muted); }
.sec-head .add { margin-left: auto; }
.list { background: var(--st-surface); border: 1px solid var(--st-line); border-radius: 8px; overflow: hidden; }
/* ── unified record row ── */
.row {
display: grid; grid-template-columns: 190px minmax(0,1fr) auto;
gap: 4px 14px; align-items: center;
padding: 8px 14px; border-bottom: 1px solid var(--st-line-soft);
transition: background .12s ease;
}
@media (prefers-reduced-motion: reduce) { .row { transition: none; } }
.row:last-child { border-bottom: none; }
.row:hover { background: var(--st-surface-2); }
.row .id { font-family: var(--st-mono); font-size: 13px; font-weight: 600; color: var(--st-ink); display: flex; align-items: center; gap: 8px; min-width: 0; }
.row .id .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dot { width: 8px; height: 8px; border-radius: 50%; flex: none; }
.dot.ok { background: var(--st-ok); }
.dot.off { background: transparent; border: 1.5px solid var(--st-faint); }
.facts { display: flex; flex-wrap: wrap; gap: 4px 6px; align-items: center; min-width: 0; }
.chip {
display: inline-flex; align-items: center; gap: 4px;
font-family: var(--st-mono); font-size: 11px; line-height: 1.3;
padding: 3px 7px; border-radius: 4px;
background: var(--st-surface-2); color: var(--st-muted);
}
.chip.owner { background: var(--st-badge-owner); color: var(--st-badge-owner-ink); }
.chip.mgr { background: var(--st-badge-mgr); color: var(--st-badge-mgr-ink); }
.chip.level { background: transparent; border: 1px solid var(--st-line); }
.chip.private { background: transparent; border: 1px dashed var(--st-badge-owner-ink); color: var(--st-badge-owner-ink); }
.chip .k { color: var(--st-faint); }
.chip .w { color: var(--st-warn); font-weight: 700; }
.chip .c { color: var(--st-crit); font-weight: 700; }
.row .act { display: flex; gap: 4px; justify-content: flex-end; }
/* row inline editor (one idiom for every type) */
.row .editor { display: none; }
.row.expanded { background: var(--st-surface-2); border-left: 3px solid var(--st-accent); padding-left: 11px; }
.row.expanded .editor {
grid-column: 1 / -1;
display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 10px 16px; padding: 10px 2px 6px;
}
.row.expanded .editor.wide { grid-template-columns: 1fr; }
.field label, .field .lbl { display: block; font-size: 11px; font-weight: 600; letter-spacing: .05em; text-transform: uppercase; color: var(--st-faint); margin-bottom: 3px; }
.field .hint { font-size: 11px; color: var(--st-faint); margin-top: 3px; }
.field input[type=text], .field input[type=password], .field input[type=number], .field select {
width: 100%; font: 13px var(--st-mono); color: var(--st-ink);
background: var(--st-surface); border: 1px solid var(--st-line); border-radius: 5px; padding: 6px 8px;
}
.toggle { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--st-muted); margin-right: 14px; cursor: pointer; }
.editor .foot { grid-column: 1 / -1; display: flex; gap: 8px; align-items: center; padding-top: 2px; flex-wrap: wrap; }
.editor .foot .danger { margin-left: auto; }
/* server key/value groups */
.kv .row { grid-template-columns: 230px minmax(0,1fr); padding: 6px 14px; }
.group {
display: flex; align-items: center; gap: 10px;
padding: 7px 14px 5px; font-family: var(--st-mono); font-size: 11px; color: var(--st-faint);
background: var(--st-surface); border-bottom: 1px solid var(--st-line-soft);
}
.group .gbtn { margin-left: auto; }
.kv .val { font-family: var(--st-mono); font-size: 12.5px; color: var(--st-muted); min-width: 0; }
.kv input.field-input, .kv .val input, .kv .val select {
width: 100%; max-width: 420px; font: 12.5px var(--st-mono); color: var(--st-ink);
background: var(--st-surface); border: 1px solid var(--st-line); border-radius: 5px; padding: 4px 8px;
}
.kv .desc { display: block; font-size: 11px; color: var(--st-faint); margin-top: 2px; }
.val-masked { color: var(--st-faint); letter-spacing: 2px; }
.note { margin: 8px 2px 0; font-size: 12px; color: var(--st-faint); }
/* threshold metric table inside editor */
.thr-table { border-collapse: collapse; width: 100%; font-family: var(--st-mono); font-size: 12px; }
.thr-table th { text-align: left; font-size: 10.5px; text-transform: uppercase; letter-spacing: .05em; color: var(--st-faint); padding: 4px 6px; border-bottom: 1px solid var(--st-line); }
.thr-table td { padding: 3px 6px; border-bottom: 1px solid var(--st-line-soft); }
.thr-table input[type=text], .thr-table input[type=number], .thr-table select {
font: 12px var(--st-mono); color: var(--st-ink);
background: var(--st-surface); border: 1px solid var(--st-line); border-radius: 4px; padding: 3px 6px;
}
.thr-scroll { overflow-x: auto; }
fieldset.plain { border: none; margin: 0; padding: 0; min-width: 0; }
/* mpick — multi-select chips */
.mpick-wrapper { position: relative; }
.mpick-display { display: flex; flex-wrap: wrap; gap: 4px; min-height: 28px; align-items: center; cursor: pointer; padding: 3px 6px; border: 1px dashed var(--st-line); border-radius: 5px; }
.mpick-display:hover { border-color: var(--st-accent); }
.mpick-tag { font-family: var(--st-mono); font-size: 11px; background: var(--st-surface-2); color: var(--st-muted); padding: 3px 7px; border-radius: 4px; }
.mpick-more { font-size: 11px; color: var(--st-faint); }
.mpick-empty { font-size: 11.5px; color: var(--st-faint); }
.mpick-panel {
position: fixed; z-index: 300; width: 360px; max-width: calc(100vw - 16px);
background: var(--st-surface); border: 1px solid var(--st-line); border-radius: 8px;
box-shadow: 0 8px 28px var(--shadow);
}
.mpick-panel-header { display: flex; justify-content: space-between; align-items: center; padding: 8px 12px; font-size: 12px; font-weight: 600; color: var(--st-muted); border-bottom: 1px solid var(--st-line-soft); }
.mpick-panel-body { display: grid; grid-template-columns: 1fr 1fr; max-height: 240px; }
.mpick-col { overflow-y: auto; padding: 6px; }
.mpick-col + .mpick-col { border-left: 1px solid var(--st-line-soft); }
.mpick-col-header { font-size: 10.5px; text-transform: uppercase; letter-spacing: .05em; color: var(--st-faint); padding: 2px 6px 6px; }
.mpick-item { display: flex; justify-content: space-between; gap: 6px; padding: 5px 8px; border-radius: 4px; font-family: var(--st-mono); font-size: 12px; cursor: pointer; color: var(--st-ink); }
.mpick-item-avail:hover { background: var(--st-accent-soft); }
.mpick-item-sel:hover { background: var(--st-surface-2); }
.mpick-none { font-size: 11.5px; color: var(--st-faint); padding: 6px; }
.mpick-panel-footer { padding: 8px 12px; border-top: 1px solid var(--st-line-soft); text-align: right; }
/* rollback modal */
.modal-overlay { position: fixed; inset: 0; z-index: 400; background: rgba(0,0,0,.45); display: none; align-items: center; justify-content: center; padding: 16px; }
.modal-box { background: var(--st-surface); color: var(--st-ink); border-radius: 10px; padding: 18px 20px; width: 480px; max-width: 100%; }
.modal-box h3 { margin: 0 0 12px; font-family: var(--st-mono); font-size: 14px; }
.backup-item { display: flex; justify-content: space-between; gap: 8px; align-items: center; padding: 6px 4px; border-bottom: 1px solid var(--st-line-soft); font-family: var(--st-mono); font-size: 12px; }
/* ── handheld ── */
@media (max-width: 760px) {
.shell { grid-template-columns: 1fr; padding: 0 10px 90px; }
.rail { display: none; }
.chips-nav {
display: flex; gap: 6px; overflow-x: auto; -webkit-overflow-scrolling: touch;
position: sticky; top: 46px; z-index: 80;
padding: 8px 10px; background: var(--st-paper); border-bottom: 1px solid var(--st-line-soft);
scrollbar-width: none;
}
.chips-nav::-webkit-scrollbar { display: none; }
.chips-nav a {
flex: none; font-family: var(--st-mono); font-size: 12px; text-decoration: none;
color: var(--st-muted); background: var(--st-surface); border: 1px solid var(--st-line);
border-radius: 999px; padding: 5px 12px;
}
.chips-nav a.active { background: var(--st-accent-soft); border-color: var(--st-accent); color: var(--st-accent); font-weight: 600; }
.st-toolbar { padding: 8px 10px; }
.st-toolbar .hintline { display: none; }
.row { grid-template-columns: minmax(0,1fr) auto; padding: 9px 12px; }
.row .id { grid-row: 1; grid-column: 1; }
.row .act { grid-row: 1; grid-column: 2; }
.row .facts { grid-column: 1 / -1; }
.kv .row { grid-template-columns: 1fr; }
section.st { scroll-margin-top: 140px; }
}
</style>
<body>
{%- macro mpick(all_items, sel, cls) -%}
<div class="mpick-wrapper"><div class="mpick-display" onclick="openMpick(this)" title="{{ sel | join(', ') | e }}">{%- if sel -%}{%- for v in sel -%}<span class="mpick-tag">{{ v | e }}</span>{%- endfor -%}{%- else -%}<span class="mpick-empty">(none — click to pick)</span>{%- endif -%}</div><select class="{{ cls }}" multiple hidden>{%- for item in all_items %}<option value="{{ item | e }}"{% if item in sel %} selected{% endif %}>{{ item | e }}</option>{%- endfor %}</select></div>
{%- endmacro %}
{% include 'nav.html' %}
{% set is_admin = not current_user or current_user.admin %}
{% set by_id = {} %}
{% for s in sections %}{% if by_id.update({s.id: s}) %}{% endif %}{% endfor %}
{% set entity_order = ['hosts', 'channels', 'thresholds', 'users', 'oauth'] %}
{% set server_group = sections | rejectattr('id', 'in', entity_order) | list %}
<!-- sticky toolbar -->
<div class="st-toolbar">
<span class="brand">hbd<span class="tld">·settings</span></span>
<span class="hintline">changes are staged until you publish them to .hb.yaml</span>
<span class="spacer"></span>
<span class="pending-pill" id="pending-banner"><span id="pending-count">0</span>&nbsp;unpublished change(s)</span>
<button class="btn quiet small" onclick="discardAll()">Discard</button>
<button class="btn primary small" onclick="publishAll()">Publish</button>
{% if is_admin %}
<button class="btn quiet small" onclick="showRollbackModal()" title="View config backups / roll back">Backups</button>
{% endif %}
</div>
<svg class="pulse" viewBox="0 0 1200 14" preserveAspectRatio="none" aria-hidden="true">
<polyline points="0,10 340,10 352,10 358,3 364,13 370,1 378,12 384,10 560,10 572,10 578,3 584,13 590,1 598,12 604,10 1200,10"/>
</svg>
<!-- mobile section chips -->
<nav class="chips-nav" id="chips-nav">
{% for sid in entity_order %}{% if sid in by_id %}
<a href="#{{ sid }}">{{ {'hosts':'hosts','channels':'channels','thresholds':'thresholds','users':'users','oauth':'oauth'}[sid] }}</a>
{% endif %}{% endfor %}
{% if server_group %}<a href="#server">server</a>{% endif %}
</nav>
<!-- rollback modal -->
<div id="rollback-modal" class="modal-overlay" onclick="if(event.target===this)closeRollbackModal()">
<div class="modal-box">
<h3>backups / rollback</h3>
<div id="rollback-list" style="max-height:300px;overflow-y:auto">Loading…</div>
<div style="margin-top:14px;text-align:right">
<button class="btn small" onclick="closeRollbackModal()">Close</button>
</div>
</div>
</div>
<div class="shell">
<nav class="rail" id="rail">
{% if 'hosts' in by_id %}<a href="#hosts">hosts <span class="n">{{ by_id['hosts'].hosts | length }}</span></a>{% endif %}
{% if 'channels' in by_id %}<a href="#channels">notification_channels <span class="n">{{ by_id['channels'].channels | length }}</span></a>{% endif %}
{% if 'thresholds' in by_id %}<a href="#thresholds">threshold_configs <span class="n">{{ by_id['thresholds'].threshold_configs | length }}</span></a>{% endif %}
{% if 'users' in by_id %}<a href="#users">users <span class="n">{{ by_id['users'].users | length }}</span></a>{% endif %}
{% if 'oauth' in by_id %}<a href="#oauth">oauth <span class="n">{{ by_id['oauth'].providers | length }}</span></a>{% endif %}
{% if server_group %}<a href="#server">server <span class="n">{{ server_group | length }} groups</span></a>{% endif %}
<div class="as">
{% if current_user %}Signed in as <b>{{ current_user.username }}</b>{% if current_user.admin %} (admin){% endif %}.
{% if not current_user.admin %}<br>You see the hosts, channels and thresholds you own or manage.{% endif %}
{% else %}Authentication is disabled — full access.{% endif %}
</div>
</nav>
<main>
{# ================= hosts ================= #}
{% if 'hosts' in by_id %}{% set sec = by_id['hosts'] %}
<section class="st" id="hosts">
<div class="sec-head">
<h2>hosts<span class="colon">:</span></h2>
<span class="count">{{ sec.hosts | length }}</span>
<span class="sub">watched machines and who runs them</span>
<button class="btn small add" onclick="addHostRow()">+ Add host</button>
</div>
<div class="list" id="hosts-list">
{% for h in sec.hosts %}
<div class="row" data-host-row="true" data-hostname="{{ h.name | e }}">
<span class="id">
<span class="dot {{ 'ok' if h.watch else 'off' }}" title="{{ 'watched' if h.watch else 'not watched' }}"></span>
<span class="name">{{ h.name | e }}</span>
</span>
<span class="facts">
{% if h.owner %}<span class="chip owner">⌂ {{ h.owner | e }}</span>{% endif %}
{% for m in h.managers %}<span class="chip mgr">{{ m | e }}</span>{% endfor %}
{% for t in h.threshold_configs %}<span class="chip">{{ t | e }}</span>{% endfor %}
{% if h.notification_channels %}<span class="chip">{{ h.notification_channels | length }} channel{{ 's' if h.notification_channels | length != 1 }}</span>{% endif %}
{% if h.dyndns %}<span class="chip">dyndns</span>{% endif %}
</span>
<span class="act">
<button class="btn small quiet" onclick="toggleEditor(this)">Edit</button>
{% if h.is_owner %}<button class="btn small quiet danger" onclick="toggleDeleteRow(this)" title="Mark for deletion"></button>{% endif %}
</span>
<div class="editor">
<div class="field">
<span class="lbl">Flags</span>
<label class="toggle"><input type="checkbox" class="host-watch" {% if h.watch %}checked{% endif %}> watch</label>
<label class="toggle"><input type="checkbox" class="host-dyndns" {% if h.dyndns %}checked{% endif %}> dyndns</label>
</div>
{% if h.is_owner %}
<div class="field">
<label>Owner</label>
<input type="text" class="host-owner" value="{{ h.owner | e }}" placeholder="(none)">
<div class="hint">Full control incl. delete and access lists</div>
</div>
<div class="field">
<span class="lbl">Managers</span>
{{ mpick(all_usernames, h.managers, 'host-managers') }}
<div class="hint">Edit settings, receive alerts</div>
</div>
<div class="field">
<span class="lbl">Monitors</span>
{{ mpick(all_usernames, h.monitors, 'host-monitors') }}
<div class="hint">View only</div>
</div>
{% else %}
<div class="field">
<span class="lbl">Access</span>
<span class="facts">
<span class="chip owner">⌂ {{ h.owner | e }}</span>
{% for m in h.managers %}<span class="chip mgr">{{ m | e }}</span>{% endfor %}
{% for m in h.monitors %}<span class="chip">{{ m | e }}</span>{% endfor %}
</span>
<div class="hint">Only the owner can change access</div>
</div>
{% endif %}
<div class="field">
<span class="lbl">Threshold configs</span>
{{ mpick(all_threshold_configs, h.threshold_configs, 'host-tc') }}
</div>
<div class="field">
<span class="lbl">Alert channels</span>
{{ mpick(all_channel_names, h.notification_channels, 'host-channels') }}
</div>
<div class="foot">
<button class="btn small primary" onclick="stageHostsSection()">Stage changes</button>
<button class="btn small quiet" onclick="toggleEditor(this)">Close</button>
</div>
</div>
</div>
{% endfor %}
{% if not sec.hosts %}<div class="row"><span class="id"><span class="name" style="color:var(--st-faint)">No hosts yet — add one above.</span></span></div>{% endif %}
</div>
</section>
{% endif %}
{# ================= notification channels ================= #}
{% if 'channels' in by_id %}{% set sec = by_id['channels'] %}
<section class="st" id="channels">
<div class="sec-head">
<h2>notification_channels<span class="colon">:</span></h2>
<span class="count">{{ sec.channels | length }}</span>
<span class="sub">where alerts go — global unless owned</span>
<button class="btn small add" onclick="openChannelEditor(null, this)">+ Add channel</button>
</div>
<div class="list" id="channels-list">
<div class="row" id="ch-new-slot" style="display:none"><div class="editor wide" style="display:grid"></div></div>
{% for ch in sec.channels %}
<div class="row" data-channel-name="{{ ch.name | e }}">
<span class="id"><span class="name">{{ ch.name | e }}</span></span>
<span class="facts">
<span class="chip">{{ ch.type_label | e }}</span>
<span class="chip level">{{ ch.min_level | e }}{{ '+' if ch.min_level == 'WARNING' }}</span>
{% if ch.owner %}<span class="chip private">⌂ {{ ch.owner | e }}</span>{% endif %}
{% for f in ch.fields %}{% if not f.sensitive and f.value %}<span class="chip"><span class="k">{{ f.label | lower | e }}</span> {{ f.value | truncate(28) | e }}</span>{% endif %}{% endfor %}
</span>
<span class="act">
{% if ch.editable %}
<button class="btn small quiet" onclick="openChannelEditor('{{ ch.name | e }}', this)">Edit</button>
<button class="btn small quiet danger" onclick="deleteChannel('{{ ch.name | e }}')"></button>
{% endif %}
</span>
<div class="editor wide"></div>
</div>
{% endfor %}
{% if not sec.channels %}<div class="row"><span class="id"><span class="name" style="color:var(--st-faint)">No channels yet — add one above.</span></span></div>{% endif %}
</div>
<p class="note">A channel with an owner (dashed badge) is private to that user.{% if is_admin %} Clear or set the owner in the editor to promote or demote.{% endif %} Channel edits save immediately — no publish step.</p>
</section>
{% endif %}
{# ================= threshold configs ================= #}
{% if 'thresholds' in by_id %}{% set sec = by_id['thresholds'] %}
<section class="st" id="thresholds">
<div class="sec-head">
<h2>threshold_configs<span class="colon">:</span></h2>
<span class="count">{{ sec.threshold_configs | length }}</span>
<span class="sub">named alert rule sets, assigned per host</span>
<button class="btn small add" onclick="addThresholdConfigCard('thresh-cfgs-{{ sec.id }}')">+ Add config</button>
</div>
{% for f in sec.fields %}
<div class="note" style="margin-bottom:6px">
{{ f.label }}:
<input type="text" class="thresh-default-config" value="{{ f.raw if f.raw is not none else '' }}"
placeholder="default" list="thresh-cfg-names" style="font:12px var(--st-mono);padding:3px 6px;border:1px solid var(--st-line);border-radius:4px;background:var(--st-surface);color:var(--st-ink)">
<datalist id="thresh-cfg-names">{% for tc in sec.threshold_configs %}<option value="{{ tc.name | e }}">{% endfor %}</datalist>
</div>
{% endfor %}
<div class="list" id="thresh-cfgs-{{ sec.id }}">
{% for tc in sec.threshold_configs %}
<div class="row thresh-cfg-card" data-config-name="{{ tc.name | e }}"{% if not tc.editable %} data-readonly="true"{% endif %}>
<span class="id"><span class="name">{{ tc.name | e }}</span></span>
<span class="facts">
{% for m in tc.metrics[:3] %}
<span class="chip">
<span class="k">{{ m.metric | e }}</span>
{%- if m.operator == 'nagios' %} nagios
{%- else %} {{ m.operator }} <span class="w">{{ m.warning if m.warning is not none else '·' }}</span>/<span class="c">{{ m.critical if m.critical is not none else '·' }}</span>
{%- endif %}
{%- if not m.enabled %} (off){% endif %}
</span>
{% endfor %}
{% if tc.metrics | length > 3 %}<span class="chip">+{{ tc.metrics | length - 3 }} more</span>{% endif %}
{% if tc.name != 'default' %}<span class="chip level">inherits default</span>{% endif %}
{% if tc.owner %}<span class="chip private">⌂ {{ tc.owner | e }}</span>{% endif %}
</span>
<span class="act">
{% if tc.editable %}
<button class="btn small quiet" onclick="toggleEditor(this)">Edit</button>
{% if tc.name != 'default' %}<button class="btn small quiet danger" onclick="deleteThresholdConfigCard(this)"></button>{% endif %}
{% else %}
<button class="btn small quiet" onclick="toggleEditor(this)">View</button>
{% endif %}
</span>
<div class="editor wide">
{% if is_admin and tc.name != 'default' %}
<div class="field" style="max-width:280px">
<label>Owner <span style="text-transform:none;font-weight:400">(empty = global)</span></label>
<input type="text" class="thresh-owner" value="{{ tc.owner or '' }}" placeholder="(global)">
</div>
{% endif %}
<fieldset class="plain" {% if not tc.editable %}disabled{% endif %}>
<div class="thr-scroll">
<table class="thr-table">
<thead><tr>
<th>Metric path</th><th>Op</th><th>Warn</th><th>Crit</th>
<th>Hyst</th><th>Count</th><th title="Grace seconds — empty uses global">Grace</th>
<th>Display</th><th>En</th><th></th>
</tr></thead>
<tbody>
{% for m in tc.metrics %}
<tr data-metric-row="true" data-metric-path="{{ m.metric | e }}">
<td style="white-space:nowrap">{{ m.metric | e }}</td>
<td>
<select class="thresh-op" style="width:76px" onchange="onThreshOpChange(this)">
{% for op in ['>', '>=', '<', '<=', '==', '!=', 'nagios'] %}
<option value="{{ op }}" {% if m.operator == op %}selected{% endif %}>{{ op }}</option>
{% endfor %}
</select>
</td>
<td><input type="number" class="thresh-warn" step="any" style="width:72px"
value="{{ m.warning if m.warning is not none else '' }}"
{% if m.operator == 'nagios' %}disabled{% endif %}></td>
<td><input type="number" class="thresh-crit" step="any" style="width:72px"
value="{{ m.critical if m.critical is not none else '' }}"
{% if m.operator == 'nagios' %}disabled{% endif %}></td>
<td><input type="number" class="thresh-hyst" step="any" style="width:64px"
value="{{ m.hysteresis if m.hysteresis is not none else 0.02 }}"></td>
<td><input type="number" class="thresh-count" step="1" min="1" style="width:52px"
value="{{ m.count if m.count is not none else 1 }}"></td>
<td><input type="number" class="thresh-grace" step="any" min="0" style="width:60px"
value="{{ m.grace if m.grace is not none else '' }}" placeholder="(global)"></td>
<td><input type="text" class="thresh-display" style="width:130px"
value="{{ m.display | e }}" placeholder="(default)"></td>
<td style="text-align:center"><input type="checkbox" class="thresh-enabled" {% if m.enabled %}checked{% endif %}></td>
<td><button class="btn small quiet danger" onclick="this.closest('tr').remove()"></button></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="foot">
<button class="btn small" onclick="addThresholdMetricRow(this.closest('.thresh-cfg-card').querySelector('tbody'))">+ Add metric</button>
{% if tc.editable %}
<button class="btn small primary" onclick="stageThresholdsSection('{{ sec.id }}')">Stage changes</button>
{% endif %}
<button class="btn small quiet" onclick="toggleEditor(this)">Close</button>
</div>
</fieldset>
</div>
</div>
{% endfor %}
</div>
</section>
{% endif %}
{# ================= users (admin) ================= #}
{% if 'users' in by_id %}{% set sec = by_id['users'] %}
<section class="st" id="users">
<div class="sec-head">
<h2>users<span class="colon">:</span></h2>
<span class="count">{{ sec.users | length }}</span>
<span class="sub">accounts and their alert subscriptions</span>
<button class="btn small add" onclick="addUserRow()">+ Add user</button>
</div>
<div class="list" id="users-list">
{% for u in sec.users %}
<div class="row" data-user-row="true" data-username="{{ u.username | e }}">
<span class="id"><span class="name">{{ u.username | e }}</span></span>
<span class="facts">
{% if u.admin %}<span class="chip owner">admin</span>{% endif %}
{% if u.full_name %}<span class="chip">{{ u.full_name | e }}</span>{% endif %}
{% if u.notification_channels %}<span class="chip"><span class="k">alerts</span> {{ u.notification_channels | join(', ') | truncate(40) | e }}</span>{% endif %}
</span>
<span class="act">
<button class="btn small quiet" onclick="toggleEditor(this)">Edit</button>
<button class="btn small quiet danger" onclick="toggleDeleteRow(this)" title="Mark for deletion"></button>
</span>
<div class="editor">
<div class="field">
<label>Display name</label>
<input type="text" class="user-full-name" value="{{ u.full_name | e }}">
</div>
<div class="field">
<label>Avatar</label>
<input type="text" class="user-avatar" value="{{ u.avatar | e }}" placeholder="URL or path">
</div>
<div class="field">
<label>New password</label>
<input type="password" class="user-password" placeholder="(leave blank to keep)" autocomplete="new-password">
</div>
<div class="field">
<span class="lbl">Flags</span>
<label class="toggle"><input type="checkbox" class="user-admin" {% if u.admin %}checked{% endif %}> admin</label>
</div>
<div class="field">
<span class="lbl">Alert channels</span>
{{ mpick(all_channel_names, u.notification_channels, 'user-ch-select') }}
</div>
<div class="foot">
<button class="btn small primary" onclick="stageUsersSection()">Stage changes</button>
<button class="btn small quiet" onclick="toggleEditor(this)">Close</button>
</div>
</div>
</div>
{% endfor %}
</div>
{% for f in sec.fields %}{% if f.editable %}
<p class="note">{{ f.label }}:
<input type="text" data-key="{{ f.key }}" data-section="{{ sec.api_section }}"
value="{{ f.raw if f.raw is not none else '' }}"
style="font:12px var(--st-mono);padding:3px 6px;border:1px solid var(--st-line);border-radius:4px;background:var(--st-surface);color:var(--st-ink)">
<span style="margin-left:4px">{{ f.description }}</span>
<span style="margin-left:4px;color:var(--st-muted)">(staged with users)</span>
</p>
{% endif %}{% endfor %}
</section>
{% endif %}
{# ================= oauth (admin) ================= #}
{% if 'oauth' in by_id %}{% set sec = by_id['oauth'] %}
<section class="st" id="oauth">
<div class="sec-head">
<h2>oauth<span class="colon">:</span></h2>
<span class="count">{{ sec.providers | length }}</span>
<span class="sub">external sign-in providers</span>
<button class="btn small add" onclick="addOAuthRow()">+ Add provider</button>
</div>
<div class="list" id="oauth-list">
{% for p in sec.providers %}
<div class="row" data-oauth-row="true" data-name="{{ p.name | e }}">
<span class="id"><span class="name">{{ p.name | e }}</span></span>
<span class="facts">
<span class="chip">{{ p.type | e }}</span>
{% if p.url %}<span class="chip"><span class="k">url</span> {{ p.url | truncate(34) | e }}</span>{% endif %}
{% if p.label %}<span class="chip">{{ p.label | e }}</span>{% endif %}
</span>
<span class="act">
<button class="btn small quiet" onclick="toggleEditor(this)">Edit</button>
<button class="btn small quiet danger" onclick="toggleDeleteRow(this)" title="Mark for deletion"></button>
</span>
<div class="editor">
<div class="field">
<label>Type</label>
<select class="oauth-type">
{% for t in ['gitea', 'github', 'nextcloud'] %}
<option value="{{ t }}" {% if p.type == t %}selected{% endif %}>{{ t }}</option>
{% endfor %}
</select>
</div>
<div class="field"><label>URL</label><input type="text" class="oauth-url" value="{{ p.url | e }}"></div>
<div class="field"><label>Client ID</label><input type="text" class="oauth-client-id" value="{{ p.client_id | e }}"></div>
<div class="field"><label>Client secret</label><input type="password" class="oauth-secret" value="{{ p.client_secret | e }}"></div>
<div class="field"><label>Button label</label><input type="text" class="oauth-label" value="{{ p.label | e }}"></div>
<div class="field"><label>Logo URL</label><input type="text" class="oauth-logo" value="{{ p.logo | e }}"></div>
<div class="foot">
<button class="btn small primary" onclick="stageOAuthSection()">Stage changes</button>
<button class="btn small quiet" onclick="toggleEditor(this)">Close</button>
</div>
</div>
</div>
{% endfor %}
{% if not sec.providers %}<div class="row"><span class="id"><span class="name" style="color:var(--st-faint)">No providers configured.</span></span></div>{% endif %}
</div>
</section>
{% endif %}
{# ================= server (grouped scalar sections) ================= #}
{% if server_group %}
<section class="st" id="server">
<div class="sec-head">
<h2>server<span class="colon">:</span></h2>
<span class="count">{{ server_group | length }} groups</span>
<span class="sub">ports, timing, persistence, DNS — one place, grouped</span>
</div>
<div class="list kv">
{% for s in server_group %}
<div id="{{ s.id }}">
<div class="group"># {{ s.title | lower }}
{% if s.api_section %}<button class="btn small gbtn" onclick="stageFormSection('{{ s.id }}', '{{ s.api_section }}')">Stage group</button>{% endif %}
</div>
{% for f in s.fields %}
<div class="row">
<span class="id"><span class="name">{{ f.key }}</span></span>
<span class="val">
{% if f.editable and s.api_section %}
{% if f.type == 'boolean' %}
<label class="toggle"><input type="checkbox" data-key="{{ f.key }}" data-section="{{ s.api_section }}" {% if f.value %}checked{% endif %}> enabled</label>
{% elif f.type in ('number', 'port', 'size') %}
<input type="number" data-key="{{ f.key }}" data-type="{{ f.type }}" data-section="{{ s.api_section }}"
value="{{ f.raw if f.raw is not none else '' }}">
{% elif f.type == 'list' %}
<input type="text" data-key="{{ f.key }}" data-type="list" data-section="{{ s.api_section }}"
value="{{ f.value | join(', ') if f.value else '' }}" placeholder="comma-separated">
{% else %}
<input type="text" data-key="{{ f.key }}" data-section="{{ s.api_section }}"
value="{{ f.raw if f.raw is not none else '' }}">
{% endif %}
{% elif f.sensitive %}
<span class="val-masked">••••••••</span>
{% elif f.type == 'boolean' %}
{{ 'enabled' if f.value else 'disabled' }}
{% elif f.type == 'list' %}
{{ f.value | join(', ') if f.value else '—' }}
{% else %}
{{ f.value if f.value is not none and f.value != '' else '—' }}
{% endif %}
{% if f.description %}<span class="desc">{{ f.description }}</span>{% endif %}
</span>
</div>
{% endfor %}
</div>
{% endfor %}
</div>
</section>
{% endif %}
</main>
</div>
<script>
// ---- Lookup arrays for CRUD rows ----
const _allChannels = {{ all_channel_names | tojson }};
const _allUsers = {{ all_usernames | tojson }};
const _allThresholdConfigs = {{ all_threshold_configs | tojson }};
const _isAdmin = {{ 'true' if (not current_user or current_user.admin) else 'false' }};
// ---- Row expansion (one edit idiom for every type) ----
function toggleEditor(el) {
const row = el.closest('.row');
const open = row.classList.toggle('expanded');
const btn = row.querySelector('.act .btn');
if (btn && (btn.textContent === 'Edit' || btn.textContent === 'Close')) {
btn.textContent = open ? 'Close' : 'Edit';
}
}
function toggleDeleteRow(btn) {
const row = btn.closest('.row');
const deleted = row.dataset.deleted === 'true';
row.dataset.deleted = deleted ? 'false' : 'true';
row.style.opacity = deleted ? '1' : '0.4';
row.querySelectorAll('input, select').forEach(el => { el.disabled = !deleted; });
btn.textContent = deleted ? '✕' : '↩';
btn.title = deleted ? 'Mark for deletion' : 'Deletion pending — click to restore, then stage';
}
function escHtml(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
// ---- Staged changes accumulator ----
const _staged = {};
function updatePendingBanner() {
const count = Object.keys(_staged).length;
const banner = document.getElementById('pending-banner');
if (count > 0) {
document.getElementById('pending-count').textContent = count;
banner.style.display = 'inline-flex';
localStorage.setItem('hbd_pending_config', JSON.stringify(_staged));
} else {
banner.style.display = 'none';
localStorage.removeItem('hbd_pending_config');
}
const navBtn = document.getElementById('nav-publish-btn');
if (navBtn) navBtn.style.display = count > 0 ? '' : 'none';
}
function flashStaged(sectionId) {
const sec = document.getElementById(sectionId);
if (!sec) return;
sec.style.outline = '2px solid var(--st-warn)';
setTimeout(() => { sec.style.outline = ''; }, 800);
}
async function publishAll() {
const btn = document.querySelector('[onclick="publishAll()"]');
btn.disabled = true;
btn.textContent = 'Saving…';
try {
const resp = await fetch('/api/0/config', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(_staged),
});
if (resp.ok) {
window.location.reload();
} else {
const err = await resp.json().catch(() => ({}));
alert('Error: ' + (err.error || resp.statusText));
btn.disabled = false;
btn.textContent = 'Publish';
}
} catch (e) {
alert('Network error: ' + e.message);
btn.disabled = false;
btn.textContent = 'Publish';
}
}
function discardAll() {
Object.keys(_staged).forEach(k => delete _staged[k]);
localStorage.removeItem('hbd_pending_config');
updatePendingBanner();
window.location.reload();
}
// ---- Server scalar groups ----
function stageFormSection(sectionId, apiSection) {
const section = document.getElementById(sectionId);
if (!_staged[apiSection] || typeof _staged[apiSection] !== 'object') {
_staged[apiSection] = {};
}
section.querySelectorAll('[data-key][data-section="' + apiSection + '"]').forEach(el => {
const key = el.dataset.key;
if (el.type === 'checkbox') {
_staged[apiSection][key] = el.checked;
} else if (el.dataset.type === 'number' || el.dataset.type === 'port') {
const v = parseInt(el.value, 10);
_staged[apiSection][key] = isNaN(v) ? null : v;
} else if (el.dataset.type === 'list') {
_staged[apiSection][key] = el.value.split(',').map(s => s.trim()).filter(Boolean);
} else {
_staged[apiSection][key] = el.value;
}
});
updatePendingBanner();
flashStaged(sectionId);
}
// ---- Hosts ----
function stageHostsSection() {
function rowToEntry(row) {
const entry = {
watch: row.querySelector('.host-watch').checked,
dyndns: row.querySelector('.host-dyndns').checked,
};
const ownerInput = row.querySelector('.host-owner');
if (ownerInput) {
const owner = ownerInput.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 tcs = [...(row.querySelector('.host-tc')?.selectedOptions || [])].map(o => o.value);
if (tcs.length) entry.threshold_config = tcs;
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 list = document.getElementById('hosts-list');
const row = document.createElement('div');
row.className = 'row expanded';
row.setAttribute('data-new-host', 'true');
row.innerHTML = `
<span class="id"><span class="dot ok"></span>
<input type="text" class="new-hostname" placeholder="hostname" required
style="font:600 13px var(--st-mono);color:var(--st-ink);background:var(--st-surface);border:1px solid var(--st-line);border-radius:5px;padding:5px 8px;min-width:150px"></span>
<span class="facts"><span class="chip level">new host</span></span>
<span class="act"><button class="btn small quiet danger" onclick="this.closest('.row').remove()">✕</button></span>
<div class="editor">
<div class="field">
<span class="lbl">Flags</span>
<label class="toggle"><input type="checkbox" class="host-watch" checked> watch</label>
<label class="toggle"><input type="checkbox" class="host-dyndns"> dyndns</label>
</div>
${_isAdmin ? `<div class="field">
<label>Owner</label>
<input type="text" class="host-owner" placeholder="(you)">
</div>
<div class="field"><span class="lbl">Managers</span>${makeMpickHTML(_allUsers, [], 'host-managers')}</div>
<div class="field"><span class="lbl">Monitors</span>${makeMpickHTML(_allUsers, [], 'host-monitors')}</div>` : ''}
<div class="field"><span class="lbl">Threshold configs</span>${makeMpickHTML(_allThresholdConfigs, [], 'host-tc')}</div>
<div class="field"><span class="lbl">Alert channels</span>${makeMpickHTML(_allChannels, [], 'host-channels')}</div>
<div class="foot">
<button class="btn small primary" onclick="stageHostsSection()">Stage changes</button>
</div>
</div>`;
list.prepend(row);
row.querySelector('.new-hostname').focus();
}
// ---- Channels (live CRUD via API, editor moves into the row) ----
let _channelSchemas = {};
let _chEditName = null;
let _chEditorHost = null;
async function _loadChannelSchemas() {
try {
const r = await fetch('/api/0/notification_channel_types');
_channelSchemas = await r.json();
} catch(e) { console.warn('Could not load channel schemas', e); }
}
function _channelEditorHTML() {
const typeOpts = Object.entries(_channelSchemas)
.map(([k, v]) => `<option value="${escHtml(k)}">${escHtml(v.label)}</option>`).join('');
return `
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:10px 16px">
<div class="field">
<label>Channel name</label>
<input type="text" id="ch-name" placeholder="e.g. pushover_ops" autocomplete="off">
</div>
<div class="field">
<label>Type</label>
<select id="ch-type" onchange="onChTypeChange()"><option value="">— select —</option>${typeOpts}</select>
</div>
<div class="field">
<label>Minimum alert level</label>
<select id="ch-min-level">
<option value="WARNING">WARNING (and above)</option>
<option value="CRITICAL">CRITICAL only</option>
</select>
</div>
${_isAdmin ? `<div class="field">
<label>Owner <span style="text-transform:none;font-weight:400">(empty = global)</span></label>
<input type="text" id="ch-owner" placeholder="(global)" autocomplete="off">
</div>` : ''}
<div id="ch-type-fields" style="grid-column:1/-1;display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:10px 16px"></div>
<div class="foot">
<button class="btn small primary" onclick="saveChannel()">Save channel</button>
<button class="btn small quiet" onclick="closeChannelEditor()">Cancel</button>
<span id="ch-modal-status" style="font-size:12px;color:var(--st-crit)"></span>
</div>
</div>`;
}
function onChTypeChange() {
const type = document.getElementById('ch-type').value;
const container = document.getElementById('ch-type-fields');
container.innerHTML = '';
if (!type || !_channelSchemas[type]) return;
(_channelSchemas[type].fields || []).forEach(sf => {
const div = document.createElement('div');
div.className = 'field';
const lbl = document.createElement('label');
lbl.textContent = sf.label + (sf.required ? ' *' : '');
const inp = document.createElement('input');
inp.type = sf.type === 'secret' ? 'password' : 'text';
inp.id = 'chf-' + sf.key;
inp.placeholder = sf.required ? '(required)' : '(optional)';
inp.autocomplete = 'off';
div.appendChild(lbl);
div.appendChild(inp);
container.appendChild(div);
});
}
async function openChannelEditor(name, btn) {
closeChannelEditor();
_chEditName = name || null;
let row;
if (name) {
row = document.querySelector(`[data-channel-name="${CSS.escape(name)}"]`);
} else {
row = document.getElementById('ch-new-slot');
row.style.display = '';
}
_chEditorHost = row;
const slot = row.querySelector('.editor');
slot.innerHTML = _channelEditorHTML();
row.classList.add('expanded');
document.getElementById('ch-name').value = name || '';
document.getElementById('ch-name').disabled = !!name;
if (name) {
try {
const r = await fetch('/api/0/notification_channels');
const channels = await r.json();
const ch = channels.find(c => c.name === name);
if (ch) {
document.getElementById('ch-type').value = ch.type;
onChTypeChange();
document.getElementById('ch-min-level').value = ch.min_level || 'WARNING';
const ownerInp = document.getElementById('ch-owner');
if (ownerInp) ownerInp.value = ch.owner || '';
(ch.fields || []).forEach(f => {
const inp = document.getElementById('chf-' + f.key);
if (inp) inp.value = f.value || '';
});
}
} catch(e) { console.warn('Failed to load channel data', e); }
}
}
function closeChannelEditor() {
if (!_chEditorHost) return;
_chEditorHost.classList.remove('expanded');
const slot = _chEditorHost.querySelector('.editor');
if (slot) slot.innerHTML = '';
if (_chEditorHost.id === 'ch-new-slot') _chEditorHost.style.display = 'none';
_chEditorHost = null;
_chEditName = null;
}
async function saveChannel() {
const name = document.getElementById('ch-name').value.trim();
const type = document.getElementById('ch-type').value;
const minLevel = document.getElementById('ch-min-level').value;
const statusEl = document.getElementById('ch-modal-status');
statusEl.textContent = '';
if (!name) { statusEl.textContent = 'Channel name is required.'; return; }
if (!type) { statusEl.textContent = 'Please select a type.'; return; }
const body = { name, type, min_level: minLevel };
const ownerInp = document.getElementById('ch-owner');
if (ownerInp) body.owner = ownerInp.value.trim();
if (_channelSchemas[type]) {
(_channelSchemas[type].fields || []).forEach(sf => {
const inp = document.getElementById('chf-' + sf.key);
if (inp) body[sf.key] = inp.value;
});
}
const isEdit = !!_chEditName;
const url = isEdit ? '/api/0/notification_channels/' + encodeURIComponent(_chEditName) : '/api/0/notification_channels';
const method = isEdit ? 'PUT' : 'POST';
try {
const r = await fetch(url, { method, headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body) });
if (r.ok) {
window.location.reload();
} else {
const err = await r.json().catch(() => ({}));
statusEl.textContent = err.error || 'Error saving channel.';
}
} catch(e) {
statusEl.textContent = 'Network error: ' + e.message;
}
}
async function deleteChannel(name) {
if (!confirm('Delete channel "' + name + '"? This cannot be undone.')) return;
try {
const r = await fetch('/api/0/notification_channels/' + encodeURIComponent(name), { method: 'DELETE' });
if (r.ok) {
window.location.reload();
} else {
const err = await r.json().catch(() => ({}));
alert('Error: ' + (err.error || 'Could not delete channel.'));
}
} catch(e) { alert('Network error: ' + e.message); }
}
// ---- Thresholds ----
function stageThresholdsSection(sectionId) {
const configs = {};
function readMetrics(card) {
const metrics = {};
card.querySelectorAll('tbody tr').forEach(row => {
if (row.dataset.deleted === 'true') return;
const metric = row.dataset.metricPath
|| (row.querySelector('.new-metric-path')?.value || '').trim();
if (!metric) return;
const op = row.querySelector('.thresh-op')?.value || '>';
const warn = row.querySelector('.thresh-warn')?.value;
const crit = row.querySelector('.thresh-crit')?.value;
const hyst = row.querySelector('.thresh-hyst')?.value;
const count = row.querySelector('.thresh-count')?.value;
const grace = row.querySelector('.thresh-grace')?.value;
const display = row.querySelector('.thresh-display')?.value || '';
const enabled = row.querySelector('.thresh-enabled')?.checked ?? true;
const entry = { operator: op, enabled: enabled };
if (warn !== '' && warn !== undefined) entry.warning = parseFloat(warn);
if (crit !== '' && crit !== undefined) entry.critical = parseFloat(crit);
if (hyst !== '' && hyst !== undefined) entry.hysteresis = parseFloat(hyst);
if (count !== '' && count !== undefined) entry.count = parseInt(count, 10);
if (grace !== '' && grace !== undefined) entry.grace = parseFloat(grace);
if (display) entry.display = display;
metrics[metric] = entry;
});
return metrics;
}
const cfgsContainer = document.getElementById('thresh-cfgs-' + sectionId);
cfgsContainer.querySelectorAll('.thresh-cfg-card').forEach(card => {
if (card.dataset.readonly === 'true') return;
const configName = card.dataset.configName
|| (card.querySelector('.new-config-name')?.value || '').trim();
if (!configName) return;
const ownerInp = card.querySelector('.thresh-owner');
configs[configName] = {
owner: ownerInp ? ownerInp.value.trim() : '',
metrics: readMetrics(card),
};
});
_staged['thresholds'] = configs;
const defInput = document.querySelector('.thresh-default-config');
if (defInput) {
if (!_staged['server']) _staged['server'] = {};
_staged['server']['default_threshold_config'] = defInput.value || 'default';
}
updatePendingBanner();
flashStaged('thresholds');
}
function onThreshOpChange(select) {
const row = select.closest('tr');
const isNagios = select.value === 'nagios';
const w = row.querySelector('.thresh-warn');
const c = row.querySelector('.thresh-crit');
if (w) w.disabled = isNagios;
if (c) c.disabled = isNagios;
}
function _threshOpSelect(selected) {
const ops = ['>', '>=', '<', '<=', '==', '!=', 'nagios'];
return '<select class="thresh-op" style="width:76px" onchange="onThreshOpChange(this)">' +
ops.map(op => `<option value="${escHtml(op)}"${op === selected ? ' selected' : ''}>${escHtml(op)}</option>`).join('') +
'</select>';
}
function addThresholdMetricRow(tbody) {
const row = document.createElement('tr');
row.innerHTML = `
<td><input type="text" class="new-metric-path" placeholder="plugin.metric" style="min-width:150px" required></td>
<td>${_threshOpSelect('>')}</td>
<td><input type="number" class="thresh-warn" step="any" style="width:72px"></td>
<td><input type="number" class="thresh-crit" step="any" style="width:72px"></td>
<td><input type="number" class="thresh-hyst" step="any" style="width:64px" value="0.02"></td>
<td><input type="number" class="thresh-count" step="1" min="1" style="width:52px" value="1"></td>
<td><input type="number" class="thresh-grace" step="any" min="0" style="width:60px" placeholder="(global)"></td>
<td><input type="text" class="thresh-display" style="width:130px" placeholder="(default)"></td>
<td style="text-align:center"><input type="checkbox" class="thresh-enabled" checked></td>
<td><button class="btn small quiet danger" onclick="this.closest('tr').remove()">✕</button></td>`;
tbody.appendChild(row);
}
function addThresholdConfigCard(containerId) {
const container = document.getElementById(containerId);
const card = document.createElement('div');
card.className = 'row expanded thresh-cfg-card';
card.innerHTML = `
<span class="id">
<input type="text" class="new-config-name" placeholder="config name (e.g. servers)"
style="font:600 13px var(--st-mono);color:var(--st-ink);background:var(--st-surface);border:1px solid var(--st-line);border-radius:5px;padding:5px 8px;min-width:170px">
</span>
<span class="facts"><span class="chip level">new config</span></span>
<span class="act"><button class="btn small quiet danger" onclick="this.closest('.row').remove()">✕</button></span>
<div class="editor wide">
${_isAdmin ? `<div class="field" style="max-width:280px">
<label>Owner <span style="text-transform:none;font-weight:400">(empty = global)</span></label>
<input type="text" class="thresh-owner" placeholder="(global)">
</div>` : ''}
<div class="thr-scroll">
<table class="thr-table">
<thead><tr>
<th>Metric path</th><th>Op</th><th>Warn</th><th>Crit</th>
<th>Hyst</th><th>Count</th><th>Grace</th><th>Display</th><th>En</th><th></th>
</tr></thead>
<tbody></tbody>
</table>
</div>
<div class="foot">
<button class="btn small" onclick="addThresholdMetricRow(this.closest('.thresh-cfg-card').querySelector('tbody'))">+ Add metric</button>
<button class="btn small primary" onclick="stageThresholdsSection('thresholds')">Stage changes</button>
</div>
</div>`;
container.prepend(card);
addThresholdMetricRow(card.querySelector('tbody'));
card.querySelector('.new-config-name').focus();
}
function deleteThresholdConfigCard(btn) {
const card = btn.closest('.thresh-cfg-card');
const name = card.dataset.configName || 'this config';
if (!confirm(`Delete config "${name}"? Stage + publish to make it permanent.`)) return;
card.remove();
}
// ---- Users ----
function stageUsersSection() {
const users = {};
document.querySelectorAll('[data-user-row]').forEach(row => {
if (row.dataset.deleted === 'true') return;
const username = row.dataset.username;
const entry = {
full_name: row.querySelector('.user-full-name').value,
avatar: row.querySelector('.user-avatar').value,
admin: row.querySelector('.user-admin').checked,
notification_channels: [...(row.querySelector('.user-ch-select')?.selectedOptions || [])].map(o => o.value),
};
const pw = row.querySelector('.user-password').value;
if (pw) entry.password = pw;
users[username] = entry;
});
document.querySelectorAll('[data-new-user]').forEach(row => {
if (row.dataset.deleted === 'true') return;
const uname = (row.querySelector('.new-username') || {value: ''}).value.trim();
if (!uname) return;
const entry = {
full_name: row.querySelector('.user-full-name').value,
avatar: row.querySelector('.user-avatar').value,
admin: row.querySelector('.user-admin').checked,
notification_channels: [...(row.querySelector('.user-ch-select')?.selectedOptions || [])].map(o => o.value),
};
const pw = row.querySelector('.user-password').value;
if (pw) entry.password = pw;
users[uname] = entry;
});
const defOwner = document.querySelector('[data-key="default_owner"]');
if (defOwner) {
if (!_staged['server']) _staged['server'] = {};
_staged['server']['default_owner'] = defOwner.value;
}
_staged['users'] = users;
updatePendingBanner();
flashStaged('users');
}
function addUserRow() {
const list = document.getElementById('users-list');
const row = document.createElement('div');
row.className = 'row expanded';
row.setAttribute('data-new-user', 'true');
row.innerHTML = `
<span class="id">
<input type="text" class="new-username" placeholder="username" required
style="font:600 13px var(--st-mono);color:var(--st-ink);background:var(--st-surface);border:1px solid var(--st-line);border-radius:5px;padding:5px 8px;min-width:140px">
</span>
<span class="facts"><span class="chip level">new user</span></span>
<span class="act"><button class="btn small quiet danger" onclick="this.closest('.row').remove()">✕</button></span>
<div class="editor">
<div class="field"><label>Display name</label><input type="text" class="user-full-name" placeholder="Display Name"></div>
<div class="field"><label>Avatar</label><input type="text" class="user-avatar" placeholder="URL or path"></div>
<div class="field"><label>Password</label><input type="password" class="user-password" placeholder="(required)" autocomplete="new-password"></div>
<div class="field"><span class="lbl">Flags</span><label class="toggle"><input type="checkbox" class="user-admin"> admin</label></div>
<div class="field"><span class="lbl">Alert channels</span>${makeMpickHTML(_allChannels, [], 'user-ch-select')}</div>
<div class="foot"><button class="btn small primary" onclick="stageUsersSection()">Stage changes</button></div>
</div>`;
list.prepend(row);
row.querySelector('.new-username').focus();
}
// ---- OAuth ----
function stageOAuthSection() {
const oauth = {};
document.querySelectorAll('[data-oauth-row]').forEach(row => {
if (row.dataset.deleted === 'true') return;
let name = row.dataset.name;
if (!name) {
const ni = row.querySelector('.oauth-name-input');
if (ni) name = ni.value.trim();
}
if (!name) return;
const entry = {
type: row.querySelector('.oauth-type').value,
url: row.querySelector('.oauth-url').value,
client_id: row.querySelector('.oauth-client-id').value,
};
const label = row.querySelector('.oauth-label').value;
if (label) entry.label = label;
const logo = row.querySelector('.oauth-logo').value;
if (logo) entry.logo = logo;
const secret = row.querySelector('.oauth-secret').value;
if (secret && secret !== '•••') entry.client_secret = secret;
oauth[name] = entry;
});
_staged['oauth'] = oauth;
updatePendingBanner();
flashStaged('oauth');
}
function addOAuthRow() {
const list = document.getElementById('oauth-list');
const row = document.createElement('div');
row.className = 'row expanded';
row.setAttribute('data-oauth-row', 'true');
row.setAttribute('data-name', '');
row.innerHTML = `
<span class="id">
<input type="text" class="oauth-name-input" placeholder="slug (e.g. gitea)"
style="font:600 13px var(--st-mono);color:var(--st-ink);background:var(--st-surface);border:1px solid var(--st-line);border-radius:5px;padding:5px 8px;min-width:140px">
</span>
<span class="facts"><span class="chip level">new provider</span></span>
<span class="act"><button class="btn small quiet danger" onclick="this.closest('.row').remove()">✕</button></span>
<div class="editor">
<div class="field"><label>Type</label><select class="oauth-type">
<option value="gitea">gitea</option><option value="github">github</option><option value="nextcloud">nextcloud</option>
</select></div>
<div class="field"><label>URL</label><input type="text" class="oauth-url" placeholder="https://…"></div>
<div class="field"><label>Client ID</label><input type="text" class="oauth-client-id" placeholder="client_id"></div>
<div class="field"><label>Client secret</label><input type="password" class="oauth-secret" placeholder="client_secret"></div>
<div class="field"><label>Button label</label><input type="text" class="oauth-label" placeholder="Sign in with…"></div>
<div class="field"><label>Logo URL</label><input type="text" class="oauth-logo" placeholder="/path/to/logo.png"></div>
<div class="foot"><button class="btn small primary" onclick="stageOAuthSection()">Stage changes</button></div>
</div>`;
list.prepend(row);
row.querySelector('.oauth-name-input').focus();
}
// ---- Rollback / backups (admin) ----
async function showRollbackModal() {
document.getElementById('rollback-modal').style.display = 'flex';
const el = document.getElementById('rollback-list');
el.textContent = 'Loading…';
try {
const resp = await fetch('/api/0/config/backups');
const data = await resp.json();
const backups = data.backups || [];
if (!backups.length) { el.textContent = 'No backups yet.'; return; }
el.innerHTML = backups.map(b => `
<div class="backup-item">
<span>${escHtml(b.split('/').pop())}</span>
<button class="btn small" onclick="rollbackTo('${escHtml(b)}')">Restore</button>
</div>`).join('');
} catch (e) {
el.textContent = 'Error loading backups: ' + e.message;
}
}
function closeRollbackModal() {
document.getElementById('rollback-modal').style.display = 'none';
}
async function rollbackTo(backupPath) {
if (!confirm('Restore this backup? The current config will be backed up first.')) return;
const resp = await fetch('/api/0/config/rollback', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({backup: backupPath}),
});
if (resp.ok) {
closeRollbackModal();
window.location.reload();
} else {
const err = await resp.json().catch(() => ({}));
alert('Rollback failed: ' + (err.error || resp.statusText));
}
}
// ---- Multi-picker ----
let _mpickPanel = null;
let _mpickTarget = null;
function _initMpickPanel() {
if (_mpickPanel) return;
const p = document.createElement('div');
p.className = 'mpick-panel';
p.style.display = 'none';
p.innerHTML = `
<div class="mpick-panel-header">
<span>Select items</span>
<button style="background:none;border:none;cursor:pointer;color:var(--st-faint);font-size:1.1em;padding:0 2px;line-height:1" onclick="closeMpick()">✕</button>
</div>
<div class="mpick-panel-body">
<div class="mpick-col" id="mpick-avail-col">
<div class="mpick-col-header">Available</div>
<div id="mpick-avail"></div>
</div>
<div class="mpick-col" id="mpick-sel-col">
<div class="mpick-col-header">Selected</div>
<div id="mpick-sel"></div>
</div>
</div>
<div class="mpick-panel-footer">
<button class="btn primary small" onclick="closeMpick()">Done</button>
</div>`;
document.body.appendChild(p);
_mpickPanel = p;
document.addEventListener('mousedown', e => {
if (!_mpickPanel || _mpickPanel.style.display === 'none') return;
if (!_mpickPanel.contains(e.target) && !e.target.closest('.mpick-display')) closeMpick();
}, true);
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && _mpickPanel && _mpickPanel.style.display !== 'none') closeMpick();
});
}
function openMpick(displayEl) {
if (displayEl.closest('.row')?.dataset.deleted === 'true') return;
_initMpickPanel();
_mpickTarget = displayEl.closest('.mpick-wrapper');
_rerenderMpick();
_mpickPanel.style.display = 'block';
const rect = displayEl.getBoundingClientRect();
const pw = _mpickPanel.offsetWidth || 360;
const ph = _mpickPanel.offsetHeight || 280;
let top = rect.bottom + 4;
let left = rect.left;
if (left + pw > window.innerWidth - 8) left = Math.max(8, window.innerWidth - pw - 8);
if (top + ph > window.innerHeight - 8) top = Math.max(8, rect.top - ph - 4);
_mpickPanel.style.top = top + 'px';
_mpickPanel.style.left = left + 'px';
}
function _rerenderMpick() {
const sel = _mpickTarget.querySelector('select');
const allOpts = [...sel.options];
const selVals = new Set([...sel.selectedOptions].map(o => o.value));
const avail = allOpts.filter(o => !selVals.has(o.value));
const chosen = allOpts.filter(o => selVals.has(o.value));
document.getElementById('mpick-avail').innerHTML = avail.length
? avail.map(o => `<div class="mpick-item mpick-item-avail" data-val="${escHtml(o.value)}" onclick="_mpickToggle(this,true)"><span>${escHtml(o.value)}</span><span class="mpick-arrow">+</span></div>`).join('')
: '<div class="mpick-none">All selected</div>';
document.getElementById('mpick-sel').innerHTML = chosen.length
? chosen.map(o => `<div class="mpick-item mpick-item-sel" data-val="${escHtml(o.value)}" onclick="_mpickToggle(this,false)"><span>${escHtml(o.value)}</span><span class="mpick-arrow"></span></div>`).join('')
: '<div class="mpick-none">None selected</div>';
}
function _mpickToggle(itemEl, toSelected) {
const val = itemEl.dataset.val;
const sel = _mpickTarget.querySelector('select');
const opt = [...sel.options].find(o => o.value === val);
if (opt) opt.selected = toSelected;
_updateMpickDisplay(_mpickTarget);
_rerenderMpick();
}
function _updateMpickDisplay(wrapper) {
const sel = wrapper.querySelector('select');
const display = wrapper.querySelector('.mpick-display');
const selected = [...sel.selectedOptions].map(o => o.value);
if (!selected.length) {
display.innerHTML = '<span class="mpick-empty">(none — click to pick)</span>';
display.title = '';
return;
}
display.innerHTML = selected.map(v => `<span class="mpick-tag">${escHtml(v)}</span>`).join('');
display.title = selected.join(', ');
}
function closeMpick() {
if (_mpickPanel) _mpickPanel.style.display = 'none';
_mpickTarget = null;
}
function makeMpickHTML(allItems, selectedItems, cls) {
const selSet = new Set(selectedItems);
const opts = allItems.map(v => `<option value="${escHtml(v)}"${selSet.has(v) ? ' selected' : ''}>${escHtml(v)}</option>`).join('');
const selected = allItems.filter(v => selSet.has(v));
const dispHtml = selected.length
? selected.map(v => `<span class="mpick-tag">${escHtml(v)}</span>`).join('')
: '<span class="mpick-empty">(none — click to pick)</span>';
const title = escHtml(selected.join(', '));
return `<div class="mpick-wrapper"><div class="mpick-display" onclick="openMpick(this)" title="${title}">${dispHtml}</div><select class="${cls}" multiple hidden>${opts}</select></div>`;
}
// ---- Section nav highlight (rail + mobile chips) ----
const _navSections = document.querySelectorAll('section.st');
const _navLinks = document.querySelectorAll('#rail a, #chips-nav a');
const _navObserver = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const id = entry.target.id;
_navLinks.forEach(a => {
a.classList.toggle('active', a.getAttribute('href') === '#' + id);
});
}
});
}, { threshold: 0.2 });
_navSections.forEach(s => _navObserver.observe(s));
document.addEventListener('DOMContentLoaded', () => {
_loadChannelSchemas();
});
</script>
</body>
</html>