Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,9 +124,15 @@ generated from `config/alertmanager/alertmanager.yml.template` whenever

The custom alert UI lives at `/alerts/`. It signs users in with Grafana
username/password credentials and sends those credentials to `alert-api` as
HTTP Basic auth. The API validates credentials against Grafana, then uses the
installer-managed Grafana service account token to create, delete, disable, and
route alert rules.
HTTP Basic auth. The browser keeps that auth header in memory only for the
current page session (it is not persisted to browser storage). Use an Editor
account for alert-ui access and reserve admin login for direct Grafana admin
tasks.

The API validates credentials against Grafana, then uses the installer-managed
Grafana service account token to create, delete, disable, and route alert
rules. If this project scope grows, migrate alert-ui auth to backend-managed
sessions (cookie + server-side session ID) instead of browser-held Basic auth.

`install_alert_ui.sh` maintains the required Grafana service account:

Expand Down
79 changes: 49 additions & 30 deletions alert-ui/app.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
/* Fridge Alert Manager — app.js
*
* Auth: Grafana Basic auth credentials stored in sessionStorage.
* Auth: Grafana Basic auth header is held in-memory only (never browser storage).
* All /alerts/api/* calls include "Authorization: Basic <base64>" automatically.
* On 401, the login modal is shown and the credential is cleared.
* On 401 or sign-out, in-memory auth is cleared and login is shown.
*
* All fetch paths are absolute from the site root so they work behind Caddy.
*
Expand All @@ -11,7 +11,7 @@
*/

// ── State ──────────────────────────────────────────────────────────────────
let authHeader = sessionStorage.getItem('fridge_auth') || '';
let authHeader = '';
let metricsData = { metrics: [], fridges: [], operators: [] };
let refreshTimer = null;
let alertSort = { key: 'status', dir: 'desc' };
Expand All @@ -36,26 +36,13 @@ function hideLogin() {
document.getElementById('login-modal').classList.remove('visible');
}

function setAuth(username, password) {
authHeader = 'Basic ' + btoa(username + ':' + password);
sessionStorage.setItem('fridge_auth', authHeader);
function setAuth(username, authorizationHeader) {
authHeader = authorizationHeader;
document.getElementById('header-username').textContent = username;
}

function clearAuth() {
authHeader = '';
sessionStorage.removeItem('fridge_auth');
}

// Decode stored auth to get username (for display only)
function storedUsername() {
if (!authHeader.startsWith('Basic ')) return '';
try {
const decoded = atob(authHeader.slice(6));
return decoded.split(':')[0];
} catch (_) {
return '';
}
}

// ── Fetch wrapper ───────────────────────────────────────────────────────────
Expand Down Expand Up @@ -108,7 +95,7 @@ document.getElementById('login-form').addEventListener('submit', async (e) => {
if (resp.status === 401) {
document.getElementById('login-error').textContent = 'Incorrect username or password.';
} else {
setAuth(username, password);
setAuth(username, testHeader);
hideLogin();
document.getElementById('login-username').value = '';
document.getElementById('login-password').value = '';
Expand Down Expand Up @@ -206,6 +193,32 @@ document.getElementById('f-fridge').addEventListener('change', (e) => {
populateMetricDropdown(e.target.value);
});

document.getElementById('alerts-body').addEventListener('click', (e) => {
const actionBtn = e.target.closest('button[data-action]');
if (actionBtn) {
e.stopPropagation();
const uid = actionBtn.dataset.uid || '';
if (actionBtn.dataset.action === 'toggle-enabled') {
toggleAlert(uid, actionBtn.dataset.enabled === 'true', actionBtn);
return;
}
if (actionBtn.dataset.action === 'toggle-no-data') {
toggleNoDataState(uid, actionBtn.dataset.targetState || 'OK', actionBtn);
return;
}
if (actionBtn.dataset.action === 'delete-alert') {
deleteAlert(uid, actionBtn);
}
return;
}

const row = e.target.closest('tr[data-alert-uid]');
if (!row) return;
const uid = row.dataset.alertUid;
const alert = (window._alertsCache || []).find((a) => a.uid === uid);
if (alert) editAlert(alert);
});

// Load notification policy (public) and render repeat_interval as hours
function parseRepeatIntervalToHours(s) {
if (!s) return null;
Expand Down Expand Up @@ -373,14 +386,17 @@ function renderAlerts(alerts) {

const condition = a.operator ? `${escHtml(a.operator)} ${a.threshold}` : '—';

const toggleBtn = `<button class="btn btn-sm ${a.enabled ? 'btn-warn' : 'btn-primary'}" onclick="event.stopPropagation(); toggleAlert('${escHtml(a.uid)}', ${!a.enabled}, this)">${a.enabled ? 'Disable' : 'Enable'}</button>`;
const toggleBtn = `<button class="btn btn-sm ${a.enabled ? 'btn-warn' : 'btn-primary'}" data-action="toggle-enabled" data-uid="${escHtml(a.uid)}" data-enabled="${!a.enabled}">${a.enabled ? 'Disable' : 'Enable'}</button>`;

const noDataState = a.no_data_state || 'OK';
const noDataBtn = `<button class="btn btn-sm ${noDataState === 'Alerting' ? 'btn-warn' : 'btn-secondary'}" onclick="event.stopPropagation(); toggleNoDataState('${escHtml(a.uid)}', '${noDataState === 'Alerting' ? 'OK' : 'Alerting'}', this)">${noDataState === 'Alerting' ? 'Fire' : 'Ignore'}</button>`;
const noDataClass = noDataState === 'Alerting' ? 'btn-warn' : 'btn-secondary';
const noDataTarget = noDataState === 'Alerting' ? 'OK' : 'Alerting';
const noDataLabel = noDataState === 'Alerting' ? 'Fire' : 'Ignore';
const noDataBtn = `<button class="btn btn-sm ${noDataClass}" data-action="toggle-no-data" data-uid="${escHtml(a.uid)}" data-target-state="${noDataTarget}">${noDataLabel}</button>`;

const deleteBtn = a.provisioned
? ''
: `<button class="btn btn-danger btn-sm" onclick="event.stopPropagation(); deleteAlert('${escHtml(a.uid)}', this)">Delete</button>`;
: `<button class="btn btn-danger btn-sm" data-action="delete-alert" data-uid="${escHtml(a.uid)}">Delete</button>`;

const pencilIcon = `<span class="inline-pencil">✎</span>`;
const titleWithIcon = `<span class="alert-title-cell">${escHtml(a.title)}${pencilIcon}</span>`;
Expand All @@ -389,7 +405,7 @@ function renderAlerts(alerts) {
? `<span class="recipient-count">${a.recipient_count}</span>`
: '<span class="recipient-count">—</span>';

return `<tr onclick="editAlert(${escHtml(JSON.stringify(a))})" class="alert-row-editable">
return `<tr class="alert-row-editable" data-alert-uid="${escHtml(a.uid)}">
<td>${titleWithIcon}</td>
<td>${stateBadge}</td>
<td>${escHtml(a.fridge)}</td>
Expand Down Expand Up @@ -648,6 +664,10 @@ function toggleNoDataForm() {
setNoDataFormState(current === 'Alerting' ? 'OK' : 'Alerting');
}

document.getElementById('btn-no-data-state').addEventListener('click', toggleNoDataForm);
document.getElementById('sort-status').addEventListener('click', () => setAlertSort('status'));
document.getElementById('sort-fridge').addEventListener('click', () => setAlertSort('fridge'));

function editAlert(alert) {
// Populate form fields with this alert's values
document.getElementById('f-name').value = alert.title || '';
Expand Down Expand Up @@ -1147,15 +1167,14 @@ function escHtml(str) {
await loadPolicy();
await loadMetrics();

// Wire up fridge-level quick-toggle buttons
const btnManny = document.getElementById('btn-manny-all');
if (btnManny) btnManny.addEventListener('click', () => toggleAllForFridge('Manny', btnManny));
const btnDodo = document.getElementById('btn-dodo-all');
if (btnDodo) btnDodo.addEventListener('click', () => toggleAllForFridge('Dodo', btnDodo));

if (authHeader) {
// Show username from stored token
document.getElementById('header-username').textContent = storedUsername();
await loadAll();
// Wire up fridge-level quick-toggle buttons
const btnManny = document.getElementById('btn-manny-all');
if (btnManny) btnManny.addEventListener('click', () => toggleAllForFridge('Manny', btnManny));
const btnDodo = document.getElementById('btn-dodo-all');
if (btnDodo) btnDodo.addEventListener('click', () => toggleAllForFridge('Dodo', btnDodo));
// Ensure buttons reflect current alert state
updateFridgeButtons();
} else {
Expand Down
8 changes: 4 additions & 4 deletions alert-ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
<div id="login-modal" role="dialog" aria-modal="true" aria-labelledby="login-title">
<div class="login-box">
<h2 id="login-title">Sign in</h2>
<p>Use your Grafana username and password.</p>
<p>Use a Grafana Editor account for this UI (avoid admin credentials here).</p>
<form id="login-form">
<div class="form-group" style="margin-bottom:12px">
<label for="login-username">Username</label>
Expand Down Expand Up @@ -61,12 +61,12 @@ <h1><a href="https://websites.umass.edu/ipelenur/2026/04/06/cooling-water-temper
<tr>
<th>Name</th>
<th>
<button type="button" class="th-sort" id="sort-status" onclick="setAlertSort('status')" aria-label="Sort by status">
<button type="button" class="th-sort" id="sort-status" aria-label="Sort by status">
Status <span id="sort-status-ind" class="th-sort-ind"></span>
</button>
</th>
<th>
<button type="button" class="th-sort" id="sort-fridge" onclick="setAlertSort('fridge')" aria-label="Sort by fridge">
<button type="button" class="th-sort" id="sort-fridge" aria-label="Sort by fridge">
Fridge <span id="sort-fridge-ind" class="th-sort-ind"></span>
</button>
</th>
Expand Down Expand Up @@ -135,7 +135,7 @@ <h2>Create New Alert</h2>

<div class="form-group">
<label>No Data Behavior</label>
<button type="button" class="btn btn-warn btn-sm" id="btn-no-data-state" onclick="toggleNoDataForm()">No Data → Fire</button>
<button type="button" class="btn btn-warn btn-sm" id="btn-no-data-state">No Data → Fire</button>
</div>

<p class="form-hint">Notifications route to all recipients via the notification policy.</p>
Expand Down
1 change: 1 addition & 0 deletions config/caddy/Caddyfile
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
redir /alerts /alerts/ 308

handle_path /alerts/* {
header Content-Security-Policy "default-src 'self'; script-src 'self'; connect-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'"
root * /srv/alert-ui
file_server
}
Expand Down
Loading