1cefc2676e
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1583 lines
70 KiB
HTML
1583 lines
70 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; }
|
||
|
||
/* ---- Threshold config cards ---- */
|
||
.thresh-cfg-card {
|
||
margin-bottom: 14px;
|
||
border: 1px solid #e0e0e0;
|
||
border-radius: 6px;
|
||
overflow: hidden;
|
||
}
|
||
.thresh-cfg-header {
|
||
background: #f5f5f5;
|
||
padding: 8px 14px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
border-bottom: 1px solid #e0e0e0;
|
||
}
|
||
.thresh-cfg-name-label {
|
||
font-weight: 600;
|
||
font-size: 0.9em;
|
||
color: #1a237e;
|
||
}
|
||
.thresh-metric-table { width: 100%; }
|
||
.thresh-metric-table th { white-space: nowrap; }
|
||
|
||
/* ---- Multi-picker ---- */
|
||
.mpick-wrapper { display: block; }
|
||
.mpick-display {
|
||
display: flex; align-items: center; gap: 4px; flex-wrap: nowrap;
|
||
cursor: pointer; padding: 3px 7px; border: 1px solid #ccc; border-radius: 4px;
|
||
min-height: 26px; min-width: 80px; width: 100%; box-sizing: border-box;
|
||
background: #fff; user-select: none; overflow: hidden;
|
||
}
|
||
.mpick-display:hover { border-color: #0066cc; background: #f8fbff; }
|
||
.mpick-tag {
|
||
padding: 1px 6px; background: #e8eaf6; color: #283593;
|
||
border-radius: 10px; font-size: 0.82em; white-space: nowrap; flex-shrink: 0;
|
||
}
|
||
.mpick-more { color: #888; font-size: 0.82em; white-space: nowrap; flex-shrink: 0; }
|
||
.mpick-empty { color: #bbb; font-style: italic; font-size: 0.82em; }
|
||
.mpick-panel {
|
||
position: fixed; background: #fff; border: 1px solid #d0d0d0;
|
||
border-radius: 6px; box-shadow: 0 4px 20px rgba(0,0,0,.18);
|
||
z-index: 2000; width: 360px; overflow: hidden;
|
||
}
|
||
.mpick-panel-header {
|
||
padding: 6px 12px; font-size: 0.78em; font-weight: 700;
|
||
text-transform: uppercase; letter-spacing: 0.04em; color: #555;
|
||
border-bottom: 1px solid #eee; display: flex;
|
||
justify-content: space-between; align-items: center; background: #f5f5f5;
|
||
}
|
||
.mpick-panel-body { display: flex; }
|
||
.mpick-col { flex: 1; min-width: 0; max-height: 200px; overflow-y: auto; }
|
||
.mpick-col-header {
|
||
padding: 4px 10px; font-size: 0.72em; font-weight: 700;
|
||
text-transform: uppercase; letter-spacing: 0.04em; color: #888;
|
||
border-bottom: 1px solid #f0f0f0; background: #fafafa;
|
||
position: sticky; top: 0; z-index: 1;
|
||
}
|
||
.mpick-col:first-child { border-right: 1px solid #eee; }
|
||
.mpick-item {
|
||
padding: 5px 10px; font-size: 0.85em; cursor: pointer;
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
border-bottom: 1px solid #f8f8f8; gap: 4px;
|
||
}
|
||
.mpick-item:last-child { border-bottom: none; }
|
||
.mpick-item-avail:hover { background: #e8f5e9; }
|
||
.mpick-item-sel:hover { background: #fce4ec; }
|
||
.mpick-arrow { font-size: 1.1em; opacity: 0.4; flex-shrink: 0; line-height: 1; }
|
||
.mpick-item:hover .mpick-arrow { opacity: 1; }
|
||
.mpick-item-avail .mpick-arrow { color: #2a7a2a; }
|
||
.mpick-item-sel .mpick-arrow { color: #c62828; }
|
||
.mpick-panel-footer {
|
||
padding: 6px 10px; border-top: 1px solid #eee;
|
||
display: flex; justify-content: flex-end; background: #f8f8f8;
|
||
}
|
||
.mpick-none { padding: 10px; font-size: .82em; color: #aaa; text-align: center; }
|
||
</style>
|
||
|
||
<body>
|
||
{%- macro mpick(all_items, sel, cls) -%}
|
||
<div class="mpick-wrapper"><div class="mpick-display" onclick="openMpick(this)" title="{{ sel | join(', ') | e }}">{%- if sel -%}{%- for v in sel[:2] -%}<span class="mpick-tag">{{ v | e }}</span>{%- endfor -%}{%- if sel|length > 2 %}<span class="mpick-more">+{{ sel|length - 2 }}</span>{%- endif -%}{%- else -%}<span class="mpick-empty">(none)</span>{%- endif -%}</div><select class="{{ cls }}" multiple hidden>{%- for item in all_items %}<option value="{{ item | e }}"{% if item in sel %} selected{% endif %}>{{ item | e }}</option>{%- endfor %}</select></div>
|
||
{%- endmacro %}
|
||
{% include 'nav.html' %}
|
||
|
||
<div class="container">
|
||
<h1>Settings</h1>
|
||
<p class="subtitle">Edit server configuration — changes are staged until you publish them to <code>.hb.yaml</code>.</p>
|
||
|
||
<!-- Pending changes banner (hidden until something is staged) -->
|
||
<div id="pending-banner" class="pending-banner" style="display:none">
|
||
<span class="pending-msg">⚠ <strong id="pending-count">0</strong> section(s) with pending changes — not yet saved to .hb.yaml</span>
|
||
<span class="pending-actions">
|
||
<button class="btn btn-secondary" onclick="discardAll()">Discard all</button>
|
||
<button class="btn btn-success" onclick="publishAll()">Publish to .hb.yaml</button>
|
||
</span>
|
||
</div>
|
||
|
||
<!-- Rollback modal -->
|
||
<div id="rollback-modal" class="modal-overlay" style="display:none" onclick="if(event.target===this)closeRollbackModal()">
|
||
<div class="modal-box">
|
||
<h3>Backups / Rollback</h3>
|
||
<div id="rollback-list" style="max-height:300px;overflow-y:auto">Loading…</div>
|
||
<div style="margin-top:14px;text-align:right">
|
||
<button class="btn btn-secondary" onclick="closeRollbackModal()">Close</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Channel add/edit modal -->
|
||
<div id="ch-modal" class="ch-modal-overlay" style="display:none" onclick="if(event.target===this)closeChannelModal()">
|
||
<div class="ch-modal-box">
|
||
<h3 id="ch-modal-title">Add Notification Channel</h3>
|
||
<div class="ch-form-row">
|
||
<label>Channel name</label>
|
||
<input type="text" id="ch-name" placeholder="e.g. pushover_ops" autocomplete="off">
|
||
</div>
|
||
<div class="ch-form-row">
|
||
<label>Type</label>
|
||
<select id="ch-type" onchange="onChTypeChange()">
|
||
<option value="">— select —</option>
|
||
</select>
|
||
</div>
|
||
<div id="ch-type-fields"></div>
|
||
<div class="ch-form-divider">Options</div>
|
||
<div class="ch-form-row">
|
||
<label>Minimum alert level</label>
|
||
<select id="ch-min-level">
|
||
<option value="WARNING">WARNING (and above)</option>
|
||
<option value="CRITICAL">CRITICAL only</option>
|
||
</select>
|
||
</div>
|
||
<div class="ch-form-row">
|
||
<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
|
||
<input type="checkbox" id="ch-private"> Private — visible only to you
|
||
</label>
|
||
</div>
|
||
<div id="ch-modal-status" class="ch-status"></div>
|
||
<div class="ch-modal-footer">
|
||
<button class="btn btn-secondary" onclick="closeChannelModal()">Cancel</button>
|
||
<button class="btn btn-primary" onclick="saveChannel()">Save</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="settings-layout">
|
||
|
||
<!-- Sidebar navigation -->
|
||
<nav class="settings-sidebar">
|
||
<button class="sidebar-toggle" id="sidebar-toggle" aria-expanded="false">Sections</button>
|
||
<div class="sidebar-nav" id="sidebar-nav">
|
||
{% for section in sections %}
|
||
<a href="#{{ section.id }}" onclick="closeSidebar()">{{ section.title }}</a>
|
||
{% endfor %}
|
||
<hr style="margin: 8px 0; border: none; border-top: 1px solid #e8e8e8;">
|
||
<a href="#" onclick="showRollbackModal(); return false;" style="color:#888;font-size:.82em">View backups / rollback</a>
|
||
</div>
|
||
</nav>
|
||
|
||
<!-- Main content -->
|
||
<div class="settings-main">
|
||
{% for section in sections %}
|
||
<div class="section" id="{{ section.id }}">
|
||
<div class="section-header">
|
||
<p class="section-title">{{ section.title }}</p>
|
||
{% if section.description %}<p class="section-desc">{{ section.description }}</p>{% endif %}
|
||
</div>
|
||
|
||
{# ---- Users CRUD ---- #}
|
||
{% if section.id == 'users' %}
|
||
<div style="padding: 12px 20px 0">
|
||
{% for f in section.fields %}
|
||
{% if f.editable %}
|
||
<div class="field-row" style="border-bottom: 1px solid #eee; margin-bottom: 8px">
|
||
<div class="field-label" style="font-size:.85em;color:#555">{{ f.label }}</div>
|
||
<div class="field-body">
|
||
<input type="text" class="field-input"
|
||
data-key="{{ f.key }}" data-section="{{ section.api_section }}"
|
||
value="{{ f.raw if f.raw is not none else '' }}">
|
||
{% if f.description %}<p class="field-desc">{{ f.description }}</p>{% endif %}
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
{% endfor %}
|
||
</div>
|
||
<div style="overflow-x:auto;padding:0 20px">
|
||
<table class="crud-table" id="users-editor">
|
||
<thead><tr>
|
||
<th>Username</th><th>Display name</th><th>Avatar URL</th>
|
||
<th>Admin</th><th>Channels</th><th style="min-width:110px">New password</th><th></th>
|
||
</tr></thead>
|
||
<tbody id="users-tbody">
|
||
{% for u in section.users %}
|
||
<tr data-user-row="true" data-username="{{ u.username | e }}">
|
||
<td style="font-family:monospace;font-size:.9em">{{ u.username | e }}</td>
|
||
<td><input class="field-input user-full-name" value="{{ u.full_name | e }}"></td>
|
||
<td><input class="field-input user-avatar" value="{{ u.avatar | e }}"></td>
|
||
<td style="text-align:center"><input type="checkbox" class="user-admin" {% if u.admin %}checked{% endif %}></td>
|
||
<td style="min-width:140px">
|
||
{{ mpick(all_channel_names, u.notification_channels, 'user-ch-select') }}
|
||
</td>
|
||
<td><input type="password" class="field-input user-password" placeholder="(leave blank to keep)"></td>
|
||
<td><button class="btn-danger" onclick="toggleDeleteRow(this)">✕</button></td>
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div class="section-footer">
|
||
<button class="btn btn-secondary" onclick="addUserRow()" style="margin-right:auto">+ Add user</button>
|
||
<button class="btn btn-primary" onclick="stageUsersSection()">Stage changes</button>
|
||
</div>
|
||
|
||
{# ---- OAuth CRUD ---- #}
|
||
{% elif section.id == 'oauth' %}
|
||
<div style="overflow-x:auto;padding:0 20px">
|
||
<table class="crud-table" id="oauth-editor">
|
||
<thead><tr>
|
||
<th>Name (slug)</th><th>Type</th><th>URL</th><th>Client ID</th>
|
||
<th>Client Secret</th><th>Label</th><th>Logo URL</th><th></th>
|
||
</tr></thead>
|
||
<tbody id="oauth-tbody">
|
||
{% for p in section.providers %}
|
||
<tr data-oauth-row="true" data-name="{{ p.name | e }}">
|
||
<td style="font-family:monospace;font-size:.9em">{{ p.name | e }}</td>
|
||
<td>
|
||
<select class="field-input oauth-type">
|
||
{% for t in ['gitea', 'github', 'nextcloud'] %}
|
||
<option value="{{ t }}" {% if p.type == t %}selected{% endif %}>{{ t }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
</td>
|
||
<td><input class="field-input oauth-url" value="{{ p.url | e }}"></td>
|
||
<td><input class="field-input oauth-client-id" value="{{ p.client_id | e }}"></td>
|
||
<td><input type="password" class="field-input oauth-secret" value="{{ p.client_secret | e }}"></td>
|
||
<td><input class="field-input oauth-label" value="{{ p.label | e }}"></td>
|
||
<td><input class="field-input oauth-logo" value="{{ p.logo | e }}"></td>
|
||
<td><button class="btn-danger" onclick="toggleDeleteRow(this)">✕</button></td>
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div class="section-footer">
|
||
<button class="btn btn-secondary" onclick="addOAuthRow()" style="margin-right:auto">+ Add provider</button>
|
||
<button class="btn btn-primary" onclick="stageOAuthSection()">Stage changes</button>
|
||
</div>
|
||
|
||
{# ---- Hosts CRUD table ---- #}
|
||
{% elif section.section_mode == 'hosts' %}
|
||
<div style="overflow-x:auto;padding:0 20px">
|
||
<table class="crud-table" id="hosts-editor">
|
||
<thead><tr>
|
||
<th>Hostname</th>
|
||
<th>Watch</th>
|
||
<th>DynDNS</th>
|
||
<th>Owner</th>
|
||
<th style="min-width:110px">Managers</th>
|
||
<th style="min-width:110px">Monitors</th>
|
||
<th style="min-width:110px">Threshold config</th>
|
||
<th style="min-width:110px">Channels</th>
|
||
<th></th>
|
||
</tr></thead>
|
||
<tbody id="hosts-tbody">
|
||
{% for h in section.hosts %}
|
||
<tr data-host-row="true" data-hostname="{{ h.name | e }}">
|
||
<td style="font-family:monospace;font-size:.9em;white-space:nowrap">{{ h.name | e }}</td>
|
||
<td style="text-align:center"><input type="checkbox" class="host-watch" {% if h.watch %}checked{% endif %}></td>
|
||
<td style="text-align:center"><input type="checkbox" class="host-dyndns" {% if h.dyndns %}checked{% endif %}></td>
|
||
<td><input class="field-input host-owner" value="{{ h.owner | e }}" placeholder="(none)" style="min-width:90px"></td>
|
||
<td>{{ mpick(all_usernames, h.managers, 'host-managers') }}</td>
|
||
<td>{{ mpick(all_usernames, h.monitors, 'host-monitors') }}</td>
|
||
<td>{{ mpick(all_threshold_configs, h.threshold_configs, 'host-tc') }}</td>
|
||
<td>{{ mpick(all_channel_names, h.notification_channels, 'host-channels') }}</td>
|
||
<td><button class="btn-danger" onclick="toggleDeleteRow(this)">✕</button></td>
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div class="section-footer">
|
||
<button class="btn btn-secondary" onclick="addHostRow()" style="margin-right:auto">+ Add host</button>
|
||
<button class="btn btn-primary" onclick="stageHostsSection()">Stage changes</button>
|
||
</div>
|
||
|
||
{# ---- Notification channels (form-based, live CRUD) ---- #}
|
||
{% elif section.section_mode == 'channels' %}
|
||
{% for f in section.fields %}
|
||
<div class="field-row" style="border-bottom:1px solid #f0f0f0">
|
||
<div class="field-label">{{ f.label }}</div>
|
||
<div class="field-body">
|
||
{% if f.type == 'list' %}
|
||
{% if f.value %}<span class="val-list">{% for item in f.value %}<span class="val-tag">{{ item }}</span>{% endfor %}</span>
|
||
{% else %}<span class="val-empty">None</span>{% endif %}
|
||
{% else %}
|
||
<div class="field-value">{{ f.value if f.value is not none else '' }}</div>
|
||
{% endif %}
|
||
{% if f.description %}<p class="field-desc">{{ f.description }}</p>{% endif %}
|
||
</div>
|
||
</div>
|
||
{% endfor %}
|
||
<div class="channel-grid" id="channel-cards">
|
||
{% for ch in section.channels %}
|
||
<div class="channel-card" id="chcard-{{ ch.name | e }}">
|
||
<div class="channel-header">
|
||
<span class="channel-name-text">{{ ch.name | e }}</span>
|
||
<span class="ch-type-badge">{{ ch.type_label | e }}</span>
|
||
{% if ch.min_level and ch.min_level != 'WARNING' %}<span class="ch-level-badge">{{ ch.min_level | e }}+</span>{% endif %}
|
||
{% if ch.private %}<span class="ch-private-badge">private</span>{% endif %}
|
||
{% if ch.owner %}<span class="ch-owner-badge">{{ ch.owner | e }}</span>{% endif %}
|
||
<span class="channel-header-actions">
|
||
<button class="btn btn-secondary" style="font-size:.78em;padding:2px 8px" onclick="openChannelModal('{{ ch.name | e }}')">Edit</button>
|
||
<button class="btn-danger" onclick="deleteChannel('{{ ch.name | e }}')">✕</button>
|
||
</span>
|
||
</div>
|
||
<div class="channel-fields">
|
||
{% for f in ch.fields %}
|
||
<div class="channel-field">
|
||
<span class="channel-field-label">{{ f.label }}</span>
|
||
<span class="channel-field-value">{% if f.sensitive %}<span class="val-masked">•••</span>{% elif f.value %}{{ f.value | e }}{% else %}<span style="color:#ccc">—</span>{% endif %}</span>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
</div>
|
||
{% endfor %}
|
||
{% if not section.channels %}<p style="color:#aaa;font-size:.88em;padding:12px 0">No channels configured yet.</p>{% endif %}
|
||
</div>
|
||
<div class="channel-add-bar">
|
||
<button class="btn btn-primary" onclick="openChannelModal()">+ Add channel</button>
|
||
</div>
|
||
|
||
{# ---- Threshold configurations (form-based) ---- #}
|
||
{% elif section.section_mode == 'thresholds' %}
|
||
{% for f in section.fields %}
|
||
<div class="field-row" style="border-bottom:1px solid #f0f0f0">
|
||
<div class="field-label">{{ f.label }}</div>
|
||
<div class="field-body">
|
||
<input type="text" class="field-input thresh-default-config"
|
||
value="{{ f.raw if f.raw is not none else '' }}"
|
||
placeholder="default"
|
||
list="thresh-cfg-names-{{ section.id }}">
|
||
<datalist id="thresh-cfg-names-{{ section.id }}">
|
||
{% for tc in section.threshold_configs %}<option value="{{ tc.name | e }}">{% endfor %}
|
||
</datalist>
|
||
{% if f.description %}<p class="field-desc">{{ f.description }}</p>{% endif %}
|
||
</div>
|
||
</div>
|
||
{% endfor %}
|
||
<div id="thresh-cfgs-{{ section.id }}" style="padding:8px 20px 0">
|
||
{% for tc in section.threshold_configs %}
|
||
<div class="thresh-cfg-card" data-config-name="{{ tc.name | e }}">
|
||
<div class="thresh-cfg-header">
|
||
<span class="thresh-cfg-name-label">{{ tc.name | e }}</span>
|
||
{% if tc.name != 'default' %}
|
||
<button class="btn-danger" style="margin-left:auto" onclick="deleteThresholdConfigCard(this)">✕ Delete</button>
|
||
{% endif %}
|
||
</div>
|
||
<div style="overflow-x:auto">
|
||
<table class="crud-table thresh-metric-table">
|
||
<thead><tr>
|
||
<th>Metric path</th><th>Op</th>
|
||
<th>Warning</th><th>Critical</th>
|
||
<th>Hysteresis</th><th>Count</th>
|
||
<th style="max-width:160px">Display</th>
|
||
<th>En</th><th></th>
|
||
</tr></thead>
|
||
<tbody>
|
||
{% for m in tc.metrics %}
|
||
<tr data-metric-row="true" data-metric-path="{{ m.metric | e }}">
|
||
<td style="font-family:monospace;font-size:.85em;white-space:nowrap">{{ m.metric | e }}</td>
|
||
<td>
|
||
<select class="field-input thresh-op" style="width:80px" onchange="onThreshOpChange(this)">
|
||
{% for op in ['>', '>=', '<', '<=', '==', '!=', 'nagios'] %}
|
||
<option value="{{ op }}" {% if m.operator == op %}selected{% endif %}>{{ op }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
</td>
|
||
<td><input type="number" class="field-input thresh-warn" step="any" style="width:80px"
|
||
value="{{ m.warning if m.warning is not none else '' }}"
|
||
{% if m.operator == 'nagios' %}disabled{% endif %}></td>
|
||
<td><input type="number" class="field-input thresh-crit" step="any" style="width:80px"
|
||
value="{{ m.critical if m.critical is not none else '' }}"
|
||
{% if m.operator == 'nagios' %}disabled{% endif %}></td>
|
||
<td><input type="number" class="field-input thresh-hyst" step="any" style="width:72px"
|
||
value="{{ m.hysteresis if m.hysteresis is not none else 0.02 }}"></td>
|
||
<td><input type="number" class="field-input thresh-count" step="1" min="1" style="width:52px"
|
||
value="{{ m.count if m.count is not none else 1 }}"></td>
|
||
<td><input type="text" class="field-input thresh-display" style="width:150px"
|
||
value="{{ m.display | e }}" placeholder="(default)"></td>
|
||
<td style="text-align:center"><input type="checkbox" class="thresh-enabled"
|
||
{% if m.enabled %}checked{% endif %}></td>
|
||
<td><button class="btn-danger" onclick="this.closest('tr').remove()">✕</button></td>
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div style="padding:6px 14px 8px;border-top:1px solid #f0f0f0">
|
||
<button class="btn btn-secondary" style="font-size:.8em;padding:3px 10px"
|
||
onclick="addThresholdMetricRow(this.closest('.thresh-cfg-card').querySelector('tbody'))">+ Add metric</button>
|
||
</div>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
<div class="section-footer" style="justify-content:space-between">
|
||
<button class="btn btn-secondary" onclick="addThresholdConfigCard('thresh-cfgs-{{ section.id }}')">+ Add config</button>
|
||
<button class="btn btn-primary" onclick="stageThresholdsSection('{{ section.id }}')">Stage changes</button>
|
||
</div>
|
||
|
||
{# ---- YAML editor section ---- #}
|
||
{% elif section.section_mode == 'yaml' %}
|
||
<div style="padding: 12px 20px">
|
||
<textarea id="yaml-{{ section.id }}" class="yaml-editor" rows="12"></textarea>
|
||
<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:6px">
|
||
<button class="btn btn-secondary" onclick="loadYamlSection('{{ section.api_section }}', 'yaml-{{ section.id }}')">Reload from file</button>
|
||
<button class="btn btn-primary" onclick="stageYamlSection('{{ section.api_section }}', 'yaml-{{ section.id }}')">Stage changes</button>
|
||
</div>
|
||
</div>
|
||
|
||
{# ---- Form section (generic fields) ---- #}
|
||
{% else %}
|
||
{% for f in section.fields %}
|
||
<div class="field-row">
|
||
<div class="field-label">{{ f.label }}</div>
|
||
<div class="field-body">
|
||
{% if f.editable and section.api_section %}
|
||
{% if f.type == 'boolean' %}
|
||
<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
|
||
<input type="checkbox" class="user-admin"
|
||
data-key="{{ f.key }}" data-section="{{ section.api_section }}"
|
||
{% if f.value %}checked{% endif %}>
|
||
<span style="font-size:.88em">{{ 'Enabled' if f.value else 'Disabled' }}</span>
|
||
</label>
|
||
{% elif f.type in ('number', 'port', 'size') %}
|
||
<input type="number" class="field-input"
|
||
data-key="{{ f.key }}" data-type="{{ f.type }}" data-section="{{ section.api_section }}"
|
||
value="{{ f.raw if f.raw is not none else '' }}">
|
||
{% else %}
|
||
<input type="text" class="field-input"
|
||
data-key="{{ f.key }}" data-section="{{ section.api_section }}"
|
||
value="{{ f.raw if f.raw is not none else '' }}">
|
||
{% endif %}
|
||
{% if f.description %}<p class="field-desc">{{ f.description }}</p>{% endif %}
|
||
{% elif f.sensitive %}
|
||
<div class="field-value"><span class="val-masked">••••••••</span></div>
|
||
{% elif f.type == 'boolean' %}
|
||
<div class="field-value">
|
||
<span class="val-boolean {{ 'on' if f.value else 'off' }}">{{ 'Enabled' if f.value else 'Disabled' }}</span>
|
||
</div>
|
||
{% elif f.type == 'list' %}
|
||
<div class="field-value">
|
||
{% if f.value %}<span class="val-list">{% for item in f.value %}<span class="val-tag">{{ item }}</span>{% endfor %}</span>
|
||
{% else %}<span class="val-empty">None</span>{% endif %}
|
||
</div>
|
||
{% else %}
|
||
<div class="field-value">{{ f.value if f.value is not none else '' }}</div>
|
||
{% endif %}
|
||
{% if f.description and not f.editable %}<p class="field-desc">{{ f.description }}</p>{% endif %}
|
||
</div>
|
||
</div>
|
||
{% endfor %}
|
||
{% if section.api_section %}
|
||
<div class="section-footer">
|
||
<button class="btn btn-primary" onclick="stageFormSection('{{ section.id }}', '{{ section.api_section }}')">Stage changes</button>
|
||
</div>
|
||
{% endif %}
|
||
{% endif %}
|
||
|
||
</div>
|
||
{% endfor %}
|
||
</div>{# /settings-main #}
|
||
</div>{# /settings-layout #}
|
||
</div>{# /container #}
|
||
|
||
<script>
|
||
// ---- Lookup arrays for CRUD rows ----
|
||
const _allChannels = {{ all_channel_names | tojson }};
|
||
const _allUsers = {{ all_usernames | tojson }};
|
||
const _allThresholdConfigs = {{ all_threshold_configs | tojson }};
|
||
|
||
// ---- Channel CRUD ----
|
||
let _channelSchemas = {};
|
||
let _chEditName = null; // null = create mode, string = edit mode
|
||
|
||
async function _loadChannelSchemas() {
|
||
try {
|
||
const r = await fetch('/api/0/notification_channel_types');
|
||
_channelSchemas = await r.json();
|
||
const sel = document.getElementById('ch-type');
|
||
if (!sel) return;
|
||
Object.entries(_channelSchemas).forEach(([k, v]) => {
|
||
const opt = document.createElement('option');
|
||
opt.value = k; opt.textContent = v.label;
|
||
sel.appendChild(opt);
|
||
});
|
||
} catch(e) { console.warn('Could not load channel schemas', e); }
|
||
}
|
||
|
||
function onChTypeChange() {
|
||
const type = document.getElementById('ch-type').value;
|
||
const container = document.getElementById('ch-type-fields');
|
||
container.innerHTML = '';
|
||
if (!type || !_channelSchemas[type]) return;
|
||
const divider = document.createElement('div');
|
||
divider.className = 'ch-form-divider';
|
||
divider.textContent = _channelSchemas[type].label + ' settings';
|
||
container.appendChild(divider);
|
||
(_channelSchemas[type].fields || []).forEach(sf => {
|
||
const row = document.createElement('div');
|
||
row.className = 'ch-form-row';
|
||
const lbl = document.createElement('label');
|
||
lbl.textContent = sf.label + (sf.required ? ' *' : '');
|
||
const inp = document.createElement(sf.type === 'secret' ? 'input' : 'input');
|
||
inp.type = sf.type === 'secret' ? 'password' : 'text';
|
||
inp.id = 'chf-' + sf.key;
|
||
inp.placeholder = sf.required ? '(required)' : '(optional)';
|
||
inp.autocomplete = 'off';
|
||
row.appendChild(lbl);
|
||
row.appendChild(inp);
|
||
container.appendChild(row);
|
||
});
|
||
}
|
||
|
||
async function openChannelModal(name) {
|
||
_chEditName = name || null;
|
||
document.getElementById('ch-modal-status').textContent = '';
|
||
document.getElementById('ch-modal-title').textContent = name ? 'Edit Channel' : 'Add Notification Channel';
|
||
document.getElementById('ch-name').value = name || '';
|
||
document.getElementById('ch-name').disabled = !!name;
|
||
document.getElementById('ch-type').value = '';
|
||
document.getElementById('ch-type-fields').innerHTML = '';
|
||
document.getElementById('ch-min-level').value = 'WARNING';
|
||
document.getElementById('ch-private').checked = false;
|
||
|
||
if (name) {
|
||
// Load existing channel data via API
|
||
try {
|
||
const r = await fetch('/api/0/notification_channels');
|
||
const channels = await r.json();
|
||
const ch = channels.find(c => c.name === name);
|
||
if (ch) {
|
||
document.getElementById('ch-type').value = ch.type;
|
||
onChTypeChange();
|
||
document.getElementById('ch-min-level').value = ch.min_level || 'WARNING';
|
||
document.getElementById('ch-private').checked = ch.private || false;
|
||
(ch.fields || []).forEach(f => {
|
||
const inp = document.getElementById('chf-' + f.key);
|
||
if (inp) inp.value = f.value || '';
|
||
});
|
||
}
|
||
} catch(e) { console.warn('Failed to load channel data', e); }
|
||
}
|
||
document.getElementById('ch-modal').style.display = 'flex';
|
||
}
|
||
|
||
function closeChannelModal() {
|
||
document.getElementById('ch-modal').style.display = 'none';
|
||
}
|
||
|
||
async function saveChannel() {
|
||
const name = document.getElementById('ch-name').value.trim();
|
||
const type = document.getElementById('ch-type').value;
|
||
const minLevel = document.getElementById('ch-min-level').value;
|
||
const isPrivate = document.getElementById('ch-private').checked;
|
||
const statusEl = document.getElementById('ch-modal-status');
|
||
statusEl.textContent = '';
|
||
|
||
if (!name) { statusEl.textContent = 'Channel name is required.'; statusEl.style.color = '#c62828'; return; }
|
||
if (!type) { statusEl.textContent = 'Please select a type.'; statusEl.style.color = '#c62828'; return; }
|
||
|
||
const body = { name, type, min_level: minLevel, private: isPrivate };
|
||
if (_channelSchemas[type]) {
|
||
(_channelSchemas[type].fields || []).forEach(sf => {
|
||
const inp = document.getElementById('chf-' + sf.key);
|
||
if (inp) body[sf.key] = inp.value;
|
||
});
|
||
}
|
||
|
||
const isEdit = !!_chEditName;
|
||
const url = isEdit ? '/api/0/notification_channels/' + encodeURIComponent(_chEditName) : '/api/0/notification_channels';
|
||
const method = isEdit ? 'PUT' : 'POST';
|
||
try {
|
||
const r = await fetch(url, { method, headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body) });
|
||
if (r.ok) {
|
||
closeChannelModal();
|
||
window.location.reload();
|
||
} else {
|
||
const err = await r.json().catch(() => ({}));
|
||
statusEl.textContent = err.error || 'Error saving channel.';
|
||
statusEl.style.color = '#c62828';
|
||
}
|
||
} catch(e) {
|
||
statusEl.textContent = 'Network error: ' + e.message;
|
||
statusEl.style.color = '#c62828';
|
||
}
|
||
}
|
||
|
||
async function deleteChannel(name) {
|
||
if (!confirm('Delete channel "' + name + '"? This cannot be undone.')) return;
|
||
try {
|
||
const r = await fetch('/api/0/notification_channels/' + encodeURIComponent(name), { method: 'DELETE' });
|
||
if (r.ok) {
|
||
window.location.reload();
|
||
} else {
|
||
const err = await r.json().catch(() => ({}));
|
||
alert('Error: ' + (err.error || 'Could not delete channel.'));
|
||
}
|
||
} catch(e) { alert('Network error: ' + e.message); }
|
||
}
|
||
|
||
// ---- Staged changes accumulator ----
|
||
const _staged = {};
|
||
|
||
function updatePendingBanner() {
|
||
const count = Object.keys(_staged).length;
|
||
const banner = document.getElementById('pending-banner');
|
||
if (count > 0) {
|
||
document.getElementById('pending-count').textContent = count;
|
||
banner.style.display = 'flex';
|
||
localStorage.setItem('hbd_pending_config', JSON.stringify(_staged));
|
||
} else {
|
||
banner.style.display = 'none';
|
||
localStorage.removeItem('hbd_pending_config');
|
||
}
|
||
const navBtn = document.getElementById('nav-publish-btn');
|
||
if (navBtn) navBtn.style.display = count > 0 ? '' : 'none';
|
||
}
|
||
|
||
function stageFormSection(sectionId, apiSection) {
|
||
const section = document.getElementById(sectionId);
|
||
if (!_staged[apiSection] || typeof _staged[apiSection] !== 'object') {
|
||
_staged[apiSection] = {};
|
||
}
|
||
section.querySelectorAll('[data-key][data-section="' + apiSection + '"]').forEach(el => {
|
||
const key = el.dataset.key;
|
||
if (el.type === 'checkbox') {
|
||
_staged[apiSection][key] = el.checked;
|
||
} else if (el.dataset.type === 'number' || el.dataset.type === 'port') {
|
||
const v = parseInt(el.value, 10);
|
||
_staged[apiSection][key] = isNaN(v) ? null : v;
|
||
} else {
|
||
_staged[apiSection][key] = el.value;
|
||
}
|
||
});
|
||
updatePendingBanner();
|
||
flashStaged(sectionId);
|
||
}
|
||
|
||
function stageYamlSection(apiSection, textareaId) {
|
||
_staged[apiSection] = document.getElementById(textareaId).value;
|
||
updatePendingBanner();
|
||
}
|
||
|
||
function stageUsersSection() {
|
||
const users = {};
|
||
document.querySelectorAll('[data-user-row]').forEach(row => {
|
||
if (row.dataset.deleted === 'true') return;
|
||
const username = row.dataset.username;
|
||
const entry = {
|
||
full_name: row.querySelector('.user-full-name').value,
|
||
avatar: row.querySelector('.user-avatar').value,
|
||
admin: row.querySelector('.user-admin').checked,
|
||
notification_channels: [...(row.querySelector('.user-ch-select')?.selectedOptions || [])].map(o => o.value),
|
||
};
|
||
const pw = row.querySelector('.user-password').value;
|
||
if (pw) entry.password = pw;
|
||
users[username] = entry;
|
||
});
|
||
document.querySelectorAll('[data-new-user]').forEach(row => {
|
||
if (row.dataset.deleted === 'true') return;
|
||
const uname = (row.querySelector('.new-username') || {value: ''}).value.trim();
|
||
if (!uname) return;
|
||
const entry = {
|
||
full_name: row.querySelector('.user-full-name').value,
|
||
avatar: row.querySelector('.user-avatar').value,
|
||
admin: row.querySelector('.user-admin').checked,
|
||
notification_channels: [...(row.querySelector('.user-ch-select')?.selectedOptions || [])].map(o => o.value),
|
||
};
|
||
const pw = row.querySelector('.user-password').value;
|
||
if (pw) entry.password = pw;
|
||
users[uname] = entry;
|
||
});
|
||
const defOwner = document.querySelector('[data-key="default_owner"]');
|
||
if (defOwner) {
|
||
if (!_staged['server']) _staged['server'] = {};
|
||
_staged['server']['default_owner'] = defOwner.value;
|
||
}
|
||
_staged['users'] = users;
|
||
updatePendingBanner();
|
||
flashStaged('users');
|
||
}
|
||
|
||
function stageOAuthSection() {
|
||
const oauth = {};
|
||
document.querySelectorAll('[data-oauth-row]').forEach(row => {
|
||
if (row.dataset.deleted === 'true') return;
|
||
let name = row.dataset.name;
|
||
if (!name) {
|
||
const ni = row.querySelector('.oauth-name-input');
|
||
if (ni) name = ni.value.trim();
|
||
}
|
||
if (!name) return;
|
||
const entry = {
|
||
type: row.querySelector('.oauth-type').value,
|
||
url: row.querySelector('.oauth-url').value,
|
||
client_id: row.querySelector('.oauth-client-id').value,
|
||
};
|
||
const label = row.querySelector('.oauth-label').value;
|
||
if (label) entry.label = label;
|
||
const logo = row.querySelector('.oauth-logo').value;
|
||
if (logo) entry.logo = logo;
|
||
const secret = row.querySelector('.oauth-secret').value;
|
||
if (secret && secret !== '•••') entry.client_secret = secret;
|
||
oauth[name] = entry;
|
||
});
|
||
_staged['oauth'] = oauth;
|
||
updatePendingBanner();
|
||
flashStaged('oauth');
|
||
}
|
||
|
||
function stageHostsSection() {
|
||
function rowToEntry(row) {
|
||
const entry = {
|
||
watch: row.querySelector('.host-watch').checked,
|
||
dyndns: row.querySelector('.host-dyndns').checked,
|
||
};
|
||
const owner = row.querySelector('.host-owner').value.trim();
|
||
if (owner) entry.owner = owner;
|
||
const managers = [...(row.querySelector('.host-managers')?.selectedOptions || [])].map(o => o.value);
|
||
if (managers.length) entry.managers = managers;
|
||
const monitors = [...(row.querySelector('.host-monitors')?.selectedOptions || [])].map(o => o.value);
|
||
if (monitors.length) entry.monitors = monitors;
|
||
const tcs = [...(row.querySelector('.host-tc')?.selectedOptions || [])].map(o => o.value);
|
||
if (tcs.length) entry.threshold_config = tcs;
|
||
const chs = [...(row.querySelector('.host-channels')?.selectedOptions || [])].map(o => o.value);
|
||
if (chs.length) entry.notification_channels = chs;
|
||
return entry;
|
||
}
|
||
const hosts = {};
|
||
document.querySelectorAll('[data-host-row]').forEach(row => {
|
||
if (row.dataset.deleted === 'true') return;
|
||
hosts[row.dataset.hostname] = rowToEntry(row);
|
||
});
|
||
document.querySelectorAll('[data-new-host]').forEach(row => {
|
||
if (row.dataset.deleted === 'true') return;
|
||
const h = (row.querySelector('.new-hostname') || {value: ''}).value.trim();
|
||
if (!h) return;
|
||
hosts[h] = rowToEntry(row);
|
||
});
|
||
_staged['hosts'] = hosts;
|
||
updatePendingBanner();
|
||
flashStaged('hosts');
|
||
}
|
||
|
||
function addHostRow() {
|
||
const tbody = document.getElementById('hosts-tbody');
|
||
const row = document.createElement('tr');
|
||
row.setAttribute('data-new-host', 'true');
|
||
row.innerHTML = `
|
||
<td><input class="field-input new-hostname" placeholder="hostname" required style="min-width:120px"></td>
|
||
<td style="text-align:center"><input type="checkbox" class="host-watch" checked></td>
|
||
<td style="text-align:center"><input type="checkbox" class="host-dyndns"></td>
|
||
<td><input class="field-input host-owner" placeholder="(none)" style="min-width:90px"></td>
|
||
<td>${makeMpickHTML(_allUsers, [], 'host-managers')}</td>
|
||
<td>${makeMpickHTML(_allUsers, [], 'host-monitors')}</td>
|
||
<td>${makeMpickHTML(_allThresholdConfigs, [], 'host-tc')}</td>
|
||
<td>${makeMpickHTML(_allChannels, [], 'host-channels')}</td>
|
||
<td><button class="btn-danger" onclick="this.closest('tr').remove()">✕</button></td>`;
|
||
tbody.appendChild(row);
|
||
}
|
||
|
||
async function publishAll() {
|
||
const btn = document.querySelector('[onclick="publishAll()"]');
|
||
btn.disabled = true;
|
||
btn.textContent = 'Saving…';
|
||
try {
|
||
const resp = await fetch('/api/0/config', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify(_staged),
|
||
});
|
||
if (resp.ok) {
|
||
window.location.reload();
|
||
} else {
|
||
const err = await resp.json().catch(() => ({}));
|
||
alert('Error: ' + (err.error || resp.statusText));
|
||
btn.disabled = false;
|
||
btn.textContent = 'Publish to .hb.yaml';
|
||
}
|
||
} catch (e) {
|
||
alert('Network error: ' + e.message);
|
||
btn.disabled = false;
|
||
btn.textContent = 'Publish to .hb.yaml';
|
||
}
|
||
}
|
||
|
||
function discardAll() {
|
||
Object.keys(_staged).forEach(k => delete _staged[k]);
|
||
localStorage.removeItem('hbd_pending_config');
|
||
updatePendingBanner();
|
||
window.location.reload();
|
||
}
|
||
|
||
async function loadYamlSection(apiSection, textareaId) {
|
||
const ta = document.getElementById(textareaId);
|
||
ta.value = 'Loading…';
|
||
try {
|
||
const resp = await fetch('/api/0/config/section/' + apiSection);
|
||
const data = await resp.json();
|
||
ta.value = data.yaml || '';
|
||
} catch (e) {
|
||
ta.value = '# Error loading: ' + e.message;
|
||
}
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
_loadChannelSchemas();
|
||
document.querySelectorAll('textarea[id^="yaml-"]').forEach(ta => {
|
||
const sectionId = ta.id.replace('yaml-', '');
|
||
const section = document.getElementById(sectionId);
|
||
if (section) {
|
||
const btn = section.querySelector('[onclick^="stageYamlSection"]');
|
||
if (btn) {
|
||
const m = btn.getAttribute('onclick').match(/stageYamlSection\('([^']+)'/);
|
||
if (m) loadYamlSection(m[1], ta.id);
|
||
}
|
||
}
|
||
});
|
||
});
|
||
|
||
function toggleDeleteRow(btn) {
|
||
const row = btn.closest('tr');
|
||
const deleted = row.dataset.deleted === 'true';
|
||
row.dataset.deleted = deleted ? 'false' : 'true';
|
||
row.style.opacity = deleted ? '1' : '0.4';
|
||
row.querySelectorAll('input, select').forEach(el => { el.disabled = !deleted; });
|
||
btn.textContent = deleted ? '✕' : '↩';
|
||
}
|
||
|
||
function addUserRow() {
|
||
const tbody = document.getElementById('users-tbody');
|
||
const row = document.createElement('tr');
|
||
row.setAttribute('data-new-user', 'true');
|
||
row.innerHTML = `
|
||
<td><input class="field-input new-username" placeholder="username" required></td>
|
||
<td><input class="field-input user-full-name" placeholder="Display Name"></td>
|
||
<td><input class="field-input user-avatar" placeholder="Avatar URL or path"></td>
|
||
<td style="text-align:center"><input type="checkbox" class="user-admin"></td>
|
||
<td>${makeMpickHTML(_allChannels, [], 'user-ch-select')}</td>
|
||
<td><input type="password" class="field-input user-password" placeholder="(required)"></td>
|
||
<td><button class="btn-danger" onclick="this.closest('tr').remove()">✕</button></td>`;
|
||
tbody.appendChild(row);
|
||
}
|
||
|
||
function addOAuthRow() {
|
||
const tbody = document.getElementById('oauth-tbody');
|
||
const row = document.createElement('tr');
|
||
row.setAttribute('data-oauth-row', 'true');
|
||
row.setAttribute('data-name', '');
|
||
row.innerHTML = `
|
||
<td><input class="field-input oauth-name-input" placeholder="slug (e.g. gitea)"></td>
|
||
<td><select class="field-input oauth-type">
|
||
<option value="gitea">gitea</option>
|
||
<option value="github">github</option>
|
||
<option value="nextcloud">nextcloud</option>
|
||
</select></td>
|
||
<td><input class="field-input oauth-url" placeholder="https://…"></td>
|
||
<td><input class="field-input oauth-client-id" placeholder="client_id"></td>
|
||
<td><input type="password" class="field-input oauth-secret" placeholder="client_secret"></td>
|
||
<td><input class="field-input oauth-label" placeholder="Sign in with…"></td>
|
||
<td><input class="field-input oauth-logo" placeholder="/path/to/logo.png"></td>
|
||
<td><button class="btn-danger" onclick="this.closest('tr').remove()">✕</button></td>`;
|
||
tbody.appendChild(row);
|
||
}
|
||
|
||
async function showRollbackModal() {
|
||
document.getElementById('rollback-modal').style.display = 'flex';
|
||
const el = document.getElementById('rollback-list');
|
||
el.innerHTML = 'Loading…';
|
||
try {
|
||
const resp = await fetch('/api/0/config/backups');
|
||
const data = await resp.json();
|
||
if (!data.backups || !data.backups.length) {
|
||
el.innerHTML = '<p style="color:#888;font-size:.88em">No backups available.</p>';
|
||
return;
|
||
}
|
||
el.innerHTML = data.backups.map(b => {
|
||
const m = b.match(/\.bak\.(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})$/);
|
||
const label = m ? `${m[1]}-${m[2]}-${m[3]} ${m[4]}:${m[5]}:${m[6]}` : b;
|
||
const safe = b.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
||
return `<div class="backup-row"><span>${label}</span><button class="btn btn-secondary" style="font-size:.8em" onclick="doRollback('${safe}')">Restore</button></div>`;
|
||
}).join('');
|
||
} catch (e) {
|
||
el.innerHTML = '<p style="color:#c62828">Error loading backups: ' + e.message + '</p>';
|
||
}
|
||
}
|
||
|
||
function closeRollbackModal() {
|
||
document.getElementById('rollback-modal').style.display = 'none';
|
||
}
|
||
|
||
async function doRollback(backupPath) {
|
||
if (!confirm('Restore this backup? The current config will be backed up first.')) return;
|
||
const resp = await fetch('/api/0/config/rollback', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({backup: backupPath}),
|
||
});
|
||
if (resp.ok) {
|
||
closeRollbackModal();
|
||
window.location.reload();
|
||
} else {
|
||
const err = await resp.json().catch(() => ({}));
|
||
alert('Rollback failed: ' + (err.error || resp.statusText));
|
||
}
|
||
}
|
||
|
||
function flashStaged(sectionId) {
|
||
const sec = document.getElementById(sectionId);
|
||
if (!sec) return;
|
||
sec.style.outline = '2px solid #e8c840';
|
||
setTimeout(() => { sec.style.outline = ''; }, 800);
|
||
}
|
||
|
||
function escHtml(s) {
|
||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
||
}
|
||
|
||
// ---- Multi-picker ----
|
||
let _mpickPanel = null;
|
||
let _mpickTarget = null;
|
||
|
||
function _initMpickPanel() {
|
||
if (_mpickPanel) return;
|
||
const p = document.createElement('div');
|
||
p.className = 'mpick-panel';
|
||
p.style.display = 'none';
|
||
p.innerHTML = `
|
||
<div class="mpick-panel-header">
|
||
<span>Select items</span>
|
||
<button style="background:none;border:none;cursor:pointer;color:#888;font-size:1.1em;padding:0 2px;line-height:1" onclick="closeMpick()">✕</button>
|
||
</div>
|
||
<div class="mpick-panel-body">
|
||
<div class="mpick-col" id="mpick-avail-col">
|
||
<div class="mpick-col-header">Available</div>
|
||
<div id="mpick-avail"></div>
|
||
</div>
|
||
<div class="mpick-col" id="mpick-sel-col">
|
||
<div class="mpick-col-header">Selected</div>
|
||
<div id="mpick-sel"></div>
|
||
</div>
|
||
</div>
|
||
<div class="mpick-panel-footer">
|
||
<button class="btn btn-primary" style="font-size:.82em;padding:4px 12px" onclick="closeMpick()">Done</button>
|
||
</div>`;
|
||
document.body.appendChild(p);
|
||
_mpickPanel = p;
|
||
document.addEventListener('mousedown', e => {
|
||
if (!_mpickPanel || _mpickPanel.style.display === 'none') return;
|
||
if (!_mpickPanel.contains(e.target) && !e.target.closest('.mpick-display')) closeMpick();
|
||
}, true);
|
||
document.addEventListener('keydown', e => {
|
||
if (e.key === 'Escape' && _mpickPanel && _mpickPanel.style.display !== 'none') closeMpick();
|
||
});
|
||
}
|
||
|
||
function openMpick(displayEl) {
|
||
if (displayEl.closest('tr')?.dataset.deleted === 'true') return;
|
||
_initMpickPanel();
|
||
_mpickTarget = displayEl.closest('.mpick-wrapper');
|
||
_rerenderMpick();
|
||
_mpickPanel.style.display = 'block';
|
||
const rect = displayEl.getBoundingClientRect();
|
||
const pw = _mpickPanel.offsetWidth || 360;
|
||
const ph = _mpickPanel.offsetHeight || 280;
|
||
let top = rect.bottom + 4;
|
||
let left = rect.left;
|
||
if (left + pw > window.innerWidth - 8) left = Math.max(8, window.innerWidth - pw - 8);
|
||
if (top + ph > window.innerHeight - 8) top = Math.max(8, rect.top - ph - 4);
|
||
_mpickPanel.style.top = top + 'px';
|
||
_mpickPanel.style.left = left + 'px';
|
||
}
|
||
|
||
function _rerenderMpick() {
|
||
const sel = _mpickTarget.querySelector('select');
|
||
const allOpts = [...sel.options];
|
||
const selVals = new Set([...sel.selectedOptions].map(o => o.value));
|
||
const avail = allOpts.filter(o => !selVals.has(o.value));
|
||
const chosen = allOpts.filter(o => selVals.has(o.value));
|
||
document.getElementById('mpick-avail').innerHTML = avail.length
|
||
? avail.map(o => `<div class="mpick-item mpick-item-avail" data-val="${escHtml(o.value)}" onclick="_mpickToggle(this,true)"><span>${escHtml(o.value)}</span><span class="mpick-arrow">+</span></div>`).join('')
|
||
: '<div class="mpick-none">All selected</div>';
|
||
document.getElementById('mpick-sel').innerHTML = chosen.length
|
||
? chosen.map(o => `<div class="mpick-item mpick-item-sel" data-val="${escHtml(o.value)}" onclick="_mpickToggle(this,false)"><span>${escHtml(o.value)}</span><span class="mpick-arrow">−</span></div>`).join('')
|
||
: '<div class="mpick-none">None selected</div>';
|
||
}
|
||
|
||
function _mpickToggle(itemEl, toSelected) {
|
||
const val = itemEl.dataset.val;
|
||
const sel = _mpickTarget.querySelector('select');
|
||
const opt = [...sel.options].find(o => o.value === val);
|
||
if (opt) opt.selected = toSelected;
|
||
_updateMpickDisplay(_mpickTarget);
|
||
_rerenderMpick();
|
||
}
|
||
|
||
function _updateMpickDisplay(wrapper) {
|
||
const sel = wrapper.querySelector('select');
|
||
const display = wrapper.querySelector('.mpick-display');
|
||
const selected = [...sel.selectedOptions].map(o => o.value);
|
||
if (!selected.length) {
|
||
display.innerHTML = '<span class="mpick-empty">(none)</span>';
|
||
display.title = '';
|
||
return;
|
||
}
|
||
const MAX = 2;
|
||
let html = selected.slice(0, MAX).map(v => `<span class="mpick-tag">${escHtml(v)}</span>`).join('');
|
||
if (selected.length > MAX) html += `<span class="mpick-more">+${selected.length - MAX}</span>`;
|
||
display.innerHTML = html;
|
||
display.title = selected.join(', ');
|
||
}
|
||
|
||
function closeMpick() {
|
||
if (_mpickPanel) _mpickPanel.style.display = 'none';
|
||
_mpickTarget = null;
|
||
}
|
||
|
||
function makeMpickHTML(allItems, selectedItems, cls) {
|
||
const selSet = new Set(selectedItems);
|
||
const opts = allItems.map(v => `<option value="${escHtml(v)}"${selSet.has(v) ? ' selected' : ''}>${escHtml(v)}</option>`).join('');
|
||
const selected = allItems.filter(v => selSet.has(v));
|
||
const MAX = 2;
|
||
let dispHtml;
|
||
if (!selected.length) {
|
||
dispHtml = '<span class="mpick-empty">(none)</span>';
|
||
} else {
|
||
dispHtml = selected.slice(0, MAX).map(v => `<span class="mpick-tag">${escHtml(v)}</span>`).join('');
|
||
if (selected.length > MAX) dispHtml += `<span class="mpick-more">+${selected.length - MAX}</span>`;
|
||
}
|
||
const title = escHtml(selected.join(', '));
|
||
return `<div class="mpick-wrapper"><div class="mpick-display" onclick="openMpick(this)" title="${title}">${dispHtml}</div><select class="${cls}" multiple hidden>${opts}</select></div>`;
|
||
}
|
||
|
||
// Highlight sidebar link for the section currently in view
|
||
const sections = document.querySelectorAll('.section');
|
||
const navLinks = document.querySelectorAll('.sidebar-nav a');
|
||
|
||
const observer = new IntersectionObserver(entries => {
|
||
entries.forEach(entry => {
|
||
if (entry.isIntersecting) {
|
||
const id = entry.target.id;
|
||
navLinks.forEach(a => {
|
||
a.classList.toggle('active', a.getAttribute('href') === '#' + id);
|
||
});
|
||
}
|
||
});
|
||
}, { threshold: 0.25 });
|
||
|
||
sections.forEach(s => observer.observe(s));
|
||
|
||
// Collapsible sidebar on mobile
|
||
var sidebarToggle = document.getElementById('sidebar-toggle');
|
||
var sidebarNav = document.getElementById('sidebar-nav');
|
||
if (sidebarToggle && sidebarNav) {
|
||
sidebarToggle.addEventListener('click', function() {
|
||
var open = sidebarNav.classList.toggle('open');
|
||
sidebarToggle.classList.toggle('open', open);
|
||
sidebarToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||
});
|
||
}
|
||
|
||
// ---- Threshold configurations form ----
|
||
function stageThresholdsSection(sectionId) {
|
||
const section = document.getElementById(sectionId);
|
||
const configs = {};
|
||
|
||
function readMetrics(card) {
|
||
const metrics = {};
|
||
card.querySelectorAll('tbody tr').forEach(row => {
|
||
if (row.dataset.deleted === 'true') return;
|
||
const metric = row.dataset.metricPath
|
||
|| (row.querySelector('.new-metric-path')?.value || '').trim();
|
||
if (!metric) return;
|
||
const op = row.querySelector('.thresh-op')?.value || '>';
|
||
const warn = row.querySelector('.thresh-warn')?.value;
|
||
const crit = row.querySelector('.thresh-crit')?.value;
|
||
const hyst = row.querySelector('.thresh-hyst')?.value;
|
||
const count = row.querySelector('.thresh-count')?.value;
|
||
const display = row.querySelector('.thresh-display')?.value || '';
|
||
const enabled = row.querySelector('.thresh-enabled')?.checked ?? true;
|
||
const entry = { operator: op, enabled: enabled };
|
||
if (warn !== '' && warn !== undefined) entry.warning = parseFloat(warn);
|
||
if (crit !== '' && crit !== undefined) entry.critical = parseFloat(crit);
|
||
if (hyst !== '' && hyst !== undefined) entry.hysteresis = parseFloat(hyst);
|
||
if (count !== '' && count !== undefined) entry.count = parseInt(count, 10);
|
||
if (display) entry.display = display;
|
||
metrics[metric] = entry;
|
||
});
|
||
return metrics;
|
||
}
|
||
|
||
const cfgsContainer = document.getElementById('thresh-cfgs-' + sectionId);
|
||
cfgsContainer.querySelectorAll('.thresh-cfg-card').forEach(card => {
|
||
const configName = card.dataset.configName
|
||
|| (card.querySelector('.new-config-name')?.value || '').trim();
|
||
if (!configName) return;
|
||
configs[configName] = readMetrics(card);
|
||
});
|
||
|
||
_staged['thresholds'] = configs;
|
||
|
||
const defInput = section.querySelector('.thresh-default-config');
|
||
if (defInput) {
|
||
if (!_staged['server']) _staged['server'] = {};
|
||
_staged['server']['default_threshold_config'] = defInput.value || 'default';
|
||
}
|
||
|
||
updatePendingBanner();
|
||
flashStaged(sectionId);
|
||
}
|
||
|
||
function onThreshOpChange(select) {
|
||
const row = select.closest('tr');
|
||
const isNagios = select.value === 'nagios';
|
||
const w = row.querySelector('.thresh-warn');
|
||
const c = row.querySelector('.thresh-crit');
|
||
if (w) w.disabled = isNagios;
|
||
if (c) c.disabled = isNagios;
|
||
}
|
||
|
||
function _threshOpSelect(selected) {
|
||
const ops = ['>', '>=', '<', '<=', '==', '!=', 'nagios'];
|
||
return '<select class="field-input thresh-op" style="width:80px" onchange="onThreshOpChange(this)">' +
|
||
ops.map(op => `<option value="${escHtml(op)}"${op === selected ? ' selected' : ''}>${escHtml(op)}</option>`).join('') +
|
||
'</select>';
|
||
}
|
||
|
||
function addThresholdMetricRow(tbody) {
|
||
const row = document.createElement('tr');
|
||
row.innerHTML = `
|
||
<td><input type="text" class="field-input new-metric-path" placeholder="plugin.metric" style="min-width:160px;font-family:monospace;font-size:.85em" required></td>
|
||
<td>${_threshOpSelect('>')}</td>
|
||
<td><input type="number" class="field-input thresh-warn" step="any" style="width:80px"></td>
|
||
<td><input type="number" class="field-input thresh-crit" step="any" style="width:80px"></td>
|
||
<td><input type="number" class="field-input thresh-hyst" step="any" style="width:72px" value="0.02"></td>
|
||
<td><input type="number" class="field-input thresh-count" step="1" min="1" style="width:52px" value="1"></td>
|
||
<td><input type="text" class="field-input thresh-display" style="width:150px" placeholder="(default)"></td>
|
||
<td style="text-align:center"><input type="checkbox" class="thresh-enabled" checked></td>
|
||
<td><button class="btn-danger" onclick="this.closest('tr').remove()">✕</button></td>`;
|
||
tbody.appendChild(row);
|
||
}
|
||
|
||
function addThresholdConfigCard(containerId) {
|
||
const container = document.getElementById(containerId);
|
||
const card = document.createElement('div');
|
||
card.className = 'thresh-cfg-card';
|
||
card.innerHTML = `
|
||
<div class="thresh-cfg-header">
|
||
<input type="text" class="field-input new-config-name" placeholder="Config name (e.g. servers)" style="max-width:220px">
|
||
<button class="btn-danger" style="margin-left:auto" onclick="this.closest('.thresh-cfg-card').remove()">✕ Delete</button>
|
||
</div>
|
||
<div style="overflow-x:auto">
|
||
<table class="crud-table thresh-metric-table">
|
||
<thead><tr>
|
||
<th>Metric path</th><th>Op</th>
|
||
<th>Warning</th><th>Critical</th>
|
||
<th>Hysteresis</th><th>Count</th>
|
||
<th style="max-width:160px">Display</th>
|
||
<th>En</th><th></th>
|
||
</tr></thead>
|
||
<tbody></tbody>
|
||
</table>
|
||
</div>
|
||
<div style="padding:6px 14px 8px;border-top:1px solid #f0f0f0">
|
||
<button class="btn btn-secondary" style="font-size:.8em;padding:3px 10px"
|
||
onclick="addThresholdMetricRow(this.closest('.thresh-cfg-card').querySelector('tbody'))">+ Add metric</button>
|
||
</div>`;
|
||
container.appendChild(card);
|
||
}
|
||
|
||
function deleteThresholdConfigCard(btn) {
|
||
const card = btn.closest('.thresh-cfg-card');
|
||
const name = card.dataset.configName || 'this config';
|
||
if (!confirm(`Delete config "${name}"?`)) return;
|
||
card.remove();
|
||
}
|
||
|
||
function closeSidebar() {
|
||
var sidebarNav = document.getElementById('sidebar-nav');
|
||
var sidebarToggle = document.getElementById('sidebar-toggle');
|
||
if (sidebarNav) { sidebarNav.classList.remove('open'); }
|
||
if (sidebarToggle) {
|
||
sidebarToggle.classList.remove('open');
|
||
sidebarToggle.setAttribute('aria-expanded', 'false');
|
||
}
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|