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
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:
@@ -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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user