feat: settings page editor with form sections, YAML editors, stage/publish/rollback
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+504
-185
@@ -265,6 +265,93 @@
|
||||
.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>
|
||||
@@ -272,7 +359,27 @@
|
||||
|
||||
<div class="container">
|
||||
<h1>Settings</h1>
|
||||
<p class="subtitle">Current server configuration — read from the config file at startup.</p>
|
||||
<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">
|
||||
|
||||
@@ -283,6 +390,8 @@
|
||||
{% 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>
|
||||
|
||||
@@ -295,212 +404,423 @@
|
||||
{% if section.description %}<p class="section-desc">{{ section.description }}</p>{% endif %}
|
||||
</div>
|
||||
|
||||
{# ---- Standard field rows ---- #}
|
||||
{# ---- 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.sensitive %}
|
||||
{% 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" %}
|
||||
{% 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>
|
||||
<span class="val-boolean {{ 'on' if f.value else 'off' }}">{{ 'Enabled' if f.value else 'Disabled' }}</span>
|
||||
</div>
|
||||
{% elif f.type == "list" %}
|
||||
{% 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>
|
||||
{% 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 %}
|
||||
<span class="val-empty">None</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% elif f.value is none or f.value == "" %}
|
||||
<div class="field-value"><span class="val-empty">Not set</span></div>
|
||||
{% else %}
|
||||
<div class="field-value">{{ f.value }}</div>
|
||||
{% endif %}
|
||||
{% if f.description %}
|
||||
<div class="field-desc">{{ f.description }}</div>
|
||||
<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 %}
|
||||
|
||||
{# ---- Users section ---- #}
|
||||
{% if section.id == "users" and section.users %}
|
||||
<div style="padding: 0 0 4px;">
|
||||
<table class="mini-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Full Name</th>
|
||||
<th>Role</th>
|
||||
<th>Avatar</th>
|
||||
<th>Channels</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for u in section.users %}
|
||||
<tr>
|
||||
<td><strong>{{ u.username }}</strong></td>
|
||||
<td>{{ u.full_name or '—' }}</td>
|
||||
<td>
|
||||
{% if u.admin %}
|
||||
<span class="badge badge-admin">Admin</span>
|
||||
{% else %}
|
||||
<span class="badge badge-user">User</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="font-size:0.8em; color:#888;">
|
||||
{% if u.avatar %}{{ u.avatar }}{% else %}—{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if u.notification_channels %}
|
||||
<span class="val-list">
|
||||
{% for ch in u.notification_channels %}
|
||||
<span class="val-tag">{{ ch }}</span>
|
||||
{% endfor %}
|
||||
</span>
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% 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 %}
|
||||
|
||||
{# ---- Notification channels section ---- #}
|
||||
{% if section.id == "channels" %}
|
||||
{% for ch in section.channels %}
|
||||
<div class="channel-card">
|
||||
<div class="channel-header">
|
||||
<span class="channel-name-text">{{ ch.name }}</span>
|
||||
<span class="ch-type-badge">{{ ch.type_label }}</span>
|
||||
</div>
|
||||
<div class="channel-fields">
|
||||
{% for cf in ch.fields %}
|
||||
<div class="channel-field">
|
||||
<span class="channel-field-label">{{ cf.label }}</span>
|
||||
<span class="channel-field-value">
|
||||
{% if cf.sensitive %}
|
||||
<span class="val-masked">••••••••</span>
|
||||
{% elif cf.value is iterable and cf.value is not string %}
|
||||
{{ cf.value | join(', ') }}
|
||||
{% else %}
|
||||
{{ cf.value }}
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if not section.channels %}
|
||||
<div class="field-row"><span class="val-empty">No notification channels configured.</span></div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{# ---- Threshold configurations section ---- #}
|
||||
{% if section.id == "thresholds" %}
|
||||
{% if section.threshold_configs %}
|
||||
{% for tc in section.threshold_configs %}
|
||||
<div class="thresh-config">
|
||||
<div class="thresh-config-name">{{ tc.name }}</div>
|
||||
{% if tc.metrics %}
|
||||
<div style="overflow-x: auto;">
|
||||
<table class="mini-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Metric</th>
|
||||
<th>Op</th>
|
||||
<th>Warning</th>
|
||||
<th>Critical</th>
|
||||
<th>Hysteresis</th>
|
||||
<th>Count</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for m in tc.metrics %}
|
||||
<tr {% if not m.enabled %} style="opacity:0.45"{% endif %}>
|
||||
<td class="metric-path">{{ m.metric }}</td>
|
||||
<td>{{ m.operator or '>' }}</td>
|
||||
<td class="warn">{{ m.warning if m.warning is not none else '—' }}</td>
|
||||
<td class="crit">{{ m.critical if m.critical is not none else '—' }}</td>
|
||||
<td class="dim">{{ '%.0f%%' % (m.hysteresis * 100) if m.hysteresis else '—' }}</td>
|
||||
<td class="dim">{{ m.count }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="val-empty">No thresholds defined.</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="field-row"><span class="val-empty">No threshold configurations defined.</span></div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{# ---- Hosts section ---- #}
|
||||
{% if section.id == "hosts" %}
|
||||
{% if section.hosts %}
|
||||
<div style="overflow-x: auto;">
|
||||
<table class="mini-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Host</th>
|
||||
<th>Watch</th>
|
||||
<th>DynDNS</th>
|
||||
<th>Owner</th>
|
||||
<th>Threshold config</th>
|
||||
<th>Channels</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for h in section.hosts %}
|
||||
<tr>
|
||||
<td><strong>{{ h.name }}</strong></td>
|
||||
<td class="host-bool">
|
||||
<span class="{{ 'dot-yes' if h.watch else 'dot-no' }}">●</span>
|
||||
</td>
|
||||
<td class="host-bool">
|
||||
<span class="{{ 'dot-yes' if h.dyndns else 'dot-no' }}">●</span>
|
||||
</td>
|
||||
<td>{{ h.owner or '—' }}</td>
|
||||
<td>{{ h.threshold_config or '—' }}</td>
|
||||
<td>
|
||||
{% if h.notification_channels %}
|
||||
<span class="val-list">
|
||||
{% for ch in h.notification_channels %}
|
||||
<span class="val-tag">{{ ch }}</span>
|
||||
{% endfor %}
|
||||
</span>
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="field-row"><span class="val-empty">No hosts defined in config.</span></div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
</div>{# /section #}
|
||||
{% 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');
|
||||
@@ -528,8 +848,7 @@
|
||||
sidebarToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<script>
|
||||
|
||||
function closeSidebar() {
|
||||
var sidebarNav = document.getElementById('sidebar-nav');
|
||||
var sidebarToggle = document.getElementById('sidebar-toggle');
|
||||
|
||||
Reference in New Issue
Block a user