feat(notifications): ntfy/Gotify push channels with live delivery (#69)

Per-user notification_channels (ntfy + Gotify): every
insertUserNotification now fans out best-effort to enabled channels —
deployment/backup/security/agent alerts reach the user's phone via the
ntfy app with zero extra infrastructure.

- POST/GET/DELETE /notifications/channels + POST .../:id/test which
  pushes a real probe so misconfiguration surfaces before alerts depend
  on it (502 DELIVERY_FAILED on endpoint errors).
- ntfy: POST to endpoint (topic in path), Title/Tags headers, optional
  Bearer token. Gotify: POST {endpoint}/message?token=...
- Delivery is best-effort by design — a dead relay never breaks the
  producing flow; one shared 5s-timeout client.
- CLI `notifications channels list|add|test|remove`, MCP tools,
  OpenAPI paths + NotificationChannel schema.
- Web settings "Push notifications" section and a Push channels card on
  the mobile notifications tab (add/test/remove).

Verified live: channel CRUD, test-ping hit a recording endpoint with
correct headers/body, and a real deployment fanned out "Deployment
succeeded" through the channel end-to-end.

Generated with [Devin](https://devin.ai)

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomáš DvořákandDevin authored and GitHub committed 2026-10-10 01:39:09 +02:00
1 parent 60145f7b21
commit 9243d020cc
19 files changed
+1261 -6

No files matched your search

+4 -2
View File
@@ -508,8 +508,10 @@ and wired refs, one click or one CLI call.
- [x] **env var edit + domain management + replica scaling** (PR #61 —
mobile v1.5: variables editor, domains screen, scale + sleep
settings, notifications tab). **cron execution history** still open.
- [ ] **Push notifications** — zero-infra path = self-hosted
ntfy/Gotify relay (no Firebase dependency), FCM/APNs optional.
- [x] **Push notifications** — per-user `notification_channels`
(ntfy + Gotify): every `insertUserNotification` fans out best-effort
to enabled channels; CRUD + live test-ping via web settings, CLI
`notifications channels`, and MCP. FCM/APNs remain optional.
- [ ] **Offline resilience** — cached last-known state + unreachable
banner.
- [ ] **Status/offline resilience** — app + CLI degrade gracefully when
@@ -0,0 +1,225 @@
package api
// Push notification channels — self-hosted ntfy and Gotify endpoints users
// attach to their account. insertUserNotification fans out best-effort.
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// jarvis: ceiling — one shared client; no retry/backoff, delivery is
// best-effort and failures are silent by design (notifications must never
// break the producing flow).
var pushHTTPClient = &http.Client{Timeout: 5 * time.Second}
type notificationChannelDTO struct {
ID string `json:"id"`
Kind string `json:"kind"`
Endpoint string `json:"endpoint"`
Enabled bool `json:"enabled"`
CreatedAt string `json:"created_at"`
}
func mapChannel(row sqlcdb.NotificationChannel) notificationChannelDTO {
return notificationChannelDTO{
ID: row.ID.String(),
Kind: row.Kind,
Endpoint: row.Endpoint,
Enabled: row.Enabled,
CreatedAt: row.CreatedAt.Time.Format(time.RFC3339),
}
}
func handleListNotificationChannels(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
db := c.MustGet("db").(*database.DB)
uid, _ := uuid.Parse(userID)
rows, err := sqlcdb.New(db.DB).ListAllNotificationChannelsByUser(c.Request.Context(), uid)
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to list channels")
return
}
out := make([]notificationChannelDTO, 0, len(rows))
for _, r := range rows {
out = append(out, mapChannel(r))
}
c.JSON(http.StatusOK, gin.H{"channels": out})
}
func handleCreateNotificationChannel(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
var req struct {
Kind string `json:"kind" binding:"required,oneof=ntfy gotify"`
Endpoint string `json:"endpoint" binding:"required"`
Token string `json:"token"`
}
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "kind (ntfy|gotify) and endpoint are required")
return
}
endpoint := strings.TrimRight(strings.TrimSpace(req.Endpoint), "/")
u, err := url.Parse(endpoint)
if err != nil || u.Scheme == "" || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
respondError(c, http.StatusBadRequest, "VALIDATION", "endpoint must be a valid http(s) URL")
return
}
db := c.MustGet("db").(*database.DB)
uid, _ := uuid.Parse(userID)
row, err := sqlcdb.New(db.DB).InsertNotificationChannel(c.Request.Context(), sqlcdb.InsertNotificationChannelParams{
UserID: uid,
Kind: req.Kind,
Endpoint: endpoint,
Token: sql.NullString{String: req.Token, Valid: req.Token != ""},
})
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create channel")
return
}
c.JSON(http.StatusCreated, gin.H{"channel": mapChannel(row)})
}
func handleDeleteNotificationChannel(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
id, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid channel id")
return
}
db := c.MustGet("db").(*database.DB)
uid, _ := uuid.Parse(userID)
n, err := sqlcdb.New(db.DB).DeleteNotificationChannel(c.Request.Context(), sqlcdb.DeleteNotificationChannelParams{
ID: id, UserID: uid,
})
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to delete channel")
return
}
if n == 0 {
respondError(c, http.StatusNotFound, "NOT_FOUND", "Channel not found")
return
}
c.JSON(http.StatusOK, gin.H{"message": "Channel deleted"})
}
// handleTestNotificationChannel pushes a probe notification through the
// channel — surfaces misconfiguration before real alerts depend on it.
func handleTestNotificationChannel(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
id, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid channel id")
return
}
db := c.MustGet("db").(*database.DB)
uid, _ := uuid.Parse(userID)
rows, err := sqlcdb.New(db.DB).ListAllNotificationChannelsByUser(c.Request.Context(), uid)
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to load channel")
return
}
var target *sqlcdb.NotificationChannel
for i := range rows {
if rows[i].ID == id {
target = &rows[i]
break
}
}
if target == nil {
respondError(c, http.StatusNotFound, "NOT_FOUND", "Channel not found")
return
}
if err := deliverPush(c.Request.Context(), *target, "test", "Containr test notification", "Your push channel is configured correctly."); err != nil {
respondError(c, http.StatusBadGateway, "DELIVERY_FAILED", fmt.Sprintf("Push failed: %v", err))
return
}
c.JSON(http.StatusOK, gin.H{"message": "Test notification sent"})
}
// deliverPush sends one notification through a channel. ntfy expects the
// body at POST {endpoint} with Title/Priority headers; Gotify takes JSON at
// POST {endpoint}/message?token=...
func deliverPush(ctx context.Context, ch sqlcdb.NotificationChannel, kind, title, body string) error {
var req *http.Request
var err error
switch ch.Kind {
case "ntfy":
req, err = http.NewRequestWithContext(ctx, http.MethodPost, ch.Endpoint, strings.NewReader(body))
if err == nil {
req.Header.Set("Title", title)
req.Header.Set("Tags", "containr,"+kind)
if ch.Token.Valid && ch.Token.String != "" {
req.Header.Set("Authorization", "Bearer "+ch.Token.String)
}
}
case "gotify":
payload, _ := json.Marshal(map[string]interface{}{
"title": title,
"message": body,
"priority": 5,
})
endpoint := ch.Endpoint + "/message"
if ch.Token.Valid && ch.Token.String != "" {
endpoint += "?token=" + url.QueryEscape(ch.Token.String)
}
req, err = http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
if err == nil {
req.Header.Set("Content-Type", "application/json")
}
default:
return fmt.Errorf("unsupported channel kind %q", ch.Kind)
}
if err != nil {
return err
}
res, err := pushHTTPClient.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode >= 400 {
return fmt.Errorf("push endpoint returned %d", res.StatusCode)
}
return nil
}
// fanoutUserPush delivers a notification to every enabled channel the user
// configured. Errors are swallowed — the in-app row already landed.
func fanoutUserPush(db *database.DB, userID, kind, title, body string) {
uid, err := uuid.Parse(userID)
if err != nil {
return
}
rows, err := sqlcdb.New(db.DB).ListNotificationChannelsByUser(context.Background(), uid)
if err != nil || len(rows) == 0 {
return
}
for _, ch := range rows {
_ = deliverPush(context.Background(), ch, kind, title, body)
}
}
@@ -0,0 +1,80 @@
package api
import (
"context"
"database/sql"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"containr/internal/database/sqlcdb"
)
func TestDeliverPushNtfy(t *testing.T) {
var gotAuth, gotTitle, gotTags, gotBody string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
gotTitle = r.Header.Get("Title")
gotTags = r.Header.Get("Tags")
b, _ := io.ReadAll(r.Body)
gotBody = string(b)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
ch := sqlcdb.NotificationChannel{
Kind: "ntfy",
Endpoint: srv.URL + "/alerts",
Token: sql.NullString{String: "tk_abc", Valid: true},
}
if err := deliverPush(context.Background(), ch, "deployment", "Deploy done", "svc is live"); err != nil {
t.Fatalf("deliverPush: %v", err)
}
if gotAuth != "Bearer tk_abc" {
t.Fatalf("auth header = %q", gotAuth)
}
if gotTitle != "Deploy done" || gotTags != "containr,deployment" || gotBody != "svc is live" {
t.Fatalf("title=%q tags=%q body=%q", gotTitle, gotTags, gotBody)
}
}
func TestDeliverPushGotify(t *testing.T) {
var gotPath, gotToken string
var payload map[string]interface{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotToken = r.URL.Query().Get("token")
_ = json.NewDecoder(r.Body).Decode(&payload)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
ch := sqlcdb.NotificationChannel{
Kind: "gotify",
Endpoint: srv.URL,
Token: sql.NullString{String: "apptoken", Valid: true},
}
if err := deliverPush(context.Background(), ch, "backup", "Backup done", "db backup ok"); err != nil {
t.Fatalf("deliverPush: %v", err)
}
if gotPath != "/message" || gotToken != "apptoken" {
t.Fatalf("path=%q token=%q", gotPath, gotToken)
}
if payload["title"] != "Backup done" || payload["message"] != "db backup ok" {
t.Fatalf("payload = %v", payload)
}
}
func TestDeliverPushFailure(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
ch := sqlcdb.NotificationChannel{Kind: "ntfy", Endpoint: srv.URL}
if err := deliverPush(context.Background(), ch, "k", "t", "b"); err == nil {
t.Fatal("expected error on 500")
}
}
@@ -41,6 +41,7 @@ func insertUserNotification(db *database.DB, userID, kind, title, body, resource
ResourceID: sql.NullString{String: resourceID, Valid: resourceID != ""},
CreatedAt: sql.NullTime{Time: time.Now(), Valid: true},
})
go fanoutUserPush(db, userID, kind, title, body)
}
func mapNotification(row sqlcdb.Notification) NotificationItem {
+4
View File
@@ -364,6 +364,10 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
authed.GET("/notifications", handleListNotifications)
authed.POST("/notifications/:id/read", handleMarkNotificationRead)
authed.POST("/notifications/read-all", handleMarkAllNotificationsRead)
authed.GET("/notifications/channels", handleListNotificationChannels)
authed.POST("/notifications/channels", handleCreateNotificationChannel)
authed.POST("/notifications/channels/:id/test", handleTestNotificationChannel)
authed.DELETE("/notifications/channels/:id", handleDeleteNotificationChannel)
// WebSocket endpoint
authed.GET("/ws", handleWebSocket)
@@ -1,6 +1,8 @@
package commands
import (
"fmt"
"github.com/spf13/cobra"
)
@@ -60,6 +62,104 @@ var notifReadCmd = &cobra.Command{
},
}
var notifChannelsCmd = &cobra.Command{
Use: "channels",
Short: "Manage push notification channels (ntfy/Gotify)",
}
var notifChannelsListCmd = &cobra.Command{
Use: "list",
Short: "List push channels",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/notifications/channels", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "channels")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, n := range items {
rows = append(rows, []string{
str(n, "id"), str(n, "kind"), str(n, "endpoint"), str(n, "enabled"), relTime(n, "created_at"),
})
}
printRows(data, []string{"ID", "KIND", "ENDPOINT", "ENABLED", "AGE"}, rows)
return nil
},
}
var notifChannelsAddCmd = &cobra.Command{
Use: "add <ntfy|gotify> <endpoint>",
Short: "Add a push channel (endpoint includes topic for ntfy)",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
token, _ := cmd.Flags().GetString("token")
data, err := c.Do("POST", "/notifications/channels", map[string]interface{}{
"kind": args[0],
"endpoint": args[1],
"token": token,
})
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var notifChannelsTestCmd = &cobra.Command{
Use: "test <id>",
Short: "Send a test notification through a channel",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("POST", "/notifications/channels/"+args[0]+"/test", map[string]interface{}{})
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var notifChannelsRemoveCmd = &cobra.Command{
Use: "remove <id>",
Short: "Remove a push channel",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
yes, _ := cmd.Flags().GetBool("yes")
if !yes && !Confirm(fmt.Sprintf("Remove channel %s?", args[0])) {
return nil
}
c, err := client()
if err != nil {
return err
}
data, err := c.Do("DELETE", "/notifications/channels/"+args[0], nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
func init() {
NotificationsCmd.AddCommand(notifListCmd, notifReadCmd)
notifChannelsAddCmd.Flags().String("token", "", "Auth token (ntfy access token or Gotify app token)")
notifChannelsRemoveCmd.Flags().Bool("yes", false, "Skip confirmation")
notifChannelsCmd.AddCommand(notifChannelsListCmd, notifChannelsAddCmd, notifChannelsTestCmd, notifChannelsRemoveCmd)
NotificationsCmd.AddCommand(notifListCmd, notifReadCmd, notifChannelsCmd)
}
@@ -193,6 +193,17 @@ type Notification struct {
CreatedAt sql.NullTime `json:"created_at"`
}
type NotificationChannel struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
Kind string `json:"kind"`
Endpoint string `json:"endpoint"`
Token sql.NullString `json:"token"`
Enabled bool `json:"enabled"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
type OutboundWebhook struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
@@ -53,6 +53,135 @@ func (q *Queries) CreateNotification(ctx context.Context, arg CreateNotification
return err
}
const deleteNotificationChannel = `-- name: DeleteNotificationChannel :execrows
DELETE FROM notification_channels WHERE id = $1 AND user_id = $2
`
type DeleteNotificationChannelParams struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
}
func (q *Queries) DeleteNotificationChannel(ctx context.Context, arg DeleteNotificationChannelParams) (int64, error) {
result, err := q.db.ExecContext(ctx, deleteNotificationChannel, arg.ID, arg.UserID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const insertNotificationChannel = `-- name: InsertNotificationChannel :one
INSERT INTO notification_channels (user_id, kind, endpoint, token)
VALUES ($1, $2, $3, $4)
RETURNING id, user_id, kind, endpoint, token, enabled, created_at, updated_at
`
type InsertNotificationChannelParams struct {
UserID uuid.UUID `json:"user_id"`
Kind string `json:"kind"`
Endpoint string `json:"endpoint"`
Token sql.NullString `json:"token"`
}
func (q *Queries) InsertNotificationChannel(ctx context.Context, arg InsertNotificationChannelParams) (NotificationChannel, error) {
row := q.db.QueryRowContext(ctx, insertNotificationChannel,
arg.UserID,
arg.Kind,
arg.Endpoint,
arg.Token,
)
var i NotificationChannel
err := row.Scan(
&i.ID,
&i.UserID,
&i.Kind,
&i.Endpoint,
&i.Token,
&i.Enabled,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const listAllNotificationChannelsByUser = `-- name: ListAllNotificationChannelsByUser :many
SELECT id, user_id, kind, endpoint, token, enabled, created_at, updated_at
FROM notification_channels
WHERE user_id = $1
ORDER BY created_at
`
func (q *Queries) ListAllNotificationChannelsByUser(ctx context.Context, userID uuid.UUID) ([]NotificationChannel, error) {
rows, err := q.db.QueryContext(ctx, listAllNotificationChannelsByUser, userID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []NotificationChannel{}
for rows.Next() {
var i NotificationChannel
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.Kind,
&i.Endpoint,
&i.Token,
&i.Enabled,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listNotificationChannelsByUser = `-- name: ListNotificationChannelsByUser :many
SELECT id, user_id, kind, endpoint, token, enabled, created_at, updated_at
FROM notification_channels
WHERE user_id = $1 AND enabled
ORDER BY created_at
`
func (q *Queries) ListNotificationChannelsByUser(ctx context.Context, userID uuid.UUID) ([]NotificationChannel, error) {
rows, err := q.db.QueryContext(ctx, listNotificationChannelsByUser, userID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []NotificationChannel{}
for rows.Next() {
var i NotificationChannel
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.Kind,
&i.Endpoint,
&i.Token,
&i.Enabled,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listNotificationsByUser = `-- name: ListNotificationsByUser :many
SELECT id, user_id, kind, title, body, resource_type, resource_id, read_at, created_at
FROM notifications
@@ -44,6 +44,7 @@ type Querier interface {
DeleteBanner(ctx context.Context, id uuid.UUID) error
DeleteDatabaseServiceByID(ctx context.Context, id string) error
DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) error
DeleteNotificationChannel(ctx context.Context, arg DeleteNotificationChannelParams) (int64, error)
DeleteOutboundWebhookByIDAndUser(ctx context.Context, arg DeleteOutboundWebhookByIDAndUserParams) error
DeleteProjectByID(ctx context.Context, projectID uuid.UUID) (int64, error)
DeleteProjectEnvironment(ctx context.Context, arg DeleteProjectEnvironmentParams) error
@@ -75,12 +76,14 @@ type Querier interface {
GetUserInviteByTokenHash(ctx context.Context, tokenHash string) (UserInvite, error)
GetUserTokenByHash(ctx context.Context, tokenHash string) (UserToken, error)
InsertAgentHeartbeat(ctx context.Context, arg InsertAgentHeartbeatParams) error
InsertNotificationChannel(ctx context.Context, arg InsertNotificationChannelParams) (NotificationChannel, error)
InsertProjectEnvironment(ctx context.Context, arg InsertProjectEnvironmentParams) error
ListActiveBanners(ctx context.Context) ([]Banner, error)
ListAgentAuthTokens(ctx context.Context) ([]AgentAuthToken, error)
ListAgentHeartbeatsSince(ctx context.Context, arg ListAgentHeartbeatsSinceParams) ([]AgentHeartbeat, error)
ListAgents(ctx context.Context) ([]NodeAgent, error)
ListAllDatabaseServices(ctx context.Context) ([]DatabaseService, error)
ListAllNotificationChannelsByUser(ctx context.Context, userID uuid.UUID) ([]NotificationChannel, error)
ListBackupTargetsByUser(ctx context.Context, userID string) ([]BackupTarget, error)
ListBanners(ctx context.Context) ([]Banner, error)
ListCommandsForAgent(ctx context.Context, nodeAgentID string) ([]AgentCommand, error)
@@ -90,6 +93,7 @@ type Querier interface {
ListDatabaseServicesByUser(ctx context.Context, userID string) ([]DatabaseService, error)
ListDueDatabaseBackups(ctx context.Context) ([]ListDueDatabaseBackupsRow, error)
ListEnabledOutboundWebhooks(ctx context.Context) ([]OutboundWebhook, error)
ListNotificationChannelsByUser(ctx context.Context, userID uuid.UUID) ([]NotificationChannel, error)
ListNotificationsByUser(ctx context.Context, arg ListNotificationsByUserParams) ([]Notification, error)
ListOutboundWebhooksByUser(ctx context.Context, userID uuid.UUID) ([]OutboundWebhook, error)
ListPendingCommands(ctx context.Context, nodeAgentID string) ([]AgentCommand, error)
+4
View File
@@ -145,6 +145,10 @@ func init() {
// Notifications
register(spec{"containr_notifications_list", "List notifications", "GET", "/notifications", nil, nil, false})
register(spec{"containr_notifications_read", "Mark a notification read, or all notifications with id=all", "POST", "/notifications/{id}/read", []string{"id"}, nil, false})
register(spec{"containr_notification_channels_list", "List push notification channels (ntfy/Gotify)", "GET", "/notifications/channels", nil, nil, false})
register(spec{"containr_notification_channels_create", "Add a push notification channel. Args: kind (ntfy|gotify), endpoint (full URL incl. ntfy topic or Gotify base), token (ntfy access token or Gotify app token).", "POST", "/notifications/channels", nil, []string{"kind", "endpoint", "token"}, false})
register(spec{"containr_notification_channels_test", "Send a test notification through a push channel. Args: id.", "POST", "/notifications/channels/{id}/test", []string{"id"}, nil, false})
register(spec{"containr_notification_channels_delete", "Delete a push notification channel. Args: id. Requires confirm=true.", "DELETE", "/notifications/channels/{id}", []string{"id"}, nil, true})
register(spec{"containr_webhooks_list", "List outbound webhooks", "GET", "/webhooks", nil, nil, false})
register(spec{"containr_webhooks_create", "Create an outbound webhook. Args: name, url, events (list, e.g. [\"service.*\",\"*\"]), secret (auto-generated if omitted), headers, enabled.", "POST", "/webhooks", nil, []string{"name", "url", "events", "secret", "headers", "enabled"}, false})
@@ -0,0 +1,16 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS public.notification_channels (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
kind varchar(20) NOT NULL CHECK (kind IN ('ntfy', 'gotify')),
endpoint text NOT NULL,
token varchar(255),
enabled boolean NOT NULL DEFAULT true,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_notification_channels_user ON public.notification_channels (user_id);
-- +goose Down
DROP TABLE IF EXISTS public.notification_channels;
@@ -17,3 +17,23 @@ UPDATE notifications SET read_at = $3 WHERE id = $1 AND user_id = $2 AND read_at
-- name: MarkAllNotificationsReadByUser :exec
UPDATE notifications SET read_at = $2 WHERE user_id = $1 AND read_at IS NULL;
-- name: ListNotificationChannelsByUser :many
SELECT id, user_id, kind, endpoint, token, enabled, created_at, updated_at
FROM notification_channels
WHERE user_id = $1 AND enabled
ORDER BY created_at;
-- name: ListAllNotificationChannelsByUser :many
SELECT id, user_id, kind, endpoint, token, enabled, created_at, updated_at
FROM notification_channels
WHERE user_id = $1
ORDER BY created_at;
-- name: InsertNotificationChannel :one
INSERT INTO notification_channels (user_id, kind, endpoint, token)
VALUES ($1, $2, $3, $4)
RETURNING id, user_id, kind, endpoint, token, enabled, created_at, updated_at;
-- name: DeleteNotificationChannel :execrows
DELETE FROM notification_channels WHERE id = $1 AND user_id = $2;
+11
View File
@@ -335,3 +335,14 @@ CREATE TABLE user_invites (
used_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE TABLE notification_channels (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
kind VARCHAR(20) NOT NULL CHECK (kind IN ('ntfy', 'gotify')),
endpoint TEXT NOT NULL,
token VARCHAR(255),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
@@ -75,6 +75,11 @@ import {
type CreateDatabaseInput,
type DatabaseEntity,
type FailoverPolicy,
listNotificationChannels,
createNotificationChannel,
deleteNotificationChannel,
testNotificationChannel,
type NotificationChannel,
} from '@/lib/api-client';
import {
demoBuilds,
@@ -139,6 +144,8 @@ import {
Cloud,
ExternalLink,
Upload,
Bell,
Send,
} from 'lucide-react';
function SecondaryPageHeader({ title, description }: { title: string; description: string }) {
@@ -1908,6 +1915,151 @@ function BackupTargetsSection() {
);
}
function NotificationChannelsSection() {
const queryClient = useQueryClient();
const channelsQuery = useQuery({
queryKey: ['notification-channels'],
queryFn: listNotificationChannels,
});
const [kind, setKind] = useState<'ntfy' | 'gotify'>('ntfy');
const [endpoint, setEndpoint] = useState('');
const [token, setToken] = useState('');
const [testResult, setTestResult] = useState<Record<string, 'ok' | string>>({});
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['notification-channels'] });
const createMutation = useMutation({
mutationFn: () =>
createNotificationChannel({
kind,
endpoint: endpoint.trim(),
token: token.trim() || undefined,
}),
onSuccess: () => {
setEndpoint('');
setToken('');
invalidate();
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteNotificationChannel(id),
onSuccess: invalidate,
});
const testMutation = useMutation({
mutationFn: (id: string) => testNotificationChannel(id),
onSuccess: (_d, id) => setTestResult((prev) => ({ ...prev, [id]: 'ok' })),
onError: (err, id) =>
setTestResult((prev) => ({
...prev,
[id]: err instanceof Error ? err.message : 'delivery failed',
})),
});
const channels = channelsQuery.data ?? [];
return (
<section className="panel p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<Bell size={18} className="text-[var(--accent-primary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Push notifications</h2>
<p className="text-xs text-[var(--text-tertiary)]">Relay notifications to a self-hosted ntfy or Gotify server</p>
</div>
</div>
<div className="space-y-3">
<div className="flex flex-wrap gap-2">
<select
value={kind}
onChange={(e) => setKind(e.target.value as 'ntfy' | 'gotify')}
className="h-9 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] focus:border-[var(--accent-primary)] transition-all"
>
<option value="ntfy">ntfy</option>
<option value="gotify">Gotify</option>
</select>
<input
value={endpoint}
onChange={(e) => setEndpoint(e.target.value)}
placeholder={kind === 'ntfy' ? 'https://ntfy.example.com/my-alerts' : 'https://gotify.example.com'}
className="h-9 flex-1 min-w-[220px] rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all"
/>
<input
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder={kind === 'ntfy' ? 'Access token (optional)' : 'App token'}
className="h-9 w-48 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all"
/>
<button
onClick={() => createMutation.mutate()}
disabled={createMutation.isPending || !endpoint.trim()}
className="h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50 disabled:cursor-not-allowed transition-all"
style={{ background: 'var(--accent-primary)' }}
>
{createMutation.isPending ? 'Adding…' : 'Add channel'}
</button>
</div>
{createMutation.isError ? (
<p className="text-xs text-[var(--error)]">
{createMutation.error instanceof Error ? createMutation.error.message : 'Failed to add channel'}
</p>
) : null}
{channelsQuery.isLoading ? (
<div className="py-4 text-center">
<Loader2 size={16} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
</div>
) : channels.length === 0 ? (
<p className="text-xs text-[var(--text-tertiary)]">
No push channels. Notifications stay in-app until you add one.
</p>
) : (
<div className="space-y-1.5 border-t border-[var(--border-subtle)] pt-3">
{channels.map((ch: NotificationChannel) => (
<div key={ch.id} className="flex items-center justify-between gap-2 text-xs">
<div className="min-w-0">
<span className="rounded px-1.5 py-0.5 bg-[var(--surface-muted)] text-[var(--text-secondary)] uppercase">
{ch.kind}
</span>
<span className="ml-2 mono text-[var(--text-primary)] break-all">{ch.endpoint}</span>
{testResult[ch.id] === 'ok' ? (
<span className="ml-2 text-[var(--success)]">delivered</span>
) : testResult[ch.id] ? (
<span className="ml-2 text-[var(--error)]">{testResult[ch.id]}</span>
) : null}
</div>
<div className="flex items-center gap-3 shrink-0">
<button
onClick={() => testMutation.mutate(ch.id)}
disabled={testMutation.isPending}
className="flex items-center gap-1 text-[var(--text-secondary)] hover:text-[var(--accent-primary)] disabled:opacity-50"
>
<Send size={12} />
Test
</button>
<button
onClick={() => {
if (window.confirm(`Remove ${ch.kind} channel ${ch.endpoint}?`)) {
deleteMutation.mutate(ch.id);
}
}}
disabled={deleteMutation.isPending}
className="text-[var(--error)] hover:underline disabled:opacity-50"
>
Remove
</button>
</div>
</div>
))}
</div>
)}
</div>
</section>
);
}
export function SettingsPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
@@ -2081,6 +2233,7 @@ export function SettingsPage() {
{/* Personal Access Tokens */}
<UserTokensSection isAdmin={profileQuery.data?.isAdmin ?? false} />
<NotificationChannelsSection />
<RegistriesSection />
<BackupTargetsSection />
+172
View File
@@ -3698,6 +3698,167 @@ export interface paths {
patch?: never;
trace?: never;
};
"/notifications/channels": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** List push notification channels (ntfy/Gotify) */
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Channel list */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
channels?: components["schemas"]["NotificationChannel"][];
};
};
};
};
};
put?: never;
/**
* Add a push notification channel
* @description ntfy endpoint should include the topic (e.g. https://ntfy.example.com/my-alerts); Gotify endpoint is the server base URL with the app token in `token`.
*/
post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": {
/** @enum {string} */
kind: "ntfy" | "gotify";
/** Format: uri */
endpoint: string;
/** @description ntfy access token (Bearer) or Gotify app token */
token?: string;
};
};
};
responses: {
/** @description Channel created */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
channel?: components["schemas"]["NotificationChannel"];
};
};
};
400: components["schemas"]["Error"];
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/notifications/channels/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
/** Delete a push notification channel */
delete: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Deleted */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["MessageResponse"];
};
};
404: components["schemas"]["Error"];
};
};
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/notifications/channels/{id}/test": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Send a test notification through a push channel */
post: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Test delivered */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["MessageResponse"];
};
};
/** @description Push delivery failed */
502: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/webhooks": {
parameters: {
query?: never;
@@ -11824,6 +11985,17 @@ export interface components {
notifications?: components["schemas"]["Notification"][];
unread?: number;
};
NotificationChannel: {
/** Format: uuid */
id?: string;
/** @enum {string} */
kind?: "ntfy" | "gotify";
/** Format: uri */
endpoint?: string;
enabled?: boolean;
/** Format: date-time */
created_at?: string;
};
OutboundWebhook: {
/** Format: uuid */
id?: string;
+36
View File
@@ -2596,6 +2596,42 @@ export async function markAllNotificationsRead(): Promise<void> {
await requestJson('/notifications/read-all', { method: 'POST' });
}
export type NotificationChannel = {
id: string;
kind: 'ntfy' | 'gotify';
endpoint: string;
enabled: boolean;
created_at: string;
};
export async function listNotificationChannels(): Promise<NotificationChannel[]> {
const payload = await requestJson<{ channels?: NotificationChannel[] }>('/notifications/channels');
return payload.channels ?? [];
}
export async function createNotificationChannel(input: {
kind: 'ntfy' | 'gotify';
endpoint: string;
token?: string;
}): Promise<NotificationChannel> {
const payload = await requestJson<{ channel: NotificationChannel }>('/notifications/channels', {
method: 'POST',
body: JSON.stringify(input),
});
return payload.channel;
}
export async function deleteNotificationChannel(id: string): Promise<void> {
await requestJson(`/notifications/channels/${encodeURIComponent(id)}`, { method: 'DELETE' });
}
export async function testNotificationChannel(id: string): Promise<void> {
await requestJson(`/notifications/channels/${encodeURIComponent(id)}/test`, {
method: 'POST',
body: JSON.stringify({}),
});
}
export type PreviewEnvironmentEntity = components['schemas']['PreviewEnvironment'];
export async function listPreviewEnvironments(projectId: string): Promise<PreviewEnvironmentEntity[]> {
+35
View File
@@ -94,6 +94,14 @@ export interface AppNotification {
created_at?: string;
}
export interface NotificationChannel {
id: string;
kind: 'ntfy' | 'gotify';
endpoint: string;
enabled: boolean;
created_at?: string;
}
// Masked value the API returns for secret variables; echoing it back on
// update preserves the stored ciphertext.
export const MASKED_SECRET = '********';
@@ -238,6 +246,33 @@ export class Api {
return this.req<unknown>('POST', '/notifications/read-all');
}
notificationChannels() {
return this.req<{ channels: NotificationChannel[] }>(
'GET',
'/notifications/channels',
).then((r) => r.channels ?? []);
}
addNotificationChannel(input: {
kind: 'ntfy' | 'gotify';
endpoint: string;
token?: string;
}) {
return this.req<{ channel: NotificationChannel }>(
'POST',
'/notifications/channels',
input,
);
}
deleteNotificationChannel(id: string) {
return this.req<unknown>('DELETE', `/notifications/channels/${id}`);
}
testNotificationChannel(id: string) {
return this.req<unknown>('POST', `/notifications/channels/${id}/test`, {});
}
serviceDeployments(id: string) {
return this.req<{ deployments: Deployment[] }>(
'GET',
+129 -3
View File
@@ -1,10 +1,10 @@
import React from 'react';
import { FlatList, RefreshControl, StyleSheet, Text, View } from 'react-native';
import React, { useState } from 'react';
import { FlatList, RefreshControl, StyleSheet, Text, TextInput, View } from 'react-native';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useApi } from '../../session';
import { colors } from '../../theme';
import { relTime } from '../../time';
import { ActionButton, Card, Empty, ErrorBox, Loading } from '../../components';
import { ActionButton, Card, Empty, ErrorBox, Loading, SectionTitle } from '../../components';
const KIND_COLOR: Record<string, string> = {
deployment_failed: colors.err,
@@ -59,6 +59,7 @@ export default function Notifications() {
) : null
}
ListEmptyComponent={<Empty text="No notifications" />}
ListFooterComponent={<PushChannelsCard />}
data={items}
keyExtractor={(n) => n.id}
renderItem={({ item }) => {
@@ -96,6 +97,122 @@ export default function Notifications() {
);
}
function PushChannelsCard() {
const api = useApi();
const qc = useQueryClient();
const channels = useQuery({
queryKey: ['notification-channels'],
queryFn: () => api.notificationChannels(),
});
const [kind, setKind] = useState<'ntfy' | 'gotify'>('ntfy');
const [endpoint, setEndpoint] = useState('');
const [token, setToken] = useState('');
const [adding, setAdding] = useState(false);
const invalidate = () => qc.invalidateQueries({ queryKey: ['notification-channels'] });
const add = useMutation({
mutationFn: () =>
api.addNotificationChannel({
kind,
endpoint: endpoint.trim(),
token: token.trim() || undefined,
}),
onSuccess: () => {
setEndpoint('');
setToken('');
setAdding(false);
invalidate();
},
});
const remove = useMutation({
mutationFn: (id: string) => api.deleteNotificationChannel(id),
onSuccess: invalidate,
});
const test = useMutation({
mutationFn: (id: string) => api.testNotificationChannel(id),
});
const list = channels.data ?? [];
return (
<View style={{ marginTop: 24 }}>
<SectionTitle>Push channels</SectionTitle>
<Card>
<Text style={styles.hint}>
Relay alerts to a self-hosted ntfy or Gotify server — your phone gets pushes via the ntfy app, no account required here.
</Text>
{list.map((ch) => (
<View key={ch.id} style={styles.channelRow}>
<View style={{ flex: 1 }}>
<Text style={styles.channelKind}>{ch.kind.toUpperCase()}</Text>
<Text style={styles.channelEndpoint} numberOfLines={1}>
{ch.endpoint}
</Text>
</View>
<Text
style={styles.channelAction}
onPress={() => test.mutate(ch.id)}
>
{test.isPending ? '…' : 'Test'}
</Text>
<Text
style={[styles.channelAction, { color: colors.err }]}
onPress={() => remove.mutate(ch.id)}
>
Remove
</Text>
</View>
))}
{adding ? (
<View style={{ marginTop: 8 }}>
<View style={styles.kindRow}>
{(['ntfy', 'gotify'] as const).map((k) => (
<Text
key={k}
style={[styles.kindChip, kind === k && styles.kindChipActive]}
onPress={() => setKind(k)}
>
{k}
</Text>
))}
</View>
<TextInput
style={styles.input}
value={endpoint}
onChangeText={setEndpoint}
placeholder={kind === 'ntfy' ? 'https://ntfy.example.com/my-alerts' : 'https://gotify.example.com'}
placeholderTextColor={colors.textDim}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
/>
<TextInput
style={styles.input}
value={token}
onChangeText={setToken}
placeholder={kind === 'ntfy' ? 'Access token (optional)' : 'App token'}
placeholderTextColor={colors.textDim}
autoCapitalize="none"
autoCorrect={false}
secureTextEntry
/>
{add.error ? <ErrorBox error={add.error} /> : null}
<ActionButton
label={add.isPending ? 'Adding…' : 'Add channel'}
disabled={add.isPending || !endpoint.trim()}
onPress={() => add.mutate()}
/>
</View>
) : (
<Text style={styles.channelAction} onPress={() => setAdding(true)}>
+ Add channel
</Text>
)}
</Card>
</View>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: colors.bg },
row: { flexDirection: 'row', alignItems: 'flex-start' },
@@ -104,4 +221,13 @@ const styles = StyleSheet.create({
body: { color: colors.textDim, fontSize: 13, marginTop: 4, lineHeight: 18 },
meta: { color: colors.textDim, fontSize: 11, marginTop: 6 },
markRead: { color: colors.accent, fontSize: 12, fontWeight: '600', marginLeft: 8 },
hint: { color: colors.textDim, fontSize: 12, lineHeight: 17, marginBottom: 8 },
channelRow: { flexDirection: 'row', alignItems: 'center', paddingVertical: 8, borderTopWidth: 1, borderTopColor: colors.border },
channelKind: { color: colors.text, fontSize: 12, fontWeight: '700' },
channelEndpoint: { color: colors.textDim, fontSize: 12, marginTop: 2 },
channelAction: { color: colors.accent, fontSize: 12, fontWeight: '600', marginLeft: 12 },
kindRow: { flexDirection: 'row', gap: 8, marginBottom: 8 },
kindChip: { color: colors.textDim, fontSize: 13, paddingVertical: 4, paddingHorizontal: 12, borderRadius: 12, borderWidth: 1, borderColor: colors.border },
kindChipActive: { color: colors.accent, borderColor: colors.accent },
input: { color: colors.text, fontSize: 14, borderWidth: 1, borderColor: colors.border, borderRadius: 8, paddingHorizontal: 10, paddingVertical: 8, marginBottom: 8 },
});
+126
View File
@@ -2474,6 +2474,114 @@ paths:
schema:
$ref: '#/components/schemas/MessageResponse'
/notifications/channels:
get:
tags:
- Notifications
summary: List push notification channels (ntfy/Gotify)
security:
- bearerAuth: []
responses:
'200':
description: Channel list
content:
application/json:
schema:
type: object
properties:
channels:
type: array
items:
$ref: '#/components/schemas/NotificationChannel'
post:
tags:
- Notifications
summary: Add a push notification channel
description: ntfy endpoint should include the topic (e.g. https://ntfy.example.com/my-alerts); Gotify endpoint is the server base URL with the app token in `token`.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [kind, endpoint]
properties:
kind:
type: string
enum: [ntfy, gotify]
endpoint:
type: string
format: uri
token:
type: string
description: ntfy access token (Bearer) or Gotify app token
responses:
'201':
description: Channel created
content:
application/json:
schema:
type: object
properties:
channel:
$ref: '#/components/schemas/NotificationChannel'
'400':
$ref: '#/components/schemas/Error'
/notifications/channels/{id}:
delete:
tags:
- Notifications
summary: Delete a push notification channel
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
responses:
'200':
description: Deleted
content:
application/json:
schema:
$ref: '#/components/schemas/MessageResponse'
'404':
$ref: '#/components/schemas/Error'
/notifications/channels/{id}/test:
post:
tags:
- Notifications
summary: Send a test notification through a push channel
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
responses:
'200':
description: Test delivered
content:
application/json:
schema:
$ref: '#/components/schemas/MessageResponse'
'502':
description: Push delivery failed
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
# Outbound Webhooks
/webhooks:
get:
@@ -8158,6 +8266,24 @@ components:
unread:
type: integer
NotificationChannel:
type: object
properties:
id:
type: string
format: uuid
kind:
type: string
enum: [ntfy, gotify]
endpoint:
type: string
format: uri
enabled:
type: boolean
created_at:
type: string
format: date-time
OutboundWebhook:
type: object
properties: