Extract the settings redesign's tokens and components into shared static/hbd-ui.css + hbd-ui.js and dedupe settings.html against them. About becomes yaml-key sections with kv rows; Alerts gets stat tiles, chip filters, and alert record rows (same fetch/ack logic); Host Overview keeps its DOM and live-update JS but is re-skinned to the token system with a page toolbar. Live Dashboard intentionally untouched pending its own redesign. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NfPpSpccTWBfZg1FTveyaU
1323 lines
70 KiB
HTML
1323 lines
70 KiB
HTML
<!DOCTYPE html>
|
||
<html>
|
||
{% include 'head.html' %}
|
||
<link rel="stylesheet" href="/static/hbd-ui.css">
|
||
<script src="/static/hbd-ui.js"></script>
|
||
|
||
<style>
|
||
/* static/style.css pins html/body to 100% height with overflow:hidden
|
||
for the dashboard pages — this page scrolls normally */
|
||
html, body { height: auto; overflow: visible; }
|
||
body { background: var(--st-paper); padding: 60px 0 0; }
|
||
|
||
.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); }
|
||
/* ── 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: calc(var(--bars-h, 94px) + 8px); align-self: start; padding-top: 18px; max-height: calc(100vh - var(--bars-h, 94px) - 20px); 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; }
|
||
|
||
/* 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; }
|
||
|
||
/* 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: var(--bars-h, 94px); 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; }
|
||
}
|
||
</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> 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
||
}
|
||
|
||
// ---- 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>
|