feat(banners): instance announcements — admin CRUD, top bar, CLI (#39)

banners table (title/body/level/active/dismissible/schedule window).
GET /banners/active for signed-in users; admin CRUD under /admin/banners.
BannerBar renders above the header with per-banner dismissal
(localStorage); admin console gains an Announcements section;
`containr banners` manages them and `auth status` prints active ones.

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-09 10:10:18 +02:00
1 parent 954a1d1f80
commit 9408464f12
21 files changed
+1301 -3

No files matched your search

+5 -2
View File
@@ -284,8 +284,11 @@ and wired refs, one click or one CLI call.
CLI + MCP + `/settings/webhooks` page with delivery log + test ping. CLI + MCP + `/settings/webhooks` page with delivery log + test ping.
- [ ] **Activity feed** — enrich `audit_logs` with `severity category - [ ] **Activity feed** — enrich `audit_logs` with `severity category
icon label metadata`; per-project timeline + global feed; TTL job. icon label metadata`; per-project timeline + global feed; TTL job.
- [ ] **Banners** — `banners(title, body, level, scope, active)`; top - [x] **Banners** — `banners` table with title/body/level/active/
bar in UI + surfaced to CLI on `auth status`. dismissible/schedule window. `GET /banners/active` for signed-in
users; admin CRUD at `/admin/banners`. `BannerBar` renders above the
header (per-banner dismiss via localStorage); admin console manages
them; `containr banners` CLI + `auth status` surfaces active ones.
- [ ] **White-label** — `app_settings.branding`: product name, logos, - [ ] **White-label** — `app_settings.branding`: product name, logos,
favicon, accent override, docs/support links. `GET /api/v1/branding` favicon, accent override, docs/support links. `GET /api/v1/branding`
public + cached. **Containr/Vertice remains default;** this is for public + cached. **Containr/Vertice remains default;** this is for
+225
View File
@@ -0,0 +1,225 @@
package api
import (
"database/sql"
"net/http"
"time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// Instance-wide announcement banners. Admins manage them under
// /admin/banners; every signed-in user reads the active set at
// /banners/active so the UI (and CLI) can surface announcements.
var bannerLevels = map[string]bool{"info": true, "success": true, "warning": true, "error": true}
// bannerJSON flattens sqlc nullable wrappers into plain API values.
type bannerJSON struct {
ID string `json:"id"`
Title string `json:"title"`
Body string `json:"body"`
Level string `json:"level"`
Active bool `json:"active"`
Dismissible bool `json:"dismissible"`
StartsAt *time.Time `json:"starts_at,omitempty"`
EndsAt *time.Time `json:"ends_at,omitempty"`
CreatedAt *time.Time `json:"created_at,omitempty"`
}
func toBannerJSON(b sqlcdb.Banner) bannerJSON {
out := bannerJSON{
ID: b.ID.String(),
Title: b.Title,
Body: b.Body,
Level: b.Level,
Active: b.Active,
Dismissible: b.Dismissible,
}
if b.StartsAt.Valid {
out.StartsAt = &b.StartsAt.Time
}
if b.EndsAt.Valid {
out.EndsAt = &b.EndsAt.Time
}
if b.CreatedAt.Valid {
out.CreatedAt = &b.CreatedAt.Time
}
return out
}
func toBannerList(banners []sqlcdb.Banner) []bannerJSON {
out := make([]bannerJSON, 0, len(banners))
for _, b := range banners {
out = append(out, toBannerJSON(b))
}
return out
}
type bannerRequest struct {
Title *string `json:"title"`
Body *string `json:"body"`
Level *string `json:"level"`
Active *bool `json:"active"`
Dismissible *bool `json:"dismissible"`
StartsAt *time.Time `json:"starts_at"`
EndsAt *time.Time `json:"ends_at"`
}
func bannerQueries(c *gin.Context) *sqlcdb.Queries {
db := c.MustGet("db").(*database.DB)
return sqlcdb.New(db.DB)
}
func parseBannerID(c *gin.Context) (uuid.UUID, bool) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, http.StatusBadRequest, "INVALID_ID", "invalid banner id")
return uuid.Nil, false
}
return id, true
}
func validBannerLevel(level string) bool { return bannerLevels[level] }
func handleListActiveBanners(c *gin.Context) {
banners, err := bannerQueries(c).ListActiveBanners(c.Request.Context())
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to list banners")
return
}
c.JSON(http.StatusOK, gin.H{"banners": toBannerList(banners)})
}
func handleAdminListBanners(c *gin.Context) {
banners, err := bannerQueries(c).ListBanners(c.Request.Context())
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to list banners")
return
}
c.JSON(http.StatusOK, gin.H{"banners": toBannerList(banners)})
}
func handleAdminCreateBanner(c *gin.Context) {
var req bannerRequest
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "INVALID_BODY", "invalid request body")
return
}
if req.Title == nil || *req.Title == "" {
respondError(c, http.StatusBadRequest, "VALIDATION", "title is required")
return
}
level := "info"
if req.Level != nil {
if !validBannerLevel(*req.Level) {
respondError(c, http.StatusBadRequest, "VALIDATION", "level must be info, success, warning or error")
return
}
level = *req.Level
}
userID, _ := requireAuthenticatedUserUUID(c)
params := sqlcdb.CreateBannerParams{
Title: *req.Title,
Level: level,
Active: true,
Dismissible: true,
CreatedBy: uuid.NullUUID{UUID: userID, Valid: true},
}
if req.Body != nil {
params.Body = *req.Body
}
if req.Active != nil {
params.Active = *req.Active
}
if req.Dismissible != nil {
params.Dismissible = *req.Dismissible
}
if req.StartsAt != nil {
params.StartsAt = sql.NullTime{Time: *req.StartsAt, Valid: true}
}
if req.EndsAt != nil {
params.EndsAt = sql.NullTime{Time: *req.EndsAt, Valid: true}
}
banner, err := bannerQueries(c).CreateBanner(c.Request.Context(), params)
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to create banner")
return
}
c.JSON(http.StatusCreated, toBannerJSON(banner))
}
func handleAdminUpdateBanner(c *gin.Context) {
id, ok := parseBannerID(c)
if !ok {
return
}
q := bannerQueries(c)
banner, err := q.GetBannerByID(c.Request.Context(), id)
if err != nil {
respondError(c, http.StatusNotFound, "NOT_FOUND", "banner not found")
return
}
var req bannerRequest
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "INVALID_BODY", "invalid request body")
return
}
params := sqlcdb.UpdateBannerParams{
ID: id,
Title: banner.Title,
Body: banner.Body,
Level: banner.Level,
Active: banner.Active,
Dismissible: banner.Dismissible,
StartsAt: banner.StartsAt,
EndsAt: banner.EndsAt,
}
if req.Title != nil {
params.Title = *req.Title
}
if req.Body != nil {
params.Body = *req.Body
}
if req.Level != nil {
if !validBannerLevel(*req.Level) {
respondError(c, http.StatusBadRequest, "VALIDATION", "level must be info, success, warning or error")
return
}
params.Level = *req.Level
}
if req.Active != nil {
params.Active = *req.Active
}
if req.Dismissible != nil {
params.Dismissible = *req.Dismissible
}
if req.StartsAt != nil {
params.StartsAt = sql.NullTime{Time: *req.StartsAt, Valid: true}
}
if req.EndsAt != nil {
params.EndsAt = sql.NullTime{Time: *req.EndsAt, Valid: true}
}
updated, err := q.UpdateBanner(c.Request.Context(), params)
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to update banner")
return
}
c.JSON(http.StatusOK, toBannerJSON(updated))
}
func handleAdminDeleteBanner(c *gin.Context) {
id, ok := parseBannerID(c)
if !ok {
return
}
if err := bannerQueries(c).DeleteBanner(c.Request.Context(), id); err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to delete banner")
return
}
c.Status(http.StatusNoContent)
}
+9
View File
@@ -336,6 +336,9 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
// Cross-cutting ops view — active/queued/failed work // Cross-cutting ops view — active/queued/failed work
authed.GET("/operations", handleGetOperations) authed.GET("/operations", handleGetOperations)
// Instance announcements shown to all signed-in users
authed.GET("/banners/active", handleListActiveBanners)
// Outbound webhooks — signed event delivery to user HTTP endpoints // Outbound webhooks — signed event delivery to user HTTP endpoints
authed.GET("/webhooks", handleListOutboundWebhooks) authed.GET("/webhooks", handleListOutboundWebhooks)
authed.POST("/webhooks", handleCreateOutboundWebhook) authed.POST("/webhooks", handleCreateOutboundWebhook)
@@ -485,6 +488,12 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
admin.GET("/admin/volumes", handleAdminListVolumes) admin.GET("/admin/volumes", handleAdminListVolumes)
admin.DELETE("/admin/volumes/:name", handleAdminDeleteVolume) admin.DELETE("/admin/volumes/:name", handleAdminDeleteVolume)
// Announcement banners
admin.GET("/admin/banners", handleAdminListBanners)
admin.POST("/admin/banners", handleAdminCreateBanner)
admin.PATCH("/admin/banners/:id", handleAdminUpdateBanner)
admin.DELETE("/admin/banners/:id", handleAdminDeleteBanner)
admin.GET("/audit-logs", handleGetAuditLogs) admin.GET("/audit-logs", handleGetAuditLogs)
admin.GET("/audit-logs/:resource/:id", handleGetResourceAuditLogs) admin.GET("/audit-logs/:resource/:id", handleGetResourceAuditLogs)
+16
View File
@@ -147,5 +147,21 @@ func runStatus(cmd *cobra.Command, args []string) error {
if p.IsAdmin { if p.IsAdmin {
fmt.Println("Role: platform admin") fmt.Println("Role: platform admin")
} }
var bannersResp struct {
Banners []struct {
Title string `json:"title"`
Body string `json:"body"`
Level string `json:"level"`
} `json:"banners"`
}
if err := client.DoJSON("GET", "/banners/active", nil, &bannersResp); err == nil {
for _, b := range bannersResp.Banners {
fmt.Printf("[%s] %s\n", b.Level, b.Title)
if b.Body != "" {
fmt.Printf(" %s\n", b.Body)
}
}
}
return nil return nil
} }
@@ -0,0 +1,133 @@
package commands
import (
"fmt"
"github.com/spf13/cobra"
)
var (
bannerTitle string
bannerLevel string
bannerBody string
bannerActive bool
)
// BannersCmd manages instance-wide announcement banners (admin).
var BannersCmd = &cobra.Command{
Use: "banners",
Short: "Manage announcement banners (admin)",
}
var bannerListCmd = &cobra.Command{
Use: "list",
Short: "List all banners",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/admin/banners", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "banners")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, b := range items {
rows = append(rows, []string{
str(b, "id"), str(b, "title"), str(b, "level"), str(b, "active"),
})
}
printRows(data, []string{"ID", "TITLE", "LEVEL", "ACTIVE"}, rows)
return nil
},
}
var bannerCreateCmd = &cobra.Command{
Use: "create <title>",
Short: "Create a banner (--body --level info|success|warning|error)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
req := map[string]interface{}{"title": args[0]}
if cmd.Flags().Changed("body") {
req["body"] = bannerBody
}
if cmd.Flags().Changed("level") {
req["level"] = bannerLevel
}
data, err := c.Do("POST", "/admin/banners", req)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var bannerUpdateCmd = &cobra.Command{
Use: "update <id>",
Short: "Update a banner (--title --body --level --active/--inactive)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
req := map[string]interface{}{}
if cmd.Flags().Changed("title") {
req["title"] = bannerTitle
}
if cmd.Flags().Changed("body") {
req["body"] = bannerBody
}
if cmd.Flags().Changed("level") {
req["level"] = bannerLevel
}
if cmd.Flags().Changed("active") {
req["active"] = bannerActive
}
if len(req) == 0 {
return fmt.Errorf("nothing to update — pass --title/--body/--level/--active")
}
data, err := c.Do("PATCH", "/admin/banners/"+args[0], req)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var bannerDeleteCmd = &cobra.Command{
Use: "delete <id>",
Short: "Delete a banner",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if !Confirm("Delete banner " + args[0] + "?") {
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
}
c, err := client()
if err != nil {
return err
}
_, err = c.Do("DELETE", "/admin/banners/"+args[0], nil)
return err
},
}
func init() {
bannerCreateCmd.Flags().StringVar(&bannerBody, "body", "", "banner body text")
bannerCreateCmd.Flags().StringVar(&bannerLevel, "level", "info", "info|success|warning|error")
bannerUpdateCmd.Flags().StringVar(&bannerTitle, "title", "", "new title")
bannerUpdateCmd.Flags().StringVar(&bannerBody, "body", "", "new body")
bannerUpdateCmd.Flags().StringVar(&bannerLevel, "level", "", "info|success|warning|error")
bannerUpdateCmd.Flags().BoolVar(&bannerActive, "active", true, "set banner active/inactive")
BannersCmd.AddCommand(bannerListCmd, bannerCreateCmd, bannerUpdateCmd, bannerDeleteCmd)
}
+1
View File
@@ -89,6 +89,7 @@ func init() {
rootCmd.AddCommand(commands.NotificationsCmd) rootCmd.AddCommand(commands.NotificationsCmd)
rootCmd.AddCommand(commands.WebhooksCmd) rootCmd.AddCommand(commands.WebhooksCmd)
rootCmd.AddCommand(commands.OperationsCmd) rootCmd.AddCommand(commands.OperationsCmd)
rootCmd.AddCommand(commands.BannersCmd)
rootCmd.AddCommand(commands.UpCmd) rootCmd.AddCommand(commands.UpCmd)
} }
@@ -0,0 +1,231 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: banners.sql
package sqlcdb
import (
"context"
"database/sql"
"github.com/google/uuid"
)
const createBanner = `-- name: CreateBanner :one
INSERT INTO banners (title, body, level, active, dismissible, created_by, starts_at, ends_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING banners.id, banners.title, banners.body, banners.level, banners.active, banners.dismissible, banners.created_by, banners.starts_at, banners.ends_at, banners.created_at, banners.updated_at
`
type CreateBannerParams struct {
Title string `json:"title"`
Body string `json:"body"`
Level string `json:"level"`
Active bool `json:"active"`
Dismissible bool `json:"dismissible"`
CreatedBy uuid.NullUUID `json:"created_by"`
StartsAt sql.NullTime `json:"starts_at"`
EndsAt sql.NullTime `json:"ends_at"`
}
func (q *Queries) CreateBanner(ctx context.Context, arg CreateBannerParams) (Banner, error) {
row := q.db.QueryRowContext(ctx, createBanner,
arg.Title,
arg.Body,
arg.Level,
arg.Active,
arg.Dismissible,
arg.CreatedBy,
arg.StartsAt,
arg.EndsAt,
)
var i Banner
err := row.Scan(
&i.ID,
&i.Title,
&i.Body,
&i.Level,
&i.Active,
&i.Dismissible,
&i.CreatedBy,
&i.StartsAt,
&i.EndsAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const deleteBanner = `-- name: DeleteBanner :exec
DELETE FROM banners
WHERE id = $1
`
func (q *Queries) DeleteBanner(ctx context.Context, id uuid.UUID) error {
_, err := q.db.ExecContext(ctx, deleteBanner, id)
return err
}
const getBannerByID = `-- name: GetBannerByID :one
SELECT banners.id, banners.title, banners.body, banners.level, banners.active, banners.dismissible, banners.created_by, banners.starts_at, banners.ends_at, banners.created_at, banners.updated_at
FROM banners
WHERE id = $1
`
func (q *Queries) GetBannerByID(ctx context.Context, id uuid.UUID) (Banner, error) {
row := q.db.QueryRowContext(ctx, getBannerByID, id)
var i Banner
err := row.Scan(
&i.ID,
&i.Title,
&i.Body,
&i.Level,
&i.Active,
&i.Dismissible,
&i.CreatedBy,
&i.StartsAt,
&i.EndsAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const listActiveBanners = `-- name: ListActiveBanners :many
SELECT banners.id, banners.title, banners.body, banners.level, banners.active, banners.dismissible, banners.created_by, banners.starts_at, banners.ends_at, banners.created_at, banners.updated_at
FROM banners
WHERE active
AND (starts_at IS NULL OR starts_at <= now())
AND (ends_at IS NULL OR ends_at > now())
ORDER BY created_at DESC
`
func (q *Queries) ListActiveBanners(ctx context.Context) ([]Banner, error) {
rows, err := q.db.QueryContext(ctx, listActiveBanners)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Banner{}
for rows.Next() {
var i Banner
if err := rows.Scan(
&i.ID,
&i.Title,
&i.Body,
&i.Level,
&i.Active,
&i.Dismissible,
&i.CreatedBy,
&i.StartsAt,
&i.EndsAt,
&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 listBanners = `-- name: ListBanners :many
SELECT banners.id, banners.title, banners.body, banners.level, banners.active, banners.dismissible, banners.created_by, banners.starts_at, banners.ends_at, banners.created_at, banners.updated_at
FROM banners
ORDER BY created_at DESC
`
func (q *Queries) ListBanners(ctx context.Context) ([]Banner, error) {
rows, err := q.db.QueryContext(ctx, listBanners)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Banner{}
for rows.Next() {
var i Banner
if err := rows.Scan(
&i.ID,
&i.Title,
&i.Body,
&i.Level,
&i.Active,
&i.Dismissible,
&i.CreatedBy,
&i.StartsAt,
&i.EndsAt,
&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 updateBanner = `-- name: UpdateBanner :one
UPDATE banners
SET title = $2,
body = $3,
level = $4,
active = $5,
dismissible = $6,
starts_at = $7,
ends_at = $8,
updated_at = now()
WHERE id = $1
RETURNING banners.id, banners.title, banners.body, banners.level, banners.active, banners.dismissible, banners.created_by, banners.starts_at, banners.ends_at, banners.created_at, banners.updated_at
`
type UpdateBannerParams struct {
ID uuid.UUID `json:"id"`
Title string `json:"title"`
Body string `json:"body"`
Level string `json:"level"`
Active bool `json:"active"`
Dismissible bool `json:"dismissible"`
StartsAt sql.NullTime `json:"starts_at"`
EndsAt sql.NullTime `json:"ends_at"`
}
func (q *Queries) UpdateBanner(ctx context.Context, arg UpdateBannerParams) (Banner, error) {
row := q.db.QueryRowContext(ctx, updateBanner,
arg.ID,
arg.Title,
arg.Body,
arg.Level,
arg.Active,
arg.Dismissible,
arg.StartsAt,
arg.EndsAt,
)
var i Banner
err := row.Scan(
&i.ID,
&i.Title,
&i.Body,
&i.Level,
&i.Active,
&i.Dismissible,
&i.CreatedBy,
&i.StartsAt,
&i.EndsAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
@@ -64,6 +64,20 @@ type BackupTarget struct {
UpdatedAt sql.NullTime `json:"updated_at"` UpdatedAt sql.NullTime `json:"updated_at"`
} }
type Banner struct {
ID uuid.UUID `json:"id"`
Title string `json:"title"`
Body string `json:"body"`
Level string `json:"level"`
Active bool `json:"active"`
Dismissible bool `json:"dismissible"`
CreatedBy uuid.NullUUID `json:"created_by"`
StartsAt sql.NullTime `json:"starts_at"`
EndsAt sql.NullTime `json:"ends_at"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
type ContainerInstance struct { type ContainerInstance struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -21,6 +21,7 @@ type Querier interface {
CreateAgent(ctx context.Context, arg CreateAgentParams) (NodeAgent, error) CreateAgent(ctx context.Context, arg CreateAgentParams) (NodeAgent, error)
CreateAgentAuthToken(ctx context.Context, arg CreateAgentAuthTokenParams) (AgentAuthToken, error) CreateAgentAuthToken(ctx context.Context, arg CreateAgentAuthTokenParams) (AgentAuthToken, error)
CreateBackupTarget(ctx context.Context, arg CreateBackupTargetParams) error CreateBackupTarget(ctx context.Context, arg CreateBackupTargetParams) error
CreateBanner(ctx context.Context, arg CreateBannerParams) (Banner, error)
CreateCommand(ctx context.Context, arg CreateCommandParams) (AgentCommand, error) CreateCommand(ctx context.Context, arg CreateCommandParams) (AgentCommand, error)
CreateContainer(ctx context.Context, arg CreateContainerParams) (ContainerInstance, error) CreateContainer(ctx context.Context, arg CreateContainerParams) (ContainerInstance, error)
CreateDatabaseBackup(ctx context.Context, arg CreateDatabaseBackupParams) error CreateDatabaseBackup(ctx context.Context, arg CreateDatabaseBackupParams) error
@@ -36,6 +37,7 @@ type Querier interface {
DatabaseServiceExistsByIDAndUser(ctx context.Context, arg DatabaseServiceExistsByIDAndUserParams) (bool, error) DatabaseServiceExistsByIDAndUser(ctx context.Context, arg DatabaseServiceExistsByIDAndUserParams) (bool, error)
DeleteAgent(ctx context.Context, id string) error DeleteAgent(ctx context.Context, id string) error
DeleteBackupTargetByIDAndUser(ctx context.Context, arg DeleteBackupTargetByIDAndUserParams) error DeleteBackupTargetByIDAndUser(ctx context.Context, arg DeleteBackupTargetByIDAndUserParams) error
DeleteBanner(ctx context.Context, id uuid.UUID) error
DeleteDatabaseServiceByID(ctx context.Context, id string) error DeleteDatabaseServiceByID(ctx context.Context, id string) error
DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) error DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) error
DeleteOutboundWebhookByIDAndUser(ctx context.Context, arg DeleteOutboundWebhookByIDAndUserParams) error DeleteOutboundWebhookByIDAndUser(ctx context.Context, arg DeleteOutboundWebhookByIDAndUserParams) error
@@ -46,6 +48,7 @@ type Querier interface {
GetAgentByHostAndIP(ctx context.Context, arg GetAgentByHostAndIPParams) (NodeAgent, error) GetAgentByHostAndIP(ctx context.Context, arg GetAgentByHostAndIPParams) (NodeAgent, error)
GetBackupTargetByID(ctx context.Context, id string) (BackupTarget, error) GetBackupTargetByID(ctx context.Context, id string) (BackupTarget, error)
GetBackupTargetByIDAndUser(ctx context.Context, arg GetBackupTargetByIDAndUserParams) (BackupTarget, error) GetBackupTargetByIDAndUser(ctx context.Context, arg GetBackupTargetByIDAndUserParams) (BackupTarget, error)
GetBannerByID(ctx context.Context, id uuid.UUID) (Banner, error)
GetCommandForAgent(ctx context.Context, arg GetCommandForAgentParams) (AgentCommand, error) GetCommandForAgent(ctx context.Context, arg GetCommandForAgentParams) (AgentCommand, error)
GetContainer(ctx context.Context, id string) (ContainerInstance, error) GetContainer(ctx context.Context, id string) (ContainerInstance, error)
GetContainerForAgent(ctx context.Context, arg GetContainerForAgentParams) (ContainerInstance, error) GetContainerForAgent(ctx context.Context, arg GetContainerForAgentParams) (ContainerInstance, error)
@@ -63,11 +66,13 @@ type Querier interface {
GetUserTokenByHash(ctx context.Context, tokenHash string) (UserToken, error) GetUserTokenByHash(ctx context.Context, tokenHash string) (UserToken, error)
InsertAgentHeartbeat(ctx context.Context, arg InsertAgentHeartbeatParams) error InsertAgentHeartbeat(ctx context.Context, arg InsertAgentHeartbeatParams) error
InsertProjectEnvironment(ctx context.Context, arg InsertProjectEnvironmentParams) error InsertProjectEnvironment(ctx context.Context, arg InsertProjectEnvironmentParams) error
ListActiveBanners(ctx context.Context) ([]Banner, error)
ListAgentAuthTokens(ctx context.Context) ([]AgentAuthToken, error) ListAgentAuthTokens(ctx context.Context) ([]AgentAuthToken, error)
ListAgentHeartbeatsSince(ctx context.Context, arg ListAgentHeartbeatsSinceParams) ([]AgentHeartbeat, error) ListAgentHeartbeatsSince(ctx context.Context, arg ListAgentHeartbeatsSinceParams) ([]AgentHeartbeat, error)
ListAgents(ctx context.Context) ([]NodeAgent, error) ListAgents(ctx context.Context) ([]NodeAgent, error)
ListAllDatabaseServices(ctx context.Context) ([]DatabaseService, error) ListAllDatabaseServices(ctx context.Context) ([]DatabaseService, error)
ListBackupTargetsByUser(ctx context.Context, userID string) ([]BackupTarget, error) ListBackupTargetsByUser(ctx context.Context, userID string) ([]BackupTarget, error)
ListBanners(ctx context.Context) ([]Banner, error)
ListCommandsForAgent(ctx context.Context, nodeAgentID string) ([]AgentCommand, error) ListCommandsForAgent(ctx context.Context, nodeAgentID string) ([]AgentCommand, error)
ListContainersForAgent(ctx context.Context, nodeAgentID string) ([]ContainerInstance, error) ListContainersForAgent(ctx context.Context, nodeAgentID string) ([]ContainerInstance, error)
ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg ListDatabaseBackupsByDatabaseAndUserParams) ([]DatabaseBackup, error) ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg ListDatabaseBackupsByDatabaseAndUserParams) ([]DatabaseBackup, error)
@@ -106,6 +111,7 @@ type Querier interface {
UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeAgent, error) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeAgent, error)
UpdateAgentHeartbeat(ctx context.Context, arg UpdateAgentHeartbeatParams) error UpdateAgentHeartbeat(ctx context.Context, arg UpdateAgentHeartbeatParams) error
UpdateBackupTargetByIDAndUser(ctx context.Context, arg UpdateBackupTargetByIDAndUserParams) error UpdateBackupTargetByIDAndUser(ctx context.Context, arg UpdateBackupTargetByIDAndUserParams) error
UpdateBanner(ctx context.Context, arg UpdateBannerParams) (Banner, error)
UpdateContainerStatus(ctx context.Context, arg UpdateContainerStatusParams) error UpdateContainerStatus(ctx context.Context, arg UpdateContainerStatusParams) error
UpdateDatabaseServiceNameAndPlanByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameAndPlanByIDAndUserParams) error UpdateDatabaseServiceNameAndPlanByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameAndPlanByIDAndUserParams) error
UpdateDatabaseServiceNameByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameByIDAndUserParams) error UpdateDatabaseServiceNameByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameByIDAndUserParams) error
+6
View File
@@ -149,6 +149,12 @@ func init() {
register(spec{"containr_operations_get", "Cross-cutting operations view: active deployments, deploy queue depth, recent failures, cron runs, backups.", "GET", "/operations", nil, nil, false}) register(spec{"containr_operations_get", "Cross-cutting operations view: active deployments, deploy queue depth, recent failures, cron runs, backups.", "GET", "/operations", nil, nil, false})
register(spec{"containr_banners_active", "List active announcement banners shown to users.", "GET", "/banners/active", nil, nil, false})
register(spec{"containr_banners_list", "List all announcement banners (admin).", "GET", "/admin/banners", nil, nil, false})
register(spec{"containr_banners_create", "Create an announcement banner (admin). Args: title, body, level (info|success|warning|error), active, dismissible, starts_at, ends_at.", "POST", "/admin/banners", nil, []string{"title", "body", "level", "active", "dismissible", "starts_at", "ends_at"}, false})
register(spec{"containr_banners_update", "Update an announcement banner (admin). Args: id plus fields to change.", "PATCH", "/admin/banners/{id}", []string{"id"}, []string{"title", "body", "level", "active", "dismissible", "starts_at", "ends_at"}, false})
register(spec{"containr_banners_delete", "Delete an announcement banner (admin). Args: id. Requires confirm=true.", "DELETE", "/admin/banners/{id}", []string{"id"}, nil, true})
// Nodes / agent tokens (admin) // Nodes / agent tokens (admin)
register(spec{"containr_nodes_list", "List node agents (admin)", "GET", "/agents", nil, nil, false}) register(spec{"containr_nodes_list", "List node agents (admin)", "GET", "/agents", nil, nil, false})
register(spec{"containr_nodes_get", "Get a node agent (admin)", "GET", "/agents/{id}", []string{"id"}, nil, false}) register(spec{"containr_nodes_get", "Get a node agent (admin)", "GET", "/agents/{id}", []string{"id"}, nil, false})
@@ -0,0 +1,21 @@
-- +goose Up
-- Instance-wide announcement banners. Admins create them; active ones are
-- shown to every signed-in user in the web UI and surfaced in the CLI.
CREATE TABLE IF NOT EXISTS banners (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
title varchar(255) NOT NULL,
body text NOT NULL DEFAULT '',
level varchar(20) NOT NULL DEFAULT 'info',
active boolean NOT NULL DEFAULT true,
dismissible boolean NOT NULL DEFAULT true,
created_by uuid REFERENCES users(id) ON DELETE SET NULL,
starts_at timestamptz,
ends_at timestamptz,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_banners_active ON banners(active);
-- +goose Down
DROP TABLE IF EXISTS banners;
+39
View File
@@ -0,0 +1,39 @@
-- name: ListBanners :many
SELECT banners.*
FROM banners
ORDER BY created_at DESC;
-- name: ListActiveBanners :many
SELECT banners.*
FROM banners
WHERE active
AND (starts_at IS NULL OR starts_at <= now())
AND (ends_at IS NULL OR ends_at > now())
ORDER BY created_at DESC;
-- name: GetBannerByID :one
SELECT banners.*
FROM banners
WHERE id = $1;
-- name: CreateBanner :one
INSERT INTO banners (title, body, level, active, dismissible, created_by, starts_at, ends_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING banners.*;
-- name: UpdateBanner :one
UPDATE banners
SET title = $2,
body = $3,
level = $4,
active = $5,
dismissible = $6,
starts_at = $7,
ends_at = $8,
updated_at = now()
WHERE id = $1
RETURNING banners.*;
-- name: DeleteBanner :exec
DELETE FROM banners
WHERE id = $1;
+14
View File
@@ -302,3 +302,17 @@ CREATE TABLE webhook_deliveries (
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
delivered_at TIMESTAMP WITH TIME ZONE delivered_at TIMESTAMP WITH TIME ZONE
); );
CREATE TABLE banners (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title VARCHAR(255) NOT NULL,
body TEXT NOT NULL DEFAULT '',
level VARCHAR(20) NOT NULL DEFAULT 'info',
active BOOLEAN NOT NULL DEFAULT true,
dismissible BOOLEAN NOT NULL DEFAULT true,
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
starts_at TIMESTAMP WITH TIME ZONE,
ends_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
@@ -41,7 +41,7 @@ import {
import { signOutAuthSession } from '@/lib/auth-client'; import { signOutAuthSession } from '@/lib/auth-client';
import { isDemoSearch } from '@/lib/demo-mode'; import { isDemoSearch } from '@/lib/demo-mode';
import { useAuthSession } from '@/lib/use-auth-session'; import { useAuthSession } from '@/lib/use-auth-session';
import { useToast } from '@/shared/components'; import { BannerBar, useToast } from '@/shared/components';
type NavItem = { label: string; href: string; icon: typeof FolderKanban; hint: string }; type NavItem = { label: string; href: string; icon: typeof FolderKanban; hint: string };
type NavSection = { title: string; items: NavItem[] }; type NavSection = { title: string; items: NavItem[] };
@@ -387,6 +387,7 @@ export function PlatformShell() {
</aside> </aside>
<div className="relative z-10 flex min-h-screen min-w-0 flex-1 flex-col overflow-hidden"> <div className="relative z-10 flex min-h-screen min-w-0 flex-1 flex-col overflow-hidden">
<BannerBar />
<header <header
className="hidden h-[46px] shrink-0 items-center border-b px-5 md:flex" className="hidden h-[46px] shrink-0 items-center border-b px-5 md:flex"
style={{ style={{
@@ -5,6 +5,7 @@ import {
CheckCircle2, CheckCircle2,
Clock, Clock,
FolderKanban, FolderKanban,
Megaphone,
Pencil, Pencil,
ShieldCheck, ShieldCheck,
Trash2, Trash2,
@@ -12,14 +13,19 @@ import {
XCircle, XCircle,
} from 'lucide-react'; } from 'lucide-react';
import { import {
createBanner,
deleteBanner,
deleteProject, deleteProject,
getAdminOverview, getAdminOverview,
listAdminBanners,
listAdminUsers, listAdminUsers,
listProjects, listProjects,
setProjectApproval, setProjectApproval,
setUserAdmin, setUserAdmin,
updateBanner,
updateProject, updateProject,
type AdminUser, type AdminUser,
type Banner,
type ProjectEntity, type ProjectEntity,
} from '@/lib/api-client'; } from '@/lib/api-client';
import { useAuthSession } from '@/lib/use-auth-session'; import { useAuthSession } from '@/lib/use-auth-session';
@@ -50,16 +56,50 @@ export function AdminPage() {
const overviewQuery = useQuery({ queryKey: ['admin-overview'], queryFn: getAdminOverview, enabled: !isDemoMode }); const overviewQuery = useQuery({ queryKey: ['admin-overview'], queryFn: getAdminOverview, enabled: !isDemoMode });
const usersQuery = useQuery({ queryKey: ['admin-users'], queryFn: listAdminUsers, enabled: !isDemoMode }); const usersQuery = useQuery({ queryKey: ['admin-users'], queryFn: listAdminUsers, enabled: !isDemoMode });
const projectsQuery = useQuery({ queryKey: ['admin-projects'], queryFn: () => listProjects({ limit: 100 }), enabled: !isDemoMode }); const projectsQuery = useQuery({ queryKey: ['admin-projects'], queryFn: () => listProjects({ limit: 100 }), enabled: !isDemoMode });
const bannersQuery = useQuery({ queryKey: ['admin-banners'], queryFn: listAdminBanners, enabled: !isDemoMode });
const [editingProject, setEditingProject] = useState<ProjectEntity | null>(null); const [editingProject, setEditingProject] = useState<ProjectEntity | null>(null);
const [editForm, setEditForm] = useState({ name: '', description: '' }); const [editForm, setEditForm] = useState({ name: '', description: '' });
const [bannerForm, setBannerForm] = useState({ title: '', body: '', level: 'info' as Banner['level'] });
const invalidate = () => { const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ['admin-overview'] }); queryClient.invalidateQueries({ queryKey: ['admin-overview'] });
queryClient.invalidateQueries({ queryKey: ['admin-users'] }); queryClient.invalidateQueries({ queryKey: ['admin-users'] });
queryClient.invalidateQueries({ queryKey: ['admin-projects'] }); queryClient.invalidateQueries({ queryKey: ['admin-projects'] });
queryClient.invalidateQueries({ queryKey: ['admin-banners'] });
queryClient.invalidateQueries({ queryKey: ['active-banners'] });
queryClient.invalidateQueries({ queryKey: ['projects'] }); queryClient.invalidateQueries({ queryKey: ['projects'] });
}; };
const bannerCreateMutation = useMutation({
mutationFn: () =>
createBanner({
title: bannerForm.title.trim(),
body: bannerForm.body.trim(),
level: bannerForm.level,
}),
onSuccess: () => {
showToast('success', 'Banner published');
setBannerForm({ title: '', body: '', level: 'info' });
invalidate();
},
onError: (error) => showToast('error', 'Create failed', error instanceof Error ? error.message : undefined),
});
const bannerToggleMutation = useMutation({
mutationFn: ({ id, active }: { id: string; active: boolean }) => updateBanner(id, { active }),
onSuccess: () => invalidate(),
onError: (error) => showToast('error', 'Update failed', error instanceof Error ? error.message : undefined),
});
const bannerDeleteMutation = useMutation({
mutationFn: (id: string) => deleteBanner(id),
onSuccess: () => {
showToast('success', 'Banner deleted');
invalidate();
},
onError: (error) => showToast('error', 'Delete failed', error instanceof Error ? error.message : undefined),
});
const approvalMutation = useMutation({ const approvalMutation = useMutation({
mutationFn: ({ id, approved }: { id: string; approved: boolean }) => setProjectApproval(id, approved), mutationFn: ({ id, approved }: { id: string; approved: boolean }) => setProjectApproval(id, approved),
onSuccess: (_, vars) => { onSuccess: (_, vars) => {
@@ -126,6 +166,7 @@ export function AdminPage() {
const pending = overview?.pending_projects ?? []; const pending = overview?.pending_projects ?? [];
const users = usersQuery.data ?? []; const users = usersQuery.data ?? [];
const allProjects = projectsQuery.data ?? []; const allProjects = projectsQuery.data ?? [];
const banners = bannersQuery.data ?? [];
return ( return (
<div className="mx-auto max-w-6xl px-5 py-8"> <div className="mx-auto max-w-6xl px-5 py-8">
@@ -258,6 +299,85 @@ export function AdminPage() {
</div> </div>
</section> </section>
<section className="mt-8">
<div className="mb-3 flex items-center gap-2">
<Megaphone size={15} className="text-[var(--accent-primary)]" />
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Announcements</h2>
</div>
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)]">
<div className="flex flex-wrap items-center gap-2 border-b border-[var(--border-subtle)] px-4 py-3">
<input
value={bannerForm.title}
onChange={(e) => setBannerForm((f) => ({ ...f, title: e.target.value }))}
placeholder="Title"
className="h-9 min-w-48 flex-1 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm focus:border-[var(--accent-primary)]"
/>
<input
value={bannerForm.body}
onChange={(e) => setBannerForm((f) => ({ ...f, body: e.target.value }))}
placeholder="Body (optional)"
className="h-9 min-w-48 flex-[2] rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm focus:border-[var(--accent-primary)]"
/>
<select
value={bannerForm.level}
onChange={(e) => setBannerForm((f) => ({ ...f, level: e.target.value as Banner['level'] }))}
className="h-9 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-2 text-sm"
>
<option value="info">info</option>
<option value="success">success</option>
<option value="warning">warning</option>
<option value="error">error</option>
</select>
<button
type="button"
onClick={() => bannerCreateMutation.mutate()}
disabled={bannerCreateMutation.isPending || bannerForm.title.trim().length === 0}
className="rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-[12px] font-semibold text-[var(--accent-on)] disabled:opacity-50"
>
{bannerCreateMutation.isPending ? 'Publishing…' : 'Publish'}
</button>
</div>
{banners.map((banner) => (
<div
key={banner.id}
className="flex items-center gap-4 border-b border-[var(--border-subtle)] px-4 py-3 last:border-b-0"
>
<div className="min-w-0 flex-1">
<p className="truncate text-[13.5px] font-medium text-[var(--text-primary)]">{banner.title}</p>
<p className="truncate text-[11.5px] text-[var(--text-tertiary)]">
{banner.level}
{banner.body ? ` · ${banner.body}` : ''}
</p>
</div>
<button
type="button"
onClick={() => bannerToggleMutation.mutate({ id: banner.id, active: !banner.active })}
disabled={bannerToggleMutation.isPending}
className="rounded-[var(--radius-md)] border border-[var(--border-default)] px-3 py-1.5 text-[12px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:opacity-50"
>
{banner.active ? 'Deactivate' : 'Activate'}
</button>
<button
type="button"
onClick={() => {
if (window.confirm(`Delete banner "${banner.title}"?`)) {
bannerDeleteMutation.mutate(banner.id);
}
}}
disabled={bannerDeleteMutation.isPending}
className="inline-flex items-center gap-1 rounded-[var(--radius-md)] border border-[var(--error)] px-2.5 py-1.5 text-[12px] font-medium text-[var(--error)] hover:bg-[var(--error-soft)] disabled:opacity-50"
title="Delete banner"
>
<Trash2 size={12} />
</button>
</div>
))}
{banners.length === 0 ? (
<p className="px-4 py-6 text-center text-[13px] text-[var(--text-tertiary)]">No announcements.</p>
) : null}
</div>
</section>
<section className="mt-8 pb-10"> <section className="mt-8 pb-10">
<div className="mb-3 flex items-center gap-2"> <div className="mb-3 flex items-center gap-2">
<Users size={15} className="text-[var(--accent-tertiary)]" /> <Users size={15} className="text-[var(--accent-tertiary)]" />
+206
View File
@@ -3809,6 +3809,212 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/banners/active": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Active announcement banners
* @description Banners currently active (enabled and within their optional schedule window).
*/
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Active banners */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
banners?: {
id?: string;
title?: string;
body?: string;
/** @enum {string} */
level?: "info" | "success" | "warning" | "error";
active?: boolean;
dismissible?: boolean;
/** Format: date-time */
starts_at?: string | null;
/** Format: date-time */
ends_at?: string | null;
/** Format: date-time */
created_at?: string;
}[];
};
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/banners": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** List all banners (admin) */
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description All banners */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
banners?: Record<string, never>[];
};
};
};
};
};
put?: never;
/** Create a banner (admin) */
post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": {
title: string;
body?: string;
/**
* @default info
* @enum {string}
*/
level?: "info" | "success" | "warning" | "error";
/** @default true */
active?: boolean;
/** @default true */
dismissible?: boolean;
/** Format: date-time */
starts_at?: string;
/** Format: date-time */
ends_at?: string;
};
};
};
responses: {
/** @description Created banner */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": Record<string, never>;
};
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/banners/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
/** Delete a banner (admin) */
delete: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Deleted */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
options?: never;
head?: never;
/** Update a banner (admin) */
patch: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": {
title?: string;
body?: string;
/** @enum {string} */
level?: "info" | "success" | "warning" | "error";
active?: boolean;
dismissible?: boolean;
/** Format: date-time */
starts_at?: string;
/** Format: date-time */
ends_at?: string;
};
};
};
responses: {
/** @description Updated banner */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": Record<string, never>;
};
};
};
};
trace?: never;
};
"/audit-logs": { "/audit-logs": {
parameters: { parameters: {
query?: never; query?: never;
+44
View File
@@ -1866,6 +1866,50 @@ export async function getOperations(): Promise<OperationsView> {
return requestJson<OperationsView>('/operations'); return requestJson<OperationsView>('/operations');
} }
export type Banner = {
id: string;
title: string;
body: string;
level: 'info' | 'success' | 'warning' | 'error';
active: boolean;
dismissible: boolean;
starts_at?: string | null;
ends_at?: string | null;
created_at: string;
};
export type BannerInput = {
title?: string;
body?: string;
level?: Banner['level'];
active?: boolean;
dismissible?: boolean;
starts_at?: string;
ends_at?: string;
};
export async function listActiveBanners(): Promise<Banner[]> {
const payload = await requestJson<{ banners?: Banner[] }>('/banners/active');
return payload.banners ?? [];
}
export async function listAdminBanners(): Promise<Banner[]> {
const payload = await requestJson<{ banners?: Banner[] }>('/admin/banners');
return payload.banners ?? [];
}
export async function createBanner(input: BannerInput): Promise<Banner> {
return requestJson<Banner>('/admin/banners', { method: 'POST', body: JSON.stringify(input) });
}
export async function updateBanner(id: string, input: BannerInput): Promise<Banner> {
return requestJson<Banner>(`/admin/banners/${id}`, { method: 'PATCH', body: JSON.stringify(input) });
}
export async function deleteBanner(id: string): Promise<void> {
await requestJson(`/admin/banners/${id}`, { method: 'DELETE' });
}
export function serviceStatusClass(status: ServiceStatus): string { export function serviceStatusClass(status: ServiceStatus): string {
switch (status) { switch (status) {
case 'running': case 'running':
@@ -0,0 +1,85 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { AlertTriangle, CheckCircle2, Info, Megaphone, X, XCircle } from 'lucide-react';
import { listActiveBanners, type Banner } from '@/lib/api-client';
import { useDemoMode } from '@/lib/demo-mode';
const DISMISSED_KEY = 'containr.dismissed-banners';
function dismissedIds(): Set<string> {
try {
return new Set(JSON.parse(localStorage.getItem(DISMISSED_KEY) ?? '[]'));
} catch {
return new Set();
}
}
const levelStyle: Record<Banner['level'], { bg: string; fg: string; icon: typeof Info }> = {
info: { bg: 'var(--accent-primary-soft)', fg: 'var(--accent-primary)', icon: Info },
success: { bg: 'var(--success-soft)', fg: 'var(--success)', icon: CheckCircle2 },
warning: { bg: 'var(--warning-soft)', fg: 'var(--warning)', icon: AlertTriangle },
error: { bg: 'var(--error-soft)', fg: 'var(--error)', icon: XCircle },
};
export function BannerBar() {
const isDemoMode = useDemoMode();
const [dismissed, setDismissed] = useState<Set<string>>(dismissedIds);
const bannersQuery = useQuery({
queryKey: ['active-banners'],
queryFn: listActiveBanners,
enabled: !isDemoMode,
refetchInterval: 60_000,
staleTime: 30_000,
});
const banners = (bannersQuery.data ?? []).filter((b) => !dismissed.has(b.id));
if (banners.length === 0) return null;
const dismiss = (id: string) => {
setDismissed((prev) => {
const next = new Set(prev);
next.add(id);
localStorage.setItem(DISMISSED_KEY, JSON.stringify([...next]));
return next;
});
};
return (
<div className="shrink-0">
{banners.map((banner) => {
const style = levelStyle[banner.level] ?? levelStyle.info;
const Icon = banner.level === 'info' ? Megaphone : style.icon;
return (
<div
key={banner.id}
className="flex items-center gap-3 border-b border-[var(--border-subtle)] px-5 py-2"
style={{ background: style.bg }}
role="status"
>
<Icon size={14} className="shrink-0" style={{ color: style.fg }} />
<div className="min-w-0 flex-1">
<span className="text-[12.5px] font-semibold" style={{ color: style.fg }}>
{banner.title}
</span>
{banner.body ? (
<span className="ml-2 text-[12px] text-[var(--text-secondary)]">{banner.body}</span>
) : null}
</div>
{banner.dismissible ? (
<button
type="button"
onClick={() => dismiss(banner.id)}
className="shrink-0 rounded p-0.5 hover:bg-black/10"
style={{ color: style.fg }}
aria-label="Dismiss announcement"
>
<X size={14} />
</button>
) : null}
</div>
);
})}
</div>
);
}
@@ -1,3 +1,4 @@
export * from './BannerBar';
export * from './DemoGate'; export * from './DemoGate';
export * from './ErrorBoundary'; export * from './ErrorBoundary';
export * from './LoadingSpinner'; export * from './LoadingSpinner';
+121
View File
@@ -2581,6 +2581,127 @@ paths:
items: items:
type: object type: object
/banners/active:
get:
tags:
- Banners
summary: Active announcement banners
description: Banners currently active (enabled and within their optional schedule window).
security:
- bearerAuth: []
responses:
'200':
description: Active banners
content:
application/json:
schema:
type: object
properties:
banners:
type: array
items:
type: object
properties:
id: {type: string}
title: {type: string}
body: {type: string}
level: {type: string, enum: [info, success, warning, error]}
active: {type: boolean}
dismissible: {type: boolean}
starts_at: {type: string, format: date-time, nullable: true}
ends_at: {type: string, format: date-time, nullable: true}
created_at: {type: string, format: date-time}
/admin/banners:
get:
tags:
- Banners
summary: List all banners (admin)
security:
- bearerAuth: []
responses:
'200':
description: All banners
content:
application/json:
schema:
type: object
properties:
banners:
type: array
items:
type: object
post:
tags:
- Banners
summary: Create a banner (admin)
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [title]
properties:
title: {type: string}
body: {type: string}
level: {type: string, enum: [info, success, warning, error], default: info}
active: {type: boolean, default: true}
dismissible: {type: boolean, default: true}
starts_at: {type: string, format: date-time}
ends_at: {type: string, format: date-time}
responses:
'201':
description: Created banner
content:
application/json:
schema:
type: object
/admin/banners/{id}:
patch:
tags:
- Banners
summary: Update a banner (admin)
security:
- bearerAuth: []
parameters:
- {name: id, in: path, required: true, schema: {type: string}}
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
title: {type: string}
body: {type: string}
level: {type: string, enum: [info, success, warning, error]}
active: {type: boolean}
dismissible: {type: boolean}
starts_at: {type: string, format: date-time}
ends_at: {type: string, format: date-time}
responses:
'200':
description: Updated banner
content:
application/json:
schema:
type: object
delete:
tags:
- Banners
summary: Delete a banner (admin)
security:
- bearerAuth: []
parameters:
- {name: id, in: path, required: true, schema: {type: string}}
responses:
'204':
description: Deleted
# Audit Logs # Audit Logs
/audit-logs: /audit-logs:
get: get:
+2
View File
@@ -84,6 +84,8 @@ webhooks list|create|update|delete|deliveries|test
deliveries are HMAC-signed (X-Containr-Signature) deliveries are HMAC-signed (X-Containr-Signature)
operations live ops view: active deploys, queue depth, operations live ops view: active deploys, queue depth,
24h failures, cron runs, backups 24h failures, cron runs, backups
banners list|create|update|delete announcement banners (admin);
active banners print on `auth status`
up deploy the current directory up deploy the current directory
``` ```