9768d13b88
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
864 lines
33 KiB
HTML
864 lines
33 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; }
|
|
|
|
/* ---- 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>
|
|
|
|
<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>
|
|
|
|
{# ---- 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 }};
|
|
|
|
// ---- 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', () => {
|
|
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
|
}
|
|
|
|
// 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>
|