From 581ecc8590fc558de6c884682394c6fe1cc50077 Mon Sep 17 00:00:00 2001 From: nke4 Date: Tue, 27 Oct 2026 11:32:37 +0100 Subject: [PATCH] Mashallah we got telegram now :o --- docker-compose.yml | 12 ++++++++---- src/alerts.rs | 20 +++++++++++++++++++- src/api.rs | 2 +- src/main.rs | 7 ++++++- web/app.js | 14 ++++++++++++-- 5 files changed, 46 insertions(+), 9 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 0894d19..4f45558 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,8 +4,12 @@ services: container_name: labdeck restart: unless-stopped ports: - - "8412:8787" # put a TLS reverse proxy in front (Caddy, Traefik, Pangolin ...), change port to your liking + - "8787:8787" # put a TLS reverse proxy in front (Caddy, Traefik, Pangolin ...), change port to your liking volumes: - - ./data:/data - # Health checks of LAN hosts work with the default bridge network. - # If you need to reach services bound to the Docker host itself, use network_mode: host instead. + - labdeck-data:/data # named volume: Docker sets the right owner automatically + # Bind mount instead? Create the folder first and give it to the container user: + # mkdir -p data && sudo chown 10001:10001 data + # - ./data:/data + +volumes: + labdeck-data: diff --git a/src/alerts.rs b/src/alerts.rs index f7fc3db..0c8172b 100644 --- a/src/alerts.rs +++ b/src/alerts.rs @@ -34,6 +34,21 @@ pub async fn send(app: &App, title: &str, body: &str, good: bool) -> Result<(), "slack" => client .post(&url) .json(&json!({ "text": format!("{} *{}*\n{}", if good { ":large_green_circle:" } else { ":red_circle:" }, title, body) })), + "telegram" => { + // URL: https://api.telegram.org/bot/sendMessage?chat_id= + let mut u = reqwest::Url::parse(&url).map_err(|e| format!("invalid Telegram URL: {e}"))?; + let chat_id = u + .query_pairs() + .find(|(k, _)| k == "chat_id") + .map(|(_, v)| v.into_owned()) + .ok_or("Telegram URL needs ?chat_id= at the end")?; + u.set_query(None); + client.post(u).json(&json!({ + "chat_id": chat_id, + "text": format!("{} {}\n{}", if good { "🟢" } else { "🔴" }, title, body), + "disable_web_page_preview": true, + })) + } "ntfy" => { let mut r = client .post(&url) @@ -63,6 +78,9 @@ pub async fn send(app: &App, title: &str, body: &str, good: bool) -> Result<(), if resp.status().is_success() { Ok(()) } else { - Err(format!("webhook answered HTTP {}", resp.status().as_u16())) + let code = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + let body: String = body.chars().take(200).collect(); + Err(format!("webhook answered HTTP {code}: {body}")) } } diff --git a/src/api.rs b/src/api.rs index 31b0118..088a1ab 100644 --- a/src/api.rs +++ b/src/api.rs @@ -504,7 +504,7 @@ async fn settings_put(State(app): Ctx, Json(mut s): Json) -> R { if !s.webhook_url.is_empty() && !(s.webhook_url.starts_with("http://") || s.webhook_url.starts_with("https://")) { return Err(bad("webhook URL must start with http:// or https://")); } - if !matches!(s.webhook_kind.as_str(), "json" | "discord" | "slack" | "ntfy") { + if !matches!(s.webhook_kind.as_str(), "json" | "discord" | "telegram" | "slack" | "ntfy") { return Err(bad("unknown webhook type")); } let c = app.db.lock().unwrap(); diff --git a/src/main.rs b/src/main.rs index 8244b78..561e542 100644 --- a/src/main.rs +++ b/src/main.rs @@ -53,7 +53,12 @@ async fn main() { let _ = std::fs::set_permissions(&data_dir, std::fs::Permissions::from_mode(0o700)); } let db_path = format!("{}/labdeck.db", data_dir.trim_end_matches('/')); - let conn = db::open(&db_path).expect("cannot open database"); + let conn = db::open(&db_path).unwrap_or_else(|e| { + eprintln!("cannot open database {db_path}: {e}"); + eprintln!("hint: the data directory must be writable by this process."); + eprintln!(" Docker bind mount: sudo chown 10001:10001 (or use a named volume)"); + std::process::exit(1); + }); let app = Arc::new(App { db: Mutex::new(conn), diff --git a/web/app.js b/web/app.js index bd8e67f..8b1ea74 100644 --- a/web/app.js +++ b/web/app.js @@ -850,9 +850,18 @@ async function buildSettings() { class: 'btn' + (currentTheme() === id ? ' primary' : ''), on: { click: () => { applyTheme(id); buildSettings(); } }, }, h('span', { class: 'sw', style: 'background:' + c }), id))); - const kind = h('select', { id: 'wk' }, [['json', 'Generic JSON'], ['discord', 'Discord'], ['slack', 'Slack'], ['ntfy', 'ntfy']] + const kind = h('select', { id: 'wk' }, [['json', 'Generic JSON'], ['discord', 'Discord'], ['telegram', 'Telegram'], ['slack', 'Slack'], ['ntfy', 'ntfy']] .map(([v, l]) => h('option', { value: v, selected: st.webhook_kind === v }, l))); - const url = h('input', { type: 'text', id: 'wu', value: st.webhook_url, placeholder: 'https://ntfy.example.com/homelab', spellcheck: false }); + const HOOK_HELP = { + json: ['https://example.com/hook', 'POSTs {title, message, status, time, source} as JSON.'], + discord: ['https://discord.com/api/webhooks//', 'Discord: channel settings > Integrations > Webhooks > New Webhook > Copy Webhook URL.'], + telegram: ['https://api.telegram.org/bot/sendMessage?chat_id=', 'Telegram: create a bot with @BotFather for the token, send the bot a message, then find your chat id via https://api.telegram.org/bot/getUpdates (result > message > chat > id). Groups have negative ids.'], + slack: ['https://hooks.slack.com/services/T000/B000/XXXX', 'Slack: create an Incoming Webhook app and copy its URL.'], + ntfy: ['https://ntfy.sh/my-homelab-alerts', 'ntfy: the full topic URL.'], + }; + const url = h('input', { type: 'text', id: 'wu', value: st.webhook_url, placeholder: HOOK_HELP[st.webhook_kind]?.[0] || '', spellcheck: false }); + const hookHelp = h('p', { class: 'dim' }, HOOK_HELP[st.webhook_kind]?.[1] || ''); + kind.addEventListener('change', () => { url.placeholder = HOOK_HELP[kind.value][0]; hookHelp.textContent = HOOK_HELP[kind.value][1]; }); const saveHook = async () => { await api('PUT', '/api/settings', { webhook_url: url.value, webhook_kind: kind.value }); }; const snipList = h('div', null, S.snippets.length ? S.snippets.map(sn => h('div', { class: 'row' }, @@ -881,6 +890,7 @@ async function buildSettings() { h('section', { class: 'section' }, h('h2', null, 'Alerts'), h('p', null, 'Get a message when a service goes down or recovers, or when a certificate is about to expire.'), h('div', { class: 'row' }, h('div', { style: 'width:180px' }, kind), h('div', { class: 'grow' }, url)), + hookHelp, h('div', { class: 'row' }, h('button', { class: 'btn primary', on: { click: async () => { try { await saveHook(); toast('Alert settings saved.'); } catch (e) { fail(e); } } } }, 'Save'), h('button', { class: 'btn', on: { click: async () => { try { await saveHook(); await api('POST', '/api/settings/test-webhook'); toast('Test message sent.'); } catch (e) { fail(e); } } } }, 'Send test message'))),