diff --git a/ROADMAP2.md b/ROADMAP2.md
index da075e8..54c4644 100644
--- a/ROADMAP2.md
+++ b/ROADMAP2.md
@@ -284,8 +284,11 @@ and wired refs, one click or one CLI call.
CLI + MCP + `/settings/webhooks` page with delivery log + test ping.
- [ ] **Activity feed** — enrich `audit_logs` with `severity category
icon label metadata`; per-project timeline + global feed; TTL job.
-- [ ] **Banners** — `banners(title, body, level, scope, active)`; top
- bar in UI + surfaced to CLI on `auth status`.
+- [x] **Banners** — `banners` table with title/body/level/active/
+ 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,
favicon, accent override, docs/support links. `GET /api/v1/branding`
public + cached. **Containr/Vertice remains default;** this is for
diff --git a/app/backend/internal/api/banners.go b/app/backend/internal/api/banners.go
new file mode 100644
index 0000000..1010bac
--- /dev/null
+++ b/app/backend/internal/api/banners.go
@@ -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)
+}
diff --git a/app/backend/internal/api/routes.go b/app/backend/internal/api/routes.go
index 05c1738..ba99669 100644
--- a/app/backend/internal/api/routes.go
+++ b/app/backend/internal/api/routes.go
@@ -336,6 +336,9 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
// Cross-cutting ops view — active/queued/failed work
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
authed.GET("/webhooks", handleListOutboundWebhooks)
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.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/:resource/:id", handleGetResourceAuditLogs)
diff --git a/app/backend/internal/cli/commands/auth.go b/app/backend/internal/cli/commands/auth.go
index 7d8087b..5649441 100644
--- a/app/backend/internal/cli/commands/auth.go
+++ b/app/backend/internal/cli/commands/auth.go
@@ -147,5 +147,21 @@ func runStatus(cmd *cobra.Command, args []string) error {
if p.IsAdmin {
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
}
diff --git a/app/backend/internal/cli/commands/banners.go b/app/backend/internal/cli/commands/banners.go
new file mode 100644
index 0000000..f65cb52
--- /dev/null
+++ b/app/backend/internal/cli/commands/banners.go
@@ -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
",
+ 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 ",
+ 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 ",
+ 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)
+}
diff --git a/app/backend/internal/cli/root.go b/app/backend/internal/cli/root.go
index 946478e..fbe54c6 100644
--- a/app/backend/internal/cli/root.go
+++ b/app/backend/internal/cli/root.go
@@ -89,6 +89,7 @@ func init() {
rootCmd.AddCommand(commands.NotificationsCmd)
rootCmd.AddCommand(commands.WebhooksCmd)
rootCmd.AddCommand(commands.OperationsCmd)
+ rootCmd.AddCommand(commands.BannersCmd)
rootCmd.AddCommand(commands.UpCmd)
}
diff --git a/app/backend/internal/database/sqlcdb/banners.sql.go b/app/backend/internal/database/sqlcdb/banners.sql.go
new file mode 100644
index 0000000..86236b8
--- /dev/null
+++ b/app/backend/internal/database/sqlcdb/banners.sql.go
@@ -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
+}
diff --git a/app/backend/internal/database/sqlcdb/models.go b/app/backend/internal/database/sqlcdb/models.go
index 86a3334..a32f383 100644
--- a/app/backend/internal/database/sqlcdb/models.go
+++ b/app/backend/internal/database/sqlcdb/models.go
@@ -64,6 +64,20 @@ type BackupTarget struct {
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 {
ID string `json:"id"`
Name string `json:"name"`
diff --git a/app/backend/internal/database/sqlcdb/querier.go b/app/backend/internal/database/sqlcdb/querier.go
index 0acb2b4..f67fb4c 100644
--- a/app/backend/internal/database/sqlcdb/querier.go
+++ b/app/backend/internal/database/sqlcdb/querier.go
@@ -21,6 +21,7 @@ type Querier interface {
CreateAgent(ctx context.Context, arg CreateAgentParams) (NodeAgent, error)
CreateAgentAuthToken(ctx context.Context, arg CreateAgentAuthTokenParams) (AgentAuthToken, error)
CreateBackupTarget(ctx context.Context, arg CreateBackupTargetParams) error
+ CreateBanner(ctx context.Context, arg CreateBannerParams) (Banner, error)
CreateCommand(ctx context.Context, arg CreateCommandParams) (AgentCommand, error)
CreateContainer(ctx context.Context, arg CreateContainerParams) (ContainerInstance, error)
CreateDatabaseBackup(ctx context.Context, arg CreateDatabaseBackupParams) error
@@ -36,6 +37,7 @@ type Querier interface {
DatabaseServiceExistsByIDAndUser(ctx context.Context, arg DatabaseServiceExistsByIDAndUserParams) (bool, error)
DeleteAgent(ctx context.Context, id string) error
DeleteBackupTargetByIDAndUser(ctx context.Context, arg DeleteBackupTargetByIDAndUserParams) error
+ DeleteBanner(ctx context.Context, id uuid.UUID) error
DeleteDatabaseServiceByID(ctx context.Context, id string) error
DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) error
DeleteOutboundWebhookByIDAndUser(ctx context.Context, arg DeleteOutboundWebhookByIDAndUserParams) error
@@ -46,6 +48,7 @@ type Querier interface {
GetAgentByHostAndIP(ctx context.Context, arg GetAgentByHostAndIPParams) (NodeAgent, error)
GetBackupTargetByID(ctx context.Context, id string) (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)
GetContainer(ctx context.Context, id string) (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)
InsertAgentHeartbeat(ctx context.Context, arg InsertAgentHeartbeatParams) 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)
ListBackupTargetsByUser(ctx context.Context, userID string) ([]BackupTarget, error)
+ ListBanners(ctx context.Context) ([]Banner, error)
ListCommandsForAgent(ctx context.Context, nodeAgentID string) ([]AgentCommand, error)
ListContainersForAgent(ctx context.Context, nodeAgentID string) ([]ContainerInstance, error)
ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg ListDatabaseBackupsByDatabaseAndUserParams) ([]DatabaseBackup, error)
@@ -106,6 +111,7 @@ type Querier interface {
UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeAgent, error)
UpdateAgentHeartbeat(ctx context.Context, arg UpdateAgentHeartbeatParams) error
UpdateBackupTargetByIDAndUser(ctx context.Context, arg UpdateBackupTargetByIDAndUserParams) error
+ UpdateBanner(ctx context.Context, arg UpdateBannerParams) (Banner, error)
UpdateContainerStatus(ctx context.Context, arg UpdateContainerStatusParams) error
UpdateDatabaseServiceNameAndPlanByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameAndPlanByIDAndUserParams) error
UpdateDatabaseServiceNameByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameByIDAndUserParams) error
diff --git a/app/backend/internal/mcpserver/server.go b/app/backend/internal/mcpserver/server.go
index c82071c..90d8fa8 100644
--- a/app/backend/internal/mcpserver/server.go
+++ b/app/backend/internal/mcpserver/server.go
@@ -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_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)
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})
diff --git a/app/backend/migrations_goose/20261104000000_banners.sql b/app/backend/migrations_goose/20261104000000_banners.sql
new file mode 100644
index 0000000..bd323c5
--- /dev/null
+++ b/app/backend/migrations_goose/20261104000000_banners.sql
@@ -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;
diff --git a/app/backend/sqlc/queries/banners.sql b/app/backend/sqlc/queries/banners.sql
new file mode 100644
index 0000000..3568a14
--- /dev/null
+++ b/app/backend/sqlc/queries/banners.sql
@@ -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;
diff --git a/app/backend/sqlc/schema.sql b/app/backend/sqlc/schema.sql
index 1ad7cb0..8c9671b 100644
--- a/app/backend/sqlc/schema.sql
+++ b/app/backend/sqlc/schema.sql
@@ -302,3 +302,17 @@ CREATE TABLE webhook_deliveries (
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
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()
+);
diff --git a/app/frontend/src/app/layout/PlatformShell.tsx b/app/frontend/src/app/layout/PlatformShell.tsx
index 8ca8627..b465182 100644
--- a/app/frontend/src/app/layout/PlatformShell.tsx
+++ b/app/frontend/src/app/layout/PlatformShell.tsx
@@ -41,7 +41,7 @@ import {
import { signOutAuthSession } from '@/lib/auth-client';
import { isDemoSearch } from '@/lib/demo-mode';
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 NavSection = { title: string; items: NavItem[] };
@@ -387,6 +387,7 @@ export function PlatformShell() {