File indexing completed on 2026-04-28 07:24:56
0001 {% extends 'base.html' %}
0002 {% load static %}
0003 {% load swf_fmt %}
0004
0005 {% block title %}Edit {{ alarm_entry_id }} — Alarms{% endblock %}
0006
0007 {% block content %}
0008 <link rel="stylesheet" href="{% static 'css/state-colors.css' %}">
0009 <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.18/codemirror.min.css">
0010 <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.18/theme/material-darker.min.css">
0011 <script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.18/codemirror.min.js"></script>
0012 <script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.18/mode/markdown/markdown.min.js"></script>
0013 <script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.18/mode/javascript/javascript.min.js"></script>
0014
0015 <style>
0016 .CodeMirror { height: auto !important; border: 1px solid #333; font-family: "SF Mono", Monaco, Menlo, monospace; font-size: 13px; }
0017 .CodeMirror-scroll { min-height: 200px; max-height: 60vh; }
0018 #content-editor .CodeMirror { min-height: 260px; }
0019 #data-editor .CodeMirror { min-height: 220px; }
0020 .autosave-indicator { font-size: 12px; color: #888; margin-left: 1em; }
0021 .autosave-indicator.saving { color: #daa520; }
0022 .autosave-indicator.saved { color: #4CAF50; }
0023 .autosave-indicator.error { color: #dc3545; }
0024 .version-row.current td { background: #2a4a2a; }
0025 .version-row td { padding: 4px 8px; font-size: 12.5px; border-top: 1px solid #2a2a2a; }
0026 .version-row .vnum { color: #daa520; white-space: nowrap; }
0027 .version-row .vts { color: #8a8a8a; white-space: nowrap; }
0028 .version-row .vwho { color: #8a8a8a; white-space: nowrap; }
0029 .version-row .vlines { color: #888; white-space: nowrap; }
0030 .version-row .vprev { color: #bbb; max-width: 600px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
0031 .local-backup-row td { background: #2a2200 !important; color: #ff9800; }
0032 </style>
0033
0034 <div class="container mt-3" style="max-width: 1100px;">
0035 <p><a href="{% url 'monitor_app:alarms_dashboard' %}">← All alarms</a></p>
0036 <h2 class="mb-3">Edit <code>{{ alarm_entry_id }}</code></h2>
0037
0038 <div class="d-flex align-items-center mb-2">
0039 <button id="save-btn" class="btn btn-sm btn-secondary" disabled>Save</button>
0040 <span id="autosave-indicator" class="autosave-indicator"></span>
0041 </div>
0042
0043 <h3 style="font-size:1em;" class="mt-3">Title</h3>
0044 <input type="text" id="title-input" class="form-control" value="{{ title }}"
0045 maxlength="255" placeholder="Short human-readable title">
0046
0047 <div class="row mt-3 g-3" style="max-width: 1000px;">
0048 <div class="col-auto">
0049 <label class="form-label text-muted" style="font-size:0.85em;"
0050 title="When OFF, this alarm's detection still runs and events still appear on the dashboard. Only email notifications are suppressed.">Emails</label><br>
0051 <div class="form-check form-switch"
0052 title="When OFF, this alarm's detection still runs and events still appear on the dashboard. Only email notifications are suppressed.">
0053 <input class="form-check-input" type="checkbox" id="enabled-input"
0054 {% if enabled %}checked{% endif %}>
0055 </div>
0056 </div>
0057 <div class="col-auto">
0058 <label class="form-label text-muted" style="font-size:0.85em;">Renotification window (hours)</label>
0059 <input type="number" id="renotification-input" class="form-control form-control-sm"
0060 value="{{ renotification_window_hours }}" min="0" step="1" style="width:170px;"
0061 title="While an event is active, we don't re-email the same entity until this many hours have passed since the last notification. 0 = one email per event lifecycle, silent until clear.">
0062 </div>
0063 </div>
0064
0065 <h3 style="font-size:1em;" class="mt-3">Recipients</h3>
0066 <p class="text-muted" style="font-size:0.85em;">
0067 Email addresses and <code>@team</code> names, separated by commas or whitespace. Examples:
0068 <code>@prodops</code>, <code>alice@example.com, @prodops</code>.
0069 </p>
0070 <input type="text" id="recipients-input" class="form-control" value="{{ recipients_text }}"
0071 placeholder="@team1, user@example.com, …">
0072
0073 <h3 style="font-size:1em;" class="mt-3">Description / email body (markdown)</h3>
0074 <div id="content-editor">
0075 <textarea id="editor">{{ content }}</textarea>
0076 </div>
0077
0078 <h3 style="font-size:1em;" class="mt-4">Params</h3>
0079 {% if params_meta %}
0080 <table class="table table-sm" style="max-width:1000px; font-size:0.9em;">
0081 <thead>
0082 <tr><th>Param</th><th>Type</th><th>Required</th><th>Default</th><th>Description</th></tr>
0083 </thead>
0084 <tbody>
0085 {% for p in params_meta %}
0086 <tr{% if p.required %} style="font-weight:600;"{% endif %}>
0087 <td><code>{{ p.name }}</code></td>
0088 <td><small>{{ p.type }}</small></td>
0089 <td>{% if p.required %}<span class="text-danger">yes</span>{% else %}no{% endif %}</td>
0090 <td><small>{% if p.has_default %}{{ p.default|default_if_none:"—" }}{% else %}—{% endif %}</small></td>
0091 <td><small>{{ p.description }}</small></td>
0092 </tr>
0093 {% endfor %}
0094 </tbody>
0095 </table>
0096 {% else %}
0097 <p class="text-muted" style="font-size:0.85em;">
0098 (This alarm's module does not declare <code>PARAMS</code>, so no
0099 help table is rendered. Check the module source.)
0100 </p>
0101 {% endif %}
0102 <div id="data-editor">
0103 <textarea id="data-json">{{ data_json }}</textarea>
0104 </div>
0105
0106 <div class="mt-3 d-flex gap-2 align-items-center">
0107 <button id="test-btn" class="btn btn-sm btn-outline-info">Test (live, no email)</button>
0108 <span id="test-status" class="text-muted" style="font-size:0.85em;"></span>
0109 </div>
0110 <div id="test-output" class="mt-2" style="display:none;">
0111 <h4 style="font-size:0.95em;">Test result
0112 <small id="test-meta" class="text-muted"></small>
0113 </h4>
0114 <pre id="test-body" style="white-space:pre-wrap; background:#111; color:#ddd; padding:10px; border-radius:4px; max-height:50vh; overflow:auto; font-size:12.5px;"></pre>
0115 </div>
0116
0117 <div id="version-history" class="mt-4"></div>
0118 </div>
0119
0120 <script>
0121 (function() {
0122 var ALARM_ENTRY_ID = "{{ alarm_entry_id|escapejs }}";
0123 var SAVE_URL = "{% url 'monitor_app:alarm_config_save' entry_id=alarm_entry_id %}";
0124 var VERSION_URL = "{% url 'monitor_app:alarm_config_version' entry_id=alarm_entry_id version_num=0 %}".slice(0, -2); // append <num>/
0125 var TEST_URL = "{% url 'monitor_app:alarm_test' entry_id=alarm_entry_id %}";
0126 var LOCAL_KEY = 'swf-alarms-autosave-' + ALARM_ENTRY_ID;
0127 var AUTOSAVE_MS = 10000;
0128
0129 var cmContent = CodeMirror.fromTextArea(document.getElementById('editor'), {
0130 mode: 'markdown', theme: 'material-darker', lineNumbers: true,
0131 lineWrapping: true, tabSize: 2,
0132 extraKeys: {
0133 'Cmd-S': function() { save(false); },
0134 'Ctrl-S': function() { save(false); },
0135 },
0136 });
0137 var cmData = CodeMirror.fromTextArea(document.getElementById('data-json'), {
0138 mode: {name: 'javascript', json: true}, theme: 'material-darker',
0139 lineNumbers: true, tabSize: 2,
0140 });
0141
0142 var titleInput = document.getElementById('title-input');
0143 var enabledInput = document.getElementById('enabled-input');
0144 var renotificationInp = document.getElementById('renotification-input');
0145 var recipientsInput = document.getElementById('recipients-input');
0146
0147 var lastSavedTitle = titleInput.value;
0148 var lastSavedEnabled = enabledInput.checked;
0149 var lastSavedRenotification = renotificationInp.value;
0150 var lastSavedRecipients = recipientsInput.value;
0151 var lastSavedContent = cmContent.getValue();
0152 var lastSavedData = cmData.getValue();
0153
0154 var indicator = document.getElementById('autosave-indicator');
0155 function setIndicator(cls, text) {
0156 indicator.className = 'autosave-indicator ' + cls;
0157 indicator.textContent = text;
0158 }
0159
0160 var saveBtn = document.getElementById('save-btn');
0161 function isDirty() {
0162 return titleInput.value !== lastSavedTitle
0163 || enabledInput.checked !== lastSavedEnabled
0164 || renotificationInp.value !== lastSavedRenotification
0165 || recipientsInput.value !== lastSavedRecipients
0166 || cmContent.getValue() !== lastSavedContent
0167 || cmData.getValue() !== lastSavedData;
0168 }
0169 function updateSaveState() {
0170 if (isDirty()) {
0171 saveBtn.classList.remove('btn-secondary');
0172 saveBtn.classList.add('btn-primary');
0173 saveBtn.disabled = false;
0174 } else {
0175 saveBtn.classList.remove('btn-primary');
0176 saveBtn.classList.add('btn-secondary');
0177 saveBtn.disabled = true;
0178 }
0179 }
0180
0181 function save(isAutosave) {
0182 var titleVal = titleInput.value;
0183 var enabledVal = enabledInput.checked;
0184 var renotificationVal = renotificationInp.value;
0185 var recipientsVal = recipientsInput.value;
0186 var contentVal = cmContent.getValue();
0187 var dataStr = cmData.getValue();
0188 var parsed;
0189 try { parsed = JSON.parse(dataStr); }
0190 catch (e) {
0191 setIndicator('error', 'Invalid JSON in params: ' + e.message);
0192 return Promise.reject(e);
0193 }
0194 if (isAutosave &&
0195 titleVal === lastSavedTitle &&
0196 enabledVal === lastSavedEnabled &&
0197 renotificationVal === lastSavedRenotification &&
0198 recipientsVal === lastSavedRecipients &&
0199 contentVal === lastSavedContent &&
0200 dataStr === lastSavedData) {
0201 return Promise.resolve();
0202 }
0203 setIndicator('saving', isAutosave ? 'Autosaving…' : 'Saving…');
0204 return fetch(SAVE_URL, {
0205 method: 'POST',
0206 headers: { 'Content-Type': 'application/json' },
0207 body: JSON.stringify({
0208 title: titleVal,
0209 enabled: enabledVal,
0210 renotification_window_hours: parseInt(renotificationVal || '0', 10),
0211 recipients: recipientsVal,
0212 content: contentVal,
0213 data: parsed,
0214 autosave: !!isAutosave,
0215 }),
0216 }).then(function(r) {
0217 if (!r.ok) return r.json().then(function(j) { throw new Error(j.error || ('HTTP ' + r.status)); });
0218 return r.json();
0219 }).then(function(j) {
0220 lastSavedTitle = titleVal;
0221 lastSavedEnabled = enabledVal;
0222 lastSavedRenotification = renotificationVal;
0223 lastSavedRecipients = recipientsVal;
0224 lastSavedContent = contentVal;
0225 lastSavedData = dataStr;
0226 try { localStorage.removeItem(LOCAL_KEY); } catch (e) {}
0227 setIndicator('saved', (isAutosave ? 'Autosaved' : 'Saved') +
0228 ' — v' + (j.version_num || 0) +
0229 ' at ' + new Date((j.modified || 0) * 1000).toLocaleTimeString());
0230 updateSaveState();
0231 renderVersions();
0232 }).catch(function(e) {
0233 setIndicator('error', 'Save failed: ' + e.message);
0234 });
0235 }
0236
0237 document.getElementById('save-btn').addEventListener('click', function() { save(false); });
0238
0239 // Autosave loop.
0240 setInterval(function() { save(true); }, AUTOSAVE_MS);
0241
0242 // localStorage backup on every change (crash-proof).
0243 function backup() {
0244 try {
0245 localStorage.setItem(LOCAL_KEY, JSON.stringify({
0246 title: titleInput.value,
0247 enabled: enabledInput.checked,
0248 renotification_window_hours: renotificationInp.value,
0249 recipients: recipientsInput.value,
0250 content: cmContent.getValue(),
0251 data: cmData.getValue(),
0252 ts: Date.now(),
0253 }));
0254 } catch (e) {}
0255 }
0256 function onChange() { backup(); updateSaveState(); }
0257 cmContent.on('change', onChange);
0258 cmData.on('change', onChange);
0259 titleInput.addEventListener('input', onChange);
0260 enabledInput.addEventListener('change', onChange);
0261 renotificationInp.addEventListener('input', onChange);
0262 recipientsInput.addEventListener('input', onChange);
0263 updateSaveState();
0264
0265 // Flush on unload (best-effort).
0266 window.addEventListener('beforeunload', function() {
0267 var titleVal = titleInput.value;
0268 var contentVal = cmContent.getValue();
0269 var dataStr = cmData.getValue();
0270 if (titleVal === lastSavedTitle &&
0271 enabledInput.checked === lastSavedEnabled &&
0272 renotificationInp.value === lastSavedRenotification &&
0273 recipientsInput.value === lastSavedRecipients &&
0274 contentVal === lastSavedContent && dataStr === lastSavedData) return;
0275 try {
0276 var blob = new Blob([JSON.stringify({
0277 title: titleVal,
0278 enabled: enabledInput.checked,
0279 renotification_window_hours: parseInt(renotificationInp.value || '0', 10),
0280 recipients: recipientsInput.value,
0281 content: contentVal,
0282 data: JSON.parse(dataStr),
0283 autosave: true,
0284 })], { type: 'application/json' });
0285 navigator.sendBeacon(SAVE_URL, blob);
0286 } catch (e) {}
0287 });
0288
0289 // Version history table
0290 var versions = JSON.parse('{{ versions_json|escapejs }}');
0291
0292 function renderVersions() {
0293 // Reload versions from the server after a save.
0294 fetch(window.location.pathname, { headers: {'X-Fragment': 'versions'} })
0295 .then(function(r) { return r.text(); })
0296 .then(function(html) {
0297 // Simple scrape: we re-render from the server-side list only on page load.
0298 // For post-save, show last version as provisional; the server rendered row
0299 // will appear on next full page load.
0300 })
0301 .catch(function() {});
0302 }
0303
0304 function renderStaticVersions() {
0305 var root = document.getElementById('version-history');
0306 root.innerHTML = '';
0307 var hasLocal = false;
0308 try { hasLocal = !!localStorage.getItem(LOCAL_KEY); } catch (e) {}
0309
0310 var html = '<h3 style="font-size:1em;">Version history ' +
0311 '<span class="text-muted">(' + versions.length + ')</span></h3>';
0312 html += '<table class="table table-sm"><tbody>';
0313
0314 // Local-backup row
0315 if (hasLocal) {
0316 try {
0317 var local = JSON.parse(localStorage.getItem(LOCAL_KEY));
0318 html += '<tr class="local-backup-row">';
0319 html += '<td class="vnum">local</td>';
0320 html += '<td class="vts">' + new Date(local.ts).toISOString().replace('T', ' ').slice(0, 19) + '</td>';
0321 html += '<td class="vwho">autosave (browser)</td>';
0322 html += '<td class="vlines">' + ((local.content || '').split('\n').length) + ' lines</td>';
0323 html += '<td class="vprev">' + escapeHtml((local.content || '').split('\n')[0].slice(0, 120)) + '</td>';
0324 html += '<td><button class="btn btn-sm btn-outline-warning" onclick="swfAlarmsRestoreLocal()">Restore</button></td>';
0325 html += '</tr>';
0326 } catch (e) {}
0327 }
0328
0329 for (var i = 0; i < versions.length; i++) {
0330 var v = versions[i];
0331 html += '<tr class="version-row">';
0332 html += '<td class="vnum">v' + v.version_num + '</td>';
0333 html += '<td class="vts">' + fmtTs(v.timestamp) + '</td>';
0334 html += '<td class="vwho">' + escapeHtml(v.changed_by || '') + '</td>';
0335 html += '<td class="vlines">' + (v.line_count || 0) + ' lines</td>';
0336 html += '<td class="vprev">' + escapeHtml((v.preview || '').slice(0, 120)) + '</td>';
0337 html += '<td><button class="btn btn-sm btn-outline-secondary" onclick="swfAlarmsRestoreVersion(' +
0338 v.version_num + ')">Load</button></td>';
0339 html += '</tr>';
0340 }
0341 html += '</tbody></table>';
0342 root.innerHTML = html;
0343 }
0344 renderStaticVersions();
0345
0346 function fmtTs(t) {
0347 if (!t) return '';
0348 return new Date(t * 1000).toISOString().replace('T', ' ').slice(0, 19);
0349 }
0350 function escapeHtml(s) {
0351 return String(s || '').replace(/[&<>"']/g, function(c) {
0352 return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c];
0353 });
0354 }
0355
0356 window.swfAlarmsRestoreVersion = function(n) {
0357 if (!confirm('Load v' + n + ' into the editor? Unsaved changes will be lost.')) return;
0358 fetch(VERSION_URL + n + '/')
0359 .then(function(r) { return r.json(); })
0360 .then(function(j) {
0361 var d = j.data || {};
0362 titleInput.value = j.title || '';
0363 enabledInput.checked = !!d.enabled;
0364 if (d.renotification_window_hours !== undefined)
0365 renotificationInp.value = d.renotification_window_hours;
0366 if (Array.isArray(d.recipients)) {
0367 recipientsInput.value = d.recipients.join(', ');
0368 } else if (d.recipients) {
0369 recipientsInput.value = String(d.recipients);
0370 }
0371 cmContent.setValue(j.content || '');
0372 try { cmData.setValue(JSON.stringify({
0373 kind: d.kind,
0374 params: d.params,
0375 }, null, 2)); } catch (e) {}
0376 setIndicator('saved', 'v' + n + ' loaded — edit and save to commit.');
0377 updateSaveState();
0378 })
0379 .catch(function(e) { setIndicator('error', 'Restore failed: ' + e.message); });
0380 };
0381 // ── Test + Preview ───────────────────────────────────────────────
0382 var testOut = document.getElementById('test-output');
0383 var testBody = document.getElementById('test-body');
0384 var testMeta = document.getElementById('test-meta');
0385 var testStat = document.getElementById('test-status');
0386
0387 function currentParamsParsed() {
0388 try { return JSON.parse(cmData.getValue()); }
0389 catch (e) { return null; }
0390 }
0391
0392 document.getElementById('test-btn').addEventListener('click', function() {
0393 var params = currentParamsParsed();
0394 if (params === null) {
0395 testStat.textContent = 'Invalid JSON in params — fix before testing.';
0396 return;
0397 }
0398 testStat.textContent = 'Running (live fetch)…';
0399 testOut.style.display = 'none';
0400 fetch(TEST_URL, {
0401 method: 'POST',
0402 headers: {'Content-Type': 'application/json'},
0403 body: JSON.stringify({params: params}),
0404 }).then(function(r) { return r.json(); })
0405 .then(function(j) {
0406 testStat.textContent = '';
0407 testOut.style.display = '';
0408 if (j.error) {
0409 testMeta.textContent = ' — error after ' + (j.elapsed_ms||0) + ' ms';
0410 testBody.textContent = j.error;
0411 return;
0412 }
0413 testMeta.textContent = ' — ' + j.count + ' detection(s) in ' + (j.elapsed_ms||0) + ' ms';
0414 if (!j.detections.length) {
0415 testBody.textContent = '(no detections — conditions did not match any entity)';
0416 return;
0417 }
0418 var lines = [];
0419 for (var i = 0; i < j.detections.length; i++) {
0420 var d = j.detections[i];
0421 lines.push('[' + (i+1) + '] ' + d.subject);
0422 if (d.body_context) lines.push(' ---');
0423 if (d.body_context) lines.push(d.body_context.replace(/^/gm, ' '));
0424 lines.push('');
0425 }
0426 testBody.textContent = lines.join('\n');
0427 })
0428 .catch(function(e) {
0429 testStat.textContent = 'Test failed: ' + e.message;
0430 });
0431 });
0432
0433 window.swfAlarmsRestoreLocal = function() {
0434 try {
0435 var local = JSON.parse(localStorage.getItem(LOCAL_KEY));
0436 if (!local) return;
0437 if (!confirm('Restore from local browser backup? Unsaved server state will be overwritten on save.')) return;
0438 titleInput.value = local.title || '';
0439 if (local.enabled !== undefined) enabledInput.checked = !!local.enabled;
0440 if (local.renotification_window_hours !== undefined)
0441 renotificationInp.value = local.renotification_window_hours;
0442 if (local.recipients !== undefined) recipientsInput.value = local.recipients;
0443 cmContent.setValue(local.content || '');
0444 cmData.setValue(local.data || '{}');
0445 setIndicator('saved', 'Local backup restored — save to commit.');
0446 updateSaveState();
0447 } catch (e) { setIndicator('error', 'Restore failed: ' + e.message); }
0448 };
0449 })();
0450 </script>
0451 {% endblock %}