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 <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) +} 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() { </aside> <div className="relative z-10 flex min-h-screen min-w-0 flex-1 flex-col overflow-hidden"> + <BannerBar /> <header className="hidden h-[46px] shrink-0 items-center border-b px-5 md:flex" style={{ diff --git a/app/frontend/src/features/admin/pages/AdminPage.tsx b/app/frontend/src/features/admin/pages/AdminPage.tsx index 4c2cda7..0459d9a 100644 --- a/app/frontend/src/features/admin/pages/AdminPage.tsx +++ b/app/frontend/src/features/admin/pages/AdminPage.tsx @@ -5,6 +5,7 @@ import { CheckCircle2, Clock, FolderKanban, + Megaphone, Pencil, ShieldCheck, Trash2, @@ -12,14 +13,19 @@ import { XCircle, } from 'lucide-react'; import { + createBanner, + deleteBanner, deleteProject, getAdminOverview, + listAdminBanners, listAdminUsers, listProjects, setProjectApproval, setUserAdmin, + updateBanner, updateProject, type AdminUser, + type Banner, type ProjectEntity, } from '@/lib/api-client'; 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 usersQuery = useQuery({ queryKey: ['admin-users'], queryFn: listAdminUsers, 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 [editForm, setEditForm] = useState({ name: '', description: '' }); + const [bannerForm, setBannerForm] = useState({ title: '', body: '', level: 'info' as Banner['level'] }); const invalidate = () => { queryClient.invalidateQueries({ queryKey: ['admin-overview'] }); queryClient.invalidateQueries({ queryKey: ['admin-users'] }); queryClient.invalidateQueries({ queryKey: ['admin-projects'] }); + queryClient.invalidateQueries({ queryKey: ['admin-banners'] }); + queryClient.invalidateQueries({ queryKey: ['active-banners'] }); 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({ mutationFn: ({ id, approved }: { id: string; approved: boolean }) => setProjectApproval(id, approved), onSuccess: (_, vars) => { @@ -126,6 +166,7 @@ export function AdminPage() { const pending = overview?.pending_projects ?? []; const users = usersQuery.data ?? []; const allProjects = projectsQuery.data ?? []; + const banners = bannersQuery.data ?? []; return ( <div className="mx-auto max-w-6xl px-5 py-8"> @@ -258,6 +299,85 @@ export function AdminPage() { </div> </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"> <div className="mb-3 flex items-center gap-2"> <Users size={15} className="text-[var(--accent-tertiary)]" /> diff --git a/app/frontend/src/generated/api-types.ts b/app/frontend/src/generated/api-types.ts index 02c0ac7..520705b 100644 --- a/app/frontend/src/generated/api-types.ts +++ b/app/frontend/src/generated/api-types.ts @@ -3809,6 +3809,212 @@ export interface paths { patch?: 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": { parameters: { query?: never; diff --git a/app/frontend/src/lib/api-client.ts b/app/frontend/src/lib/api-client.ts index bae7c39..542bce7 100644 --- a/app/frontend/src/lib/api-client.ts +++ b/app/frontend/src/lib/api-client.ts @@ -1866,6 +1866,50 @@ export async function getOperations(): Promise<OperationsView> { 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 { switch (status) { case 'running': diff --git a/app/frontend/src/shared/components/BannerBar.tsx b/app/frontend/src/shared/components/BannerBar.tsx new file mode 100644 index 0000000..d1315bc --- /dev/null +++ b/app/frontend/src/shared/components/BannerBar.tsx @@ -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> + ); +} diff --git a/app/frontend/src/shared/components/index.ts b/app/frontend/src/shared/components/index.ts index 63b6d8c..2359caa 100644 --- a/app/frontend/src/shared/components/index.ts +++ b/app/frontend/src/shared/components/index.ts @@ -1,3 +1,4 @@ +export * from './BannerBar'; export * from './DemoGate'; export * from './ErrorBoundary'; export * from './LoadingSpinner'; diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 8955f56..78d7be5 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -2581,6 +2581,127 @@ paths: items: 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: get: diff --git a/docs/guides/CLI.md b/docs/guides/CLI.md index 3049c6c..8a9b5ee 100644 --- a/docs/guides/CLI.md +++ b/docs/guides/CLI.md @@ -84,6 +84,8 @@ webhooks list|create|update|delete|deliveries|test deliveries are HMAC-signed (X-Containr-Signature) operations live ops view: active deploys, queue depth, 24h failures, cron runs, backups +banners list|create|update|delete announcement banners (admin); + active banners print on `auth status` up deploy the current directory ```