mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
feat(notifications): persistent notification center
- notifications table (goose migration) + sqlc queries; list with unread count, mark-one, mark-all endpoints under /notifications. - Deployment success/failure now writes a notification row for the owner (insertUserNotification — best-effort, never breaks the deploy flow). - Shell bell reads the real persisted feed: unread badge, click-to-read, mark-all, 30s polling. Replaces the transient builds+audit derivation. Verified e2e: migration applies clean, list/read/read-all round-trips. Authored By: TDvorak <[email protected]>
This commit is contained in:
1 parent
08d92fb10f
commit
07ac54aa05
14 files changed
+685
-45
No files matched your search
+7
-2
@@ -315,8 +315,13 @@ All of these have working APIs and no UI. Cheapest feature wins in the repo.
|
||||
- [ ] Note: APwhy's own auth/RBAC/deploy/database endpoints are *not*
|
||||
ported — Better Auth owns identity, Containr owns deployments and
|
||||
databases natively.
|
||||
- [ ] **Notifications depth**: notification center persistence (read/unread),
|
||||
not just a transient bell feed.
|
||||
- [x] **Notifications depth**: `notifications` table + `GET /notifications`,
|
||||
`POST /notifications/:id/read`, `POST /notifications/read-all`. Bell
|
||||
feed now reads real persisted rows with unread badge + mark-read.
|
||||
Producer wired: deployment success/failure writes a notification.
|
||||
Verified e2e: list, unread count, mark-one, mark-all. Further producers
|
||||
(backup completion, agent offline, upgrade available) can reuse
|
||||
`insertUserNotification`.
|
||||
- [x] **GitHub App install surfacing**: Settings → Git Providers shows an
|
||||
"Install GitHub App" action that opens `install_url`, and handles the
|
||||
`?installation_id=` return by calling `POST /git/github-app/connect`.
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"containr/internal/database"
|
||||
"containr/internal/deployment"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -368,12 +369,19 @@ func runDeploymentAndSync(
|
||||
`UPDATE services SET status = 'running', updated_at = $1 WHERE id = $2`,
|
||||
time.Now(), service.ID,
|
||||
)
|
||||
insertUserNotification(db, userID, "deployment", "Deployment succeeded",
|
||||
fmt.Sprintf("Service %s is now running.", service.Name), "service", service.ID.String())
|
||||
return
|
||||
case "failed":
|
||||
_, _ = db.Exec(
|
||||
`UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`,
|
||||
time.Now(), service.ID,
|
||||
)
|
||||
body := fmt.Sprintf("Service %s failed to deploy.", service.Name)
|
||||
if current.Error != "" {
|
||||
body = fmt.Sprintf("Service %s failed to deploy: %s", service.Name, current.Error)
|
||||
}
|
||||
insertUserNotification(db, userID, "deployment", "Deployment failed", body, "service", service.ID.String())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
"context"
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type NotificationItem struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
ResourceType string `json:"resource_type"`
|
||||
ResourceID string `json:"resource_id"`
|
||||
ReadAt *time.Time `json:"read_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// insertUserNotification records a persistent notification for a user.
|
||||
// Best-effort: notification delivery must never break the caller's flow.
|
||||
func insertUserNotification(db *database.DB, userID, kind, title, body, resourceType, resourceID string) {
|
||||
userUUID, err := uuid.Parse(userID)
|
||||
if err != nil || db == nil {
|
||||
return
|
||||
}
|
||||
_ = sqlcdb.New(db.DB).CreateNotification(context.Background(), sqlcdb.CreateNotificationParams{
|
||||
ID: uuid.New(),
|
||||
UserID: userUUID,
|
||||
Kind: kind,
|
||||
Title: title,
|
||||
Body: sql.NullString{String: body, Valid: body != ""},
|
||||
ResourceType: sql.NullString{String: resourceType, Valid: resourceType != ""},
|
||||
ResourceID: sql.NullString{String: resourceID, Valid: resourceID != ""},
|
||||
CreatedAt: sql.NullTime{Time: time.Now(), Valid: true},
|
||||
})
|
||||
}
|
||||
|
||||
func mapNotification(row sqlcdb.Notification) NotificationItem {
|
||||
var readAt *time.Time
|
||||
if row.ReadAt.Valid {
|
||||
t := row.ReadAt.Time
|
||||
readAt = &t
|
||||
}
|
||||
return NotificationItem{
|
||||
ID: row.ID.String(),
|
||||
Kind: row.Kind,
|
||||
Title: row.Title,
|
||||
Body: databaseNullString(row.Body),
|
||||
ResourceType: databaseNullString(row.ResourceType),
|
||||
ResourceID: databaseNullString(row.ResourceID),
|
||||
ReadAt: readAt,
|
||||
CreatedAt: databaseNullTime(row.CreatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func handleListNotifications(c *gin.Context) {
|
||||
userID, ok := requireAuthenticatedUserID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
db, exists := c.Get("db")
|
||||
if !exists {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
||||
return
|
||||
}
|
||||
q := sqlcdb.New(db.(*database.DB).DB)
|
||||
limit := int64(20)
|
||||
if raw := c.Query("limit"); raw != "" {
|
||||
if parsed, err := strconv.ParseInt(raw, 10, 64); err == nil && parsed > 0 && parsed <= 100 {
|
||||
limit = parsed
|
||||
}
|
||||
}
|
||||
userUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user id"})
|
||||
return
|
||||
}
|
||||
rows, err := q.ListNotificationsByUser(c.Request.Context(), sqlcdb.ListNotificationsByUserParams{
|
||||
UserID: userUUID,
|
||||
Limit: int32(limit),
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list notifications"})
|
||||
return
|
||||
}
|
||||
unread, err := q.CountUnreadNotificationsByUser(c.Request.Context(), userUUID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to count notifications"})
|
||||
return
|
||||
}
|
||||
items := make([]NotificationItem, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, mapNotification(row))
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"notifications": items, "unread": unread})
|
||||
}
|
||||
|
||||
func handleMarkNotificationRead(c *gin.Context) {
|
||||
userID, ok := requireAuthenticatedUserID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
db, exists := c.Get("db")
|
||||
if !exists {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid notification id"})
|
||||
return
|
||||
}
|
||||
userUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user id"})
|
||||
return
|
||||
}
|
||||
q := sqlcdb.New(db.(*database.DB).DB)
|
||||
if err := q.MarkNotificationReadByIDAndUser(c.Request.Context(), sqlcdb.MarkNotificationReadByIDAndUserParams{
|
||||
ID: id,
|
||||
UserID: userUUID,
|
||||
ReadAt: sql.NullTime{Time: time.Now(), Valid: true},
|
||||
}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to mark notification"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Notification marked read"})
|
||||
}
|
||||
|
||||
func handleMarkAllNotificationsRead(c *gin.Context) {
|
||||
userID, ok := requireAuthenticatedUserID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
db, exists := c.Get("db")
|
||||
if !exists {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
||||
return
|
||||
}
|
||||
userUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user id"})
|
||||
return
|
||||
}
|
||||
q := sqlcdb.New(db.(*database.DB).DB)
|
||||
if err := q.MarkAllNotificationsReadByUser(c.Request.Context(), sqlcdb.MarkAllNotificationsReadByUserParams{
|
||||
UserID: userUUID,
|
||||
ReadAt: sql.NullTime{Time: time.Now(), Valid: true},
|
||||
}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to mark notifications"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "All notifications marked read"})
|
||||
}
|
||||
@@ -252,6 +252,11 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
protected.POST("/databases/:id/restore", databaseHandler.RestoreBackup)
|
||||
protected.GET("/databases/:id/backups/:bid/download", databaseHandler.DownloadBackup)
|
||||
|
||||
// Notification routes
|
||||
protected.GET("/notifications", handleListNotifications)
|
||||
protected.POST("/notifications/:id/read", handleMarkNotificationRead)
|
||||
protected.POST("/notifications/read-all", handleMarkAllNotificationsRead)
|
||||
|
||||
// Node Agent routes
|
||||
agentHandler.SetupRoutes(protected)
|
||||
|
||||
|
||||
@@ -137,6 +137,18 @@ type NodeAgent struct {
|
||||
Metadata pqtype.NullRawMessage `json:"metadata"`
|
||||
}
|
||||
|
||||
type Notification struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Kind string `json:"kind"`
|
||||
Title string `json:"title"`
|
||||
Body sql.NullString `json:"body"`
|
||||
ResourceType sql.NullString `json:"resource_type"`
|
||||
ResourceID sql.NullString `json:"resource_id"`
|
||||
ReadAt sql.NullTime `json:"read_at"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
}
|
||||
|
||||
type Project struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: notifications.sql
|
||||
|
||||
package sqlcdb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const countUnreadNotificationsByUser = `-- name: CountUnreadNotificationsByUser :one
|
||||
SELECT COUNT(*) FROM notifications WHERE user_id = $1 AND read_at IS NULL
|
||||
`
|
||||
|
||||
func (q *Queries) CountUnreadNotificationsByUser(ctx context.Context, userID uuid.UUID) (int64, error) {
|
||||
row := q.db.QueryRowContext(ctx, countUnreadNotificationsByUser, userID)
|
||||
var count int64
|
||||
err := row.Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
const createNotification = `-- name: CreateNotification :exec
|
||||
INSERT INTO notifications (id, user_id, kind, title, body, resource_type, resource_id, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
`
|
||||
|
||||
type CreateNotificationParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Kind string `json:"kind"`
|
||||
Title string `json:"title"`
|
||||
Body sql.NullString `json:"body"`
|
||||
ResourceType sql.NullString `json:"resource_type"`
|
||||
ResourceID sql.NullString `json:"resource_id"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateNotification(ctx context.Context, arg CreateNotificationParams) error {
|
||||
_, err := q.db.ExecContext(ctx, createNotification,
|
||||
arg.ID,
|
||||
arg.UserID,
|
||||
arg.Kind,
|
||||
arg.Title,
|
||||
arg.Body,
|
||||
arg.ResourceType,
|
||||
arg.ResourceID,
|
||||
arg.CreatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const listNotificationsByUser = `-- name: ListNotificationsByUser :many
|
||||
SELECT id, user_id, kind, title, body, resource_type, resource_id, read_at, created_at
|
||||
FROM notifications
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2
|
||||
`
|
||||
|
||||
type ListNotificationsByUserParams struct {
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Limit int32 `json:"limit"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListNotificationsByUser(ctx context.Context, arg ListNotificationsByUserParams) ([]Notification, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listNotificationsByUser, arg.UserID, arg.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Notification{}
|
||||
for rows.Next() {
|
||||
var i Notification
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.Kind,
|
||||
&i.Title,
|
||||
&i.Body,
|
||||
&i.ResourceType,
|
||||
&i.ResourceID,
|
||||
&i.ReadAt,
|
||||
&i.CreatedAt,
|
||||
); 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 markAllNotificationsReadByUser = `-- name: MarkAllNotificationsReadByUser :exec
|
||||
UPDATE notifications SET read_at = $2 WHERE user_id = $1 AND read_at IS NULL
|
||||
`
|
||||
|
||||
type MarkAllNotificationsReadByUserParams struct {
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
ReadAt sql.NullTime `json:"read_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) MarkAllNotificationsReadByUser(ctx context.Context, arg MarkAllNotificationsReadByUserParams) error {
|
||||
_, err := q.db.ExecContext(ctx, markAllNotificationsReadByUser, arg.UserID, arg.ReadAt)
|
||||
return err
|
||||
}
|
||||
|
||||
const markNotificationReadByIDAndUser = `-- name: MarkNotificationReadByIDAndUser :exec
|
||||
UPDATE notifications SET read_at = $3 WHERE id = $1 AND user_id = $2 AND read_at IS NULL
|
||||
`
|
||||
|
||||
type MarkNotificationReadByIDAndUserParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
ReadAt sql.NullTime `json:"read_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) MarkNotificationReadByIDAndUser(ctx context.Context, arg MarkNotificationReadByIDAndUserParams) error {
|
||||
_, err := q.db.ExecContext(ctx, markNotificationReadByIDAndUser, arg.ID, arg.UserID, arg.ReadAt)
|
||||
return err
|
||||
}
|
||||
@@ -15,12 +15,14 @@ type Querier interface {
|
||||
CountDatabaseServicesByUserAndName(ctx context.Context, arg CountDatabaseServicesByUserAndNameParams) (int64, error)
|
||||
CountProjectsByUser(ctx context.Context, arg CountProjectsByUserParams) (int64, error)
|
||||
CountServicesByProjectAndName(ctx context.Context, arg CountServicesByProjectAndNameParams) (int64, error)
|
||||
CountUnreadNotificationsByUser(ctx context.Context, userID uuid.UUID) (int64, error)
|
||||
CreateAgent(ctx context.Context, arg CreateAgentParams) (NodeAgent, error)
|
||||
CreateAgentAuthToken(ctx context.Context, arg CreateAgentAuthTokenParams) (AgentAuthToken, error)
|
||||
CreateCommand(ctx context.Context, arg CreateCommandParams) (AgentCommand, error)
|
||||
CreateContainer(ctx context.Context, arg CreateContainerParams) (ContainerInstance, error)
|
||||
CreateDatabaseBackup(ctx context.Context, arg CreateDatabaseBackupParams) error
|
||||
CreateDatabaseService(ctx context.Context, arg CreateDatabaseServiceParams) error
|
||||
CreateNotification(ctx context.Context, arg CreateNotificationParams) error
|
||||
CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error)
|
||||
CreateServiceFromTemplate(ctx context.Context, arg CreateServiceFromTemplateParams) error
|
||||
DatabaseServiceExistsByIDAndUser(ctx context.Context, arg DatabaseServiceExistsByIDAndUserParams) (bool, error)
|
||||
@@ -50,10 +52,13 @@ type Querier interface {
|
||||
ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg ListDatabaseBackupsByDatabaseAndUserParams) ([]DatabaseBackup, error)
|
||||
ListDatabaseServicesByUser(ctx context.Context, userID string) ([]ListDatabaseServicesByUserRow, error)
|
||||
ListDueDatabaseBackups(ctx context.Context) ([]ListDueDatabaseBackupsRow, error)
|
||||
ListNotificationsByUser(ctx context.Context, arg ListNotificationsByUserParams) ([]Notification, error)
|
||||
ListPendingCommands(ctx context.Context, nodeAgentID string) ([]AgentCommand, error)
|
||||
ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error)
|
||||
ListServiceTemplates(ctx context.Context) ([]ServiceTemplate, error)
|
||||
ListServiceTemplatesByCategory(ctx context.Context, category string) ([]ServiceTemplate, error)
|
||||
MarkAllNotificationsReadByUser(ctx context.Context, arg MarkAllNotificationsReadByUserParams) error
|
||||
MarkNotificationReadByIDAndUser(ctx context.Context, arg MarkNotificationReadByIDAndUserParams) error
|
||||
RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error)
|
||||
SetDatabaseBackupScheduleByIDAndUser(ctx context.Context, arg SetDatabaseBackupScheduleByIDAndUserParams) error
|
||||
SetDatabaseBackupStatusByID(ctx context.Context, arg SetDatabaseBackupStatusByIDParams) error
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE IF NOT EXISTS public.notifications (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
user_id uuid NOT NULL,
|
||||
kind character varying(50) NOT NULL,
|
||||
title character varying(255) NOT NULL,
|
||||
body text,
|
||||
resource_type character varying(50),
|
||||
resource_id character varying(255),
|
||||
read_at timestamp with time zone,
|
||||
created_at timestamp with time zone DEFAULT now(),
|
||||
CONSTRAINT notifications_pkey PRIMARY KEY (id),
|
||||
CONSTRAINT fk_notifications_user FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_user_created ON public.notifications(user_id, created_at DESC);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS public.notifications;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- name: CreateNotification :exec
|
||||
INSERT INTO notifications (id, user_id, kind, title, body, resource_type, resource_id, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8);
|
||||
|
||||
-- name: ListNotificationsByUser :many
|
||||
SELECT id, user_id, kind, title, body, resource_type, resource_id, read_at, created_at
|
||||
FROM notifications
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2;
|
||||
|
||||
-- name: CountUnreadNotificationsByUser :one
|
||||
SELECT COUNT(*) FROM notifications WHERE user_id = $1 AND read_at IS NULL;
|
||||
|
||||
-- name: MarkNotificationReadByIDAndUser :exec
|
||||
UPDATE notifications SET read_at = $3 WHERE id = $1 AND user_id = $2 AND read_at IS NULL;
|
||||
|
||||
-- name: MarkAllNotificationsReadByUser :exec
|
||||
UPDATE notifications SET read_at = $2 WHERE user_id = $1 AND read_at IS NULL;
|
||||
@@ -183,3 +183,15 @@ CREATE TABLE agent_heartbeats (
|
||||
version VARCHAR(50) NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE notifications (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
kind VARCHAR(50) NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
body TEXT,
|
||||
resource_type VARCHAR(50),
|
||||
resource_id VARCHAR(255),
|
||||
read_at TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Bell,
|
||||
@@ -21,8 +21,9 @@ import {
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
getUpgradeStatus,
|
||||
listAuditLogs,
|
||||
listBuilds,
|
||||
listNotifications,
|
||||
markAllNotificationsRead,
|
||||
markNotificationRead,
|
||||
pullUpgradeImage,
|
||||
} from '@/lib/api-client';
|
||||
import { signOutAuthSession } from '@/lib/auth-client';
|
||||
@@ -64,15 +65,19 @@ export function PlatformShell() {
|
||||
queryFn: getUpgradeStatus,
|
||||
enabled: !isDemoMode,
|
||||
});
|
||||
const buildsQuery = useQuery({
|
||||
queryKey: ['shell-build-notifications'],
|
||||
queryFn: () => listBuilds({ page: 1, limit: 5 }),
|
||||
const notificationsQuery = useQuery({
|
||||
queryKey: ['shell-notifications'],
|
||||
queryFn: () => listNotifications(20),
|
||||
enabled: !isDemoMode,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
const auditQuery = useQuery({
|
||||
queryKey: ['shell-audit-notifications'],
|
||||
queryFn: () => listAuditLogs({ page: 1, limit: 5 }),
|
||||
enabled: !isDemoMode,
|
||||
const markReadMutation = useMutation({
|
||||
mutationFn: (id: string) => markNotificationRead(id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['shell-notifications'] }),
|
||||
});
|
||||
const markAllReadMutation = useMutation({
|
||||
mutationFn: markAllNotificationsRead,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['shell-notifications'] }),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -91,19 +96,8 @@ export function PlatformShell() {
|
||||
},
|
||||
});
|
||||
|
||||
const notifications = useMemo(() => {
|
||||
const builds = (buildsQuery.data?.builds ?? []).map((build) => ({
|
||||
id: `build-${build.id}`,
|
||||
title: `${build.status} build`,
|
||||
body: build.imageName || build.serviceId || build.id,
|
||||
}));
|
||||
const audits = (auditQuery.data ?? []).map((log) => ({
|
||||
id: `audit-${log.id}`,
|
||||
title: log.action,
|
||||
body: `${log.resource}${log.resourceId ? ` / ${log.resourceId}` : ''}`,
|
||||
}));
|
||||
return [...builds, ...audits].slice(0, 6);
|
||||
}, [auditQuery.data, buildsQuery.data?.builds]);
|
||||
const notifications = notificationsQuery.data?.notifications ?? [];
|
||||
const unreadCount = notificationsQuery.data?.unread ?? 0;
|
||||
|
||||
const signOut = async () => {
|
||||
try {
|
||||
@@ -248,8 +242,10 @@ export function PlatformShell() {
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<Bell size={15} />
|
||||
{notifications.length > 0 ? (
|
||||
<span className="absolute right-1.5 top-1.5 h-2 w-2 rounded-full bg-[var(--accent-primary)]" />
|
||||
{unreadCount > 0 ? (
|
||||
<span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-[var(--accent-primary)] px-1 text-[10px] font-semibold text-[var(--accent-on)]">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
|
||||
@@ -258,32 +254,54 @@ export function PlatformShell() {
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-[var(--text-primary)]">Notifications</p>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Build and audit activity</p>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">
|
||||
{unreadCount > 0 ? `${unreadCount} unread` : 'All caught up'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{unreadCount > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => markAllReadMutation.mutate()}
|
||||
className="rounded-[var(--radius-sm)] px-2 py-1 text-xs text-[var(--text-tertiary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)]"
|
||||
>
|
||||
Mark all read
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => queryClient.invalidateQueries({ queryKey: ['shell-notifications'] })}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-[var(--radius-sm)] text-[var(--text-tertiary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)]"
|
||||
aria-label="Refresh notifications"
|
||||
>
|
||||
<RefreshCw size={13} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['shell-build-notifications'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['shell-audit-notifications'] });
|
||||
}}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-[var(--radius-sm)] text-[var(--text-tertiary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)]"
|
||||
aria-label="Refresh notifications"
|
||||
>
|
||||
<RefreshCw size={13} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="max-h-80 space-y-2 overflow-y-auto">
|
||||
{notifications.length > 0 ? (
|
||||
notifications.map((item) => (
|
||||
<div key={item.id} className="rounded-[var(--radius-md)] bg-[var(--surface-muted)] p-3">
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">{item.title}</p>
|
||||
<p className="mt-1 truncate text-xs text-[var(--text-tertiary)]">{item.body}</p>
|
||||
</div>
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => { if (!item.read_at) markReadMutation.mutate(item.id ?? ''); }}
|
||||
className="w-full rounded-[var(--radius-md)] bg-[var(--surface-muted)] p-3 text-left hover:bg-[var(--surface-raised)]"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
{!item.read_at ? (
|
||||
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--accent-primary)]" />
|
||||
) : null}
|
||||
<div className="min-w-0">
|
||||
<p className={`text-sm ${item.read_at ? 'text-[var(--text-secondary)]' : 'font-medium text-[var(--text-primary)]'}`}>{item.title}</p>
|
||||
<p className="mt-1 truncate text-xs text-[var(--text-tertiary)]">{item.body}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="flex items-center gap-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] p-3 text-sm text-[var(--text-secondary)]">
|
||||
<CheckCircle2 size={15} className="text-[var(--success)]" />
|
||||
No recent events
|
||||
No notifications yet
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1314,6 +1314,118 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/notifications": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** List notifications for the current user */
|
||||
get: {
|
||||
parameters: {
|
||||
query?: {
|
||||
limit?: number;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Notifications and unread count */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["NotificationListResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/notifications/{id}/read": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Mark a notification as read */
|
||||
post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Marked read */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["MessageResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/notifications/read-all": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Mark all notifications as read */
|
||||
post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description All marked read */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["MessageResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/audit-logs": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -7030,6 +7142,23 @@ export interface components {
|
||||
*/
|
||||
created_at?: string;
|
||||
};
|
||||
Notification: {
|
||||
/** Format: uuid */
|
||||
id?: string;
|
||||
kind?: string;
|
||||
title?: string;
|
||||
body?: string;
|
||||
resource_type?: string;
|
||||
resource_id?: string;
|
||||
/** Format: date-time */
|
||||
read_at?: string | null;
|
||||
/** Format: date-time */
|
||||
created_at?: string;
|
||||
};
|
||||
NotificationListResponse: {
|
||||
notifications?: components["schemas"]["Notification"][];
|
||||
unread?: number;
|
||||
};
|
||||
AuditLogListResponse: {
|
||||
audit_logs?: components["schemas"]["AuditLog"][];
|
||||
};
|
||||
|
||||
@@ -1427,3 +1427,25 @@ export async function listCronExecutions(id: string): Promise<CronExecutionEntit
|
||||
export async function triggerCronJob(id: string): Promise<void> {
|
||||
await requestJson(`/cron-jobs/${encodeURIComponent(id)}/trigger`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export type NotificationEntity = components['schemas']['Notification'];
|
||||
|
||||
export type NotificationList = {
|
||||
notifications: NotificationEntity[];
|
||||
unread: number;
|
||||
};
|
||||
|
||||
export async function listNotifications(limit = 20): Promise<NotificationList> {
|
||||
const payload = await requestJson<{ notifications?: NotificationEntity[]; unread?: number }>(
|
||||
`/notifications?limit=${limit}`,
|
||||
);
|
||||
return { notifications: payload.notifications ?? [], unread: payload.unread ?? 0 };
|
||||
}
|
||||
|
||||
export async function markNotificationRead(id: string): Promise<void> {
|
||||
await requestJson(`/notifications/${encodeURIComponent(id)}/read`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function markAllNotificationsRead(): Promise<void> {
|
||||
await requestJson('/notifications/read-all', { method: 'POST' });
|
||||
}
|
||||
@@ -897,6 +897,65 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
|
||||
# Notifications
|
||||
/notifications:
|
||||
get:
|
||||
tags:
|
||||
- Notifications
|
||||
summary: List notifications for the current user
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- name: limit
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
default: 20
|
||||
responses:
|
||||
'200':
|
||||
description: Notifications and unread count
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/NotificationListResponse'
|
||||
|
||||
/notifications/{id}/read:
|
||||
post:
|
||||
tags:
|
||||
- Notifications
|
||||
summary: Mark a notification as read
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
responses:
|
||||
'200':
|
||||
description: Marked read
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MessageResponse'
|
||||
|
||||
/notifications/read-all:
|
||||
post:
|
||||
tags:
|
||||
- Notifications
|
||||
summary: Mark all notifications as read
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: All marked read
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MessageResponse'
|
||||
|
||||
# Audit Logs
|
||||
/audit-logs:
|
||||
get:
|
||||
@@ -4740,6 +4799,40 @@ components:
|
||||
format: date-time
|
||||
description: Timestamp when the audit log entry was created
|
||||
|
||||
Notification:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
kind:
|
||||
type: string
|
||||
title:
|
||||
type: string
|
||||
body:
|
||||
type: string
|
||||
resource_type:
|
||||
type: string
|
||||
resource_id:
|
||||
type: string
|
||||
read_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
NotificationListResponse:
|
||||
type: object
|
||||
properties:
|
||||
notifications:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Notification'
|
||||
unread:
|
||||
type: integer
|
||||
|
||||
AuditLogListResponse:
|
||||
type: object
|
||||
properties:
|
||||
@@ -6380,6 +6473,8 @@ tags:
|
||||
description: Service template catalog and deployment
|
||||
- name: Audit
|
||||
description: Audit event history and resource-level trails
|
||||
- name: Notifications
|
||||
description: Persistent user notifications with read state
|
||||
- name: Databases
|
||||
description: Database management
|
||||
- name: Git
|
||||
|
||||
Reference in new issue
Block a user