Files
heartbeat/hbd/server/templates/settings.html
T
Andreas Wrede 500d256d76 feat: replace YAML notification channel editor with form-based UI
Notification channels are now managed through a proper web form instead
of a raw YAML textarea. Any authenticated user can create channels; private
channels (owner-scoped) are hidden from other users. The user profile
channel selector becomes a tag/chip picker with a "My Channels" CRUD section.

- settings.py: add CHANNEL_TYPE_SCHEMAS for all 6 notifier types; channel
  section switches to section_mode="channels"; cards include owner/private/min_level
- configio.py: add apply_channel() and delete_channel() for per-entry CRUD
- notify.py: strip owner/private metadata before dispatching to drivers
- http.py: add GET/POST /api/0/notification_channels, PUT/DELETE /{name},
  GET /api/0/notification_channel_types; visibility helper filters private
  channels per user; PUT /api/0/users/me validates against visible channels
- settings.html: card grid with edit/delete per channel; add/edit modal
  with type dropdown and dynamically rendered type-specific fields
- profile.html: chip picker replaces checkbox list; My Channels section
  for creating/editing/deleting user-owned channels
- tests: update test_settings_sections, test_http_users_me; add
  test_notification_channels_api (16 new tests, 46 total passing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 07:34:26 -04:00

1107 lines
45 KiB
HTML

<!DOCTYPE html>
<html>
{% include 'head.html' %}
<style>
html, body { overflow: visible; }
.container {
max-width: 960px;
}
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 {
width: 100%;
max-width: 360px;
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 { max-width: none; }
/* ---- 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; }
</style>
<body>
{% 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:120px">
{% for ch in all_channel_names %}
<label style="display:block;font-size:.82em;white-space:nowrap">
<input type="checkbox" class="user-ch" value="{{ ch | e }}" {% if ch in u.notification_channels %}checked{% endif %}> {{ ch | e }}
</label>
{% endfor %}
</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>
{# ---- 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>
{# ---- 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>
// ---- Channel names for add-user row ----
const _allChannels = {{ all_channel_names | 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';
} else {
banner.style.display = '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.querySelectorAll('.user-ch:checked')].map(cb => cb.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.querySelectorAll('.user-ch:checked')].map(cb => cb.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');
}
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]);
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 chHtml = _allChannels.map(ch =>
`<label style="display:block;font-size:.82em;white-space:nowrap"><input type="checkbox" class="user-ch" value="${escHtml(ch)}"> ${escHtml(ch)}</label>`
).join('');
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>${chHtml}</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;');
}
// 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');
});
}
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>