feat: replace YAML notification channel editor with form-based UI

Notification channels are now managed through a proper web form instead
of a raw YAML textarea. Any authenticated user can create channels; private
channels (owner-scoped) are hidden from other users. The user profile
channel selector becomes a tag/chip picker with a "My Channels" CRUD section.

- settings.py: add CHANNEL_TYPE_SCHEMAS for all 6 notifier types; channel
  section switches to section_mode="channels"; cards include owner/private/min_level
- configio.py: add apply_channel() and delete_channel() for per-entry CRUD
- notify.py: strip owner/private metadata before dispatching to drivers
- http.py: add GET/POST /api/0/notification_channels, PUT/DELETE /{name},
  GET /api/0/notification_channel_types; visibility helper filters private
  channels per user; PUT /api/0/users/me validates against visible channels
- settings.html: card grid with edit/delete per channel; add/edit modal
  with type dropdown and dynamically rendered type-specific fields
- profile.html: chip picker replaces checkbox list; My Channels section
  for creating/editing/deleting user-owned channels
- tests: update test_settings_sections, test_http_users_me; add
  test_notification_channels_api (16 new tests, 46 total passing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Andreas Wrede
2026-05-11 07:34:26 -04:00
parent a7a45bf8c3
commit 500d256d76
9 changed files with 1134 additions and 36 deletions
+248 -2
View File
@@ -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),