From 459198c2d85e5e2542e55faf0955af95da3695aa Mon Sep 17 00:00:00 2001 From: Kyle Schultz Date: Tue, 7 Jul 2026 15:40:15 -0700 Subject: [PATCH 01/42] feat: add notification destinations --- backend/api/settings.py | 218 +++++++++++++++++++- backend/database.py | 2 +- backend/migrations.py | 42 ++++ backend/models.py | 12 ++ backend/services/collector.py | 32 ++- backend/services/image_checker.py | 13 +- backend/services/notifications.py | 196 ++++++++++++++++++ backend/tests/test_collector_logs.py | 2 +- backend/tests/test_migrations.py | 3 +- frontend/src/api.ts | 23 ++- frontend/src/pages/Settings.tsx | 295 ++++++++++++++++++++++++++- frontend/src/types.ts | 20 ++ 12 files changed, 821 insertions(+), 37 deletions(-) create mode 100644 backend/services/notifications.py diff --git a/backend/api/settings.py b/backend/api/settings.py index d3211b8..2afb2cc 100644 --- a/backend/api/settings.py +++ b/backend/api/settings.py @@ -1,4 +1,6 @@ -from typing import Dict, List +import json +from datetime import datetime +from typing import Any, Dict, List from zoneinfo import available_timezones from fastapi import APIRouter, Depends, HTTPException @@ -6,15 +8,16 @@ from sqlmodel import Session, select from database import get_session -from models import AppSetting, ContainerAlertSetting +from models import AppSetting, ContainerAlertSetting, NotificationDestination from services.app_settings import get_setting, set_setting -from services import discord +from services import discord, notifications router = APIRouter(prefix="/api/settings", tags=["settings"]) # The event types exposed in the UI. # "die" events reuse the "crash" setting (see events.py). ALERT_EVENT_TYPES = ("crash", "restart", "oom", "update_available") +DESTINATION_TYPES = ("discord", "slack", "email", "webhook") _DEFAULT_LOG_RETENTION_DAYS = 7 _DEFAULT_EXITED_CONTAINER_TTL_SECONDS = 300 @@ -135,6 +138,209 @@ def patch_alert_setting( return existing.dict() +# ── Notification destinations ───────────────────────────────────────────────── + +_SECRET_CONFIG_KEYS = {"webhook_url", "password", "secret"} + + +class NotificationDestinationPayload(BaseModel): + name: str = Field(min_length=1, max_length=128) + destination_type: str = Field(max_length=32) + enabled: bool = True + config: dict[str, Any] = Field(default_factory=dict) + + @field_validator("destination_type") + @classmethod + def validate_destination_type(cls, value: str) -> str: + if value not in DESTINATION_TYPES: + raise ValueError(f"destination_type must be one of {DESTINATION_TYPES}") + return value + + +class NotificationDestinationPatch(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=128) + enabled: bool | None = None + config: dict[str, Any] | None = None + + +def _load_destination_config(destination: NotificationDestination) -> dict[str, Any]: + try: + parsed = json.loads(destination.config_json or "{}") + except json.JSONDecodeError: + return {} + return parsed if isinstance(parsed, dict) else {} + + +def _public_destination(destination: NotificationDestination) -> dict: + config = _load_destination_config(destination) + public_config = {k: v for k, v in config.items() if k not in _SECRET_CONFIG_KEYS} + return { + "id": destination.id, + "name": destination.name, + "destination_type": destination.destination_type, + "enabled": destination.enabled, + "configured": bool(config), + "config": public_config, + "created_at": destination.created_at, + "updated_at": destination.updated_at, + } + + +def _clean_destination_config( + destination_type: str, + config: dict[str, Any], + existing: dict[str, Any] | None = None, +) -> dict[str, Any]: + base = dict(existing or {}) + incoming = {k: v for k, v in config.items() if v is not None and v != ""} + merged = {**base, **incoming} + + if destination_type == "discord": + webhook_url = str(merged.get("webhook_url") or "") + if not (webhook_url.startswith("https://discord.com/webhooks/") or webhook_url.startswith("https://discord.com/api/webhooks/")): + raise HTTPException(status_code=422, detail="Discord webhook URL must be a Discord webhook URL.") + return {"webhook_url": webhook_url} + + if destination_type == "slack": + webhook_url = str(merged.get("webhook_url") or "") + if not webhook_url.startswith("https://hooks.slack.com/services/"): + raise HTTPException(status_code=422, detail="Slack webhook URL must start with https://hooks.slack.com/services/.") + return {"webhook_url": webhook_url} + + if destination_type == "webhook": + webhook_url = str(merged.get("webhook_url") or "") + if not (webhook_url.startswith("https://") or webhook_url.startswith("http://")): + raise HTTPException(status_code=422, detail="Webhook URL must start with http:// or https://.") + result = {"webhook_url": webhook_url} + secret = str(merged.get("secret") or "") + if secret: + result["secret"] = secret + return result + + if destination_type == "email": + host = str(merged.get("host") or "") + from_email = str(merged.get("from_email") or "") + to_emails = str(merged.get("to_emails") or "") + if not host or not from_email or not to_emails: + raise HTTPException(status_code=422, detail="Email destinations require host, from_email, and to_emails.") + try: + port = int(merged.get("port") or 587) + except (TypeError, ValueError): + raise HTTPException(status_code=422, detail="Email port must be a number.") + return { + "host": host, + "port": port, + "username": str(merged.get("username") or ""), + "password": str(merged.get("password") or ""), + "from_email": from_email, + "to_emails": to_emails, + "use_tls": bool(merged.get("use_tls", True)), + } + + raise HTTPException(status_code=422, detail=f"Unknown destination type: {destination_type}") + + +def _upsert_discord_destination(session: Session, webhook_url: str) -> None: + existing = session.exec( + select(NotificationDestination) + .where(NotificationDestination.destination_type == "discord") + ).first() + if webhook_url == "": + if existing: + existing.enabled = False + existing.updated_at = datetime.utcnow() + session.add(existing) + return + + config_json = json.dumps({"webhook_url": webhook_url}) + if existing: + existing.config_json = config_json + existing.enabled = True + existing.updated_at = datetime.utcnow() + session.add(existing) + return + + session.add(NotificationDestination( + name="Discord", + destination_type="discord", + enabled=True, + config_json=config_json, + )) + + +@router.get("/notification-destinations") +def get_notification_destinations(session: Session = Depends(get_session)) -> list[dict]: + rows = session.exec(select(NotificationDestination)).all() + return [_public_destination(row) for row in rows] + + +@router.post("/notification-destinations") +def create_notification_destination( + payload: NotificationDestinationPayload, + session: Session = Depends(get_session), +) -> dict: + destination = NotificationDestination( + name=payload.name, + destination_type=payload.destination_type, + enabled=payload.enabled, + config_json=json.dumps(_clean_destination_config(payload.destination_type, payload.config)), + ) + session.add(destination) + session.commit() + session.refresh(destination) + return _public_destination(destination) + + +@router.patch("/notification-destinations/{destination_id}") +def update_notification_destination( + destination_id: int, + payload: NotificationDestinationPatch, + session: Session = Depends(get_session), +) -> dict: + destination = session.get(NotificationDestination, destination_id) + if destination is None: + raise HTTPException(status_code=404, detail="Notification destination not found") + if payload.name is not None: + destination.name = payload.name + if payload.enabled is not None: + destination.enabled = payload.enabled + if payload.config is not None: + existing = _load_destination_config(destination) + destination.config_json = json.dumps(_clean_destination_config(destination.destination_type, payload.config, existing)) + destination.updated_at = datetime.utcnow() + session.add(destination) + session.commit() + session.refresh(destination) + return _public_destination(destination) + + +@router.delete("/notification-destinations/{destination_id}") +def delete_notification_destination( + destination_id: int, + session: Session = Depends(get_session), +) -> dict: + destination = session.get(NotificationDestination, destination_id) + if destination is None: + raise HTTPException(status_code=404, detail="Notification destination not found") + session.delete(destination) + session.commit() + return {"ok": True} + + +@router.post("/notification-destinations/{destination_id}/test") +async def test_notification_destination( + destination_id: int, + session: Session = Depends(get_session), +) -> dict: + destination = session.get(NotificationDestination, destination_id) + if destination is None: + raise HTTPException(status_code=404, detail="Notification destination not found") + ok = await notifications.send_test(destination) + if ok: + return {"ok": True} + return {"ok": False, "error": "Destination test failed. Check the configuration and try again."} + + # ── Generic key-value settings ──────────────────────────────────────────────── @router.get("") @@ -158,6 +364,8 @@ def patch_settings( except (ValueError, TypeError): raise HTTPException(status_code=422, detail=f"'{key}' must be a valid number") set_setting(session, key, value) + if key == "discord_webhook_url": + _upsert_discord_destination(session, value) session.commit() rows = session.exec(select(AppSetting)).all() return {row.key: row.value for row in rows if row.key not in _SENSITIVE_SETTING_KEYS} @@ -248,6 +456,7 @@ def patch_general_settings( ) -> dict: if payload.discord_webhook_url is not None: set_setting(session, "discord_webhook_url", payload.discord_webhook_url) + _upsert_discord_destination(session, payload.discord_webhook_url) if payload.log_retention_days is not None: set_setting(session, "log_retention_days", str(payload.log_retention_days)) if payload.exited_container_ttl_seconds is not None: @@ -267,7 +476,8 @@ def get_wizard_status(session: Session = Depends(get_session)) -> dict: try: dismissed = get_setting(session, "wizard_dismissed") webhook = get_setting(session, "discord_webhook_url") or "" - completed = bool(dismissed) or bool(webhook) + has_destination = session.exec(select(NotificationDestination)).first() is not None + completed = bool(dismissed) or bool(webhook) or has_destination except Exception: completed = False return {"completed": completed} diff --git a/backend/database.py b/backend/database.py index 7c3e5e9..91f4a84 100644 --- a/backend/database.py +++ b/backend/database.py @@ -6,7 +6,7 @@ # Explicit imports ensure all models are registered in SQLModel.metadata # before create_db_and_tables() is called. -from models import AppSetting, Container, ContainerAlertSetting, ContainerEvent, ContainerLog, ContainerMetricsHistory, ContainerNetworkHistory, Operation # noqa: F401 +from models import AppSetting, Container, ContainerAlertSetting, ContainerEvent, ContainerLog, ContainerMetricsHistory, ContainerNetworkHistory, NotificationDestination, Operation # noqa: F401 DB_PATH = Path(os.getenv("DATABASE_PATH", "/data/nestview.db")) DB_PATH.parent.mkdir(parents=True, exist_ok=True) diff --git a/backend/migrations.py b/backend/migrations.py index 0b4f973..0c09dc5 100644 --- a/backend/migrations.py +++ b/backend/migrations.py @@ -1,3 +1,4 @@ +import json import logging from typing import Callable @@ -318,6 +319,46 @@ def _migrate_014(engine: Engine) -> None: logger.info("migration 014: added running operation uniqueness index") +def _migrate_015(engine: Engine) -> None: + """Create notification destinations and migrate an existing Discord webhook.""" + inspector = inspect(engine) + if "notification_destination" in inspector.get_table_names(): + logger.info("migration 015: table notification_destination already present, skipping") + return + + with engine.connect() as conn: + conn.execute(text(""" + CREATE TABLE notification_destination ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + destination_type TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + config_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + """)) + conn.execute(text( + "CREATE INDEX ix_notification_destination_destination_type " + "ON notification_destination (destination_type)" + )) + + row = conn.execute( + text("SELECT value FROM app_setting WHERE key = 'discord_webhook_url'") + ).fetchone() + webhook_url = row[0] if row and row[0] else "" + if webhook_url: + conn.execute(text(""" + INSERT INTO notification_destination + (name, destination_type, enabled, config_json, created_at, updated_at) + VALUES + ('Discord', 'discord', 1, :config_json, datetime('now'), datetime('now')) + """), {"config_json": json.dumps({"webhook_url": webhook_url})}) + + conn.commit() + logger.info("migration 015: created notification_destination table") + + MIGRATIONS: list[tuple[str, Callable]] = [ ("001", _migrate_001), ("002", _migrate_002), @@ -333,6 +374,7 @@ def _migrate_014(engine: Engine) -> None: ("012", _migrate_012), ("013", _migrate_013), ("014", _migrate_014), + ("015", _migrate_015), ] diff --git a/backend/models.py b/backend/models.py index 01d5f7f..185ce0d 100644 --- a/backend/models.py +++ b/backend/models.py @@ -96,6 +96,18 @@ class ContainerAlertSetting(SQLModel, table=True): enabled: bool = Field(default=True) +class NotificationDestination(SQLModel, table=True): + __tablename__ = "notification_destination" + + id: Optional[int] = Field(default=None, primary_key=True) + name: str = Field(max_length=128) + destination_type: str = Field(index=True, max_length=32) + enabled: bool = Field(default=True) + config_json: str = Field(default="{}") + created_at: datetime = Field(default_factory=datetime.utcnow) + updated_at: datetime = Field(default_factory=datetime.utcnow) + + class Operation(SQLModel, table=True): __tablename__ = "operation" __table_args__ = ( diff --git a/backend/services/collector.py b/backend/services/collector.py index a4f97cf..f358b2c 100644 --- a/backend/services/collector.py +++ b/backend/services/collector.py @@ -22,7 +22,7 @@ from database import engine from models import Container, ContainerAlertSetting, ContainerEvent, ContainerLog, ContainerMetricsHistory, ContainerNetworkHistory -from services import discord +from services import notifications from services.app_settings import get_setting logger = logging.getLogger(__name__) @@ -717,22 +717,20 @@ def _watch_events() -> None: if event_type in _ALERT_EVENT_TYPES and not _alert_suppressed( container_name, event_type, session ): - webhook_url = get_setting(session, "discord_webhook_url") or "" - if webhook_url: - try: - alerted = asyncio.run(discord.send_alert( - webhook_url=webhook_url, - container_name=container_name, - event_type=event_type, - details=details, - timestamp=ts, - )) - if alerted: - db_event.alerted = True - session.add(db_event) - session.commit() - except Exception as exc: - logger.error("Discord alert failed: %s", exc) + try: + alerted = asyncio.run(notifications.send_alert( + session=session, + container_name=container_name, + event_type=event_type, + details=details, + timestamp=ts, + )) + if alerted: + db_event.alerted = True + session.add(db_event) + session.commit() + except Exception as exc: + logger.error("Alert notification failed: %s", exc) except Exception as exc: logger.error("Event write failed: %s", exc) except Exception as exc: diff --git a/backend/services/image_checker.py b/backend/services/image_checker.py index f08bf9c..a2c59c6 100644 --- a/backend/services/image_checker.py +++ b/backend/services/image_checker.py @@ -17,8 +17,7 @@ from database import engine from models import Container, ContainerAlertSetting -from services import discord -from services.app_settings import get_setting +from services import notifications logger = logging.getLogger(__name__) @@ -306,19 +305,15 @@ def _maybe_send_update_alert(session: Session, container: Container) -> None: if _update_alert_suppressed(container.name, session): return - webhook_url = get_setting(session, "discord_webhook_url") or "" - if not webhook_url: - return - try: - sent = asyncio.run(discord.send_alert( - webhook_url=webhook_url, + sent = asyncio.run(notifications.send_alert( + session=session, container_name=container.name, event_type="update_available", details=f"Image: {container.image}", )) except Exception as exc: - logger.warning("image_checker: discord alert failed for %r: %s", container.name, type(exc).__name__) + logger.warning("image_checker: alert failed for %r: %s", container.name, type(exc).__name__) sent = False if sent: diff --git a/backend/services/notifications.py b/backend/services/notifications.py new file mode 100644 index 0000000..5d7bbee --- /dev/null +++ b/backend/services/notifications.py @@ -0,0 +1,196 @@ +import json +import logging +import smtplib +from datetime import datetime +from email.message import EmailMessage +from typing import Any + +import httpx +from sqlmodel import Session, select + +from models import NotificationDestination +from services import discord +from services.app_settings import get_setting + +logger = logging.getLogger(__name__) + +DESTINATION_TYPES = ("discord", "slack", "email", "webhook") + + +def _load_config(destination: NotificationDestination) -> dict[str, Any]: + try: + value = json.loads(destination.config_json or "{}") + except json.JSONDecodeError: + logger.warning("notification destination %s has invalid config JSON", destination.id) + return {} + return value if isinstance(value, dict) else {} + + +def _alert_title(event_type: str) -> str: + return discord.EVENT_TITLES.get(event_type, f"Container Event: {event_type}") + + +def _alert_text(container_name: str, event_type: str, details: str | None = None) -> str: + text = f"{_alert_title(event_type)}: {container_name}" + if details: + text = f"{text} ({details})" + return text + + +async def _send_slack(config: dict[str, Any], container_name: str, event_type: str, details: str | None, timestamp: datetime | None) -> bool: + webhook_url = str(config.get("webhook_url") or "") + if not webhook_url: + return False + + text = _alert_text(container_name, event_type, details) + payload = { + "text": text, + "blocks": [ + {"type": "header", "text": {"type": "plain_text", "text": _alert_title(event_type)}}, + { + "type": "section", + "fields": [ + {"type": "mrkdwn", "text": f"*Container*\n`{container_name}`"}, + {"type": "mrkdwn", "text": f"*Event*\n{event_type}"}, + ], + }, + ], + } + if details: + payload["blocks"].append({"type": "section", "text": {"type": "mrkdwn", "text": f"*Details*\n{details}"}}) + if timestamp: + payload["blocks"].append({"type": "context", "elements": [{"type": "mrkdwn", "text": f"Nestview • {timestamp.isoformat()}"}]}) + + try: + async with httpx.AsyncClient() as client: + resp = await client.post(webhook_url, json=payload, timeout=10) + return 200 <= resp.status_code < 300 + except Exception as exc: + logger.warning("Slack notification failed: %s", type(exc).__name__) + return False + + +async def _send_webhook(config: dict[str, Any], container_name: str, event_type: str, details: str | None, timestamp: datetime | None) -> bool: + webhook_url = str(config.get("webhook_url") or "") + if not webhook_url: + return False + + payload = { + "source": "nestview", + "event_type": event_type, + "title": _alert_title(event_type), + "container_name": container_name, + "details": details, + "timestamp": (timestamp or datetime.utcnow()).isoformat(), + } + headers = {"Content-Type": "application/json"} + secret = str(config.get("secret") or "") + if secret: + headers["X-Nestview-Secret"] = secret + + try: + async with httpx.AsyncClient() as client: + resp = await client.post(webhook_url, json=payload, headers=headers, timeout=10) + return 200 <= resp.status_code < 300 + except Exception as exc: + logger.warning("Generic webhook notification failed: %s", type(exc).__name__) + return False + + +def _send_email(config: dict[str, Any], container_name: str, event_type: str, details: str | None, timestamp: datetime | None) -> bool: + host = str(config.get("host") or "") + port = int(config.get("port") or 587) + sender = str(config.get("from_email") or "") + recipients = [item.strip() for item in str(config.get("to_emails") or "").split(",") if item.strip()] + if not host or not sender or not recipients: + return False + + msg = EmailMessage() + msg["Subject"] = f"Nestview: {_alert_title(event_type)}" + msg["From"] = sender + msg["To"] = ", ".join(recipients) + body = _alert_text(container_name, event_type, details) + if timestamp: + body = f"{body}\n\nTimestamp: {timestamp.isoformat()}" + msg.set_content(body) + + username = str(config.get("username") or "") + password = str(config.get("password") or "") + use_tls = bool(config.get("use_tls", True)) + + try: + with smtplib.SMTP(host, port, timeout=10) as smtp: + if use_tls: + smtp.starttls() + if username: + smtp.login(username, password) + smtp.send_message(msg) + return True + except Exception as exc: + logger.warning("Email notification failed: %s", type(exc).__name__) + return False + + +async def _send_destination(destination: NotificationDestination, container_name: str, event_type: str, details: str | None, timestamp: datetime | None) -> bool: + config = _load_config(destination) + if destination.destination_type == "discord": + return await discord.send_alert( + webhook_url=str(config.get("webhook_url") or ""), + container_name=container_name, + event_type=event_type, + details=details, + timestamp=timestamp, + ) + if destination.destination_type == "slack": + return await _send_slack(config, container_name, event_type, details, timestamp) + if destination.destination_type == "webhook": + return await _send_webhook(config, container_name, event_type, details, timestamp) + if destination.destination_type == "email": + return _send_email(config, container_name, event_type, details, timestamp) + return False + + +def _configured_destinations(session: Session) -> list[NotificationDestination]: + destinations = session.exec( + select(NotificationDestination).where(NotificationDestination.enabled == True) # noqa: E712 + ).all() + if destinations: + return destinations + + # Backward compatibility for databases that have not migrated yet or setup + # flows that still write only the legacy Discord setting. + webhook_url = get_setting(session, "discord_webhook_url") or "" + if webhook_url: + return [ + NotificationDestination( + name="Discord", + destination_type="discord", + enabled=True, + config_json=json.dumps({"webhook_url": webhook_url}), + ) + ] + return [] + + +async def send_alert(session: Session, container_name: str, event_type: str, details: str | None = None, timestamp: datetime | None = None) -> bool: + sent_any = False + for destination in _configured_destinations(session): + try: + sent_any = await _send_destination(destination, container_name, event_type, details, timestamp) or sent_any + except Exception as exc: + logger.warning( + "notification destination failed: type=%s error=%s", + destination.destination_type, + type(exc).__name__, + ) + return sent_any + + +async def send_test(destination: NotificationDestination) -> bool: + return await _send_destination( + destination, + container_name="nestview-test", + event_type="restart", + details="Test notification from Nestview.", + timestamp=datetime.utcnow(), + ) diff --git a/backend/tests/test_collector_logs.py b/backend/tests/test_collector_logs.py index 27271c1..c5ab7f1 100644 --- a/backend/tests/test_collector_logs.py +++ b/backend/tests/test_collector_logs.py @@ -28,7 +28,7 @@ def collector_engine(monkeypatch): collector._log_buffer.clear() with Session(engine) as session: - session.add(AppSetting(key="log_retention_days", value="7")) + session.add(AppSetting(key="log_retention_days", value="3650")) session.commit() return engine diff --git a/backend/tests/test_migrations.py b/backend/tests/test_migrations.py index 3870993..8782f04 100644 --- a/backend/tests/test_migrations.py +++ b/backend/tests/test_migrations.py @@ -23,7 +23,7 @@ def test_run_migrations_advances_schema_version_and_is_idempotent(): ).all() assert schema_version is not None - assert schema_version.value == "014" + assert schema_version.value == "015" assert analytics_last_ping is not None assert analytics_last_ping.value == "" assert retention is not None @@ -35,6 +35,7 @@ def test_run_migrations_advances_schema_version_and_is_idempotent(): "update_available", } assert "operation" in inspect(engine).get_table_names() + assert "notification_destination" in inspect(engine).get_table_names() assert "ix_operation_running_target" in { index["name"] for index in inspect(engine).get_indexes("operation") } diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 0fdd6a8..b3e7a2b 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,4 +1,4 @@ -import type { AlertEventType, AlertSetting, AnalyticsStatus, AuthStatus, Container, ContainerLog, ContainerEvent, GeneralSettings, MeResponse, MetricsHistoryPoint, NetworkHistoryPoint, OperationStatus, SystemInfo, WizardStatus } from "./types"; +import type { AlertEventType, AlertSetting, AnalyticsStatus, AuthStatus, Container, ContainerLog, ContainerEvent, GeneralSettings, MeResponse, MetricsHistoryPoint, NetworkHistoryPoint, NotificationDestination, NotificationDestinationPayload, OperationStatus, SystemInfo, WizardStatus } from "./types"; const BASE = "/api"; @@ -80,6 +80,19 @@ async function post(path: string, body?: unknown): Promise { return res.json(); } +async function del(path: string): Promise { + const res = await fetch(`${BASE}${path}`, { method: "DELETE" }); + if (res.status === 401) { + handle401(path); + throw new Error("401 Unauthorized"); + } + if (!res.ok) { + const resBody = await res.json().catch(() => ({})); + throw new Error(errorMessageFromBody(resBody, `${res.status} ${res.statusText}`)); + } + return res.json(); +} + export const api = { version: () => fetch(`${BASE}/version`).then((r) => r.json()) as Promise<{ version: string; build_sha: string | null }>, containers: { @@ -145,6 +158,14 @@ export const api = { alertDefaults: () => get<{ event_type: string; enabled: boolean }[]>("/settings/alerts/defaults"), setAlertDefaults: (payload: { event_type: AlertEventType; enabled: boolean }[]) => patch<{ event_type: string; enabled: boolean }[]>("/settings/alerts/defaults", payload), + notificationDestinations: () => get("/settings/notification-destinations"), + createNotificationDestination: (body: NotificationDestinationPayload) => + post("/settings/notification-destinations", body), + updateNotificationDestination: (id: number, body: Partial>) => + patch(`/settings/notification-destinations/${id}`, body), + deleteNotificationDestination: (id: number) => del<{ ok: boolean }>(`/settings/notification-destinations/${id}`), + testNotificationDestination: (id: number) => + post<{ ok: boolean; error?: string }>(`/settings/notification-destinations/${id}/test`), general: () => get("/settings/general"), saveGeneral: (body: Partial) => patch("/settings/general", body), diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index f2bf158..840b9ad 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -2,7 +2,7 @@ import React, { useState, useRef, useMemo, useEffect } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { api } from "../api"; import { useAuth } from "../AuthContext"; -import type { AlertEventType, AlertSetting, AnalyticsStatus, Container, GeneralSettings, SystemInfo } from "../types"; +import type { AlertEventType, AlertSetting, AnalyticsStatus, Container, GeneralSettings, NotificationDestination, NotificationDestinationPayload, NotificationDestinationType, SystemInfo } from "../types"; import WebhookField from "../components/WebhookField"; import DiscordWebhookHelpModal from "../components/DiscordWebhookHelpModal"; import TimezoneSelect from "../components/TimezoneSelect"; @@ -810,11 +810,188 @@ function AddExceptionModal({ // ── Notifications tab ───────────────────────────────────────────────────────── +const DESTINATION_LABELS: Record = { + discord: "Discord", + slack: "Slack", + email: "Email", + webhook: "Webhook", +}; + +function defaultDestinationDraft(type: NotificationDestinationType): NotificationDestinationPayload { + const name = DESTINATION_LABELS[type]; + if (type === "email") { + return { + name, + destination_type: type, + enabled: true, + config: { host: "", port: 587, username: "", password: "", from_email: "", to_emails: "", use_tls: true }, + }; + } + return { name, destination_type: type, enabled: true, config: { webhook_url: "" } }; +} + +function mergeDestinationDraft(destination: NotificationDestination): NotificationDestinationPayload { + return { + name: destination.name, + destination_type: destination.destination_type, + enabled: destination.enabled, + config: { ...defaultDestinationDraft(destination.destination_type).config, ...destination.config }, + }; +} + +function DestinationEditor({ + destination, + onCancel, + onSave, + isSaving, +}: { + destination: NotificationDestination | null; + onCancel: () => void; + onSave: (draft: NotificationDestinationPayload) => void; + isSaving: boolean; +}) { + const [draft, setDraft] = useState(() => + destination ? mergeDestinationDraft(destination) : defaultDestinationDraft("slack") + ); + + function setType(type: NotificationDestinationType) { + setDraft(defaultDestinationDraft(type)); + } + + function setConfig(key: string, value: string | number | boolean) { + setDraft(prev => ({ ...prev, config: { ...prev.config, [key]: value } })); + } + + const isEmail = draft.destination_type === "email"; + const saveLabel = destination ? "Save destination" : "Add destination"; + + return ( +
+
+
+

{destination ? "Edit destination" : "Add destination"}

+

Secrets are stored server-side and are not shown after saving.

+
+ +
+ + +
+ + {!isEmail && ( + + )} + + {isEmail && ( +
+
+ + +
+
+ + +
+
+ + +
+ +
+ )} + + {draft.destination_type === "webhook" && ( + + )} + + + +
+ + +
+
+
+ ); +} + function NotificationsTab({ onDirtyChange }: { onDirtyChange: (dirty: boolean) => void }) { const queryClient = useQueryClient(); const { isAuthenticated } = useAuth(); const { toastState, showToast, dismissToast } = useToast(); const [showAddModal, setShowAddModal] = useState(false); + const [editingDestination, setEditingDestination] = useState(null); + const [showDestinationEditor, setShowDestinationEditor] = useState(false); const { data: defaultsRaw = [], isLoading: loadingDefaults } = useQuery<{ event_type: string; enabled: boolean }[]>({ queryKey: ["alert-defaults"], @@ -828,6 +1005,12 @@ function NotificationsTab({ onDirtyChange }: { onDirtyChange: (dirty: boolean) = enabled: isAuthenticated, }); + const { data: destinations = [], isLoading: loadingDestinations } = useQuery({ + queryKey: ["notification-destinations"], + queryFn: api.settings.notificationDestinations, + enabled: isAuthenticated, + }); + const { data: allContainers = [] } = useQuery({ queryKey: ["containers"], queryFn: api.containers.list, @@ -912,7 +1095,45 @@ function NotificationsTab({ onDirtyChange }: { onDirtyChange: (dirty: boolean) = onError: (err: Error) => showToast(err.message, "error"), }); - const isLoading = loadingDefaults || loadingAlerts; + const { mutate: saveDestination, isPending: isSavingDestination } = useMutation({ + mutationFn: (draft: NotificationDestinationPayload) => { + if (editingDestination) { + return api.settings.updateNotificationDestination(editingDestination.id, { + name: draft.name, + enabled: draft.enabled, + config: draft.config, + }); + } + return api.settings.createNotificationDestination(draft); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["notification-destinations"] }); + setShowDestinationEditor(false); + setEditingDestination(null); + showToast("Destination saved", "success"); + }, + onError: (err: Error) => showToast(err.message, "error"), + }); + + const { mutate: testDestination, isPending: isTestingDestination } = useMutation({ + mutationFn: (id: number) => api.settings.testNotificationDestination(id), + onSuccess: (result) => { + if (result.ok) showToast("Destination test sent", "success"); + else showToast(result.error ?? "Destination test failed", "error"); + }, + onError: (err: Error) => showToast(err.message, "error"), + }); + + const { mutate: deleteDestination, isPending: isDeletingDestination } = useMutation({ + mutationFn: (id: number) => api.settings.deleteNotificationDestination(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["notification-destinations"] }); + showToast("Destination deleted", "success"); + }, + onError: (err: Error) => showToast(err.message, "error"), + }); + + const isLoading = loadingDefaults || loadingAlerts || loadingDestinations; if (isLoading || draftDefaults === null || draftExceptions === null) { return
Loading...
; @@ -926,8 +1147,76 @@ function NotificationsTab({ onDirtyChange }: { onDirtyChange: (dirty: boolean) = {toastState && ( )} + {showDestinationEditor && ( + { setShowDestinationEditor(false); setEditingDestination(null); }} + onSave={(draft) => saveDestination(draft)} + /> + )}
+ {/* Destinations card */} +
+
+
+

Destinations

+

Where Nestview sends enabled alerts.

+
+ +
+ + {destinations.length === 0 ? ( +
+

No alert destinations configured.

+
+ ) : ( +
+ {destinations.map((destination) => ( +
+
+
+ {destination.name} + {DESTINATION_LABELS[destination.destination_type]} + {!destination.enabled && Disabled} +
+

{destination.configured ? "Configured" : "Needs configuration"}

+
+ + + +
+ ))} +
+ )} +
+ {/* Global defaults card */}
@@ -1023,7 +1312,7 @@ function NotificationsTab({ onDirtyChange }: { onDirtyChange: (dirty: boolean) = )}
-

Alert toggles only take effect when a Discord webhook URL is configured. Events are always recorded regardless.

+

Alert toggles only take effect when at least one destination is enabled. Events are always recorded regardless.

{/* Save bar */}
diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 72818f6..d724bf0 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -76,6 +76,26 @@ export interface GeneralSettings { network_history_retention_hours: number; } +export type NotificationDestinationType = "discord" | "slack" | "email" | "webhook"; + +export interface NotificationDestination { + id: number; + name: string; + destination_type: NotificationDestinationType; + enabled: boolean; + configured: boolean; + config: Record; + created_at: string; + updated_at: string; +} + +export interface NotificationDestinationPayload { + name: string; + destination_type: NotificationDestinationType; + enabled: boolean; + config: Record; +} + export interface WizardStatus { completed: boolean; } From 7eaa440c93440f4ddb063e1ae74b22e7460661fe Mon Sep 17 00:00:00 2001 From: Kyle Schultz Date: Tue, 7 Jul 2026 15:47:54 -0700 Subject: [PATCH 02/42] feat: add Slack webhook setup help --- VERSION | 2 +- .../src/components/SlackWebhookHelpModal.tsx | 60 +++++++++++++++++++ frontend/src/pages/Settings.tsx | 16 ++++- 3 files changed, 75 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/SlackWebhookHelpModal.tsx diff --git a/VERSION b/VERSION index 347f583..bc80560 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.4.1 +1.5.0 diff --git a/frontend/src/components/SlackWebhookHelpModal.tsx b/frontend/src/components/SlackWebhookHelpModal.tsx new file mode 100644 index 0000000..3190e28 --- /dev/null +++ b/frontend/src/components/SlackWebhookHelpModal.tsx @@ -0,0 +1,60 @@ +import { useEffect } from "react"; + +interface Props { + onClose: () => void; +} + +const STEPS = [ + <>Go to api.slack.com/apps and create or open a Slack app for your workspace., + <>In the app settings, open Incoming Webhooks., + <>Toggle Activate Incoming Webhooks on., + <>Click Add New Webhook to Workspace., + <>Pick the channel Nestview should post to, then click Allow., + <>Copy the generated https://hooks.slack.com/services/... URL and paste it into Nestview., +]; + +export default function SlackWebhookHelpModal({ onClose }: Props) { + useEffect(() => { + const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [onClose]); + + return ( +
+
e.stopPropagation()} + > +
+

How to create a Slack webhook

+ +
+
+
    + {STEPS.map((step, i) => ( +
  1. + + {i + 1} + + {step} +
  2. + ))} +
+

+ Slack webhook URLs are channel-specific secrets. Keep the URL private and create one webhook per channel you want Nestview to notify. +

+
+
+
+ ); +} diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 840b9ad..8ddedb1 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -5,6 +5,7 @@ import { useAuth } from "../AuthContext"; import type { AlertEventType, AlertSetting, AnalyticsStatus, Container, GeneralSettings, NotificationDestination, NotificationDestinationPayload, NotificationDestinationType, SystemInfo } from "../types"; import WebhookField from "../components/WebhookField"; import DiscordWebhookHelpModal from "../components/DiscordWebhookHelpModal"; +import SlackWebhookHelpModal from "../components/SlackWebhookHelpModal"; import TimezoneSelect from "../components/TimezoneSelect"; import Toast from "../components/Toast"; import InfoPopover from "../components/InfoPopover"; @@ -853,6 +854,7 @@ function DestinationEditor({ const [draft, setDraft] = useState(() => destination ? mergeDestinationDraft(destination) : defaultDestinationDraft("slack") ); + const [showSlackHelp, setShowSlackHelp] = useState(false); function setType(type: NotificationDestinationType) { setDraft(defaultDestinationDraft(type)); @@ -867,6 +869,7 @@ function DestinationEditor({ return (
+ {showSlackHelp && setShowSlackHelp(false)} />}

{destination ? "Edit destination" : "Add destination"}

@@ -900,8 +903,17 @@ function DestinationEditor({ {!isEmail && (