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:
+248
-2
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user