Files
heartbeat/hbd/server/templates/settings.html
T
2026-05-12 11:42:54 -04:00

1581 lines
70 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>
html, body { overflow: visible; }
.container {
max-width: none;
}
h1 { color: #333; margin-bottom: 5px; margin-top: 15px; font-size: 1.5em; }
.subtitle { color: #666; margin-bottom: 24px; font-size: 0.9em; }
/* ---- Sidebar + content layout ---- */
.settings-layout {
display: flex;
gap: 24px;
align-items: flex-start;
}
.settings-sidebar {
width: 180px;
flex-shrink: 0;
position: sticky;
top: 60px;
}
.sidebar-nav a {
display: block;
padding: 6px 10px;
border-radius: 4px;
text-decoration: none;
font-size: 0.85em;
color: #444;
margin-bottom: 2px;
transition: background 0.1s, color 0.1s;
}
.sidebar-nav a:hover { background: #e8eaf6; color: #1a237e; }
.sidebar-nav a.active { background: #e3f2fd; color: #0066cc; font-weight: 600; }
.settings-main { flex: 1; min-width: 0; }
/* ---- Section card ---- */
.section {
background: #fff;
border-radius: 8px;
box-shadow: 0 1px 4px rgba(0,0,0,.08);
margin-bottom: 24px;
overflow: hidden;
}
.section-header {
padding: 14px 20px 12px;
border-bottom: 1px solid #eee;
}
.section-title {
font-size: 0.95em;
font-weight: 700;
color: #222;
text-transform: uppercase;
letter-spacing: 0.5px;
margin: 0 0 3px;
}
.section-desc {
font-size: 0.82em;
color: #888;
margin: 0;
}
/* ---- Field rows ---- */
.field-row {
display: flex;
align-items: baseline;
padding: 10px 20px;
border-bottom: 1px solid #f5f5f5;
gap: 16px;
}
.field-row:last-child { border-bottom: none; }
.field-label {
width: 200px;
flex-shrink: 0;
font-size: 0.88em;
font-weight: 500;
color: #444;
}
.field-body { flex: 1; min-width: 0; }
.field-value {
font-size: 0.9em;
color: #222;
word-break: break-all;
}
.field-desc {
font-size: 0.78em;
color: #999;
margin-top: 2px;
}
/* ---- Value type renderers ---- */
.val-boolean {
display: inline-block;
padding: 2px 9px;
border-radius: 10px;
font-size: 0.8em;
font-weight: 600;
}
.val-boolean.on { background: #e8f5e9; color: #2e7d32; }
.val-boolean.off { background: #fce4ec; color: #c62828; }
.val-masked {
font-family: monospace;
color: #bbb;
letter-spacing: 2px;
}
.val-list { display: flex; flex-wrap: wrap; gap: 5px; }
.val-tag {
display: inline-block;
padding: 2px 9px;
background: #e8eaf6;
color: #283593;
border-radius: 10px;
font-size: 0.8em;
}
.val-empty { color: #ccc; font-style: italic; font-size: 0.88em; }
/* ---- Users table ---- */
.mini-table {
width: 100%;
border-collapse: collapse;
font-size: 0.875em;
}
.mini-table th {
background: #f5f5f5;
padding: 7px 12px;
text-align: left;
font-weight: 600;
color: #555;
font-size: 0.82em;
text-transform: uppercase;
letter-spacing: 0.4px;
border-bottom: 1px solid #e0e0e0;
}
.mini-table td {
padding: 7px 12px;
border-bottom: 1px solid #f0f0f0;
color: #333;
vertical-align: middle;
}
.mini-table tbody tr:last-child td { border-bottom: none; }
.mini-table tbody tr:hover { background: #fafafa; }
.badge {
display: inline-block;
padding: 1px 8px;
border-radius: 10px;
font-size: 0.75em;
font-weight: 600;
}
.badge-admin { background: #e8f0fe; color: #1a73e8; }
.badge-user { background: #f1f3f4; color: #666; }
/* ---- Notification channels ---- */
.channel-card {
border: 1px solid #e8eaf6;
border-radius: 6px;
margin: 12px 20px;
overflow: hidden;
}
.channel-header {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 14px;
background: #f8f9ff;
border-bottom: 1px solid #e8eaf6;
}
.channel-name-text { font-weight: 600; font-size: 0.9em; color: #222; }
.ch-type-badge {
padding: 2px 8px;
border-radius: 8px;
font-size: 0.75em;
font-weight: 600;
background: #e8eaf6;
color: #3949ab;
}
.channel-fields { padding: 6px 0; }
.channel-field {
display: flex;
padding: 5px 14px;
font-size: 0.85em;
border-bottom: 1px solid #f5f5f5;
gap: 12px;
}
.channel-field:last-child { border-bottom: none; }
.channel-field-label { width: 130px; flex-shrink: 0; color: #777; }
.channel-field-value { color: #333; word-break: break-all; }
/* ---- Channel management (form-based section) ---- */
.channel-header-actions { margin-left: auto; display: flex; gap: 6px; }
.ch-owner-badge { padding: 2px 7px; border-radius: 8px; font-size: .72em; font-weight: 600; background: #e8f5e9; color: #2e7d32; }
.ch-private-badge { padding: 2px 7px; border-radius: 8px; font-size: .72em; font-weight: 600; background: #fce4ec; color: #c62828; }
.ch-level-badge { padding: 2px 7px; border-radius: 8px; font-size: .72em; font-weight: 600; background: #fff3e0; color: #e65100; }
.channel-grid { padding: 12px 20px 0; }
.channel-add-bar { display: flex; justify-content: flex-end; padding: 10px 20px; border-top: 1px solid #f0f0f0; }
/* Channel modal */
.ch-modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,.4);
display: flex; align-items: center; justify-content: center; z-index: 1001;
}
.ch-modal-box {
background: #fff; border-radius: 8px; padding: 24px;
min-width: 360px; max-width: 520px; width: 95%;
box-shadow: 0 8px 32px rgba(0,0,0,.2);
}
.ch-modal-box h3 { margin: 0 0 16px; font-size: 1em; }
.ch-form-row { margin-bottom: 12px; }
.ch-form-row label { display: block; font-size: .83em; font-weight: 600; color: #555; margin-bottom: 3px; }
.ch-form-row input[type=text], .ch-form-row input[type=password], .ch-form-row select {
width: 100%; border: 1px solid #ccc; border-radius: 4px; padding: 5px 8px;
font-size: .88em; box-sizing: border-box; font-family: inherit;
}
.ch-form-row input:focus, .ch-form-row select:focus { border-color: #0066cc; outline: none; }
.ch-form-divider { font-size: .78em; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; color: #888; margin: 14px 0 8px; border-top: 1px solid #eee; padding-top: 10px; }
.ch-modal-footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; }
.ch-status { font-size: .83em; margin-top: 8px; }
/* ---- Hosts table ---- */
/* ---- Mobile: collapsible sidebar ---- */
.sidebar-toggle {
display: none;
width: 100%;
padding: 8px 12px;
background: #e8eaf6;
border: none;
border-radius: 6px;
font-size: 0.9em;
font-weight: 600;
color: #283593;
cursor: pointer;
text-align: left;
margin-bottom: 16px;
}
.sidebar-toggle::after { content: ' ▾'; float: right; }
.sidebar-toggle.open::after { content: ' ▴'; }
@media (max-width: 640px) {
.sidebar-toggle { display: block; }
.settings-layout { flex-direction: column; gap: 0; }
.settings-sidebar {
width: 100%;
position: static;
margin-bottom: 0;
}
.sidebar-nav {
display: none;
background: white;
border-radius: 6px;
box-shadow: 0 1px 4px rgba(0,0,0,.1);
margin-bottom: 16px;
padding: 4px 0;
}
.sidebar-nav.open { display: block; }
.sidebar-nav a { padding: 10px 16px; font-size: 1em; }
.field-row { flex-direction: column; gap: 4px; }
.field-label { width: 100%; font-size: 0.82em; color: #888; }
}
.host-bool { text-align: center; }
.dot-yes { color: #2e7d32; font-size: 1.1em; }
.dot-no { color: #ddd; font-size: 1.1em; }
/* ---- Threshold configurations ---- */
.thresh-config { margin: 12px 20px 20px; }
.thresh-config-name {
font-weight: 600; font-size: 0.9em; color: #1a237e;
margin-bottom: 6px;
}
.mini-table .warn { color: #e65100; font-weight: 600; }
.mini-table .crit { color: #b71c1c; font-weight: 600; }
.mini-table .dim { color: #aaa; }
.mini-table .metric-path { font-family: monospace; font-size: 0.88em; }
/* ---- Editable inputs ---- */
.field-input {
border: 1px solid #ccc;
border-radius: 4px;
padding: 4px 8px;
font-size: 0.88em;
box-sizing: border-box;
font-family: inherit;
}
.field-input:focus { border-color: #0066cc; outline: none; box-shadow: 0 0 0 2px rgba(0,102,204,.15); }
/* ---- Section footer (Stage Changes button) ---- */
.section-footer {
padding: 10px 20px;
border-top: 1px solid #f0f0f0;
display: flex;
justify-content: flex-end;
}
/* ---- Pending changes banner ---- */
.pending-banner {
position: sticky;
top: 8px;
z-index: 100;
background: #fffbe6;
border: 1px solid #e8c840;
border-radius: 6px;
padding: 10px 16px;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 0.87em;
margin-bottom: 16px;
box-shadow: 0 2px 8px rgba(0,0,0,.08);
}
.pending-banner .pending-msg { color: #7a6000; }
.pending-banner .pending-actions { display: flex; gap: 8px; }
/* ---- YAML editor ---- */
.yaml-editor {
width: 100%;
font-family: monospace;
font-size: 0.83em;
border: 1px solid #ccc;
border-radius: 4px;
padding: 8px;
box-sizing: border-box;
background: #fafafa;
resize: vertical;
min-height: 140px;
}
.yaml-editor:focus { border-color: #0066cc; outline: none; }
/* ---- Button styles ---- */
.btn { border: none; border-radius: 4px; padding: 5px 12px; font-size: 0.85em; cursor: pointer; }
.btn-primary { background: #0066cc; color: #fff; }
.btn-primary:hover { background: #0055aa; }
.btn-success { background: #2a7a2a; color: #fff; }
.btn-success:hover { background: #226622; }
.btn-secondary { background: #888; color: #fff; }
.btn-secondary:hover { background: #666; }
.btn-danger { background: transparent; color: #c62828; border: 1px solid #e0e0e0; border-radius: 4px; padding: 2px 7px; font-size: 0.82em; cursor: pointer; }
.btn-danger:hover { background: #fce4ec; }
/* ---- CRUD table for users / oauth ---- */
.crud-table { width: 100%; border-collapse: collapse; font-size: 0.83em; }
.crud-table th { background: #f5f5f5; padding: 6px 10px; text-align: left; font-weight: 600; color: #555; font-size: .78em; text-transform: uppercase; letter-spacing: .03em; border-bottom: 1px solid #e0e0e0; }
.crud-table td { padding: 6px 10px; border-bottom: 1px solid #f0f0f0; vertical-align: top; }
.crud-table tbody tr:last-child td { border-bottom: none; }
.crud-table .field-input { width: 100%; }
/* ---- Rollback modal ---- */
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,.4);
display: flex; align-items: center; justify-content: center; z-index: 1000;
}
.modal-box {
background: #fff; border-radius: 8px; padding: 24px;
min-width: 340px; max-width: 520px; width: 90%;
box-shadow: 0 8px 32px rgba(0,0,0,.18);
}
.modal-box h3 { margin: 0 0 12px; font-size: 1em; }
.backup-row { display: flex; align-items: center; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid #f0f0f0; font-size: .87em; }
.backup-row:last-child { border-bottom: none; }
/* ---- Threshold config cards ---- */
.thresh-cfg-card {
margin-bottom: 14px;
border: 1px solid #e0e0e0;
border-radius: 6px;
overflow: hidden;
}
.thresh-cfg-header {
background: #f5f5f5;
padding: 8px 14px;
display: flex;
align-items: center;
gap: 10px;
border-bottom: 1px solid #e0e0e0;
}
.thresh-cfg-name-label {
font-weight: 600;
font-size: 0.9em;
color: #1a237e;
}
.thresh-metric-table { width: 100%; }
.thresh-metric-table th { white-space: nowrap; }
/* ---- Multi-picker ---- */
.mpick-wrapper { display: block; }
.mpick-display {
display: flex; align-items: center; gap: 4px; flex-wrap: nowrap;
cursor: pointer; padding: 3px 7px; border: 1px solid #ccc; border-radius: 4px;
min-height: 26px; min-width: 80px; width: 100%; box-sizing: border-box;
background: #fff; user-select: none; overflow: hidden;
}
.mpick-display:hover { border-color: #0066cc; background: #f8fbff; }
.mpick-tag {
padding: 1px 6px; background: #e8eaf6; color: #283593;
border-radius: 10px; font-size: 0.82em; white-space: nowrap; flex-shrink: 0;
}
.mpick-more { color: #888; font-size: 0.82em; white-space: nowrap; flex-shrink: 0; }
.mpick-empty { color: #bbb; font-style: italic; font-size: 0.82em; }
.mpick-panel {
position: fixed; background: #fff; border: 1px solid #d0d0d0;
border-radius: 6px; box-shadow: 0 4px 20px rgba(0,0,0,.18);
z-index: 2000; width: 360px; overflow: hidden;
}
.mpick-panel-header {
padding: 6px 12px; font-size: 0.78em; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.04em; color: #555;
border-bottom: 1px solid #eee; display: flex;
justify-content: space-between; align-items: center; background: #f5f5f5;
}
.mpick-panel-body { display: flex; }
.mpick-col { flex: 1; min-width: 0; max-height: 200px; overflow-y: auto; }
.mpick-col-header {
padding: 4px 10px; font-size: 0.72em; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.04em; color: #888;
border-bottom: 1px solid #f0f0f0; background: #fafafa;
position: sticky; top: 0; z-index: 1;
}
.mpick-col:first-child { border-right: 1px solid #eee; }
.mpick-item {
padding: 5px 10px; font-size: 0.85em; cursor: pointer;
display: flex; align-items: center; justify-content: space-between;
border-bottom: 1px solid #f8f8f8; gap: 4px;
}
.mpick-item:last-child { border-bottom: none; }
.mpick-item-avail:hover { background: #e8f5e9; }
.mpick-item-sel:hover { background: #fce4ec; }
.mpick-arrow { font-size: 1.1em; opacity: 0.4; flex-shrink: 0; line-height: 1; }
.mpick-item:hover .mpick-arrow { opacity: 1; }
.mpick-item-avail .mpick-arrow { color: #2a7a2a; }
.mpick-item-sel .mpick-arrow { color: #c62828; }
.mpick-panel-footer {
padding: 6px 10px; border-top: 1px solid #eee;
display: flex; justify-content: flex-end; background: #f8f8f8;
}
.mpick-none { padding: 10px; font-size: .82em; color: #aaa; text-align: center; }
</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[:2] -%}<span class="mpick-tag">{{ v | e }}</span>{%- endfor -%}{%- if sel|length > 2 %}<span class="mpick-more">+{{ sel|length - 2 }}</span>{%- endif -%}{%- else -%}<span class="mpick-empty">(none)</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' %}
<div class="container">
<h1>Settings</h1>
<p class="subtitle">Edit server configuration — changes are staged until you publish them to <code>.hb.yaml</code>.</p>
<!-- Pending changes banner (hidden until something is staged) -->
<div id="pending-banner" class="pending-banner" style="display:none">
<span class="pending-msg"><strong id="pending-count">0</strong> section(s) with pending changes — not yet saved to .hb.yaml</span>
<span class="pending-actions">
<button class="btn btn-secondary" onclick="discardAll()">Discard all</button>
<button class="btn btn-success" onclick="publishAll()">Publish to .hb.yaml</button>
</span>
</div>
<!-- Rollback modal -->
<div id="rollback-modal" class="modal-overlay" style="display:none" 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 btn-secondary" onclick="closeRollbackModal()">Close</button>
</div>
</div>
</div>
<!-- Channel add/edit modal -->
<div id="ch-modal" class="ch-modal-overlay" style="display:none" onclick="if(event.target===this)closeChannelModal()">
<div class="ch-modal-box">
<h3 id="ch-modal-title">Add Notification Channel</h3>
<div class="ch-form-row">
<label>Channel name</label>
<input type="text" id="ch-name" placeholder="e.g. pushover_ops" autocomplete="off">
</div>
<div class="ch-form-row">
<label>Type</label>
<select id="ch-type" onchange="onChTypeChange()">
<option value="">— select —</option>
</select>
</div>
<div id="ch-type-fields"></div>
<div class="ch-form-divider">Options</div>
<div class="ch-form-row">
<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>
<div class="ch-form-row">
<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
<input type="checkbox" id="ch-private"> Private — visible only to you
</label>
</div>
<div id="ch-modal-status" class="ch-status"></div>
<div class="ch-modal-footer">
<button class="btn btn-secondary" onclick="closeChannelModal()">Cancel</button>
<button class="btn btn-primary" onclick="saveChannel()">Save</button>
</div>
</div>
</div>
<div class="settings-layout">
<!-- Sidebar navigation -->
<nav class="settings-sidebar">
<button class="sidebar-toggle" id="sidebar-toggle" aria-expanded="false">Sections</button>
<div class="sidebar-nav" id="sidebar-nav">
{% for section in sections %}
<a href="#{{ section.id }}" onclick="closeSidebar()">{{ section.title }}</a>
{% endfor %}
<hr style="margin: 8px 0; border: none; border-top: 1px solid #e8e8e8;">
<a href="#" onclick="showRollbackModal(); return false;" style="color:#888;font-size:.82em">View backups / rollback</a>
</div>
</nav>
<!-- Main content -->
<div class="settings-main">
{% for section in sections %}
<div class="section" id="{{ section.id }}">
<div class="section-header">
<p class="section-title">{{ section.title }}</p>
{% if section.description %}<p class="section-desc">{{ section.description }}</p>{% endif %}
</div>
{# ---- Users CRUD ---- #}
{% if section.id == 'users' %}
<div style="padding: 12px 20px 0">
{% for f in section.fields %}
{% if f.editable %}
<div class="field-row" style="border-bottom: 1px solid #eee; margin-bottom: 8px">
<div class="field-label" style="font-size:.85em;color:#555">{{ f.label }}</div>
<div class="field-body">
<input type="text" class="field-input"
data-key="{{ f.key }}" data-section="{{ section.api_section }}"
value="{{ f.raw if f.raw is not none else '' }}">
{% if f.description %}<p class="field-desc">{{ f.description }}</p>{% endif %}
</div>
</div>
{% endif %}
{% endfor %}
</div>
<div style="overflow-x:auto;padding:0 20px">
<table class="crud-table" id="users-editor">
<thead><tr>
<th>Username</th><th>Display name</th><th>Avatar URL</th>
<th>Admin</th><th>Channels</th><th style="min-width:110px">New password</th><th></th>
</tr></thead>
<tbody id="users-tbody">
{% for u in section.users %}
<tr data-user-row="true" data-username="{{ u.username | e }}">
<td style="font-family:monospace;font-size:.9em">{{ u.username | e }}</td>
<td><input class="field-input user-full-name" value="{{ u.full_name | e }}"></td>
<td><input class="field-input user-avatar" value="{{ u.avatar | e }}"></td>
<td style="text-align:center"><input type="checkbox" class="user-admin" {% if u.admin %}checked{% endif %}></td>
<td style="min-width:140px">
{{ mpick(all_channel_names, u.notification_channels, 'user-ch-select') }}
</td>
<td><input type="password" class="field-input user-password" placeholder="(leave blank to keep)"></td>
<td><button class="btn-danger" onclick="toggleDeleteRow(this)"></button></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="section-footer">
<button class="btn btn-secondary" onclick="addUserRow()" style="margin-right:auto">+ Add user</button>
<button class="btn btn-primary" onclick="stageUsersSection()">Stage changes</button>
</div>
{# ---- OAuth CRUD ---- #}
{% elif section.id == 'oauth' %}
<div style="overflow-x:auto;padding:0 20px">
<table class="crud-table" id="oauth-editor">
<thead><tr>
<th>Name (slug)</th><th>Type</th><th>URL</th><th>Client ID</th>
<th>Client Secret</th><th>Label</th><th>Logo URL</th><th></th>
</tr></thead>
<tbody id="oauth-tbody">
{% for p in section.providers %}
<tr data-oauth-row="true" data-name="{{ p.name | e }}">
<td style="font-family:monospace;font-size:.9em">{{ p.name | e }}</td>
<td>
<select class="field-input oauth-type">
{% for t in ['gitea', 'github', 'nextcloud'] %}
<option value="{{ t }}" {% if p.type == t %}selected{% endif %}>{{ t }}</option>
{% endfor %}
</select>
</td>
<td><input class="field-input oauth-url" value="{{ p.url | e }}"></td>
<td><input class="field-input oauth-client-id" value="{{ p.client_id | e }}"></td>
<td><input type="password" class="field-input oauth-secret" value="{{ p.client_secret | e }}"></td>
<td><input class="field-input oauth-label" value="{{ p.label | e }}"></td>
<td><input class="field-input oauth-logo" value="{{ p.logo | e }}"></td>
<td><button class="btn-danger" onclick="toggleDeleteRow(this)"></button></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="section-footer">
<button class="btn btn-secondary" onclick="addOAuthRow()" style="margin-right:auto">+ Add provider</button>
<button class="btn btn-primary" onclick="stageOAuthSection()">Stage changes</button>
</div>
{# ---- Hosts CRUD table ---- #}
{% elif section.section_mode == 'hosts' %}
<div style="overflow-x:auto;padding:0 20px">
<table class="crud-table" id="hosts-editor">
<thead><tr>
<th>Hostname</th>
<th>Watch</th>
<th>DynDNS</th>
<th>Owner</th>
<th style="min-width:110px">Managers</th>
<th style="min-width:110px">Monitors</th>
<th style="min-width:110px">Threshold config</th>
<th style="min-width:110px">Channels</th>
<th></th>
</tr></thead>
<tbody id="hosts-tbody">
{% for h in section.hosts %}
<tr data-host-row="true" data-hostname="{{ h.name | e }}">
<td style="font-family:monospace;font-size:.9em;white-space:nowrap">{{ h.name | e }}</td>
<td style="text-align:center"><input type="checkbox" class="host-watch" {% if h.watch %}checked{% endif %}></td>
<td style="text-align:center"><input type="checkbox" class="host-dyndns" {% if h.dyndns %}checked{% endif %}></td>
<td><input class="field-input host-owner" value="{{ h.owner | e }}" placeholder="(none)" style="min-width:90px"></td>
<td>{{ mpick(all_usernames, h.managers, 'host-managers') }}</td>
<td>{{ mpick(all_usernames, h.monitors, 'host-monitors') }}</td>
<td>{{ mpick(all_threshold_configs, h.threshold_configs, 'host-tc') }}</td>
<td>{{ mpick(all_channel_names, h.notification_channels, 'host-channels') }}</td>
<td><button class="btn-danger" onclick="toggleDeleteRow(this)"></button></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="section-footer">
<button class="btn btn-secondary" onclick="addHostRow()" style="margin-right:auto">+ Add host</button>
<button class="btn btn-primary" onclick="stageHostsSection()">Stage changes</button>
</div>
{# ---- Notification channels (form-based, live CRUD) ---- #}
{% elif section.section_mode == 'channels' %}
{% for f in section.fields %}
<div class="field-row" style="border-bottom:1px solid #f0f0f0">
<div class="field-label">{{ f.label }}</div>
<div class="field-body">
{% if f.type == 'list' %}
{% if f.value %}<span class="val-list">{% for item in f.value %}<span class="val-tag">{{ item }}</span>{% endfor %}</span>
{% else %}<span class="val-empty">None</span>{% endif %}
{% else %}
<div class="field-value">{{ f.value if f.value is not none else '' }}</div>
{% endif %}
{% if f.description %}<p class="field-desc">{{ f.description }}</p>{% endif %}
</div>
</div>
{% endfor %}
<div class="channel-grid" id="channel-cards">
{% for ch in section.channels %}
<div class="channel-card" id="chcard-{{ ch.name | e }}">
<div class="channel-header">
<span class="channel-name-text">{{ ch.name | e }}</span>
<span class="ch-type-badge">{{ ch.type_label | e }}</span>
{% if ch.min_level and ch.min_level != 'WARNING' %}<span class="ch-level-badge">{{ ch.min_level | e }}+</span>{% endif %}
{% if ch.private %}<span class="ch-private-badge">private</span>{% endif %}
{% if ch.owner %}<span class="ch-owner-badge">{{ ch.owner | e }}</span>{% endif %}
<span class="channel-header-actions">
<button class="btn btn-secondary" style="font-size:.78em;padding:2px 8px" onclick="openChannelModal('{{ ch.name | e }}')">Edit</button>
<button class="btn-danger" onclick="deleteChannel('{{ ch.name | e }}')"></button>
</span>
</div>
<div class="channel-fields">
{% for f in ch.fields %}
<div class="channel-field">
<span class="channel-field-label">{{ f.label }}</span>
<span class="channel-field-value">{% if f.sensitive %}<span class="val-masked">•••</span>{% elif f.value %}{{ f.value | e }}{% else %}<span style="color:#ccc"></span>{% endif %}</span>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
{% if not section.channels %}<p style="color:#aaa;font-size:.88em;padding:12px 0">No channels configured yet.</p>{% endif %}
</div>
<div class="channel-add-bar">
<button class="btn btn-primary" onclick="openChannelModal()">+ Add channel</button>
</div>
{# ---- Threshold configurations (form-based) ---- #}
{% elif section.section_mode == 'thresholds' %}
{% for f in section.fields %}
<div class="field-row" style="border-bottom:1px solid #f0f0f0">
<div class="field-label">{{ f.label }}</div>
<div class="field-body">
<input type="text" class="field-input thresh-default-config"
value="{{ f.raw if f.raw is not none else '' }}"
placeholder="default"
list="thresh-cfg-names-{{ section.id }}">
<datalist id="thresh-cfg-names-{{ section.id }}">
{% for tc in section.threshold_configs %}<option value="{{ tc.name | e }}">{% endfor %}
</datalist>
{% if f.description %}<p class="field-desc">{{ f.description }}</p>{% endif %}
</div>
</div>
{% endfor %}
<div id="thresh-cfgs-{{ section.id }}" style="padding:8px 20px 0">
{% for tc in section.threshold_configs %}
<div class="thresh-cfg-card" data-config-name="{{ tc.name | e }}">
<div class="thresh-cfg-header">
<span class="thresh-cfg-name-label">{{ tc.name | e }}</span>
{% if tc.name != 'default' %}
<button class="btn-danger" style="margin-left:auto" onclick="deleteThresholdConfigCard(this)">✕ Delete</button>
{% endif %}
</div>
<div style="overflow-x:auto">
<table class="crud-table thresh-metric-table">
<thead><tr>
<th>Metric path</th><th>Op</th>
<th>Warning</th><th>Critical</th>
<th>Hysteresis</th><th>Count</th>
<th style="max-width:160px">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="font-family:monospace;font-size:.85em;white-space:nowrap">{{ m.metric | e }}</td>
<td>
<select class="field-input thresh-op" style="width:80px" 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="field-input thresh-warn" step="any" style="width:80px"
value="{{ m.warning if m.warning is not none else '' }}"
{% if m.operator == 'nagios' %}disabled{% endif %}></td>
<td><input type="number" class="field-input thresh-crit" step="any" style="width:80px"
value="{{ m.critical if m.critical is not none else '' }}"
{% if m.operator == 'nagios' %}disabled{% endif %}></td>
<td><input type="number" class="field-input thresh-hyst" step="any" style="width:72px"
value="{{ m.hysteresis if m.hysteresis is not none else 0.02 }}"></td>
<td><input type="number" class="field-input thresh-count" step="1" min="1" style="width:52px"
value="{{ m.count if m.count is not none else 1 }}"></td>
<td><input type="text" class="field-input thresh-display" style="width:150px"
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-danger" onclick="this.closest('tr').remove()"></button></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div style="padding:6px 14px 8px;border-top:1px solid #f0f0f0">
<button class="btn btn-secondary" style="font-size:.8em;padding:3px 10px"
onclick="addThresholdMetricRow(this.closest('.thresh-cfg-card').querySelector('tbody'))">+ Add metric</button>
</div>
</div>
{% endfor %}
</div>
<div class="section-footer" style="justify-content:space-between">
<button class="btn btn-secondary" onclick="addThresholdConfigCard('thresh-cfgs-{{ section.id }}')">+ Add config</button>
<button class="btn btn-primary" onclick="stageThresholdsSection('{{ section.id }}')">Stage changes</button>
</div>
{# ---- YAML editor section ---- #}
{% elif section.section_mode == 'yaml' %}
<div style="padding: 12px 20px">
<textarea id="yaml-{{ section.id }}" class="yaml-editor" rows="12"></textarea>
<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:6px">
<button class="btn btn-secondary" onclick="loadYamlSection('{{ section.api_section }}', 'yaml-{{ section.id }}')">Reload from file</button>
<button class="btn btn-primary" onclick="stageYamlSection('{{ section.api_section }}', 'yaml-{{ section.id }}')">Stage changes</button>
</div>
</div>
{# ---- Form section (generic fields) ---- #}
{% else %}
{% for f in section.fields %}
<div class="field-row">
<div class="field-label">{{ f.label }}</div>
<div class="field-body">
{% if f.editable and section.api_section %}
{% if f.type == 'boolean' %}
<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
<input type="checkbox" class="user-admin"
data-key="{{ f.key }}" data-section="{{ section.api_section }}"
{% if f.value %}checked{% endif %}>
<span style="font-size:.88em">{{ 'Enabled' if f.value else 'Disabled' }}</span>
</label>
{% elif f.type in ('number', 'port', 'size') %}
<input type="number" class="field-input"
data-key="{{ f.key }}" data-type="{{ f.type }}" data-section="{{ section.api_section }}"
value="{{ f.raw if f.raw is not none else '' }}">
{% else %}
<input type="text" class="field-input"
data-key="{{ f.key }}" data-section="{{ section.api_section }}"
value="{{ f.raw if f.raw is not none else '' }}">
{% endif %}
{% if f.description %}<p class="field-desc">{{ f.description }}</p>{% endif %}
{% elif f.sensitive %}
<div class="field-value"><span class="val-masked">••••••••</span></div>
{% elif f.type == 'boolean' %}
<div class="field-value">
<span class="val-boolean {{ 'on' if f.value else 'off' }}">{{ 'Enabled' if f.value else 'Disabled' }}</span>
</div>
{% elif f.type == 'list' %}
<div class="field-value">
{% if f.value %}<span class="val-list">{% for item in f.value %}<span class="val-tag">{{ item }}</span>{% endfor %}</span>
{% else %}<span class="val-empty">None</span>{% endif %}
</div>
{% else %}
<div class="field-value">{{ f.value if f.value is not none else '' }}</div>
{% endif %}
{% if f.description and not f.editable %}<p class="field-desc">{{ f.description }}</p>{% endif %}
</div>
</div>
{% endfor %}
{% if section.api_section %}
<div class="section-footer">
<button class="btn btn-primary" onclick="stageFormSection('{{ section.id }}', '{{ section.api_section }}')">Stage changes</button>
</div>
{% endif %}
{% endif %}
</div>
{% endfor %}
</div>{# /settings-main #}
</div>{# /settings-layout #}
</div>{# /container #}
<script>
// ---- Lookup arrays for CRUD rows ----
const _allChannels = {{ all_channel_names | tojson }};
const _allUsers = {{ all_usernames | tojson }};
const _allThresholdConfigs = {{ all_threshold_configs | tojson }};
// ---- Channel CRUD ----
let _channelSchemas = {};
let _chEditName = null; // null = create mode, string = edit mode
async function _loadChannelSchemas() {
try {
const r = await fetch('/api/0/notification_channel_types');
_channelSchemas = await r.json();
const sel = document.getElementById('ch-type');
if (!sel) return;
Object.entries(_channelSchemas).forEach(([k, v]) => {
const opt = document.createElement('option');
opt.value = k; opt.textContent = v.label;
sel.appendChild(opt);
});
} catch(e) { console.warn('Could not load channel schemas', e); }
}
function onChTypeChange() {
const type = document.getElementById('ch-type').value;
const container = document.getElementById('ch-type-fields');
container.innerHTML = '';
if (!type || !_channelSchemas[type]) return;
const divider = document.createElement('div');
divider.className = 'ch-form-divider';
divider.textContent = _channelSchemas[type].label + ' settings';
container.appendChild(divider);
(_channelSchemas[type].fields || []).forEach(sf => {
const row = document.createElement('div');
row.className = 'ch-form-row';
const lbl = document.createElement('label');
lbl.textContent = sf.label + (sf.required ? ' *' : '');
const inp = document.createElement(sf.type === 'secret' ? 'input' : 'input');
inp.type = sf.type === 'secret' ? 'password' : 'text';
inp.id = 'chf-' + sf.key;
inp.placeholder = sf.required ? '(required)' : '(optional)';
inp.autocomplete = 'off';
row.appendChild(lbl);
row.appendChild(inp);
container.appendChild(row);
});
}
async function openChannelModal(name) {
_chEditName = name || null;
document.getElementById('ch-modal-status').textContent = '';
document.getElementById('ch-modal-title').textContent = name ? 'Edit Channel' : 'Add Notification Channel';
document.getElementById('ch-name').value = name || '';
document.getElementById('ch-name').disabled = !!name;
document.getElementById('ch-type').value = '';
document.getElementById('ch-type-fields').innerHTML = '';
document.getElementById('ch-min-level').value = 'WARNING';
document.getElementById('ch-private').checked = false;
if (name) {
// Load existing channel data via API
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';
document.getElementById('ch-private').checked = ch.private || false;
(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); }
}
document.getElementById('ch-modal').style.display = 'flex';
}
function closeChannelModal() {
document.getElementById('ch-modal').style.display = 'none';
}
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 isPrivate = document.getElementById('ch-private').checked;
const statusEl = document.getElementById('ch-modal-status');
statusEl.textContent = '';
if (!name) { statusEl.textContent = 'Channel name is required.'; statusEl.style.color = '#c62828'; return; }
if (!type) { statusEl.textContent = 'Please select a type.'; statusEl.style.color = '#c62828'; return; }
const body = { name, type, min_level: minLevel, private: isPrivate };
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) {
closeChannelModal();
window.location.reload();
} else {
const err = await r.json().catch(() => ({}));
statusEl.textContent = err.error || 'Error saving channel.';
statusEl.style.color = '#c62828';
}
} catch(e) {
statusEl.textContent = 'Network error: ' + e.message;
statusEl.style.color = '#c62828';
}
}
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); }
}
// ---- 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 = '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 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 {
_staged[apiSection][key] = el.value;
}
});
updatePendingBanner();
flashStaged(sectionId);
}
function stageYamlSection(apiSection, textareaId) {
_staged[apiSection] = document.getElementById(textareaId).value;
updatePendingBanner();
}
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 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 stageHostsSection() {
function rowToEntry(row) {
const entry = {
watch: row.querySelector('.host-watch').checked,
dyndns: row.querySelector('.host-dyndns').checked,
};
const owner = row.querySelector('.host-owner').value.trim();
if (owner) entry.owner = owner;
const managers = [...(row.querySelector('.host-managers')?.selectedOptions || [])].map(o => o.value);
if (managers.length) entry.managers = managers;
const monitors = [...(row.querySelector('.host-monitors')?.selectedOptions || [])].map(o => o.value);
if (monitors.length) entry.monitors = monitors;
const 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 tbody = document.getElementById('hosts-tbody');
const row = document.createElement('tr');
row.setAttribute('data-new-host', 'true');
row.innerHTML = `
<td><input class="field-input new-hostname" placeholder="hostname" required style="min-width:120px"></td>
<td style="text-align:center"><input type="checkbox" class="host-watch" checked></td>
<td style="text-align:center"><input type="checkbox" class="host-dyndns"></td>
<td><input class="field-input host-owner" placeholder="(none)" style="min-width:90px"></td>
<td>${makeMpickHTML(_allUsers, [], 'host-managers')}</td>
<td>${makeMpickHTML(_allUsers, [], 'host-monitors')}</td>
<td>${makeMpickHTML(_allThresholdConfigs, [], 'host-tc')}</td>
<td>${makeMpickHTML(_allChannels, [], 'host-channels')}</td>
<td><button class="btn-danger" onclick="this.closest('tr').remove()">✕</button></td>`;
tbody.appendChild(row);
}
async function publishAll() {
const btn = document.querySelector('[onclick="publishAll()"]');
btn.disabled = true;
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 to .hb.yaml';
}
} catch (e) {
alert('Network error: ' + e.message);
btn.disabled = false;
btn.textContent = 'Publish to .hb.yaml';
}
}
function discardAll() {
Object.keys(_staged).forEach(k => delete _staged[k]);
localStorage.removeItem('hbd_pending_config');
updatePendingBanner();
window.location.reload();
}
async function loadYamlSection(apiSection, textareaId) {
const ta = document.getElementById(textareaId);
ta.value = 'Loading…';
try {
const resp = await fetch('/api/0/config/section/' + apiSection);
const data = await resp.json();
ta.value = data.yaml || '';
} catch (e) {
ta.value = '# Error loading: ' + e.message;
}
}
document.addEventListener('DOMContentLoaded', () => {
_loadChannelSchemas();
document.querySelectorAll('textarea[id^="yaml-"]').forEach(ta => {
const sectionId = ta.id.replace('yaml-', '');
const section = document.getElementById(sectionId);
if (section) {
const btn = section.querySelector('[onclick^="stageYamlSection"]');
if (btn) {
const m = btn.getAttribute('onclick').match(/stageYamlSection\('([^']+)'/);
if (m) loadYamlSection(m[1], ta.id);
}
}
});
});
function toggleDeleteRow(btn) {
const row = btn.closest('tr');
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 ? '✕' : '↩';
}
function addUserRow() {
const tbody = document.getElementById('users-tbody');
const row = document.createElement('tr');
row.setAttribute('data-new-user', 'true');
row.innerHTML = `
<td><input class="field-input new-username" placeholder="username" required></td>
<td><input class="field-input user-full-name" placeholder="Display Name"></td>
<td><input class="field-input user-avatar" placeholder="Avatar URL or path"></td>
<td style="text-align:center"><input type="checkbox" class="user-admin"></td>
<td>${makeMpickHTML(_allChannels, [], 'user-ch-select')}</td>
<td><input type="password" class="field-input user-password" placeholder="(required)"></td>
<td><button class="btn-danger" onclick="this.closest('tr').remove()">✕</button></td>`;
tbody.appendChild(row);
}
function addOAuthRow() {
const tbody = document.getElementById('oauth-tbody');
const row = document.createElement('tr');
row.setAttribute('data-oauth-row', 'true');
row.setAttribute('data-name', '');
row.innerHTML = `
<td><input class="field-input oauth-name-input" placeholder="slug (e.g. gitea)"></td>
<td><select class="field-input oauth-type">
<option value="gitea">gitea</option>
<option value="github">github</option>
<option value="nextcloud">nextcloud</option>
</select></td>
<td><input class="field-input oauth-url" placeholder="https://…"></td>
<td><input class="field-input oauth-client-id" placeholder="client_id"></td>
<td><input type="password" class="field-input oauth-secret" placeholder="client_secret"></td>
<td><input class="field-input oauth-label" placeholder="Sign in with…"></td>
<td><input class="field-input oauth-logo" placeholder="/path/to/logo.png"></td>
<td><button class="btn-danger" onclick="this.closest('tr').remove()">✕</button></td>`;
tbody.appendChild(row);
}
async function showRollbackModal() {
document.getElementById('rollback-modal').style.display = 'flex';
const el = document.getElementById('rollback-list');
el.innerHTML = 'Loading…';
try {
const resp = await fetch('/api/0/config/backups');
const data = await resp.json();
if (!data.backups || !data.backups.length) {
el.innerHTML = '<p style="color:#888;font-size:.88em">No backups available.</p>';
return;
}
el.innerHTML = data.backups.map(b => {
const m = b.match(/\.bak\.(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})$/);
const label = m ? `${m[1]}-${m[2]}-${m[3]} ${m[4]}:${m[5]}:${m[6]}` : b;
const safe = b.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
return `<div class="backup-row"><span>${label}</span><button class="btn btn-secondary" style="font-size:.8em" onclick="doRollback('${safe}')">Restore</button></div>`;
}).join('');
} catch (e) {
el.innerHTML = '<p style="color:#c62828">Error loading backups: ' + e.message + '</p>';
}
}
function closeRollbackModal() {
document.getElementById('rollback-modal').style.display = 'none';
}
async function doRollback(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));
}
}
function flashStaged(sectionId) {
const sec = document.getElementById(sectionId);
if (!sec) return;
sec.style.outline = '2px solid #e8c840';
setTimeout(() => { sec.style.outline = ''; }, 800);
}
function escHtml(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
// ---- 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:#888;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 btn-primary" style="font-size:.82em;padding:4px 12px" 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('tr')?.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)</span>';
display.title = '';
return;
}
const MAX = 2;
let html = selected.slice(0, MAX).map(v => `<span class="mpick-tag">${escHtml(v)}</span>`).join('');
if (selected.length > MAX) html += `<span class="mpick-more">+${selected.length - MAX}</span>`;
display.innerHTML = html;
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 MAX = 2;
let dispHtml;
if (!selected.length) {
dispHtml = '<span class="mpick-empty">(none)</span>';
} else {
dispHtml = selected.slice(0, MAX).map(v => `<span class="mpick-tag">${escHtml(v)}</span>`).join('');
if (selected.length > MAX) dispHtml += `<span class="mpick-more">+${selected.length - MAX}</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>`;
}
// Highlight sidebar link for the section currently in view
const sections = document.querySelectorAll('.section');
const navLinks = document.querySelectorAll('.sidebar-nav a');
const observer = 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.25 });
sections.forEach(s => observer.observe(s));
// Collapsible sidebar on mobile
var sidebarToggle = document.getElementById('sidebar-toggle');
var sidebarNav = document.getElementById('sidebar-nav');
if (sidebarToggle && sidebarNav) {
sidebarToggle.addEventListener('click', function() {
var open = sidebarNav.classList.toggle('open');
sidebarToggle.classList.toggle('open', open);
sidebarToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
});
}
// ---- Threshold configurations form ----
function stageThresholdsSection(sectionId) {
const section = document.getElementById(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 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 (display) entry.display = display;
metrics[metric] = entry;
});
return metrics;
}
const cfgsContainer = document.getElementById('thresh-cfgs-' + sectionId);
cfgsContainer.querySelectorAll('.thresh-cfg-card').forEach(card => {
const configName = card.dataset.configName
|| (card.querySelector('.new-config-name')?.value || '').trim();
if (!configName) return;
configs[configName] = readMetrics(card);
});
_staged['thresholds'] = configs;
const defInput = section.querySelector('.thresh-default-config');
if (defInput) {
if (!_staged['server']) _staged['server'] = {};
_staged['server']['default_threshold_config'] = defInput.value || 'default';
}
updatePendingBanner();
flashStaged(sectionId);
}
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="field-input thresh-op" style="width:80px" 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="field-input new-metric-path" placeholder="plugin.metric" style="min-width:160px;font-family:monospace;font-size:.85em" required></td>
<td>${_threshOpSelect('>')}</td>
<td><input type="number" class="field-input thresh-warn" step="any" style="width:80px"></td>
<td><input type="number" class="field-input thresh-crit" step="any" style="width:80px"></td>
<td><input type="number" class="field-input thresh-hyst" step="any" style="width:72px" value="0.02"></td>
<td><input type="number" class="field-input thresh-count" step="1" min="1" style="width:52px" value="1"></td>
<td><input type="text" class="field-input thresh-display" style="width:150px" placeholder="(default)"></td>
<td style="text-align:center"><input type="checkbox" class="thresh-enabled" checked></td>
<td><button class="btn-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 = 'thresh-cfg-card';
card.innerHTML = `
<div class="thresh-cfg-header">
<input type="text" class="field-input new-config-name" placeholder="Config name (e.g. servers)" style="max-width:220px">
<button class="btn-danger" style="margin-left:auto" onclick="this.closest('.thresh-cfg-card').remove()">✕ Delete</button>
</div>
<div style="overflow-x:auto">
<table class="crud-table thresh-metric-table">
<thead><tr>
<th>Metric path</th><th>Op</th>
<th>Warning</th><th>Critical</th>
<th>Hysteresis</th><th>Count</th>
<th style="max-width:160px">Display</th>
<th>En</th><th></th>
</tr></thead>
<tbody></tbody>
</table>
</div>
<div style="padding:6px 14px 8px;border-top:1px solid #f0f0f0">
<button class="btn btn-secondary" style="font-size:.8em;padding:3px 10px"
onclick="addThresholdMetricRow(this.closest('.thresh-cfg-card').querySelector('tbody'))">+ Add metric</button>
</div>`;
container.appendChild(card);
}
function deleteThresholdConfigCard(btn) {
const card = btn.closest('.thresh-cfg-card');
const name = card.dataset.configName || 'this config';
if (!confirm(`Delete config "${name}"?`)) return;
card.remove();
}
function closeSidebar() {
var sidebarNav = document.getElementById('sidebar-nav');
var sidebarToggle = document.getElementById('sidebar-toggle');
if (sidebarNav) { sidebarNav.classList.remove('open'); }
if (sidebarToggle) {
sidebarToggle.classList.remove('open');
sidebarToggle.setAttribute('aria-expanded', 'false');
}
}
</script>
</body>
</html>