diff --git a/hbd/server/configio.py b/hbd/server/configio.py index 1e8495e..5044688 100644 --- a/hbd/server/configio.py +++ b/hbd/server/configio.py @@ -93,6 +93,19 @@ def apply_structured_section(data, section: str, values: dict) -> None: raise ValueError(f"Unknown structured section: {section!r}") +def apply_channel(data, name: str, channel_cfg: dict) -> None: + """Insert or replace a single notification channel entry, preserving others.""" + if not data.get("notification_channels"): + data["notification_channels"] = {} + data["notification_channels"][name] = channel_cfg + + +def delete_channel(data, name: str) -> None: + """Remove a notification channel by name. No-op if not found.""" + nc = data.get("notification_channels") or {} + nc.pop(name, None) + + def apply_yaml_section(data, section: str, yaml_text: str) -> None: """Replace the named logical section by parsing yaml_text.""" parsed = _make_yaml().load(yaml_text) diff --git a/hbd/server/http.py b/hbd/server/http.py index 78f9947..075e6d3 100644 --- a/hbd/server/http.py +++ b/hbd/server/http.py @@ -956,7 +956,23 @@ async def start( ch_cfg = config.get("notification_channels", {}).get(ch_name, {}) notif_channels.append({"name": ch_name, "type": ch_cfg.get("type", "")}) - all_channel_names = sorted((config.get("notification_channels") or {}).keys()) + # Build visible channels list for chip picker and My Channels management. + visible_channels = _visible_channels_for_user(current_user) if current_user else {} + all_channels = sorted( + [ + { + "name": name, + "type": cfg.get("type", ""), + "owner": cfg.get("owner"), + "private": bool(cfg.get("private", False)), + } + for name, cfg in visible_channels.items() + if isinstance(cfg, dict) + ], + key=lambda c: c["name"], + ) + # Keep all_channel_names for backwards-compat with any template references. + all_channel_names = [c["name"] for c in all_channels] tmpl = env.get_template("profile.html") body = tmpl.render( @@ -967,6 +983,7 @@ async def start( managed_hosts=managed, monitored_hosts=monitored, notification_channels=notif_channels, + all_channels=all_channels, all_channel_names=all_channel_names, active_page="profile", ) @@ -1255,6 +1272,226 @@ async def start( return web.json_response({"ok": True}) + # ------------------------------------------------------------------------- + # Notification channel helpers + # ------------------------------------------------------------------------- + + def _visible_channels_for_user(user): + """Return {name: cfg} of channels visible to user (public + own private).""" + all_channels = config.get("notification_channels") or {} + if user is None: + return {} + if user.admin: + return dict(all_channels) + visible = {} + for name, cfg in all_channels.items(): + if not isinstance(cfg, dict): + continue + if not cfg.get("private") or cfg.get("owner") == user.username: + visible[name] = cfg + return visible + + def _build_channel_response(ch_name, ch_cfg): + """Serialize a channel config dict for the API response.""" + ch_type = ch_cfg.get("type", "") + schema_fields = settings_mod.CHANNEL_TYPE_SCHEMAS.get(ch_type, {}).get("fields", []) + fields = [] + for sf in schema_fields: + k = sf["key"] + v = ch_cfg.get(k, "") + sensitive = sf["type"] == "secret" + fields.append({ + "key": k, + "label": sf["label"], + "value": "•••" if (sensitive and v) else ( + ", ".join(v) if isinstance(v, list) else str(v or "") + ), + "sensitive": sensitive, + }) + return { + "name": ch_name, + "type": ch_type, + "type_label": settings_mod._CHANNEL_TYPE_LABELS.get(ch_type, ch_type.title()), + "owner": ch_cfg.get("owner"), + "private": bool(ch_cfg.get("private", False)), + "min_level": ch_cfg.get("min_level", "WARNING"), + "fields": fields, + } + + # ------------------------------------------------------------------------- + # Notification channel API (any authenticated user) + # ------------------------------------------------------------------------- + + async def api_notification_channel_types(request): + """GET /api/0/notification_channel_types — channel type schemas.""" + user, err = _require_auth(request) + if err: + return err + return web.json_response(settings_mod.CHANNEL_TYPE_SCHEMAS) + + async def api_notification_channels_get(request): + """GET /api/0/notification_channels — list channels visible to current user.""" + user, err = _require_auth(request) + if err: + return err + visible = _visible_channels_for_user(user) + result = [ + _build_channel_response(name, cfg) + for name, cfg in visible.items() + if isinstance(cfg, dict) + ] + return web.json_response(result) + + async def api_notification_channels_post(request): + """POST /api/0/notification_channels — create a new channel.""" + user, err = _require_auth(request) + if err: + return err + if user is None: + return web.json_response({"error": "Authentication required"}, status=401) + if not _config_path: + return web.json_response({"error": "Config path not available"}, status=503) + + try: + body = await request.json() + except Exception: + return web.json_response({"error": "Invalid JSON"}, status=400) + + name = (body.get("name") or "").strip() + if not name: + return web.json_response({"error": "Channel name is required"}, status=400) + ch_type = (body.get("type") or "").strip() + if ch_type not in settings_mod.CHANNEL_TYPE_SCHEMAS: + return web.json_response({"error": f"Unknown channel type: {ch_type!r}"}, status=400) + if name in (config.get("notification_channels") or {}): + return web.json_response({"error": f"Channel {name!r} already exists"}, status=409) + + schema = settings_mod.CHANNEL_TYPE_SCHEMAS[ch_type] + channel_cfg = {"type": ch_type} + for sf in schema["fields"]: + k = sf["key"] + v = body.get(k, "") + if v: + channel_cfg[k] = v + elif sf["required"]: + return web.json_response({"error": f"Field {k!r} is required"}, status=400) + + if body.get("min_level"): + channel_cfg["min_level"] = body["min_level"] + channel_cfg["owner"] = user.username + if body.get("private"): + channel_cfg["private"] = True + + try: + disk_data = configio_mod.read_roundtrip(_config_path) + configio_mod.apply_channel(disk_data, name, channel_cfg) + configio_mod.write_config(_config_path, disk_data) + except Exception as exc: + logger.error("Channel create failed: %s", exc) + return web.json_response({"error": str(exc)}, status=500) + + if hasattr(config, "reload"): + await config.reload() + return web.json_response({"ok": True, "name": name}) + + async def api_notification_channel_put(request): + """PUT /api/0/notification_channels/{name} — update a channel.""" + user, err = _require_auth(request) + if err: + return err + if user is None: + return web.json_response({"error": "Authentication required"}, status=401) + if not _config_path: + return web.json_response({"error": "Config path not available"}, status=503) + + ch_name = request.match_info["name"] + existing_channels = config.get("notification_channels") or {} + if ch_name not in existing_channels: + return web.json_response({"error": f"Channel {ch_name!r} not found"}, status=404) + + existing_cfg = existing_channels[ch_name] + if not isinstance(existing_cfg, dict): + return web.json_response({"error": "Invalid channel config"}, status=500) + + owner = existing_cfg.get("owner") + if not user.admin and owner != user.username: + return web.json_response({"error": "Forbidden"}, status=403) + + try: + body = await request.json() + except Exception: + return web.json_response({"error": "Invalid JSON"}, status=400) + + ch_type = existing_cfg.get("type", "") + schema_fields = settings_mod.CHANNEL_TYPE_SCHEMAS.get(ch_type, {}).get("fields", []) + secret_keys = {sf["key"] for sf in schema_fields if sf["type"] == "secret"} + + try: + disk_data = configio_mod.read_roundtrip(_config_path) + existing_on_disk = (disk_data.get("notification_channels") or {}).get(ch_name, {}) + + channel_cfg = {"type": ch_type} + for sf in schema_fields: + k = sf["key"] + v = body.get(k, "") + if k in secret_keys and (not v or v == "•••"): + existing_val = existing_on_disk.get(k, "") + if existing_val: + channel_cfg[k] = existing_val + elif v: + channel_cfg[k] = v + + if body.get("min_level"): + channel_cfg["min_level"] = body["min_level"] + if owner is not None: + channel_cfg["owner"] = owner + if "private" in body: + channel_cfg["private"] = bool(body["private"]) + elif existing_on_disk.get("private"): + channel_cfg["private"] = True + + configio_mod.apply_channel(disk_data, ch_name, channel_cfg) + configio_mod.write_config(_config_path, disk_data) + except Exception as exc: + logger.error("Channel update failed: %s", exc) + return web.json_response({"error": str(exc)}, status=500) + + if hasattr(config, "reload"): + await config.reload() + return web.json_response({"ok": True}) + + async def api_notification_channel_delete(request): + """DELETE /api/0/notification_channels/{name} — delete a channel.""" + user, err = _require_auth(request) + if err: + return err + if user is None: + return web.json_response({"error": "Authentication required"}, status=401) + if not _config_path: + return web.json_response({"error": "Config path not available"}, status=503) + + ch_name = request.match_info["name"] + existing_channels = config.get("notification_channels") or {} + if ch_name not in existing_channels: + return web.json_response({"error": f"Channel {ch_name!r} not found"}, status=404) + + existing_cfg = existing_channels[ch_name] + owner = existing_cfg.get("owner") if isinstance(existing_cfg, dict) else None + if not user.admin and owner != user.username: + return web.json_response({"error": "Forbidden"}, status=403) + + try: + disk_data = configio_mod.read_roundtrip(_config_path) + configio_mod.delete_channel(disk_data, ch_name) + configio_mod.write_config(_config_path, disk_data) + except Exception as exc: + logger.error("Channel delete failed: %s", exc) + return web.json_response({"error": str(exc)}, status=500) + + if hasattr(config, "reload"): + await config.reload() + return web.json_response({"ok": True}) + async def api_user_self_put(request): """PUT /api/0/users/me — update own full_name, avatar, notification_channels, password.""" user, err = _require_auth(request) @@ -1297,7 +1534,10 @@ async def start( if "avatar" in body: user_entry["avatar"] = str(body["avatar"]) if "notification_channels" in body: - user_entry["notification_channels"] = [str(ch) for ch in body["notification_channels"]] + visible = _visible_channels_for_user(user) + user_entry["notification_channels"] = [ + str(ch) for ch in body["notification_channels"] if ch in visible + ] if password_change: user_entry["password"] = users_mod.hash_password(password_change["new"]) @@ -1337,6 +1577,12 @@ async def start( web.get("/api/0/config/backups", api_config_backups_get), web.post("/api/0/config", api_config_post), web.post("/api/0/config/rollback", api_config_rollback), + # Notification channel API (any authenticated user) + web.get("/api/0/notification_channel_types", api_notification_channel_types), + web.get("/api/0/notification_channels", api_notification_channels_get), + web.post("/api/0/notification_channels", api_notification_channels_post), + web.put("/api/0/notification_channels/{name}", api_notification_channel_put), + web.delete("/api/0/notification_channels/{name}", api_notification_channel_delete), # Hosts web.get("/api/0/hosts", api_hosts), web.get("/api/0/alert_summary", api_alert_summary), diff --git a/hbd/server/notify.py b/hbd/server/notify.py index 63f6d22..15287ca 100644 --- a/hbd/server/notify.py +++ b/hbd/server/notify.py @@ -366,6 +366,9 @@ _TIMEOUT = 15 # seconds per channel send async def _dispatch_to_channel(channel_name: str, channel_cfg: dict, notif: Notification) -> bool: """Send *notif* to a single named channel, honouring min_level.""" + # Strip ownership metadata — notifier drivers only need delivery credentials. + channel_cfg = {k: v for k, v in channel_cfg.items() if k not in ("owner", "private")} + level = notif.level.upper() if level != "RECOVER": min_level = channel_cfg.get("min_level", "WARNING").upper() diff --git a/hbd/server/settings.py b/hbd/server/settings.py index fdb900e..dfc1f33 100644 --- a/hbd/server/settings.py +++ b/hbd/server/settings.py @@ -27,13 +27,65 @@ _SECRET_KEYS = frozenset({ "smtp_password", "smtp_user", "api_password", "access_token", }) -_CHANNEL_TYPE_LABELS = { - "pushover": "Pushover", - "email": "E-mail", - "signal": "Signal", - "mattermost": "Mattermost", +CHANNEL_TYPE_SCHEMAS = { + "pushover": { + "label": "Pushover", + "fields": [ + {"key": "token", "label": "App token", "type": "secret", "required": True}, + {"key": "user", "label": "User key", "type": "secret", "required": True}, + {"key": "sound", "label": "Sound", "type": "text", "required": False}, + ], + }, + "email": { + "label": "E-mail", + "fields": [ + {"key": "recipients", "label": "Recipients (comma-separated)", "type": "list", "required": True}, + {"key": "sender", "label": "From address", "type": "text", "required": True}, + {"key": "smtp_server", "label": "SMTP server", "type": "text", "required": True}, + {"key": "smtp_port", "label": "SMTP port", "type": "port", "required": False}, + {"key": "smtp_user", "label": "SMTP username", "type": "text", "required": False}, + {"key": "smtp_password", "label": "SMTP password", "type": "secret", "required": False}, + ], + }, + "signal": { + "label": "Signal", + "fields": [ + {"key": "user", "label": "Sender number", "type": "text", "required": True}, + {"key": "recipient", "label": "Recipient number", "type": "text", "required": True}, + {"key": "cli_path", "label": "signal-cli path", "type": "text", "required": False}, + ], + }, + "matrix": { + "label": "Matrix", + "fields": [ + {"key": "homeserver", "label": "Homeserver URL", "type": "text", "required": True}, + {"key": "access_token", "label": "Access token", "type": "secret", "required": True}, + {"key": "room_id", "label": "Room ID", "type": "text", "required": True}, + ], + }, + "sms_voipms": { + "label": "SMS (voip.ms)", + "fields": [ + {"key": "api_user", "label": "API username", "type": "text", "required": True}, + {"key": "api_password", "label": "API password", "type": "secret", "required": True}, + {"key": "did", "label": "DID (from)", "type": "text", "required": True}, + {"key": "dst", "label": "Destination", "type": "text", "required": True}, + ], + }, + "mattermost": { + "label": "Mattermost", + "fields": [ + {"key": "host", "label": "Host", "type": "text", "required": True}, + {"key": "token", "label": "Webhook token", "type": "secret", "required": True}, + {"key": "channel", "label": "Channel", "type": "text", "required": True}, + {"key": "username", "label": "Bot username", "type": "text", "required": False}, + {"key": "icon", "label": "Icon URL", "type": "text", "required": False}, + ], + }, } +_CHANNEL_TYPE_LABELS = {k: v["label"] for k, v in CHANNEL_TYPE_SCHEMAS.items()} + def _mask(value): """Return a masked placeholder for sensitive values.""" @@ -143,6 +195,7 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list: } # ---- Notification channels (complex, built separately) ---------------- + _METADATA_KEYS = {"type", "owner", "private", "min_level"} notif_channels = [] for ch_name, ch_cfg in (config.get("notification_channels") or {}).items(): if not isinstance(ch_cfg, dict): @@ -150,7 +203,7 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list: ch_type = ch_cfg.get("type", "") fields = [] for k, v in ch_cfg.items(): - if k == "type": + if k in _METADATA_KEYS: continue sensitive = k in _SECRET_KEYS fields.append({ @@ -165,6 +218,9 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list: "name": ch_name, "type": ch_type, "type_label": _CHANNEL_TYPE_LABELS.get(ch_type, ch_type.title()), + "owner": ch_cfg.get("owner"), + "private": bool(ch_cfg.get("private", False)), + "min_level": ch_cfg.get("min_level", "WARNING"), "fields": fields, }) @@ -368,7 +424,7 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list: "id": "channels", "title": "Notification Channels", "description": "Named notification providers. Credentials are masked.", - "section_mode": "yaml", + "section_mode": "channels", "api_section": "notification_channels", "channels": notif_channels, "fields": [ diff --git a/hbd/server/templates/profile.html b/hbd/server/templates/profile.html index f486317..ab8f6c4 100644 --- a/hbd/server/templates/profile.html +++ b/hbd/server/templates/profile.html @@ -215,11 +215,59 @@ .save-row { display: flex; align-items: center; margin-top: 8px; } .btn-save { background: #0066cc; color: #fff; border: none; border-radius: 4px; padding: 5px 14px; font-size: .85em; cursor: pointer; } .btn-save:hover { background: #0055aa; } - .channel-item { display: flex; align-items: flex-start; gap: 8px; padding: 6px 0; border-bottom: 1px solid #f5f5f5; } - .channel-item:last-child { border-bottom: none; } - .channel-item label { display: flex; align-items: flex-start; gap: 8px; cursor: pointer; font-size: .88em; } - .channel-item .ch-name { font-weight: 500; color: #222; } - .channel-item .ch-meta { font-size: .8em; color: #888; } + /* ---- Channel chip picker ---- */ + .ch-picker { } + .ch-picker-label { font-size: .8em; font-weight: 600; color: #888; text-transform: uppercase; letter-spacing: .04em; margin-bottom: 6px; } + .ch-chips { display: flex; flex-wrap: wrap; gap: 6px; min-height: 32px; margin-bottom: 10px; } + .ch-chip { + display: inline-flex; align-items: center; gap: 5px; + padding: 4px 10px; border-radius: 14px; font-size: .85em; font-weight: 500; cursor: pointer; + border: none; font-family: inherit; + } + .ch-chip.selected { background: #e3f2fd; color: #1565c0; } + .ch-chip.selected:hover { background: #bbdefb; } + .ch-chip.available { background: #f1f3f4; color: #555; } + .ch-chip.available:hover { background: #e8eaf6; color: #283593; } + .ch-chip-x { font-size: .9em; line-height: 1; color: inherit; opacity: .7; } + + /* ---- My Channels card list ---- */ + .my-ch-card { + border: 1px solid #e8eaf6; border-radius: 6px; margin-bottom: 8px; overflow: hidden; + } + .my-ch-header { + display: flex; align-items: center; gap: 8px; padding: 8px 12px; + background: #f8f9ff; border-bottom: 1px solid #e8eaf6; + } + .my-ch-name { font-weight: 600; font-size: .9em; color: #222; } + .my-ch-type { padding: 2px 7px; border-radius: 8px; font-size: .72em; font-weight: 600; background: #e8eaf6; color: #3949ab; } + .my-ch-private { padding: 2px 7px; border-radius: 8px; font-size: .72em; font-weight: 600; background: #fce4ec; color: #c62828; } + .my-ch-actions { margin-left: auto; display: flex; gap: 5px; } + .btn-sm-edit { background: #888; color: #fff; border: none; border-radius: 4px; padding: 2px 8px; font-size: .78em; cursor: pointer; } + .btn-sm-edit:hover { background: #666; } + .btn-sm-del { background: transparent; color: #c62828; border: 1px solid #e0e0e0; border-radius: 4px; padding: 2px 7px; font-size: .78em; cursor: pointer; } + .btn-sm-del:hover { background: #fce4ec; } + + /* ---- Channel modal (for My Channels CRUD) ---- */ + .ch-modal-overlay { + position: fixed; inset: 0; background: rgba(0,0,0,.4); + display: flex; align-items: center; justify-content: center; z-index: 1001; + } + .ch-modal-box { + background: #fff; border-radius: 8px; padding: 24px; + min-width: 360px; max-width: 520px; width: 95%; + box-shadow: 0 8px 32px rgba(0,0,0,.2); + } + .ch-modal-box h3 { margin: 0 0 16px; font-size: 1em; } + .ch-form-row { margin-bottom: 12px; } + .ch-form-row label { display: block; font-size: .83em; font-weight: 600; color: #555; margin-bottom: 3px; } + .ch-form-row input[type=text], .ch-form-row input[type=password], .ch-form-row select { + width: 100%; border: 1px solid #ccc; border-radius: 4px; padding: 5px 8px; + font-size: .88em; box-sizing: border-box; font-family: inherit; + } + .ch-form-row input:focus, .ch-form-row select:focus { border-color: #0066cc; outline: none; } + .ch-form-divider { font-size: .78em; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; color: #888; margin: 14px 0 8px; border-top: 1px solid #eee; padding-top: 10px; } + .ch-modal-footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; } + .ch-modal-status { font-size: .83em; margin-top: 8px; }
@@ -318,37 +366,117 @@ {% endif %} - +Select which channels send you alerts. Channels are defined by the administrator.
- {% if all_channel_names %} -Click a channel to add or remove it from your alert list.
+ {% if all_channels %} +No notification channels configured.
+No notification channels available. You can create your own below.
{% endif %} -Channels you own. Public channels are available to all users; private channels are visible only to you.
+No channels yet.
+ {% endif %} +