feat: public flood notifications over self-hosted ntfy
CI / Format & lint (push) Successful in 11s
Security / Static analysis (push) Successful in 12s
CI / Test suite (push) Successful in 18s
Docs / Validate documentation (push) Successful in 11s
Security / Dependency vulnerabilities (push) Successful in 1m30s
Security / License report (push) Successful in 47s

Anyone can now get push alerts on their phone without an account: the
monitor publishes to an ntfy server (one Go binary, ~30 MB RSS) and
subscribers pick topics in the free iOS/Android/web app.

Semantics are transitions, never state. One message when a gauge crosses
its warning or danger threshold, one all-clear when it drops back (0.10 m
hysteresis), nothing while it sits above. A three-day flood is two
messages; a quiet season is zero. Topics: ping-warning / ping-danger
(basin digest), ping-<station>-warning / -danger, ping-p1-outlook (opt-in:
model P(warning within 24 h) at P.1 rises through 50 %, clears below 25 %,
message says it is experimental), ping-status (feed stale >= 3 h /
recovered). Priority 5 on danger so it rings through Do Not Disturb.

src/notify.py runs once per collection cycle in the API process (leader
only, after the forecast precompute, same data the dashboard shows).
Last-sent state lives in a notification_state table so a restart never
re-sends; a failed publish leaves state untouched so the crossing is
retried next cycle instead of lost. Off unless NTFY_SERVER is set.

Dashboard: a "Get alerts" button (only when configured) opens a panel
with the server, per-topic cards, ntfy:// deep links and web links, app
store links and a disclaimer. EN + TH. GET /api/notifications feeds it.

scripts/install_ntfy.sh: .deb install, server.yml (loopback listen,
anonymous read, token-only write scoped to ping-*, 72 h cache, signup/
login/metrics off, tight visitor limits), systemd, user + token, .env.
Verified against ntfy 2.28.0: anon publish 403, token publish 200, token
on foreign topic 403, anon read 200, and a seeded crossing through the
real _notify_transitions path arrived in the topic with priority, tags,
click and action button. docs/NOTIFICATIONS.md has the deployment and
reverse-proxy notes. Tests: 10 for the state machine (159 total).
This commit is contained in:
2026-09-12 00:18:38 +02:00
parent 0ec675e9c5
commit 777b230baf
10 changed files with 1252 additions and 2 deletions
+148
View File
@@ -113,6 +113,25 @@
.lang-toggle { padding: 9px 12px; font-size: .78rem; font-weight: 800; white-space: nowrap; }
.lang-toggle[data-active-lang="th"] { background: var(--mint); border-color: var(--mint-border); color: var(--mint-ink); }
.theme-toggle { padding: 9px 11px; font-size: .95rem; line-height: 1; }
.alerts-panel { margin-bottom: 14px; padding: 18px 20px; border-radius: 16px; background: var(--card); border: 1px solid var(--border); box-shadow: var(--shadow); }
.alerts-head { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
.alerts-head h2 { margin: 0; font-size: 1.15rem; }
.alerts-close { background: transparent; border: 0; color: var(--muted); font-size: 1.1rem; cursor: pointer; padding: 4px 8px; }
.alerts-intro { color: var(--muted); font-size: .92rem; line-height: 1.5; margin: 8px 0 12px; }
.alerts-server { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; font-size: .9rem; margin-bottom: 12px; }
.alerts-server code { background: var(--surface); border: 1px solid var(--border); padding: 4px 8px; border-radius: 8px; font-size: .9rem; }
.alerts-server button { padding: 4px 10px; font-size: .8rem; }
.alerts-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 10px; }
.alerts-topic { border: 1px solid var(--border); border-radius: 12px; padding: 10px 12px; background: var(--surface); display: flex; flex-direction: column; gap: 4px; }
.alerts-topic .name { font-weight: 600; font-size: .95rem; }
.alerts-topic .desc { color: var(--muted); font-size: .82rem; line-height: 1.4; }
.alerts-topic .row { display: flex; align-items: center; gap: 8px; margin-top: 4px; flex-wrap: wrap; }
.alerts-topic code { font-size: .82rem; background: var(--card); border: 1px solid var(--border); padding: 2px 6px; border-radius: 6px; }
.alerts-topic a { font-size: .82rem; }
.alerts-topic.danger { border-color: rgba(220, 38, 38, .45); }
.alerts-topic.outlook { border-style: dashed; }
.alerts-foot { color: var(--muted); font-size: .82rem; margin: 12px 0 0; line-height: 1.6; }
.alerts-disclaimer { display: block; margin-top: 4px; }
.leaflet-popup-content-wrapper, .leaflet-popup-tip { background: var(--card); color: var(--ink); }
.leaflet-container a.leaflet-popup-close-button { color: var(--muted); }
.leaflet-bar a, .leaflet-control-attribution { background: var(--surface); color: var(--ink); border-color: var(--border); }
@@ -348,10 +367,33 @@
<button id="refresh-button" type="button" data-i18n="action.refresh">↻ Refresh</button>
<button id="lang-toggle" class="lang-toggle" type="button" data-active-lang="en" aria-label="Switch to Thai">ไทย</button>
<button id="theme-toggle" class="theme-toggle" type="button" data-i18n-aria="theme.toggle" aria-label="Switch to dark mode" title="Switch to dark mode">🌙</button>
<button id="alerts-button" type="button" data-i18n="alerts.button" style="display:none">🔔 Get alerts</button>
<button id="replay-2024" type="button" data-i18n="replay.start">▶ Replay Oct 2024 flood</button>
</div>
</header>
<section id="alerts-panel" class="alerts-panel" style="display:none" aria-labelledby="alerts-title">
<div class="alerts-head">
<h2 id="alerts-title" data-i18n="alerts.title">Flood alerts on your phone</h2>
<button type="button" class="alerts-close" id="alerts-close" data-i18n-aria="alerts.close" aria-label="Close"></button>
</div>
<p class="alerts-intro" data-i18n="alerts.intro">Free push notifications when a gauge crosses its warning or danger level, and an all-clear when it drops back. No account: install the ntfy app (iOS / Android / any browser), add the server, subscribe to the topics you want. You get a message only when something changes: a few per flood, none in a quiet season.</p>
<div class="alerts-server">
<span data-i18n="alerts.server">Server</span>
<code id="alerts-server-url"></code>
<button type="button" id="alerts-copy" data-i18n="alerts.copy">Copy</button>
</div>
<div class="alerts-grid" id="alerts-topics"></div>
<p class="alerts-foot">
<span data-i18n="alerts.apps">Apps:</span>
<a href="https://apps.apple.com/us/app/ntfy/id1625396347" target="_blank" rel="noopener">iOS</a> ·
<a href="https://play.google.com/store/apps/details?id=io.heckel.ntfy" target="_blank" rel="noopener">Android</a> ·
<a href="https://f-droid.org/en/packages/io.heckel.ntfy/" target="_blank" rel="noopener">F-Droid</a> ·
<a id="alerts-web-link" href="#" target="_blank" rel="noopener" data-i18n="alerts.web">Web (no install)</a>
<span class="alerts-disclaimer" data-i18n="alerts.disclaimer">Unofficial community service, best effort. For official warnings follow ThaiWater / TMD / your district office.</span>
</p>
</section>
<section id="flood-verdict" role="status" aria-live="polite" style="display:none;margin-bottom:14px;padding:15px 18px;border-radius:16px;border:1px solid;display:none">
<div style="display:flex;gap:12px;align-items:baseline;flex-wrap:wrap">
<strong id="verdict-icon" style="font-size:1.2rem"></strong>
@@ -585,6 +627,31 @@
'skill.worse': (v, prev, d) => `Current model ${v} has a higher peak error than ${prev} so far (+${d} cm).`,
'skill.caveat.quiet': 'All verified hours so far were below 2 m: this measures quiet-river accuracy only. The model is built and judged for flood onset (lead time before 3.70 m), which no quiet week can test — see the backtests in the documentation.',
'skill.caveat.regime': 'Versions served different weeks; the ≥ 2 m column compares them on the hours that matter.',
'alerts.button': '🔔 Get alerts',
'alerts.title': 'Flood alerts on your phone',
'alerts.intro': 'Free push notifications when a gauge crosses its warning or danger level, and an all-clear when it drops back. No account: install the ntfy app (iOS / Android / any browser), add the server, subscribe to the topics you want. You get a message only when something changes: a few per flood, none in a quiet season.',
'alerts.server': 'Server',
'alerts.copy': 'Copy',
'alerts.copied': 'Copied',
'alerts.close': 'Close',
'alerts.apps': 'Apps:',
'alerts.web': 'Web (no install)',
'alerts.disclaimer': 'Unofficial community service, best effort. For official warnings follow ThaiWater / TMD / your district office.',
'alerts.subscribe': 'Subscribe in app',
'alerts.t.warning': 'Any gauge: warning level',
'alerts.t.warning.d': 'One message when any Ping River gauge crosses its warning level, and when levels fall back. The one to pick if unsure.',
'alerts.t.danger': 'Any gauge: danger level',
'alerts.t.danger.d': 'Only the serious crossings, basin-wide. Highest priority: rings through Do Not Disturb on most phones.',
'alerts.t.p1.warning': 'Chiang Mai city (P.1) warning',
'alerts.t.p1.warning.d': 'Nawarat Bridge crosses 3.70 m (stage 1: low-lying riverside areas), and the all-clear.',
'alerts.t.p1.danger': 'Chiang Mai city (P.1) danger',
'alerts.t.p1.danger.d': 'Nawarat Bridge crosses 4.20 m (stage 5: inner city districts).',
'alerts.t.p103.warning': 'Ring Road 3 (P.103) warning',
'alerts.t.p103.warning.d': 'Downstream city gauge crosses 5.95 m.',
'alerts.t.outlook': 'Early warning (model forecast)',
'alerts.t.outlook.d': 'Experimental: the forecast model gives a ≥ 50 % chance that P.1 reaches its warning level within 24 h. Up to ~13 h earlier than the gauge, but it can be wrong.',
'alerts.t.status': 'Monitor status',
'alerts.t.status.d': 'Gauge feed stale / recovered. For people who rely on the dashboard.',
'skill.single': (v) => `Only ${v} has enough verified hours yet; the next retrain adds a row to compare.`,
'skill.young': (v, n, min) => `${v} has ${n} verified hours; a comparison needs ${min}.`,
'skill.none': 'No verified forecasts yet — the first appear 24 h after a model starts serving.',
@@ -781,6 +848,31 @@
'skill.worse': (v, prev, d) => `โมเดลปัจจุบัน ${v} มีค่าคลาดเคลื่อนสูงกว่า ${prev} (+${d} ซม.)`,
'skill.caveat.quiet': 'ชั่วโมงที่ตรวจสอบทั้งหมดอยู่ต่ำกว่า 2 ม.: วัดได้เพียงความแม่นยำช่วงน้ำปกติ โมเดลถูกสร้างและประเมินสำหรับช่วงน้ำเริ่มท่วม (เวลาเตือนล่วงหน้าก่อน 3.70 ม.) ซึ่งสัปดาห์ปกติทดสอบไม่ได้ — ดูผลทดสอบย้อนหลังในเอกสาร',
'skill.caveat.regime': 'แต่ละเวอร์ชันให้บริการคนละช่วงเวลา คอลัมน์ ≥ 2 ม. เปรียบเทียบเฉพาะชั่วโมงที่สำคัญ',
'alerts.button': '🔔 รับการแจ้งเตือน',
'alerts.title': 'แจ้งเตือนน้ำท่วมบนมือถือของคุณ',
'alerts.intro': 'การแจ้งเตือนฟรีเมื่อระดับน้ำที่สถานีใดข้ามระดับเฝ้าระวังหรือระดับอันตราย และแจ้งเมื่อกลับสู่ปกติ ไม่ต้องสมัครสมาชิก: ติดตั้งแอป ntfy (iOS / Android / เบราว์เซอร์) เพิ่มเซิร์ฟเวอร์ แล้วเลือกหัวข้อที่ต้องการ คุณจะได้รับข้อความเฉพาะเมื่อมีการเปลี่ยนแปลง: ไม่กี่ข้อความต่อเหตุการณ์น้ำท่วม และไม่มีเลยในช่วงปกติ',
'alerts.server': 'เซิร์ฟเวอร์',
'alerts.copy': 'คัดลอก',
'alerts.copied': 'คัดลอกแล้ว',
'alerts.close': 'ปิด',
'alerts.apps': 'แอป:',
'alerts.web': 'เว็บ (ไม่ต้องติดตั้ง)',
'alerts.disclaimer': 'บริการชุมชนอย่างไม่เป็นทางการ พยายามอย่างดีที่สุด สำหรับคำเตือนอย่างเป็นทางการโปรดติดตาม ThaiWater / กรมอุตุนิยมวิทยา / สำนักงานอำเภอของคุณ',
'alerts.subscribe': 'สมัครในแอป',
'alerts.t.warning': 'สถานีใดก็ได้: ระดับเฝ้าระวัง',
'alerts.t.warning.d': 'หนึ่งข้อความเมื่อสถานีใดในแม่น้ำปิงข้ามระดับเฝ้าระวัง และเมื่อระดับน้ำลดลง หากไม่แน่ใจให้เลือกอันนี้',
'alerts.t.danger': 'สถานีใดก็ได้: ระดับอันตราย',
'alerts.t.danger.d': 'เฉพาะการข้ามระดับที่ร้ายแรง ทั้งลุ่มน้ำ ความสำคัญสูงสุด: ดังผ่านโหมดห้ามรบกวนในโทรศัพท์ส่วนใหญ่',
'alerts.t.p1.warning': 'เมืองเชียงใหม่ (P.1) ระดับเฝ้าระวัง',
'alerts.t.p1.warning.d': 'สะพานนวรัฐข้าม 3.70 ม. (ระยะที่ 1: พื้นที่ริมน้ำที่ต่ำ) และแจ้งเมื่อกลับสู่ปกติ',
'alerts.t.p1.danger': 'เมืองเชียงใหม่ (P.1) ระดับอันตราย',
'alerts.t.p1.danger.d': 'สะพานนวรัฐข้าม 4.20 ม. (ระยะที่ 5: ย่านใจกลางเมือง)',
'alerts.t.p103.warning': 'ถนนวงแหวน 3 (P.103) ระดับเฝ้าระวัง',
'alerts.t.p103.warning.d': 'สถานีท้ายเมืองข้าม 5.95 ม.',
'alerts.t.outlook': 'เตือนล่วงหน้า (แบบจำลองพยากรณ์)',
'alerts.t.outlook.d': 'ทดลอง: แบบจำลองพยากรณ์ให้โอกาส ≥ 50% ที่ P.1 จะถึงระดับเฝ้าระวังภายใน 24 ชม. เร็วกว่าสถานีวัดได้ถึง ~13 ชม. แต่อาจผิดพลาดได้',
'alerts.t.status': 'สถานะระบบ',
'alerts.t.status.d': 'ข้อมูลสถานีล่าช้า / กลับมาปกติ สำหรับผู้ที่พึ่งพาแดชบอร์ด',
'skill.single': (v) => `มีเพียง ${v} ที่มีข้อมูลตรวจสอบเพียงพอ การฝึกครั้งถัดไปจะเพิ่มแถวให้เปรียบเทียบ`,
'skill.young': (v, n, min) => `${v} มีข้อมูลตรวจสอบ ${n} ชั่วโมง ต้องการอย่างน้อย ${min} เพื่อเปรียบเทียบ`,
'skill.none': 'ยังไม่มีพยากรณ์ที่ตรวจสอบได้ — จะเริ่มมี 24 ชม. หลังโมเดลเริ่มทำงาน',
@@ -905,6 +997,60 @@
return typeof value === 'function' ? value(...args) : value;
}
// ---- public push notifications (ntfy) -------------------------------------
let ALERTS_CFG = null;
const ALERT_TOPICS = [
{ key: 'warning', topic: 'warning', cls: '' },
{ key: 'danger', topic: 'danger', cls: 'danger' },
{ key: 'p1.warning', topic: 'p1-warning', cls: '' },
{ key: 'p1.danger', topic: 'p1-danger', cls: 'danger' },
{ key: 'p103.warning', topic: 'p103-warning', cls: '' },
{ key: 'outlook', topic: 'p1-outlook', cls: 'outlook' },
{ key: 'status', topic: 'status', cls: '' },
];
async function loadAlertsConfig() {
try {
const r = await fetch('/api/notifications');
if (!r.ok) return;
const cfg = await r.json();
if (!cfg.enabled || !cfg.server) return;
ALERTS_CFG = cfg;
$('alerts-button').style.display = '';
renderAlertsPanel();
} catch (e) { /* no notifications configured */ }
}
function renderAlertsPanel() {
if (!ALERTS_CFG) return;
const server = ALERTS_CFG.server.replace(/\/$/, '');
const host = server.replace(/^https?:\/\//, '');
$('alerts-server-url').textContent = host;
$('alerts-web-link').href = server + '/' + ALERTS_CFG.prefix + '-warning';
$('alerts-topics').innerHTML = ALERT_TOPICS.map(tp => {
const full = ALERTS_CFG.prefix + '-' + tp.topic;
const url = server + '/' + full;
return `<div class="alerts-topic ${tp.cls}">
<div class="name">${esc(t('alerts.t.' + tp.key))}</div>
<div class="desc">${esc(t('alerts.t.' + tp.key + '.d'))}</div>
<div class="row"><code>${esc(full)}</code> <a href="ntfy://${esc(host)}/${esc(full)}">${esc(t('alerts.subscribe'))}</a> · <a href="${esc(url)}" target="_blank" rel="noopener">web</a></div>
</div>`;
}).join('');
}
function esc(x) { return String(x).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
$('alerts-button').addEventListener('click', () => {
const p = $('alerts-panel');
const open = p.style.display === 'none';
p.style.display = open ? '' : 'none';
if (open) { renderAlertsPanel(); p.scrollIntoView({ behavior: 'smooth', block: 'start' }); }
});
$('alerts-close').addEventListener('click', () => { $('alerts-panel').style.display = 'none'; });
$('alerts-copy').addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(ALERTS_CFG ? ALERTS_CFG.server : '');
$('alerts-copy').textContent = t('alerts.copied');
setTimeout(() => { $('alerts-copy').textContent = t('alerts.copy'); }, 1500);
} catch (e) { /* clipboard blocked */ }
});
function applyTranslations() {
document.documentElement.lang = state.lang;
document.querySelectorAll('[data-i18n]').forEach((el) => {
@@ -959,6 +1105,7 @@
function cssVar(name) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); }
function setLang(lang) {
setTimeout(renderAlertsPanel, 0);
state.lang = lang;
try { localStorage.setItem(LANG_KEY, lang); } catch (e) { /* private mode */ }
applyTranslations();
@@ -2052,6 +2199,7 @@
: t('forecast.expand', stations.length);
card.style.display = 'block';
loadSkill(); // non-blocking; panel stays hidden until there is verified data
loadAlertsConfig(); // shows the "Get alerts" button only when ntfy is configured
} catch (error) {
card.style.display = 'none';
}