From b0d1241da1743df182d87fdc6d18d45530f2309d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EC=B0=BD=EC=9A=B1?= Date: Sun, 12 Jul 2026 11:49:07 +0900 Subject: [PATCH] feat(cloud): dashboard rows link to detail + desk Web Audio on critical --- cloud/public/assets/app.js | 59 +++++++++++++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/cloud/public/assets/app.js b/cloud/public/assets/app.js index f787fa9..ade6666 100644 --- a/cloud/public/assets/app.js +++ b/cloud/public/assets/app.js @@ -55,6 +55,55 @@ return badge; } + // --- desk audio alert: repeating beep while any unacknowledged critical exists --- + // No binary asset (air-gapped friendly): a WebAudio oscillator tone. The browser + // blocks audio until a user gesture, so the context is created on first click. + var audioCtx = null; + var alarmTimer = null; + var beepedCritical = {}; // event_id -> true; each new critical beeps once immediately + + function unlockAudio() { + if (!audioCtx) { + var AC = window.AudioContext || window.webkitAudioContext; + if (AC) audioCtx = new AC(); + } + document.removeEventListener('click', unlockAudio); + } + + function beep() { + if (!audioCtx) return; + try { + var osc = audioCtx.createOscillator(); + var gain = audioCtx.createGain(); + osc.type = 'sine'; + osc.frequency.value = 880; + gain.gain.value = 0.2; + osc.connect(gain); + gain.connect(audioCtx.destination); + osc.start(); + osc.stop(audioCtx.currentTime + 0.4); + } catch (e) { + // audio unavailable; the visual list still surfaces the critical event + } + } + + function updateAlarm(events) { + var hasUnackedCritical = false; + events.forEach(function (ev) { + var acked = ev.acknowledged_by !== null && ev.acknowledged_by !== undefined && ev.acknowledged_by !== ''; + if (ev.severity === 'critical' && !acked) { + hasUnackedCritical = true; + if (!beepedCritical[ev.event_id]) { beepedCritical[ev.event_id] = true; beep(); } + } + }); + if (hasUnackedCritical && !alarmTimer) { + alarmTimer = setInterval(beep, 3000); + } else if (!hasUnackedCritical && alarmTimer) { + clearInterval(alarmTimer); + alarmTimer = null; + } + } + function buildRow(ev) { var isAcked = ev.acknowledged_by !== null && ev.acknowledged_by !== undefined && ev.acknowledged_by !== ''; @@ -68,7 +117,11 @@ var type = document.createElement('div'); type.className = 'event-type'; - type.textContent = String(ev.event_type || ''); + var typeLink = document.createElement('a'); + typeLink.className = 'event-detail-link'; + typeLink.href = '/events/' + encodeURIComponent(ev.event_id) + '/view'; + typeLink.textContent = String(ev.event_type || ''); + type.appendChild(typeLink); meta.appendChild(type); var sub = document.createElement('div'); @@ -118,6 +171,8 @@ return aAcked ? 1 : -1; // unacknowledged first }); + updateAlarm(sorted); // before any early return, so an emptied list also silences the alarm + root.textContent = ''; root.removeAttribute('aria-busy'); if (sorted.length === 0) { @@ -154,6 +209,8 @@ }); } + document.addEventListener('click', unlockAudio); + loadCsrfToken(); loadEvents(); setInterval(loadEvents, POLL_INTERVAL_MS);