mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
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:
1 parent
60145f7b21
commit
9243d020cc
19 files changed
+1261
-6
No files matched your search
+4
-2
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
@@ -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 />
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user