feat: replace YAML notification channel editor with form-based UI
Notification channels are now managed through a proper web form instead
of a raw YAML textarea. Any authenticated user can create channels; private
channels (owner-scoped) are hidden from other users. The user profile
channel selector becomes a tag/chip picker with a "My Channels" CRUD section.
- settings.py: add CHANNEL_TYPE_SCHEMAS for all 6 notifier types; channel
section switches to section_mode="channels"; cards include owner/private/min_level
- configio.py: add apply_channel() and delete_channel() for per-entry CRUD
- notify.py: strip owner/private metadata before dispatching to drivers
- http.py: add GET/POST /api/0/notification_channels, PUT/DELETE /{name},
GET /api/0/notification_channel_types; visibility helper filters private
channels per user; PUT /api/0/users/me validates against visible channels
- settings.html: card grid with edit/delete per channel; add/edit modal
with type dropdown and dynamically rendered type-specific fields
- profile.html: chip picker replaces checkbox list; My Channels section
for creating/editing/deleting user-owned channels
- tests: update test_settings_sections, test_http_users_me; add
test_notification_channels_api (16 new tests, 46 total passing)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -207,6 +207,36 @@
|
||||
.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 {
|
||||
@@ -381,6 +411,42 @@
|
||||
</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 -->
|
||||
@@ -488,6 +554,52 @@
|
||||
<button class="btn btn-primary" onclick="stageOAuthSection()">Stage changes</button>
|
||||
</div>
|
||||
|
||||
{# ---- Notification channels (form-based, live CRUD) ---- #}
|
||||
{% elif section.section_mode == 'channels' %}
|
||||
{% for f in section.fields %}
|
||||
<div class="field-row" style="border-bottom:1px solid #f0f0f0">
|
||||
<div class="field-label">{{ f.label }}</div>
|
||||
<div class="field-body">
|
||||
{% if f.type == 'list' %}
|
||||
{% if f.value %}<span class="val-list">{% for item in f.value %}<span class="val-tag">{{ item }}</span>{% endfor %}</span>
|
||||
{% else %}<span class="val-empty">None</span>{% endif %}
|
||||
{% else %}
|
||||
<div class="field-value">{{ f.value if f.value is not none else '' }}</div>
|
||||
{% endif %}
|
||||
{% if f.description %}<p class="field-desc">{{ f.description }}</p>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="channel-grid" id="channel-cards">
|
||||
{% for ch in section.channels %}
|
||||
<div class="channel-card" id="chcard-{{ ch.name | e }}">
|
||||
<div class="channel-header">
|
||||
<span class="channel-name-text">{{ ch.name | e }}</span>
|
||||
<span class="ch-type-badge">{{ ch.type_label | e }}</span>
|
||||
{% if ch.min_level and ch.min_level != 'WARNING' %}<span class="ch-level-badge">{{ ch.min_level | e }}+</span>{% endif %}
|
||||
{% if ch.private %}<span class="ch-private-badge">private</span>{% endif %}
|
||||
{% if ch.owner %}<span class="ch-owner-badge">{{ ch.owner | e }}</span>{% endif %}
|
||||
<span class="channel-header-actions">
|
||||
<button class="btn btn-secondary" style="font-size:.78em;padding:2px 8px" onclick="openChannelModal('{{ ch.name | e }}')">Edit</button>
|
||||
<button class="btn-danger" onclick="deleteChannel('{{ ch.name | e }}')">✕</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="channel-fields">
|
||||
{% for f in ch.fields %}
|
||||
<div class="channel-field">
|
||||
<span class="channel-field-label">{{ f.label }}</span>
|
||||
<span class="channel-field-value">{% if f.sensitive %}<span class="val-masked">•••</span>{% elif f.value %}{{ f.value | e }}{% else %}<span style="color:#ccc">—</span>{% endif %}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if not section.channels %}<p style="color:#aaa;font-size:.88em;padding:12px 0">No channels configured yet.</p>{% endif %}
|
||||
</div>
|
||||
<div class="channel-add-bar">
|
||||
<button class="btn btn-primary" onclick="openChannelModal()">+ Add channel</button>
|
||||
</div>
|
||||
|
||||
{# ---- YAML editor section ---- #}
|
||||
{% elif section.section_mode == 'yaml' %}
|
||||
<div style="padding: 12px 20px">
|
||||
@@ -557,6 +669,136 @@
|
||||
// ---- Channel names for add-user row ----
|
||||
const _allChannels = {{ all_channel_names | tojson }};
|
||||
|
||||
// ---- Channel CRUD ----
|
||||
let _channelSchemas = {};
|
||||
let _chEditName = null; // null = create mode, string = edit mode
|
||||
|
||||
async function _loadChannelSchemas() {
|
||||
try {
|
||||
const r = await fetch('/api/0/notification_channel_types');
|
||||
_channelSchemas = await r.json();
|
||||
const sel = document.getElementById('ch-type');
|
||||
if (!sel) return;
|
||||
Object.entries(_channelSchemas).forEach(([k, v]) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = k; opt.textContent = v.label;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
} catch(e) { console.warn('Could not load channel schemas', e); }
|
||||
}
|
||||
|
||||
function onChTypeChange() {
|
||||
const type = document.getElementById('ch-type').value;
|
||||
const container = document.getElementById('ch-type-fields');
|
||||
container.innerHTML = '';
|
||||
if (!type || !_channelSchemas[type]) return;
|
||||
const divider = document.createElement('div');
|
||||
divider.className = 'ch-form-divider';
|
||||
divider.textContent = _channelSchemas[type].label + ' settings';
|
||||
container.appendChild(divider);
|
||||
(_channelSchemas[type].fields || []).forEach(sf => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'ch-form-row';
|
||||
const lbl = document.createElement('label');
|
||||
lbl.textContent = sf.label + (sf.required ? ' *' : '');
|
||||
const inp = document.createElement(sf.type === 'secret' ? 'input' : 'input');
|
||||
inp.type = sf.type === 'secret' ? 'password' : 'text';
|
||||
inp.id = 'chf-' + sf.key;
|
||||
inp.placeholder = sf.required ? '(required)' : '(optional)';
|
||||
inp.autocomplete = 'off';
|
||||
row.appendChild(lbl);
|
||||
row.appendChild(inp);
|
||||
container.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
async function openChannelModal(name) {
|
||||
_chEditName = name || null;
|
||||
document.getElementById('ch-modal-status').textContent = '';
|
||||
document.getElementById('ch-modal-title').textContent = name ? 'Edit Channel' : 'Add Notification Channel';
|
||||
document.getElementById('ch-name').value = name || '';
|
||||
document.getElementById('ch-name').disabled = !!name;
|
||||
document.getElementById('ch-type').value = '';
|
||||
document.getElementById('ch-type-fields').innerHTML = '';
|
||||
document.getElementById('ch-min-level').value = 'WARNING';
|
||||
document.getElementById('ch-private').checked = false;
|
||||
|
||||
if (name) {
|
||||
// Load existing channel data via API
|
||||
try {
|
||||
const r = await fetch('/api/0/notification_channels');
|
||||
const channels = await r.json();
|
||||
const ch = channels.find(c => c.name === name);
|
||||
if (ch) {
|
||||
document.getElementById('ch-type').value = ch.type;
|
||||
onChTypeChange();
|
||||
document.getElementById('ch-min-level').value = ch.min_level || 'WARNING';
|
||||
document.getElementById('ch-private').checked = ch.private || false;
|
||||
(ch.fields || []).forEach(f => {
|
||||
const inp = document.getElementById('chf-' + f.key);
|
||||
if (inp) inp.value = f.value || '';
|
||||
});
|
||||
}
|
||||
} catch(e) { console.warn('Failed to load channel data', e); }
|
||||
}
|
||||
document.getElementById('ch-modal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeChannelModal() {
|
||||
document.getElementById('ch-modal').style.display = 'none';
|
||||
}
|
||||
|
||||
async function saveChannel() {
|
||||
const name = document.getElementById('ch-name').value.trim();
|
||||
const type = document.getElementById('ch-type').value;
|
||||
const minLevel = document.getElementById('ch-min-level').value;
|
||||
const isPrivate = document.getElementById('ch-private').checked;
|
||||
const statusEl = document.getElementById('ch-modal-status');
|
||||
statusEl.textContent = '';
|
||||
|
||||
if (!name) { statusEl.textContent = 'Channel name is required.'; statusEl.style.color = '#c62828'; return; }
|
||||
if (!type) { statusEl.textContent = 'Please select a type.'; statusEl.style.color = '#c62828'; return; }
|
||||
|
||||
const body = { name, type, min_level: minLevel, private: isPrivate };
|
||||
if (_channelSchemas[type]) {
|
||||
(_channelSchemas[type].fields || []).forEach(sf => {
|
||||
const inp = document.getElementById('chf-' + sf.key);
|
||||
if (inp) body[sf.key] = inp.value;
|
||||
});
|
||||
}
|
||||
|
||||
const isEdit = !!_chEditName;
|
||||
const url = isEdit ? '/api/0/notification_channels/' + encodeURIComponent(_chEditName) : '/api/0/notification_channels';
|
||||
const method = isEdit ? 'PUT' : 'POST';
|
||||
try {
|
||||
const r = await fetch(url, { method, headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body) });
|
||||
if (r.ok) {
|
||||
closeChannelModal();
|
||||
window.location.reload();
|
||||
} else {
|
||||
const err = await r.json().catch(() => ({}));
|
||||
statusEl.textContent = err.error || 'Error saving channel.';
|
||||
statusEl.style.color = '#c62828';
|
||||
}
|
||||
} catch(e) {
|
||||
statusEl.textContent = 'Network error: ' + e.message;
|
||||
statusEl.style.color = '#c62828';
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteChannel(name) {
|
||||
if (!confirm('Delete channel "' + name + '"? This cannot be undone.')) return;
|
||||
try {
|
||||
const r = await fetch('/api/0/notification_channels/' + encodeURIComponent(name), { method: 'DELETE' });
|
||||
if (r.ok) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
const err = await r.json().catch(() => ({}));
|
||||
alert('Error: ' + (err.error || 'Could not delete channel.'));
|
||||
}
|
||||
} catch(e) { alert('Network error: ' + e.message); }
|
||||
}
|
||||
|
||||
// ---- Staged changes accumulator ----
|
||||
const _staged = {};
|
||||
|
||||
@@ -707,6 +949,7 @@
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
_loadChannelSchemas();
|
||||
document.querySelectorAll('textarea[id^="yaml-"]').forEach(ta => {
|
||||
const sectionId = ta.id.replace('yaml-', '');
|
||||
const section = document.getElementById(sectionId);
|
||||
|
||||
Reference in New Issue
Block a user