mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b47ced0135 | ||
|
|
9408464f12 |
No files matched your search
+11
-6
@@ -284,12 +284,17 @@ 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`.
|
||||
- [ ] **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
|
||||
self-hosters who rebrand their instance.
|
||||
- [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.
|
||||
- [x] **White-label** — `app_settings` `branding.*` keys: product name,
|
||||
logo/favicon URL, accent color, docs/support links. Public
|
||||
`GET /api/v1/branding` (defaults = Containr); admin writes via
|
||||
`PUT /settings`. `useBranding()` applies title/accent/favicon;
|
||||
`BrandWordmark` replaces hardcoded marks; admin Platform section
|
||||
edits all fields. MCP `containr_branding_get` + `settings_update`.
|
||||
- [x] **Operations page** — `GET /operations` aggregates active
|
||||
deployments, in-memory deploy-queue depth (`Queue.Snapshot()`),
|
||||
24h failures, cron runs and backups scoped to the caller's projects.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -258,6 +258,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
public.POST("/auth/login", handleLogin)
|
||||
public.POST("/auth/register", handleRegister)
|
||||
public.GET("/maintenance", handleMaintenancePage)
|
||||
public.GET("/branding", handleGetBranding)
|
||||
|
||||
// Wake callback for sleep-mode placeholders. Unauthenticated but
|
||||
// non-destructive: it only reconciles the service back to running.
|
||||
@@ -336,6 +337,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 +489,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)
|
||||
|
||||
|
||||
@@ -23,10 +23,49 @@ const (
|
||||
settingSignupEnabled = "signup_enabled"
|
||||
settingCloudflareTunnelToken = "cloudflare_tunnel_token"
|
||||
|
||||
// White-label branding keys — unset fields fall back to Containr
|
||||
// defaults. Self-hosters rebrand their instance via these.
|
||||
settingBrandProductName = "branding.product_name"
|
||||
settingBrandLogoURL = "branding.logo_url"
|
||||
settingBrandFaviconURL = "branding.favicon_url"
|
||||
settingBrandAccent = "branding.accent_color"
|
||||
settingBrandDocsURL = "branding.docs_url"
|
||||
settingBrandSupportURL = "branding.support_url"
|
||||
|
||||
cloudflaredContainerName = "containr-cloudflared"
|
||||
cloudflaredImage = "cloudflare/cloudflared:latest"
|
||||
)
|
||||
|
||||
// brandingConfig is the public white-label payload. Fields are always
|
||||
// populated — defaults keep the instance Containr-branded.
|
||||
type brandingConfig struct {
|
||||
ProductName string `json:"product_name"`
|
||||
LogoURL string `json:"logo_url"`
|
||||
FaviconURL string `json:"favicon_url"`
|
||||
AccentColor string `json:"accent_color"`
|
||||
DocsURL string `json:"docs_url"`
|
||||
SupportURL string `json:"support_url"`
|
||||
}
|
||||
|
||||
func resolveBranding(db *database.DB) brandingConfig {
|
||||
return brandingConfig{
|
||||
ProductName: settingValue(db, settingBrandProductName, "BRAND_NAME", "Containr"),
|
||||
LogoURL: settingValue(db, settingBrandLogoURL, "BRAND_LOGO_URL", ""),
|
||||
FaviconURL: settingValue(db, settingBrandFaviconURL, "BRAND_FAVICON_URL", ""),
|
||||
AccentColor: settingValue(db, settingBrandAccent, "BRAND_ACCENT_COLOR", ""),
|
||||
DocsURL: settingValue(db, settingBrandDocsURL, "BRAND_DOCS_URL", ""),
|
||||
SupportURL: settingValue(db, settingBrandSupportURL, "BRAND_SUPPORT_URL", ""),
|
||||
}
|
||||
}
|
||||
|
||||
// GET /branding — public so the login page and shell can brand themselves
|
||||
// before a session exists.
|
||||
func handleGetBranding(c *gin.Context) {
|
||||
db, _ := c.Get("db")
|
||||
database, _ := db.(*database.DB)
|
||||
c.JSON(http.StatusOK, resolveBranding(database))
|
||||
}
|
||||
|
||||
// settingValue resolves a setting: app_settings row first, env fallback,
|
||||
// then the caller's default.
|
||||
func settingValue(db *database.DB, key, envKey, fallback string) string {
|
||||
@@ -110,6 +149,7 @@ func handleGetSettings(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"signup_enabled": signupEnabled(db),
|
||||
"cloudflare_tunnel": cloudflareTunnelState(c.Request.Context(), db, client),
|
||||
"branding": resolveBranding(db),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -122,6 +162,14 @@ func handleUpdateSettings(c *gin.Context) {
|
||||
var req struct {
|
||||
SignupEnabled *bool `json:"signup_enabled"`
|
||||
CloudflareTunnelToken *string `json:"cloudflare_tunnel_token"`
|
||||
Branding *struct {
|
||||
ProductName *string `json:"product_name"`
|
||||
LogoURL *string `json:"logo_url"`
|
||||
FaviconURL *string `json:"favicon_url"`
|
||||
AccentColor *string `json:"accent_color"`
|
||||
DocsURL *string `json:"docs_url"`
|
||||
SupportURL *string `json:"support_url"`
|
||||
} `json:"branding"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
@@ -158,6 +206,36 @@ func handleUpdateSettings(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
if req.Branding != nil {
|
||||
fields := []struct {
|
||||
key string
|
||||
value *string
|
||||
}{
|
||||
{settingBrandProductName, req.Branding.ProductName},
|
||||
{settingBrandLogoURL, req.Branding.LogoURL},
|
||||
{settingBrandFaviconURL, req.Branding.FaviconURL},
|
||||
{settingBrandAccent, req.Branding.AccentColor},
|
||||
{settingBrandDocsURL, req.Branding.DocsURL},
|
||||
{settingBrandSupportURL, req.Branding.SupportURL},
|
||||
}
|
||||
for _, f := range fields {
|
||||
if f.value == nil {
|
||||
continue
|
||||
}
|
||||
v := strings.TrimSpace(*f.value)
|
||||
var err error
|
||||
if v == "" {
|
||||
err = deleteSetting(db, f.key)
|
||||
} else {
|
||||
err = setSetting(db, f.key, v, false)
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save branding"})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleGetSettings(c)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -149,6 +149,15 @@ 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})
|
||||
|
||||
register(spec{"containr_branding_get", "Get instance white-label branding (product name, logo, accent, links).", "GET", "/branding", nil, nil, false})
|
||||
register(spec{"containr_settings_update", "Update platform settings (admin). Args: signup_enabled (bool), cloudflare_tunnel_token (string, empty clears), branding (object: product_name, logo_url, favicon_url, accent_color, docs_url, support_url — empty string resets a field).", "PUT", "/settings", nil, []string{"signup_enabled", "cloudflare_tunnel_token", "branding"}, false})
|
||||
|
||||
// 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})
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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()
|
||||
);
|
||||
@@ -13,6 +13,7 @@ import { WebhooksPage } from '@/features/webhooks/pages/WebhooksPage';
|
||||
import { OperationsPage } from '@/features/operations/pages/OperationsPage';
|
||||
import { AdminPage } from '@/features/admin/pages/AdminPage';
|
||||
import { LandingPage } from '@/features/landing/LandingPage';
|
||||
import { useBranding } from '@/lib/use-branding';
|
||||
import {
|
||||
DocsPage,
|
||||
PeoplePage,
|
||||
@@ -126,6 +127,7 @@ function NotFoundPage() {
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
useBranding();
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<Routes>
|
||||
|
||||
@@ -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, BrandWordmark, 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={{
|
||||
@@ -397,7 +398,7 @@ export function PlatformShell() {
|
||||
}}
|
||||
>
|
||||
<NavLink to={href('/projects')} className="mr-5 text-sm font-extrabold tracking-tight text-[var(--text-primary)]" style={{ fontFamily: 'Inter, sans-serif' }}>
|
||||
contain<span style={{ color: 'var(--accent-primary)' }}>r</span>
|
||||
<BrandWordmark />
|
||||
</NavLink>
|
||||
|
||||
<div className="flex items-center gap-5">
|
||||
|
||||
@@ -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)]" />
|
||||
|
||||
@@ -15,6 +15,8 @@ import { listProjects, listTemplates } from '@/lib/api-client';
|
||||
import { DocsBrowser } from '@/features/docs/DocsBrowser';
|
||||
import { useAuthSession } from '@/lib/use-auth-session';
|
||||
import { useDemoMode } from '@/lib/demo-mode';
|
||||
import { useBranding } from '@/lib/use-branding';
|
||||
import { BrandWordmark } from '@/shared/components';
|
||||
import { demoProjects, demoTemplates } from '@/lib/demo-data';
|
||||
|
||||
const features = [
|
||||
@@ -28,6 +30,7 @@ const features = [
|
||||
|
||||
export function LandingPage() {
|
||||
const isDemoMode = useDemoMode();
|
||||
const { logoUrl, productName } = useBranding();
|
||||
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
|
||||
const signedIn = !isDemoMode && Boolean(sessionQuery.data);
|
||||
const demo = isDemoMode ? '?demo=1' : '';
|
||||
@@ -45,9 +48,9 @@ export function LandingPage() {
|
||||
<header className="sticky top-0 z-40 border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/80 backdrop-blur-xl">
|
||||
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-5">
|
||||
<Link to="/" className="flex items-center gap-2.5">
|
||||
<img src="/containr.svg" alt="Containr" className="h-7 w-7 rounded-lg" />
|
||||
<img src={logoUrl || '/containr.svg'} alt={productName} className="h-7 w-7 rounded-lg object-contain" />
|
||||
<span className="text-[15px] font-extrabold tracking-tight text-[var(--text-primary)]">
|
||||
contain<span style={{ color: 'var(--accent-primary)' }}>r</span>
|
||||
<BrandWordmark />
|
||||
</span>
|
||||
</Link>
|
||||
<nav className="flex items-center gap-1 text-[13px]">
|
||||
|
||||
@@ -48,6 +48,8 @@ import {
|
||||
updateCurrentUserProfile,
|
||||
getPlatformSettings,
|
||||
updatePlatformSettings,
|
||||
type Branding,
|
||||
type BrandingInput,
|
||||
getHAStatus,
|
||||
setHAEnabled,
|
||||
triggerFailover,
|
||||
@@ -1158,6 +1160,7 @@ function PlatformSettingsSection() {
|
||||
|
||||
const [signupDraft, setSignupDraft] = useState<boolean | null>(null);
|
||||
const [tokenDraft, setTokenDraft] = useState('');
|
||||
const [brandDraft, setBrandDraft] = useState<BrandingInput>({});
|
||||
const [feedback, setFeedback] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -1168,8 +1171,10 @@ function PlatformSettingsSection() {
|
||||
mutationFn: updatePlatformSettings,
|
||||
onSuccess: (settings) => {
|
||||
queryClient.setQueryData(['platform-settings'], settings);
|
||||
queryClient.invalidateQueries({ queryKey: ['branding'] });
|
||||
setSignupDraft(null);
|
||||
setTokenDraft('');
|
||||
setBrandDraft({});
|
||||
setFeedback('Settings saved.');
|
||||
setError(null);
|
||||
},
|
||||
@@ -1279,6 +1284,59 @@ function PlatformSettingsSection() {
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Branding</span>
|
||||
<span className="text-xs text-[var(--text-tertiary)]">leave blank to keep Containr defaults</span>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--text-secondary)] mb-3">
|
||||
White-label this instance — product name, logo, accent color and links apply
|
||||
to the shell, login page and document title.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2 max-sm:grid-cols-1">
|
||||
{(
|
||||
[
|
||||
['productName', 'Product name'],
|
||||
['logoUrl', 'Logo URL'],
|
||||
['faviconUrl', 'Favicon URL'],
|
||||
['accentColor', 'Accent color (#hex)'],
|
||||
['docsUrl', 'Docs URL'],
|
||||
['supportUrl', 'Support URL'],
|
||||
] as const
|
||||
).map(([field, label]) => (
|
||||
<label key={field} className="block">
|
||||
<span className="mb-1 block text-[11px] text-[var(--text-tertiary)]">{label}</span>
|
||||
<input
|
||||
value={brandDraft[field] ?? settingsQuery.data?.branding[field] ?? ''}
|
||||
onChange={(e) => setBrandDraft((d) => ({ ...d, [field]: e.target.value }))}
|
||||
className="h-9 w-full px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-void)] text-sm focus:border-[var(--accent-primary)] transition-colors"
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2 mt-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
const b = { ...(settingsQuery.data?.branding ?? {}), ...brandDraft } as Branding;
|
||||
saveMutation.mutate({ branding: b });
|
||||
}}
|
||||
disabled={saveMutation.isPending || Object.keys(brandDraft).length === 0}
|
||||
className="h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
>
|
||||
Save branding
|
||||
</button>
|
||||
{Object.keys(brandDraft).length > 0 ? (
|
||||
<button
|
||||
onClick={() => setBrandDraft({})}
|
||||
className="h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-secondary)]"
|
||||
>
|
||||
Reset draft
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{feedback ? <p className="text-xs text-[var(--success)]">{feedback}</p> : null}
|
||||
{error ? <p className="text-xs text-[var(--error)]">{error}</p> : null}
|
||||
</div>
|
||||
|
||||
@@ -3809,6 +3809,254 @@ 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;
|
||||
};
|
||||
"/branding": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* White-label branding (public)
|
||||
* @description Instance branding payload — product name, logo/favicon URLs,
|
||||
* accent color, docs/support links. Unauthenticated so the login
|
||||
* page and shell can brand themselves before a session exists.
|
||||
* Unset fields return Containr defaults.
|
||||
*/
|
||||
get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Branding configuration */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Branding"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/audit-logs": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -4774,6 +5022,15 @@ export interface paths {
|
||||
signup_enabled?: boolean;
|
||||
/** @description Cloudflare Tunnel token. Empty string clears the stored token and removes the cloudflared container. */
|
||||
cloudflare_tunnel_token?: string;
|
||||
/** @description White-label fields. Empty string clears a field back to the Containr default. */
|
||||
branding?: {
|
||||
product_name?: string;
|
||||
logo_url?: string;
|
||||
favicon_url?: string;
|
||||
accent_color?: string;
|
||||
docs_url?: string;
|
||||
support_url?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -9409,6 +9666,16 @@ export interface components {
|
||||
/** @description cloudflared container state - running/exited/missing, or unavailable when Docker is unreachable */
|
||||
container?: string;
|
||||
};
|
||||
branding?: components["schemas"]["Branding"];
|
||||
};
|
||||
/** @description White-label instance branding. Unset fields fall back to Containr defaults. */
|
||||
Branding: {
|
||||
product_name?: string;
|
||||
logo_url?: string;
|
||||
favicon_url?: string;
|
||||
accent_color?: string;
|
||||
docs_url?: string;
|
||||
support_url?: string;
|
||||
};
|
||||
User: {
|
||||
/** @description User ID */
|
||||
|
||||
@@ -24,6 +24,24 @@ export type UserProfile = {
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type Branding = {
|
||||
productName: string;
|
||||
logoUrl: string;
|
||||
faviconUrl: string;
|
||||
accentColor: string;
|
||||
docsUrl: string;
|
||||
supportUrl: string;
|
||||
};
|
||||
|
||||
export type BrandingInput = Partial<{
|
||||
productName: string;
|
||||
logoUrl: string;
|
||||
faviconUrl: string;
|
||||
accentColor: string;
|
||||
docsUrl: string;
|
||||
supportUrl: string;
|
||||
}>;
|
||||
|
||||
export type PlatformSettings = {
|
||||
signupEnabled: boolean;
|
||||
cloudflareTunnel: {
|
||||
@@ -31,6 +49,7 @@ export type PlatformSettings = {
|
||||
source: 'app' | 'env' | 'none';
|
||||
container: string;
|
||||
};
|
||||
branding: Branding;
|
||||
};
|
||||
|
||||
export type ServiceEntity = {
|
||||
@@ -542,6 +561,37 @@ function normalizeUserProfile(profile: RawUserProfile): UserProfile | null {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeBranding(raw: {
|
||||
product_name?: string;
|
||||
logo_url?: string;
|
||||
favicon_url?: string;
|
||||
accent_color?: string;
|
||||
docs_url?: string;
|
||||
support_url?: string;
|
||||
} | undefined | null): Branding {
|
||||
return {
|
||||
productName: raw?.product_name ?? 'Containr',
|
||||
logoUrl: raw?.logo_url ?? '',
|
||||
faviconUrl: raw?.favicon_url ?? '',
|
||||
accentColor: raw?.accent_color ?? '',
|
||||
docsUrl: raw?.docs_url ?? '',
|
||||
supportUrl: raw?.support_url ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
// GET /branding is public — used before login to theme the shell.
|
||||
export async function getBranding(): Promise<Branding> {
|
||||
const raw = await requestJson<{
|
||||
product_name?: string;
|
||||
logo_url?: string;
|
||||
favicon_url?: string;
|
||||
accent_color?: string;
|
||||
docs_url?: string;
|
||||
support_url?: string;
|
||||
}>('/branding');
|
||||
return normalizeBranding(raw);
|
||||
}
|
||||
|
||||
function normalizePlatformSettings(raw: components['schemas']['PlatformSettings']): PlatformSettings {
|
||||
return {
|
||||
signupEnabled: raw.signup_enabled ?? false,
|
||||
@@ -550,6 +600,7 @@ function normalizePlatformSettings(raw: components['schemas']['PlatformSettings'
|
||||
source: raw.cloudflare_tunnel?.source ?? 'none',
|
||||
container: raw.cloudflare_tunnel?.container ?? 'missing',
|
||||
},
|
||||
branding: normalizeBranding(raw.branding),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -561,6 +612,7 @@ export async function getPlatformSettings(): Promise<PlatformSettings> {
|
||||
export async function updatePlatformSettings(input: {
|
||||
signupEnabled?: boolean;
|
||||
cloudflareTunnelToken?: string;
|
||||
branding?: BrandingInput;
|
||||
}): Promise<PlatformSettings> {
|
||||
const body: Record<string, unknown> = {};
|
||||
if (input.signupEnabled !== undefined) {
|
||||
@@ -569,6 +621,16 @@ export async function updatePlatformSettings(input: {
|
||||
if (input.cloudflareTunnelToken !== undefined) {
|
||||
body.cloudflare_tunnel_token = input.cloudflareTunnelToken;
|
||||
}
|
||||
if (input.branding !== undefined) {
|
||||
const b: Record<string, string> = {};
|
||||
if (input.branding.productName !== undefined) b.product_name = input.branding.productName;
|
||||
if (input.branding.logoUrl !== undefined) b.logo_url = input.branding.logoUrl;
|
||||
if (input.branding.faviconUrl !== undefined) b.favicon_url = input.branding.faviconUrl;
|
||||
if (input.branding.accentColor !== undefined) b.accent_color = input.branding.accentColor;
|
||||
if (input.branding.docsUrl !== undefined) b.docs_url = input.branding.docsUrl;
|
||||
if (input.branding.supportUrl !== undefined) b.support_url = input.branding.supportUrl;
|
||||
body.branding = b;
|
||||
}
|
||||
const raw = await requestJson<components['schemas']['PlatformSettings']>('/settings', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
@@ -1866,6 +1928,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':
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getBranding, type Branding } from '@/lib/api-client';
|
||||
import { useDemoMode } from '@/lib/demo-mode';
|
||||
|
||||
const DEFAULT_TITLE = 'containr';
|
||||
|
||||
/**
|
||||
* Loads the instance's white-label branding (public endpoint) and applies
|
||||
* it: document title, favicon, and the accent CSS variable. Defaults keep
|
||||
* the app Containr-branded.
|
||||
*/
|
||||
export function useBranding(): Branding {
|
||||
const isDemoMode = useDemoMode();
|
||||
const brandingQuery = useQuery({
|
||||
queryKey: ['branding'],
|
||||
queryFn: getBranding,
|
||||
staleTime: 5 * 60_000,
|
||||
enabled: !isDemoMode,
|
||||
});
|
||||
|
||||
const branding = brandingQuery.data ?? {
|
||||
productName: 'Containr',
|
||||
logoUrl: '',
|
||||
faviconUrl: '',
|
||||
accentColor: '',
|
||||
docsUrl: '',
|
||||
supportUrl: '',
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const name = branding.productName;
|
||||
document.title = name && name !== 'Containr' ? name : DEFAULT_TITLE;
|
||||
|
||||
const root = document.documentElement;
|
||||
if (branding.accentColor && /^#[0-9a-fA-F]{3,8}$/.test(branding.accentColor)) {
|
||||
root.style.setProperty('--accent-primary', branding.accentColor);
|
||||
} else {
|
||||
root.style.removeProperty('--accent-primary');
|
||||
}
|
||||
|
||||
if (branding.faviconUrl) {
|
||||
let link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
|
||||
if (!link) {
|
||||
link = document.createElement('link');
|
||||
link.rel = 'icon';
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
link.href = branding.faviconUrl;
|
||||
}
|
||||
}, [branding.productName, branding.accentColor, branding.faviconUrl]);
|
||||
|
||||
return branding;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useBranding } from '@/lib/use-branding';
|
||||
|
||||
/**
|
||||
* Instance wordmark — renders the configured logo when set, otherwise the
|
||||
* product name with the signature accent on the last character
|
||||
* ("contain[r]"). Falls back to Containr branding.
|
||||
*/
|
||||
export function BrandWordmark({ className = '' }: { className?: string }) {
|
||||
const branding = useBranding();
|
||||
|
||||
if (branding.logoUrl) {
|
||||
return (
|
||||
<img
|
||||
src={branding.logoUrl}
|
||||
alt={branding.productName}
|
||||
className={`h-6 w-auto object-contain ${className}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const name = branding.productName;
|
||||
return (
|
||||
<span className={className}>
|
||||
{name.slice(0, -1)}
|
||||
<span style={{ color: 'var(--accent-primary)' }}>{name.slice(-1)}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './BannerBar';
|
||||
export * from './DemoGate';
|
||||
export * from './ErrorBoundary';
|
||||
export * from './LoadingSpinner';
|
||||
@@ -9,3 +10,4 @@ export * from './CommandPalette';
|
||||
export * from './StatusBadge';
|
||||
export { useToast } from '../hooks/use-toast';
|
||||
export type { ToastType, ToastContextValue } from '../hooks/use-toast';
|
||||
export * from './BrandWordmark';
|
||||
@@ -2581,6 +2581,145 @@ 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
|
||||
|
||||
/branding:
|
||||
get:
|
||||
tags:
|
||||
- Branding
|
||||
summary: White-label branding (public)
|
||||
description: |
|
||||
Instance branding payload — product name, logo/favicon URLs,
|
||||
accent color, docs/support links. Unauthenticated so the login
|
||||
page and shell can brand themselves before a session exists.
|
||||
Unset fields return Containr defaults.
|
||||
responses:
|
||||
'200':
|
||||
description: Branding configuration
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Branding'
|
||||
|
||||
# Audit Logs
|
||||
/audit-logs:
|
||||
get:
|
||||
@@ -3204,6 +3343,16 @@ paths:
|
||||
cloudflare_tunnel_token:
|
||||
type: string
|
||||
description: Cloudflare Tunnel token. Empty string clears the stored token and removes the cloudflared container.
|
||||
branding:
|
||||
type: object
|
||||
description: White-label fields. Empty string clears a field back to the Containr default.
|
||||
properties:
|
||||
product_name: {type: string}
|
||||
logo_url: {type: string}
|
||||
favicon_url: {type: string}
|
||||
accent_color: {type: string}
|
||||
docs_url: {type: string}
|
||||
support_url: {type: string}
|
||||
responses:
|
||||
'200':
|
||||
description: Updated platform settings
|
||||
@@ -5969,6 +6118,25 @@ components:
|
||||
container:
|
||||
type: string
|
||||
description: cloudflared container state - running/exited/missing, or unavailable when Docker is unreachable
|
||||
branding:
|
||||
$ref: '#/components/schemas/Branding'
|
||||
|
||||
Branding:
|
||||
type: object
|
||||
description: White-label instance branding. Unset fields fall back to Containr defaults.
|
||||
properties:
|
||||
product_name:
|
||||
type: string
|
||||
logo_url:
|
||||
type: string
|
||||
favicon_url:
|
||||
type: string
|
||||
accent_color:
|
||||
type: string
|
||||
docs_url:
|
||||
type: string
|
||||
support_url:
|
||||
type: string
|
||||
|
||||
User:
|
||||
type: object
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
Reference in new issue
Block a user