mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50e39fe140 | ||
|
|
b47ced0135 | ||
|
|
9408464f12 | ||
|
|
954a1d1f80 | ||
|
|
cf7afd2ba7 | ||
|
|
f524d95894 | ||
|
|
f499678964 |
No files matched your search
+32
-17
@@ -86,7 +86,7 @@ Legend: ✅ done · 🟡 partial/different · ❌ missing · ➖ not applicable
|
||||
| Multi-tenant organisations + custom RBAC (per-resource CRUD, create-limits, read-scope) | 🟡 `is_admin` + `project_members` | Defer full RBAC; design PAT scopes role-compatible |
|
||||
| Team invite links/emails | 🟡 manual user create only | `POST /invites` → link join |
|
||||
| User API keys → full REST API | ❌ **the big gap** — session/JWT only | PATs (Phase A) |
|
||||
| Outbound webhooks (collection events, HMAC, custom headers) | ❌ | `webhooks` table + delivery log |
|
||||
| Outbound webhooks (collection events, HMAC, custom headers) | ✅ `outbound_webhooks` + `webhook_deliveries`, HMAC `X-Containr-Signature`, wildcard events, 3-retry, UI page | — |
|
||||
| Banners (global + per-tenant, dismissible) | ❌ | `banners` table + top bar |
|
||||
| White-label (logo/favicon/title/OG/theme colors) | ❌ | `app_settings.branding` — **Containr remains the default brand** |
|
||||
| Activity feed (icon/severity/category/metadata, 90-day TTL job) | 🟡 `audit_logs` coarser | Enrich + project feed UI |
|
||||
@@ -278,25 +278,38 @@ and wired refs, one click or one CLI call.
|
||||
|
||||
## 8. Phase G — Platform polish
|
||||
|
||||
- [ ] **Outbound webhooks** — `webhooks(url, secret, events[], headers,
|
||||
enabled)` + `webhook_deliveries` log; HMAC `X-Containr-Signature`;
|
||||
events: `deployment.* service.* database.* security.* agent.*`.
|
||||
- [x] **Outbound webhooks** — `outbound_webhooks(url, secret, events[],
|
||||
headers, enabled)` + `webhook_deliveries` log; HMAC
|
||||
`X-Containr-Signature`; wildcard events on every audited action;
|
||||
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.
|
||||
- [ ] **Operations page** — unified view of running/queued/failed jobs
|
||||
(deploys, builds, backups, cron, scans, agent commands) — dflow's
|
||||
bubble panel as a real page; flush/stuck-job tools once queue exists.
|
||||
- [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.
|
||||
UI at `/operations` (Operate nav) with live refetch; `containr
|
||||
operations` CLI + `containr_operations_get` MCP. Remaining: agent
|
||||
commands once node fleet lands; flush/stuck-job tools.
|
||||
- [ ] **Interactive terminal** — upgrade exec to WS PTY (`xterm.js`);
|
||||
`nodes/:id/terminal` via agent (dflow's wetty without the extra
|
||||
container).
|
||||
- [ ] **Team invites** — `POST /invites` → signed link → join flow;
|
||||
replaces manual-only user creation.
|
||||
- [x] **Team invites** — `POST /admin/invites` returns a one-time
|
||||
link (sha256-hashed token at rest, optional email binding, TTL);
|
||||
public `GET /auth/invites/:token` validates and
|
||||
`POST /auth/accept-invite` registers — works while public signup is
|
||||
closed. `/auth/accept-invite` page, admin manage/revoke UI,
|
||||
`containr invites` CLI + MCP tools.
|
||||
- [ ] **Impersonate user** — admin support tool, audit-logged.
|
||||
- [ ] **GitHub App manifest flow** — `POST /git/github-app/manifest`
|
||||
returns manifest + redirect; instance self-provisions its App
|
||||
@@ -345,8 +358,10 @@ and wired refs, one click or one CLI call.
|
||||
- [ ] **dflow importer** — `containr migrate dflow` via Payload REST
|
||||
export or Mongo dump: servers→nodes, projects, services(+vars/
|
||||
domains/volumes), databases, templates. Onboards dflow refugees.
|
||||
- [ ] **`containr import compose`** — Phase-E parser straight to a
|
||||
project (no template intermediate).
|
||||
- [x] **`containr import compose`** — `POST /projects/:id/import-compose`
|
||||
deploys a compose file straight into a project; CLI `import compose`,
|
||||
MCP tool, workspace import modal; project delete cascades to
|
||||
graph-provisioned managed databases (`database_services.project_id`).
|
||||
- [ ] **Template registry** — optional `CONTAINR_TEMPLATE_REGISTRY_URL`
|
||||
fetch/publish; bundled catalog works offline (dflow's self-contained
|
||||
principle — nothing phones home).
|
||||
|
||||
@@ -52,6 +52,8 @@ func LogAudit(userID, resource, resourceID, action string, details map[string]in
|
||||
|
||||
if err != nil {
|
||||
}
|
||||
|
||||
emitWebhookEvent(userID, resource, resourceID, action, details)
|
||||
}
|
||||
|
||||
func LogAuditWithRequest(c *gin.Context, resource, resourceID, action string, details map[string]interface{}) {
|
||||
@@ -79,6 +81,8 @@ func LogAuditWithRequest(c *gin.Context, resource, resourceID, action string, de
|
||||
|
||||
if err != nil {
|
||||
}
|
||||
|
||||
emitWebhookEvent(userIDStr, resource, resourceID, action, details)
|
||||
}
|
||||
|
||||
var auditDB *database.DB
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// Team invites: an admin creates a single-use link; the invitee registers
|
||||
// through it even while public signup is closed. Tokens are random 32-byte
|
||||
// values shared once — only their sha256 hash is stored.
|
||||
|
||||
type inviteJSON struct {
|
||||
ID string `json:"id"`
|
||||
Email *string `json:"email,omitempty"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
UsedAt *time.Time `json:"used_at,omitempty"`
|
||||
CreatedAt *time.Time `json:"created_at,omitempty"`
|
||||
}
|
||||
|
||||
func toInviteJSON(i sqlcdb.UserInvite) inviteJSON {
|
||||
out := inviteJSON{ID: i.ID.String(), ExpiresAt: i.ExpiresAt}
|
||||
if i.Email.Valid && i.Email.String != "" {
|
||||
out.Email = &i.Email.String
|
||||
}
|
||||
if i.UsedAt.Valid {
|
||||
out.UsedAt = &i.UsedAt.Time
|
||||
}
|
||||
if i.CreatedAt.Valid {
|
||||
out.CreatedAt = &i.CreatedAt.Time
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func inviteTokenHash(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func generateInviteToken() (string, error) {
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
// POST /admin/invites — {email?, expires_in_hours?} → {invite, token, url}
|
||||
func handleAdminCreateInvite(c *gin.Context) {
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
ExpiresInHours *int `json:"expires_in_hours"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
respondError(c, http.StatusBadRequest, "INVALID_BODY", "invalid request body")
|
||||
return
|
||||
}
|
||||
ttl := 24 * 7
|
||||
if req.ExpiresInHours != nil {
|
||||
if *req.ExpiresInHours < 1 || *req.ExpiresInHours > 24*90 {
|
||||
respondError(c, http.StatusBadRequest, "VALIDATION", "expires_in_hours must be 1–2160")
|
||||
return
|
||||
}
|
||||
ttl = *req.ExpiresInHours
|
||||
}
|
||||
token, err := generateInviteToken()
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to generate token")
|
||||
return
|
||||
}
|
||||
userID, _ := requireAuthenticatedUserUUID(c)
|
||||
params := sqlcdb.CreateUserInviteParams{
|
||||
TokenHash: inviteTokenHash(token),
|
||||
CreatedBy: uuid.NullUUID{UUID: userID, Valid: true},
|
||||
ExpiresAt: time.Now().UTC().Add(time.Duration(ttl) * time.Hour),
|
||||
}
|
||||
if email := strings.ToLower(strings.TrimSpace(req.Email)); email != "" {
|
||||
params.Email = sql.NullString{String: email, Valid: true}
|
||||
}
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
invite, err := sqlcdb.New(db.DB).CreateUserInvite(c.Request.Context(), params)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to create invite")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"invite": toInviteJSON(invite),
|
||||
"token": token,
|
||||
"url": "/auth/accept-invite?token=" + token,
|
||||
})
|
||||
}
|
||||
|
||||
// GET /admin/invites
|
||||
func handleAdminListInvites(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
invites, err := sqlcdb.New(db.DB).ListUserInvites(c.Request.Context())
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to list invites")
|
||||
return
|
||||
}
|
||||
out := make([]inviteJSON, 0, len(invites))
|
||||
for _, i := range invites {
|
||||
out = append(out, toInviteJSON(i))
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"invites": out})
|
||||
}
|
||||
|
||||
// DELETE /admin/invites/:id — revoke an unused invite.
|
||||
func handleAdminDeleteInvite(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
respondError(c, http.StatusBadRequest, "INVALID_ID", "invalid invite id")
|
||||
return
|
||||
}
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
if err := sqlcdb.New(db.DB).DeleteUserInvite(c.Request.Context(), id); err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to delete invite")
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// GET /auth/invites/:token — public. Lets the accept page validate the link
|
||||
// and pre-fill a bound email before the user commits to registering.
|
||||
func handleGetInvite(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
invite, err := sqlcdb.New(db.DB).GetUserInviteByTokenHash(c.Request.Context(), inviteTokenHash(c.Param("token")))
|
||||
if err != nil {
|
||||
respondError(c, http.StatusNotFound, "NOT_FOUND", "invite not found")
|
||||
return
|
||||
}
|
||||
expired := time.Now().After(invite.ExpiresAt)
|
||||
var email *string
|
||||
if invite.Email.Valid && invite.Email.String != "" {
|
||||
email = &invite.Email.String
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"valid": !invite.UsedAt.Valid && !expired,
|
||||
"used": invite.UsedAt.Valid,
|
||||
"expired": expired,
|
||||
"email": email,
|
||||
"expires_at": invite.ExpiresAt,
|
||||
})
|
||||
}
|
||||
|
||||
// POST /auth/accept-invite — {token, name, email, password}
|
||||
func handleAcceptInvite(c *gin.Context) {
|
||||
var req struct {
|
||||
Token string `json:"token" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Email string `json:"email" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
respondError(c, http.StatusBadRequest, "INVALID_BODY", "token, name, email and password are required")
|
||||
return
|
||||
}
|
||||
if len(req.Password) < 8 {
|
||||
respondError(c, http.StatusBadRequest, "VALIDATION", "password must be at least 8 characters")
|
||||
return
|
||||
}
|
||||
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
||||
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
q := sqlcdb.New(db.DB)
|
||||
invite, err := q.GetUserInviteByTokenHash(c.Request.Context(), inviteTokenHash(req.Token))
|
||||
if err != nil {
|
||||
respondError(c, http.StatusNotFound, "NOT_FOUND", "invite not found")
|
||||
return
|
||||
}
|
||||
if invite.UsedAt.Valid {
|
||||
respondError(c, http.StatusGone, "INVITE_USED", "invite already used")
|
||||
return
|
||||
}
|
||||
if time.Now().After(invite.ExpiresAt) {
|
||||
respondError(c, http.StatusGone, "INVITE_EXPIRED", "invite expired")
|
||||
return
|
||||
}
|
||||
if invite.Email.Valid && invite.Email.String != "" && invite.Email.String != req.Email {
|
||||
respondError(c, http.StatusForbidden, "INVITE_EMAIL_MISMATCH", "invite is bound to a different email")
|
||||
return
|
||||
}
|
||||
|
||||
var existing int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM users WHERE email = $1`, req.Email).Scan(&existing); err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "INTERNAL", "database error")
|
||||
return
|
||||
}
|
||||
if existing > 0 {
|
||||
respondError(c, http.StatusConflict, "CONFLICT", "user already exists")
|
||||
return
|
||||
}
|
||||
|
||||
if err := createBetterAuthUser(c, strings.TrimSpace(req.Name), req.Email, req.Password); err != nil && !errors.Is(err, errBetterAuthUserExists) {
|
||||
respondError(c, http.StatusInternalServerError, "INTERNAL", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to hash password")
|
||||
return
|
||||
}
|
||||
|
||||
var user User
|
||||
err = db.QueryRow(`
|
||||
INSERT INTO users (email, password_hash, name)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, email, name, COALESCE(avatar_url, ''), is_admin, created_at
|
||||
`, req.Email, string(hashedPassword), strings.TrimSpace(req.Name)).
|
||||
Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusConflict, "CONFLICT", "user already exists")
|
||||
return
|
||||
}
|
||||
|
||||
userUUID, _ := uuid.Parse(user.ID)
|
||||
if err := q.MarkUserInviteUsed(c.Request.Context(), sqlcdb.MarkUserInviteUsedParams{
|
||||
ID: invite.ID,
|
||||
UsedBy: uuid.NullUUID{UUID: userUUID, Valid: userUUID != uuid.Nil},
|
||||
}); err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to consume invite")
|
||||
return
|
||||
}
|
||||
|
||||
jwtSecret := c.MustGet("jwt_secret").(string)
|
||||
token, err := generateJWT(user.ID, user.Email, jwtSecret)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to generate token")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, AuthResponse{Token: token, User: user})
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"containr/internal/database"
|
||||
"containr/internal/deployqueue"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GET /operations — a single cross-cutting view of platform work: active
|
||||
// and recent deployments, the per-service deploy queue, recent cron runs,
|
||||
// and recent backups. Scoped to the caller's projects (admins see all).
|
||||
|
||||
type operationDeployment struct {
|
||||
ID string `json:"id"`
|
||||
ServiceID string `json:"service_id"`
|
||||
ServiceName string `json:"service_name"`
|
||||
ProjectName string `json:"project_name"`
|
||||
Status string `json:"status"`
|
||||
Image string `json:"image"`
|
||||
Error *string `json:"error,omitempty"`
|
||||
StartedAt *time.Time `json:"started_at,omitempty"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type operationCronRun struct {
|
||||
ID string `json:"id"`
|
||||
JobName string `json:"job_name"`
|
||||
Schedule string `json:"schedule"`
|
||||
Status string `json:"status"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
FinishedAt *time.Time `json:"finished_at,omitempty"`
|
||||
Error *string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type operationBackup struct {
|
||||
ID string `json:"id"`
|
||||
DatabaseID string `json:"database_id"`
|
||||
DBName string `json:"database_name"`
|
||||
Status string `json:"status"`
|
||||
Size string `json:"size"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Completed *time.Time `json:"completed_at,omitempty"`
|
||||
}
|
||||
|
||||
func handleGetOperations(c *gin.Context) {
|
||||
userID, ok := requireAuthenticatedUserID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
isAdmin := contextIsAdmin(c)
|
||||
|
||||
// Same visibility rule as the deployment list endpoint.
|
||||
visibility := `p.is_approved OR p.owner_id = $1 OR $2::bool
|
||||
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1)`
|
||||
|
||||
active := []operationDeployment{}
|
||||
rows, err := db.Query(
|
||||
`SELECT d.id, d.service_id, s.name, p.name, d.status,
|
||||
COALESCE(d.image_name, ''), d.error, d.started_at, d.completed_at, d.created_at
|
||||
FROM deployments d
|
||||
JOIN services s ON s.id = d.service_id
|
||||
JOIN projects p ON p.id = s.project_id
|
||||
WHERE d.status IN ('queued','pending','building','deploying','rolling_back')
|
||||
AND (`+visibility+`)
|
||||
ORDER BY d.created_at ASC`, userID, isAdmin)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var d operationDeployment
|
||||
var sid string
|
||||
_ = rows.Scan(&d.ID, &sid, &d.ServiceName, &d.ProjectName, &d.Status, &d.Image, &d.Error, &d.StartedAt, &d.CompletedAt, &d.CreatedAt)
|
||||
d.ServiceID = sid
|
||||
active = append(active, d)
|
||||
}
|
||||
}
|
||||
|
||||
recentFailed := []operationDeployment{}
|
||||
rows2, err2 := db.Query(
|
||||
`SELECT d.id, d.service_id, s.name, p.name, d.status,
|
||||
COALESCE(d.image_name, ''), d.error, d.started_at, d.completed_at, d.created_at
|
||||
FROM deployments d
|
||||
JOIN services s ON s.id = d.service_id
|
||||
JOIN projects p ON p.id = s.project_id
|
||||
WHERE d.status IN ('failed','cancelled','rolled_back')
|
||||
AND d.updated_at > NOW() - INTERVAL '24 hours'
|
||||
AND (`+visibility+`)
|
||||
ORDER BY d.updated_at DESC
|
||||
LIMIT 20`, userID, isAdmin)
|
||||
if err2 == nil {
|
||||
defer rows2.Close()
|
||||
for rows2.Next() {
|
||||
var d operationDeployment
|
||||
var sid string
|
||||
_ = rows2.Scan(&d.ID, &sid, &d.ServiceName, &d.ProjectName, &d.Status, &d.Image, &d.Error, &d.StartedAt, &d.CompletedAt, &d.CreatedAt)
|
||||
d.ServiceID = sid
|
||||
recentFailed = append(recentFailed, d)
|
||||
}
|
||||
}
|
||||
|
||||
cronRuns := []operationCronRun{}
|
||||
rows3, err3 := db.Query(
|
||||
`SELECT e.id, j.name, j.schedule, e.status, e.started_at, e.finished_at, e.error
|
||||
FROM cron_executions e
|
||||
JOIN cron_jobs j ON j.id = e.cron_job_id
|
||||
JOIN projects p ON p.id = j.project_id
|
||||
WHERE e.started_at > NOW() - INTERVAL '24 hours'
|
||||
AND (`+visibility+`)
|
||||
ORDER BY e.started_at DESC
|
||||
LIMIT 30`, userID, isAdmin)
|
||||
if err3 == nil {
|
||||
defer rows3.Close()
|
||||
for rows3.Next() {
|
||||
var r operationCronRun
|
||||
_ = rows3.Scan(&r.ID, &r.JobName, &r.Schedule, &r.Status, &r.StartedAt, &r.FinishedAt, &r.Error)
|
||||
cronRuns = append(cronRuns, r)
|
||||
}
|
||||
}
|
||||
|
||||
backups := []operationBackup{}
|
||||
rows4, err4 := db.Query(
|
||||
`SELECT b.id, b.database_id, ds.name, b.status, b.size, b.created_at, b.completed_at
|
||||
FROM database_backups b
|
||||
JOIN database_services ds ON ds.id = b.database_id
|
||||
WHERE b.created_at > NOW() - INTERVAL '24 hours'
|
||||
AND (ds.user_id = $1 OR $2::bool)
|
||||
ORDER BY b.created_at DESC
|
||||
LIMIT 20`, userID, isAdmin)
|
||||
if err4 == nil {
|
||||
defer rows4.Close()
|
||||
for rows4.Next() {
|
||||
var b operationBackup
|
||||
_ = rows4.Scan(&b.ID, &b.DatabaseID, &b.DBName, &b.Status, &b.Size, &b.CreatedAt, &b.Completed)
|
||||
backups = append(backups, b)
|
||||
}
|
||||
}
|
||||
|
||||
var queue []deployqueue.ServiceQueueState
|
||||
if queueValue, exists := c.Get("deploy_queue"); exists && queueValue != nil {
|
||||
queue = queueValue.(*deployqueue.Queue).Snapshot()
|
||||
}
|
||||
if queue == nil {
|
||||
queue = []deployqueue.ServiceQueueState{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"active_deployments": active,
|
||||
"recent_failures": recentFailed,
|
||||
"cron_runs": cronRuns,
|
||||
"backups": backups,
|
||||
"deploy_queue": queue,
|
||||
})
|
||||
}
|
||||
@@ -23,6 +23,11 @@ import (
|
||||
)
|
||||
|
||||
func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg *config.Config) {
|
||||
// Audit + outbound webhook delivery need a package-level handle for
|
||||
// call sites that don't carry a gin context.
|
||||
SetAuditDB(db)
|
||||
StartWebhookDispatcher()
|
||||
|
||||
// Expose Better Auth through backend so frontend can use a single backend origin.
|
||||
setupAuthProxyRoutes(router, cfg, db)
|
||||
|
||||
@@ -253,6 +258,12 @@ 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)
|
||||
|
||||
// Invite accept flow — validates the link, then registers
|
||||
// even while public signup is closed.
|
||||
public.GET("/auth/invites/:token", handleGetInvite)
|
||||
public.POST("/auth/accept-invite", handleAcceptInvite)
|
||||
|
||||
// Wake callback for sleep-mode placeholders. Unauthenticated but
|
||||
// non-destructive: it only reconciles the service back to running.
|
||||
@@ -328,6 +339,20 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
// WebSocket endpoint
|
||||
authed.GET("/ws", handleWebSocket)
|
||||
|
||||
// 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)
|
||||
authed.PATCH("/webhooks/:id", handleUpdateOutboundWebhook)
|
||||
authed.DELETE("/webhooks/:id", handleDeleteOutboundWebhook)
|
||||
authed.GET("/webhooks/:id/deliveries", handleListOutboundWebhookDeliveries)
|
||||
authed.POST("/webhooks/:id/test", handleTestOutboundWebhook)
|
||||
|
||||
// Owned-resource mutations — handlers scope to the caller and
|
||||
// admit admins. New projects land unapproved until an admin
|
||||
// publishes them.
|
||||
@@ -469,6 +494,17 @@ 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)
|
||||
|
||||
// Team invites — single-use registration links
|
||||
admin.GET("/admin/invites", handleAdminListInvites)
|
||||
admin.POST("/admin/invites", handleAdminCreateInvite)
|
||||
admin.DELETE("/admin/invites/:id", handleAdminDeleteInvite)
|
||||
admin.GET("/audit-logs", handleGetAuditLogs)
|
||||
admin.GET("/audit-logs/:resource/:id", handleGetResourceAuditLogs)
|
||||
|
||||
|
||||
@@ -454,6 +454,10 @@ func handleCreateService(c *gin.Context) {
|
||||
}
|
||||
service.Domains = loadServiceDomains(db.(*database.DB), service.ID)
|
||||
|
||||
LogAuditWithRequest(c, "service", service.ID.String(), "create", map[string]interface{}{
|
||||
"name": service.Name, "type": service.Type, "project_id": service.ProjectID,
|
||||
})
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"service": service})
|
||||
}
|
||||
|
||||
@@ -876,5 +880,7 @@ func handleDeleteService(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
LogAuditWithRequest(c, "service", serviceID.String(), "delete", map[string]interface{}{})
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Service deleted successfully"})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,543 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/sqlc-dev/pqtype"
|
||||
)
|
||||
|
||||
// Outbound webhooks: every audited action (LogAudit) emits an event named
|
||||
// "resource.action" (e.g. "service.deploy"). Webhooks subscribe with an
|
||||
// event list supporting "service.*" and "*" wildcards. Deliveries are
|
||||
// HMAC-signed and retried up to 3 times with backoff; each attempt lands
|
||||
// in webhook_deliveries.
|
||||
|
||||
var (
|
||||
webhookQueue = make(chan webhookJob, 512)
|
||||
webhookWorkers sync.Once
|
||||
)
|
||||
|
||||
type webhookJob struct {
|
||||
Webhook sqlcdb.OutboundWebhook
|
||||
Event string
|
||||
Payload []byte
|
||||
}
|
||||
|
||||
type webhookEventPayload struct {
|
||||
Event string `json:"event"`
|
||||
Resource string `json:"resource"`
|
||||
ResourceID string `json:"resource_id"`
|
||||
Action string `json:"action"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
Details map[string]interface{} `json:"details,omitempty"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
// emitWebhookEvent fans an audited action out to all enabled webhooks whose
|
||||
// event list matches. Called from LogAudit — fire-and-forget.
|
||||
func emitWebhookEvent(userID, resource, resourceID, action string, details map[string]interface{}) {
|
||||
db := GetAuditDB()
|
||||
if db == nil {
|
||||
return
|
||||
}
|
||||
// Actions that already carry a resource prefix ("service.clone") pass
|
||||
// through unchanged; bare actions ("create") get resource-qualified.
|
||||
event := action
|
||||
if !strings.Contains(action, ".") {
|
||||
event = resource + "." + action
|
||||
}
|
||||
|
||||
queries := sqlcdb.New(db.DB)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
hooks, err := queries.ListEnabledOutboundWebhooks(ctx)
|
||||
if err != nil || len(hooks) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(webhookEventPayload{
|
||||
Event: event, Resource: resource, ResourceID: resourceID,
|
||||
Action: action, UserID: userID, Details: details, Timestamp: time.Now().UTC(),
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
uid, uidErr := uuid.Parse(strings.TrimSpace(userID))
|
||||
for _, hook := range hooks {
|
||||
if !webhookEventMatches(hook.Events, event) {
|
||||
continue
|
||||
}
|
||||
// Webhooks only receive events for their owner — unless subscribed to "*"
|
||||
// is deliberate admin reach; owner scoping keeps per-user isolation.
|
||||
if uidErr != nil || hook.UserID != uid {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case webhookQueue <- webhookJob{Webhook: hook, Event: event, Payload: payload}:
|
||||
default:
|
||||
// Queue full: drop rather than block the request path.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func webhookEventMatches(events json.RawMessage, event string) bool {
|
||||
var subs []string
|
||||
if err := json.Unmarshal(events, &subs); err != nil || len(subs) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, sub := range subs {
|
||||
sub = strings.TrimSpace(sub)
|
||||
if sub == "*" || sub == event {
|
||||
return true
|
||||
}
|
||||
if strings.HasSuffix(sub, ".*") && strings.HasPrefix(event, strings.TrimSuffix(sub, "*")) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// StartWebhookDispatcher launches delivery workers; call once at boot.
|
||||
func StartWebhookDispatcher() {
|
||||
webhookWorkers.Do(func() {
|
||||
for i := 0; i < 4; i++ {
|
||||
go webhookDeliveryWorker()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func webhookDeliveryWorker() {
|
||||
for job := range webhookQueue {
|
||||
deliverWebhook(job)
|
||||
}
|
||||
}
|
||||
|
||||
var webhookHTTPClient = &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
func deliverWebhook(job webhookJob) {
|
||||
db := GetAuditDB()
|
||||
if db == nil {
|
||||
return
|
||||
}
|
||||
queries := sqlcdb.New(db.DB)
|
||||
ctx := context.Background()
|
||||
|
||||
deliveryID := uuid.New()
|
||||
_ = queries.CreateWebhookDelivery(ctx, sqlcdb.CreateWebhookDeliveryParams{
|
||||
ID: deliveryID,
|
||||
WebhookID: job.Webhook.ID,
|
||||
Event: job.Event,
|
||||
Payload: pqtype.NullRawMessage{RawMessage: job.Payload, Valid: true},
|
||||
Status: "pending",
|
||||
})
|
||||
|
||||
var lastStatus string = "failed"
|
||||
var respStatus sql.NullInt32
|
||||
var respBody sql.NullString
|
||||
var durationMs sql.NullInt32
|
||||
var deliveredAt sql.NullTime
|
||||
|
||||
for attempt := int32(1); attempt <= 3; attempt++ {
|
||||
start := time.Now()
|
||||
status, code, body := postWebhook(job, deliveryID, attempt)
|
||||
durationMs = sql.NullInt32{Int32: int32(time.Since(start).Milliseconds()), Valid: true}
|
||||
respStatus = code
|
||||
respBody = body
|
||||
if status == "success" {
|
||||
lastStatus = "success"
|
||||
deliveredAt = sql.NullTime{Time: time.Now().UTC(), Valid: true}
|
||||
_ = queries.UpdateWebhookDeliveryResult(ctx, sqlcdb.UpdateWebhookDeliveryResultParams{
|
||||
ID: deliveryID,
|
||||
Status: lastStatus,
|
||||
ResponseStatus: respStatus,
|
||||
ResponseBody: respBody,
|
||||
Attempts: attempt,
|
||||
DurationMs: durationMs,
|
||||
DeliveredAt: deliveredAt,
|
||||
})
|
||||
return
|
||||
}
|
||||
if attempt < 3 {
|
||||
time.Sleep(time.Duration(attempt*attempt) * 2 * time.Second)
|
||||
}
|
||||
}
|
||||
_ = queries.UpdateWebhookDeliveryResult(ctx, sqlcdb.UpdateWebhookDeliveryResultParams{
|
||||
ID: deliveryID,
|
||||
Status: lastStatus,
|
||||
ResponseStatus: respStatus,
|
||||
ResponseBody: respBody,
|
||||
Attempts: 3,
|
||||
DurationMs: durationMs,
|
||||
DeliveredAt: deliveredAt,
|
||||
})
|
||||
}
|
||||
|
||||
func postWebhook(job webhookJob, deliveryID uuid.UUID, attempt int32) (string, sql.NullInt32, sql.NullString) {
|
||||
req, err := http.NewRequest(http.MethodPost, job.Webhook.Url, bytes.NewReader(job.Payload))
|
||||
if err != nil {
|
||||
return "failed", sql.NullInt32{}, sql.NullString{String: err.Error(), Valid: true}
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "Containr-Webhook/1.0")
|
||||
req.Header.Set("X-Containr-Event", job.Event)
|
||||
req.Header.Set("X-Containr-Delivery", deliveryID.String())
|
||||
if job.Webhook.Secret != "" {
|
||||
mac := hmac.New(sha256.New, []byte(job.Webhook.Secret))
|
||||
mac.Write(job.Payload)
|
||||
req.Header.Set("X-Containr-Signature", "sha256="+hex.EncodeToString(mac.Sum(nil)))
|
||||
}
|
||||
if job.Webhook.Headers.Valid {
|
||||
var custom map[string]string
|
||||
if err := json.Unmarshal(job.Webhook.Headers.RawMessage, &custom); err == nil {
|
||||
for k, v := range custom {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := webhookHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return "failed", sql.NullInt32{}, sql.NullString{String: err.Error(), Valid: true}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
code := sql.NullInt32{Int32: int32(resp.StatusCode), Valid: true}
|
||||
bodyStr := sql.NullString{String: string(body), Valid: len(body) > 0}
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
return "success", code, bodyStr
|
||||
}
|
||||
return "failed", code, bodyStr
|
||||
}
|
||||
|
||||
// ---------- HTTP API ----------
|
||||
|
||||
type outboundWebhookResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Secret string `json:"secret,omitempty"`
|
||||
Events []string `json:"events"`
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func outboundWebhookJSON(row sqlcdb.OutboundWebhook, revealSecret bool) outboundWebhookResponse {
|
||||
var events []string
|
||||
_ = json.Unmarshal(row.Events, &events)
|
||||
if events == nil {
|
||||
events = []string{}
|
||||
}
|
||||
var headers map[string]string
|
||||
if row.Headers.Valid {
|
||||
_ = json.Unmarshal(row.Headers.RawMessage, &headers)
|
||||
}
|
||||
resp := outboundWebhookResponse{
|
||||
ID: row.ID.String(),
|
||||
Name: row.Name,
|
||||
URL: row.Url,
|
||||
Events: events,
|
||||
Headers: headers,
|
||||
Enabled: row.Enabled,
|
||||
}
|
||||
if revealSecret {
|
||||
resp.Secret = row.Secret
|
||||
}
|
||||
if row.CreatedAt.Valid {
|
||||
resp.CreatedAt = row.CreatedAt.Time
|
||||
}
|
||||
if row.UpdatedAt.Valid {
|
||||
resp.UpdatedAt = row.UpdatedAt.Time
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func validWebhookURL(raw string) error {
|
||||
u, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||
return errors.New("url must be a valid http(s) URL")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type webhookMutationRequest struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Secret *string `json:"secret"`
|
||||
Events []string `json:"events"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func handleListOutboundWebhooks(c *gin.Context) {
|
||||
userID, ok := requireAuthenticatedUserUUID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
rows, err := sqlcdb.New(db.DB).ListOutboundWebhooksByUser(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list webhooks"})
|
||||
return
|
||||
}
|
||||
out := make([]outboundWebhookResponse, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, outboundWebhookJSON(row, false))
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"webhooks": out})
|
||||
}
|
||||
|
||||
func handleCreateOutboundWebhook(c *gin.Context) {
|
||||
userID, ok := requireAuthenticatedUserUUID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req webhookMutationRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if req.Name == "" {
|
||||
respondError(c, http.StatusBadRequest, "VALIDATION", "name is required")
|
||||
return
|
||||
}
|
||||
if err := validWebhookURL(req.URL); err != nil {
|
||||
respondError(c, http.StatusBadRequest, "VALIDATION", err.Error())
|
||||
return
|
||||
}
|
||||
if len(req.Events) == 0 {
|
||||
respondError(c, http.StatusBadRequest, "VALIDATION", "at least one event subscription is required (e.g. \"service.*\" or \"*\")")
|
||||
return
|
||||
}
|
||||
|
||||
secret := ""
|
||||
if req.Secret != nil {
|
||||
secret = strings.TrimSpace(*req.Secret)
|
||||
}
|
||||
if secret == "" {
|
||||
buf := make([]byte, 24)
|
||||
_, _ = rand.Read(buf)
|
||||
secret = hex.EncodeToString(buf)
|
||||
}
|
||||
eventsJSON, _ := json.Marshal(req.Events)
|
||||
var headersJSON pqtype.NullRawMessage
|
||||
if len(req.Headers) > 0 {
|
||||
if raw, err := json.Marshal(req.Headers); err == nil {
|
||||
headersJSON = pqtype.NullRawMessage{RawMessage: raw, Valid: true}
|
||||
}
|
||||
}
|
||||
enabled := true
|
||||
if req.Enabled != nil {
|
||||
enabled = *req.Enabled
|
||||
}
|
||||
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
id := uuid.New()
|
||||
err := sqlcdb.New(db.DB).CreateOutboundWebhook(c.Request.Context(), sqlcdb.CreateOutboundWebhookParams{
|
||||
ID: id,
|
||||
UserID: userID,
|
||||
Name: req.Name,
|
||||
Url: strings.TrimSpace(req.URL),
|
||||
Secret: secret,
|
||||
Events: eventsJSON,
|
||||
Headers: headersJSON,
|
||||
Enabled: enabled,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create webhook"})
|
||||
return
|
||||
}
|
||||
LogAuditWithRequest(c, "webhook", id.String(), "create", map[string]interface{}{"name": req.Name})
|
||||
c.JSON(http.StatusCreated, outboundWebhookJSON(sqlcdb.OutboundWebhook{
|
||||
ID: id, Name: req.Name, Url: strings.TrimSpace(req.URL), Secret: secret,
|
||||
Events: eventsJSON, Headers: headersJSON, Enabled: enabled,
|
||||
CreatedAt: sql.NullTime{Time: time.Now(), Valid: true},
|
||||
UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true},
|
||||
}, true))
|
||||
}
|
||||
|
||||
func handleUpdateOutboundWebhook(c *gin.Context) {
|
||||
userID, ok := requireAuthenticatedUserUUID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
webhookID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid webhook id")
|
||||
return
|
||||
}
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
queries := sqlcdb.New(db.DB)
|
||||
row, err := queries.GetOutboundWebhookByIDAndUser(c.Request.Context(), sqlcdb.GetOutboundWebhookByIDAndUserParams{
|
||||
ID: webhookID, UserID: userID,
|
||||
})
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
respondError(c, http.StatusNotFound, "NOT_FOUND", "Webhook not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load webhook"})
|
||||
return
|
||||
}
|
||||
|
||||
var req webhookMutationRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
name := row.Name
|
||||
if req.Name != "" {
|
||||
name = strings.TrimSpace(req.Name)
|
||||
}
|
||||
targetURL := row.Url
|
||||
if req.URL != "" {
|
||||
if err := validWebhookURL(req.URL); err != nil {
|
||||
respondError(c, http.StatusBadRequest, "VALIDATION", err.Error())
|
||||
return
|
||||
}
|
||||
targetURL = strings.TrimSpace(req.URL)
|
||||
}
|
||||
secret := row.Secret
|
||||
if req.Secret != nil {
|
||||
secret = strings.TrimSpace(*req.Secret)
|
||||
}
|
||||
eventsJSON := row.Events
|
||||
if req.Events != nil {
|
||||
if len(req.Events) == 0 {
|
||||
respondError(c, http.StatusBadRequest, "VALIDATION", "at least one event subscription is required")
|
||||
return
|
||||
}
|
||||
eventsJSON, _ = json.Marshal(req.Events)
|
||||
}
|
||||
headersJSON := row.Headers
|
||||
if req.Headers != nil {
|
||||
if raw, err := json.Marshal(req.Headers); err == nil {
|
||||
headersJSON = pqtype.NullRawMessage{RawMessage: raw, Valid: true}
|
||||
}
|
||||
}
|
||||
enabled := row.Enabled
|
||||
if req.Enabled != nil {
|
||||
enabled = *req.Enabled
|
||||
}
|
||||
|
||||
err = queries.UpdateOutboundWebhook(c.Request.Context(), sqlcdb.UpdateOutboundWebhookParams{
|
||||
ID: webhookID, UserID: userID,
|
||||
Name: name, Url: targetURL, Secret: secret,
|
||||
Events: eventsJSON, Headers: headersJSON, Enabled: enabled,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update webhook"})
|
||||
return
|
||||
}
|
||||
LogAuditWithRequest(c, "webhook", webhookID.String(), "update", map[string]interface{}{"name": name})
|
||||
row.Name, row.Url, row.Secret, row.Events, row.Headers, row.Enabled = name, targetURL, secret, eventsJSON, headersJSON, enabled
|
||||
c.JSON(http.StatusOK, outboundWebhookJSON(row, true))
|
||||
}
|
||||
|
||||
func handleDeleteOutboundWebhook(c *gin.Context) {
|
||||
userID, ok := requireAuthenticatedUserUUID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
webhookID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid webhook id")
|
||||
return
|
||||
}
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
err = sqlcdb.New(db.DB).DeleteOutboundWebhookByIDAndUser(c.Request.Context(), sqlcdb.DeleteOutboundWebhookByIDAndUserParams{
|
||||
ID: webhookID, UserID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete webhook"})
|
||||
return
|
||||
}
|
||||
LogAuditWithRequest(c, "webhook", webhookID.String(), "delete", map[string]interface{}{})
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Webhook deleted"})
|
||||
}
|
||||
|
||||
func handleListOutboundWebhookDeliveries(c *gin.Context) {
|
||||
userID, ok := requireAuthenticatedUserUUID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
webhookID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid webhook id")
|
||||
return
|
||||
}
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
queries := sqlcdb.New(db.DB)
|
||||
if _, err := queries.GetOutboundWebhookByIDAndUser(c.Request.Context(), sqlcdb.GetOutboundWebhookByIDAndUserParams{
|
||||
ID: webhookID, UserID: userID,
|
||||
}); errors.Is(err, sql.ErrNoRows) {
|
||||
respondError(c, http.StatusNotFound, "NOT_FOUND", "Webhook not found")
|
||||
return
|
||||
}
|
||||
rows, err := queries.ListWebhookDeliveriesByWebhook(c.Request.Context(), webhookID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list deliveries"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"deliveries": rows})
|
||||
}
|
||||
|
||||
func handleTestOutboundWebhook(c *gin.Context) {
|
||||
userID, ok := requireAuthenticatedUserUUID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
webhookID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid webhook id")
|
||||
return
|
||||
}
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
row, err := sqlcdb.New(db.DB).GetOutboundWebhookByIDAndUser(c.Request.Context(), sqlcdb.GetOutboundWebhookByIDAndUserParams{
|
||||
ID: webhookID, UserID: userID,
|
||||
})
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
respondError(c, http.StatusNotFound, "NOT_FOUND", "Webhook not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load webhook"})
|
||||
return
|
||||
}
|
||||
if !row.Enabled {
|
||||
respondError(c, http.StatusBadRequest, "VALIDATION", "Webhook is disabled")
|
||||
return
|
||||
}
|
||||
payload, _ := json.Marshal(webhookEventPayload{
|
||||
Event: "ping", Resource: "webhook", ResourceID: webhookID.String(),
|
||||
Action: "ping", UserID: userID.String(), Timestamp: time.Now().UTC(),
|
||||
})
|
||||
select {
|
||||
case webhookQueue <- webhookJob{Webhook: row, Event: "ping", Payload: payload}:
|
||||
c.JSON(http.StatusAccepted, gin.H{"message": "Test delivery queued"})
|
||||
default:
|
||||
respondError(c, http.StatusServiceUnavailable, "UNAVAILABLE", "Delivery queue is full")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWebhookEventMatches(t *testing.T) {
|
||||
subs := func(list ...string) json.RawMessage {
|
||||
raw, _ := json.Marshal(list)
|
||||
return raw
|
||||
}
|
||||
cases := []struct {
|
||||
events json.RawMessage
|
||||
event string
|
||||
want bool
|
||||
}{
|
||||
{subs("service.*"), "service.deploy", true},
|
||||
{subs("service.*"), "database.create", false},
|
||||
{subs("*"), "service.deploy", true},
|
||||
{subs("deployment.fail", "service.*"), "deployment.fail", true},
|
||||
{subs("deployment.fail"), "deployment.success", false},
|
||||
{subs(" ping "), "ping", true},
|
||||
{subs(), "service.deploy", false},
|
||||
{json.RawMessage(`"not-a-list"`), "service.deploy", false},
|
||||
{json.RawMessage(`invalid`), "service.deploy", false},
|
||||
}
|
||||
for i, tc := range cases {
|
||||
if got := webhookEventMatches(tc.events, tc.event); got != tc.want {
|
||||
t.Errorf("case %d: matches(%s, %q) = %v, want %v", i, tc.events, tc.event, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var inviteEmail string
|
||||
var inviteTTL int
|
||||
|
||||
// InvitesCmd manages team invite links (admin).
|
||||
var InvitesCmd = &cobra.Command{
|
||||
Use: "invites",
|
||||
Short: "Manage team invite links (admin)",
|
||||
}
|
||||
|
||||
var inviteListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List invites",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("GET", "/admin/invites", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, err := unwrapList(data, "invites")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows := make([][]string, 0, len(items))
|
||||
for _, i := range items {
|
||||
state := "open"
|
||||
if _, used := i["used_at"]; used && i["used_at"] != nil {
|
||||
state = "used"
|
||||
}
|
||||
rows = append(rows, []string{
|
||||
str(i, "id"), str(i, "email"), str(i, "expires_at"), state,
|
||||
})
|
||||
}
|
||||
printRows(data, []string{"ID", "EMAIL", "EXPIRES", "STATE"}, rows)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var inviteCreateCmd = &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "Create an invite link (--email to bind, --hours for TTL)",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req := map[string]interface{}{}
|
||||
if cmd.Flags().Changed("email") {
|
||||
req["email"] = inviteEmail
|
||||
}
|
||||
if cmd.Flags().Changed("hours") {
|
||||
req["expires_in_hours"] = inviteTTL
|
||||
}
|
||||
data, err := c.Do("POST", "/admin/invites", req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var inviteRevokeCmd = &cobra.Command{
|
||||
Use: "revoke <id>",
|
||||
Short: "Revoke an invite",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if !Confirm("Revoke invite " + 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/invites/"+args[0], nil)
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
inviteCreateCmd.Flags().StringVar(&inviteEmail, "email", "", "bind invite to this email")
|
||||
inviteCreateCmd.Flags().IntVar(&inviteTTL, "hours", 168, "invite lifetime in hours (1-2160)")
|
||||
InvitesCmd.AddCommand(inviteListCmd, inviteCreateCmd, inviteRevokeCmd)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// OperationsCmd shows the cross-cutting operations view.
|
||||
var OperationsCmd = &cobra.Command{
|
||||
Use: "operations",
|
||||
Aliases: []string{"ops"},
|
||||
Short: "Show active and recent platform work (deploys, cron, backups)",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("GET", "/operations", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var webhookEvents []string
|
||||
var webhookHeaders []string
|
||||
var webhookSecret string
|
||||
var webhookName string
|
||||
|
||||
// WebhooksCmd manages outbound webhooks — signed HTTP event delivery.
|
||||
var WebhooksCmd = &cobra.Command{
|
||||
Use: "webhooks",
|
||||
Aliases: []string{"hooks"},
|
||||
Short: "Manage outbound webhooks (signed event delivery)",
|
||||
}
|
||||
|
||||
var webhookListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List webhooks",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("GET", "/webhooks", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, err := unwrapList(data, "webhooks")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows := make([][]string, 0, len(items))
|
||||
for _, w := range items {
|
||||
events := ""
|
||||
if ev, ok := w["events"].([]interface{}); ok {
|
||||
names := make([]string, 0, len(ev))
|
||||
for _, e := range ev {
|
||||
if s, ok := e.(string); ok {
|
||||
names = append(names, s)
|
||||
}
|
||||
}
|
||||
events = strings.Join(names, ",")
|
||||
}
|
||||
rows = append(rows, []string{
|
||||
str(w, "id"), str(w, "name"), str(w, "url"), events, str(w, "enabled"),
|
||||
})
|
||||
}
|
||||
printRows(data, []string{"ID", "NAME", "URL", "EVENTS", "ENABLED"}, rows)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func webhookBody(req map[string]interface{}) (map[string]interface{}, error) {
|
||||
if len(webhookEvents) > 0 {
|
||||
req["events"] = webhookEvents
|
||||
}
|
||||
if webhookSecret != "" {
|
||||
req["secret"] = webhookSecret
|
||||
}
|
||||
if webhookName != "" {
|
||||
req["name"] = webhookName
|
||||
}
|
||||
if len(webhookHeaders) > 0 {
|
||||
hdrs, err := parseKVFlags(webhookHeaders)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req["headers"] = hdrs
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
var webhookCreateCmd = &cobra.Command{
|
||||
Use: "create <name> <url>",
|
||||
Short: "Create a webhook (events repeat with --event; use \"*\" for all)",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if len(webhookEvents) == 0 {
|
||||
return fmt.Errorf("at least one --event is required (e.g. --event 'service.*' or --event '*')")
|
||||
}
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := webhookBody(map[string]interface{}{"name": args[0], "url": args[1]})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("POST", "/webhooks", body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var webhookUpdateCmd = &cobra.Command{
|
||||
Use: "update <id>",
|
||||
Short: "Update a webhook (--name --url --event --secret --header --enabled/--disabled)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := webhookBody(map[string]interface{}{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if url, _ := cmd.Flags().GetString("url"); url != "" {
|
||||
body["url"] = url
|
||||
}
|
||||
if cmd.Flags().Changed("enabled") {
|
||||
body["enabled"] = true
|
||||
}
|
||||
if cmd.Flags().Changed("disabled") {
|
||||
body["enabled"] = false
|
||||
}
|
||||
data, err := c.Do("PATCH", "/webhooks/"+args[0], body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var webhookDeleteCmd = &cobra.Command{
|
||||
Use: "delete <id>",
|
||||
Short: "Delete a webhook",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if !Confirm("Delete webhook " + args[0] + "?") {
|
||||
return nil
|
||||
}
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("DELETE", "/webhooks/"+args[0], nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var webhookDeliveriesCmd = &cobra.Command{
|
||||
Use: "deliveries <id>",
|
||||
Short: "Show recent delivery attempts for a webhook",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("GET", "/webhooks/"+args[0]+"/deliveries", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, err := unwrapList(data, "deliveries")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows := make([][]string, 0, len(items))
|
||||
for _, d := range items {
|
||||
rows = append(rows, []string{
|
||||
str(d, "id"), str(d, "event"), str(d, "status"),
|
||||
str(d, "response_status"), str(d, "attempts"), relTime(d, "created_at"),
|
||||
})
|
||||
}
|
||||
printRows(data, []string{"ID", "EVENT", "STATUS", "HTTP", "TRIES", "AGE"}, rows)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var webhookTestCmd = &cobra.Command{
|
||||
Use: "test <id>",
|
||||
Short: "Queue a test ping delivery",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("POST", "/webhooks/"+args[0]+"/test", map[string]interface{}{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
webhookCreateCmd.Flags().StringArrayVar(&webhookEvents, "event", nil, "Event subscription (e.g. 'service.*', 'deployment.fail', '*'; repeatable)")
|
||||
webhookCreateCmd.Flags().StringVar(&webhookSecret, "secret", "", "Signing secret (auto-generated when omitted)")
|
||||
webhookCreateCmd.Flags().StringArrayVar(&webhookHeaders, "header", nil, "Extra request header KEY=VALUE (repeatable)")
|
||||
webhookUpdateCmd.Flags().StringVar(&webhookName, "name", "", "New name")
|
||||
webhookUpdateCmd.Flags().String("url", "", "New URL")
|
||||
webhookUpdateCmd.Flags().StringArrayVar(&webhookEvents, "event", nil, "Replace event subscriptions (repeatable)")
|
||||
webhookUpdateCmd.Flags().StringVar(&webhookSecret, "secret", "", "New signing secret")
|
||||
webhookUpdateCmd.Flags().StringArrayVar(&webhookHeaders, "header", nil, "Replace request headers KEY=VALUE (repeatable)")
|
||||
webhookUpdateCmd.Flags().Bool("enabled", false, "Enable the webhook")
|
||||
webhookUpdateCmd.Flags().Bool("disabled", false, "Disable the webhook")
|
||||
|
||||
WebhooksCmd.AddCommand(webhookListCmd, webhookCreateCmd, webhookUpdateCmd, webhookDeleteCmd, webhookDeliveriesCmd, webhookTestCmd)
|
||||
}
|
||||
@@ -87,6 +87,10 @@ func init() {
|
||||
rootCmd.AddCommand(commands.GatewayCmd)
|
||||
rootCmd.AddCommand(commands.AdminCmd)
|
||||
rootCmd.AddCommand(commands.NotificationsCmd)
|
||||
rootCmd.AddCommand(commands.WebhooksCmd)
|
||||
rootCmd.AddCommand(commands.OperationsCmd)
|
||||
rootCmd.AddCommand(commands.BannersCmd)
|
||||
rootCmd.AddCommand(commands.InvitesCmd)
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: invites.sql
|
||||
|
||||
package sqlcdb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const createUserInvite = `-- name: CreateUserInvite :one
|
||||
INSERT INTO user_invites (token_hash, email, created_by, expires_at)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING user_invites.id, user_invites.token_hash, user_invites.email, user_invites.created_by, user_invites.expires_at, user_invites.used_by, user_invites.used_at, user_invites.created_at
|
||||
`
|
||||
|
||||
type CreateUserInviteParams struct {
|
||||
TokenHash string `json:"token_hash"`
|
||||
Email sql.NullString `json:"email"`
|
||||
CreatedBy uuid.NullUUID `json:"created_by"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateUserInvite(ctx context.Context, arg CreateUserInviteParams) (UserInvite, error) {
|
||||
row := q.db.QueryRowContext(ctx, createUserInvite,
|
||||
arg.TokenHash,
|
||||
arg.Email,
|
||||
arg.CreatedBy,
|
||||
arg.ExpiresAt,
|
||||
)
|
||||
var i UserInvite
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.TokenHash,
|
||||
&i.Email,
|
||||
&i.CreatedBy,
|
||||
&i.ExpiresAt,
|
||||
&i.UsedBy,
|
||||
&i.UsedAt,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const deleteUserInvite = `-- name: DeleteUserInvite :exec
|
||||
DELETE FROM user_invites
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteUserInvite(ctx context.Context, id uuid.UUID) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteUserInvite, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const getUserInviteByTokenHash = `-- name: GetUserInviteByTokenHash :one
|
||||
SELECT user_invites.id, user_invites.token_hash, user_invites.email, user_invites.created_by, user_invites.expires_at, user_invites.used_by, user_invites.used_at, user_invites.created_at
|
||||
FROM user_invites
|
||||
WHERE token_hash = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserInviteByTokenHash(ctx context.Context, tokenHash string) (UserInvite, error) {
|
||||
row := q.db.QueryRowContext(ctx, getUserInviteByTokenHash, tokenHash)
|
||||
var i UserInvite
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.TokenHash,
|
||||
&i.Email,
|
||||
&i.CreatedBy,
|
||||
&i.ExpiresAt,
|
||||
&i.UsedBy,
|
||||
&i.UsedAt,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listUserInvites = `-- name: ListUserInvites :many
|
||||
SELECT user_invites.id, user_invites.token_hash, user_invites.email, user_invites.created_by, user_invites.expires_at, user_invites.used_by, user_invites.used_at, user_invites.created_at
|
||||
FROM user_invites
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
func (q *Queries) ListUserInvites(ctx context.Context) ([]UserInvite, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listUserInvites)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []UserInvite{}
|
||||
for rows.Next() {
|
||||
var i UserInvite
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.TokenHash,
|
||||
&i.Email,
|
||||
&i.CreatedBy,
|
||||
&i.ExpiresAt,
|
||||
&i.UsedBy,
|
||||
&i.UsedAt,
|
||||
&i.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const markUserInviteUsed = `-- name: MarkUserInviteUsed :exec
|
||||
UPDATE user_invites
|
||||
SET used_by = $2, used_at = now()
|
||||
WHERE id = $1 AND used_at IS NULL
|
||||
`
|
||||
|
||||
type MarkUserInviteUsedParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
UsedBy uuid.NullUUID `json:"used_by"`
|
||||
}
|
||||
|
||||
func (q *Queries) MarkUserInviteUsed(ctx context.Context, arg MarkUserInviteUsedParams) error {
|
||||
_, err := q.db.ExecContext(ctx, markUserInviteUsed, arg.ID, arg.UsedBy)
|
||||
return 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"`
|
||||
@@ -175,6 +189,19 @@ type Notification struct {
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
}
|
||||
|
||||
type OutboundWebhook struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
Url string `json:"url"`
|
||||
Secret string `json:"secret"`
|
||||
Events json.RawMessage `json:"events"`
|
||||
Headers pqtype.NullRawMessage `json:"headers"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
}
|
||||
|
||||
type Project struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -274,6 +301,17 @@ type ServiceTemplate struct {
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
}
|
||||
|
||||
type UserInvite struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
TokenHash string `json:"token_hash"`
|
||||
Email sql.NullString `json:"email"`
|
||||
CreatedBy uuid.NullUUID `json:"created_by"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
UsedBy uuid.NullUUID `json:"used_by"`
|
||||
UsedAt sql.NullTime `json:"used_at"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
}
|
||||
|
||||
type UserToken struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
@@ -286,3 +324,17 @@ type UserToken struct {
|
||||
RevokedAt sql.NullTime `json:"revoked_at"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
}
|
||||
|
||||
type WebhookDelivery struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
WebhookID uuid.UUID `json:"webhook_id"`
|
||||
Event string `json:"event"`
|
||||
Payload pqtype.NullRawMessage `json:"payload"`
|
||||
Status string `json:"status"`
|
||||
ResponseStatus sql.NullInt32 `json:"response_status"`
|
||||
ResponseBody sql.NullString `json:"response_body"`
|
||||
Attempts int32 `json:"attempts"`
|
||||
DurationMs sql.NullInt32 `json:"duration_ms"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
DeliveredAt sql.NullTime `json:"delivered_at"`
|
||||
}
|
||||
@@ -21,28 +21,36 @@ 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
|
||||
CreateDatabaseService(ctx context.Context, arg CreateDatabaseServiceParams) error
|
||||
CreateExternalDatabaseService(ctx context.Context, arg CreateExternalDatabaseServiceParams) error
|
||||
CreateNotification(ctx context.Context, arg CreateNotificationParams) error
|
||||
CreateOutboundWebhook(ctx context.Context, arg CreateOutboundWebhookParams) error
|
||||
CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error)
|
||||
CreateServiceFromTemplate(ctx context.Context, arg CreateServiceFromTemplateParams) error
|
||||
CreateUserInvite(ctx context.Context, arg CreateUserInviteParams) (UserInvite, error)
|
||||
CreateUserTemplate(ctx context.Context, arg CreateUserTemplateParams) error
|
||||
CreateUserToken(ctx context.Context, arg CreateUserTokenParams) (UserToken, error)
|
||||
CreateWebhookDelivery(ctx context.Context, arg CreateWebhookDeliveryParams) error
|
||||
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
|
||||
DeleteProjectByID(ctx context.Context, projectID uuid.UUID) (int64, error)
|
||||
DeleteUserInvite(ctx context.Context, id uuid.UUID) error
|
||||
DeleteUserTemplate(ctx context.Context, arg DeleteUserTemplateParams) (int64, error)
|
||||
GetActiveAgentAuthTokenByHash(ctx context.Context, tokenHash string) (AgentAuthToken, error)
|
||||
GetAgent(ctx context.Context, id string) (NodeAgent, error)
|
||||
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)
|
||||
@@ -50,26 +58,33 @@ type Querier interface {
|
||||
GetDatabaseServiceByID(ctx context.Context, id string) (DatabaseService, error)
|
||||
GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetDatabaseServiceByIDAndUserParams) (DatabaseService, error)
|
||||
GetDatabaseServiceOwnerID(ctx context.Context, id string) (string, error)
|
||||
GetOutboundWebhookByID(ctx context.Context, id uuid.UUID) (OutboundWebhook, error)
|
||||
GetOutboundWebhookByIDAndUser(ctx context.Context, arg GetOutboundWebhookByIDAndUserParams) (OutboundWebhook, error)
|
||||
GetProjectByIDForUser(ctx context.Context, arg GetProjectByIDForUserParams) (Project, error)
|
||||
GetProjectOwnerByID(ctx context.Context, projectID uuid.UUID) (uuid.UUID, error)
|
||||
GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
|
||||
GetProjectRoleForUser(ctx context.Context, arg GetProjectRoleForUserParams) (string, error)
|
||||
GetServiceTemplateByID(ctx context.Context, id string) (ServiceTemplate, error)
|
||||
GetUserInviteByTokenHash(ctx context.Context, tokenHash string) (UserInvite, error)
|
||||
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)
|
||||
ListDatabaseServicesByProject(ctx context.Context, projectID uuid.NullUUID) ([]DatabaseService, error)
|
||||
ListDatabaseServicesByUser(ctx context.Context, userID string) ([]DatabaseService, error)
|
||||
ListDueDatabaseBackups(ctx context.Context) ([]ListDueDatabaseBackupsRow, error)
|
||||
ListEnabledOutboundWebhooks(ctx context.Context) ([]OutboundWebhook, error)
|
||||
ListNotificationsByUser(ctx context.Context, arg ListNotificationsByUserParams) ([]Notification, error)
|
||||
ListOutboundWebhooksByUser(ctx context.Context, userID uuid.UUID) ([]OutboundWebhook, error)
|
||||
ListPendingCommands(ctx context.Context, nodeAgentID string) ([]AgentCommand, error)
|
||||
ListPendingProjects(ctx context.Context) ([]Project, error)
|
||||
// Visibility model: a project is readable when it is approved for public
|
||||
@@ -78,9 +93,12 @@ type Querier interface {
|
||||
ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error)
|
||||
ListServiceTemplatesByCategoryForUser(ctx context.Context, arg ListServiceTemplatesByCategoryForUserParams) ([]ServiceTemplate, error)
|
||||
ListServiceTemplatesForUser(ctx context.Context, ownerID uuid.NullUUID) ([]ServiceTemplate, error)
|
||||
ListUserInvites(ctx context.Context) ([]UserInvite, error)
|
||||
ListUserTokens(ctx context.Context, userID uuid.UUID) ([]UserToken, error)
|
||||
ListWebhookDeliveriesByWebhook(ctx context.Context, webhookID uuid.UUID) ([]WebhookDelivery, error)
|
||||
MarkAllNotificationsReadByUser(ctx context.Context, arg MarkAllNotificationsReadByUserParams) error
|
||||
MarkNotificationReadByIDAndUser(ctx context.Context, arg MarkNotificationReadByIDAndUserParams) error
|
||||
MarkUserInviteUsed(ctx context.Context, arg MarkUserInviteUsedParams) error
|
||||
RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error)
|
||||
RevokeUserToken(ctx context.Context, arg RevokeUserTokenParams) (int64, error)
|
||||
SetDatabaseBackupRemoteKeyByID(ctx context.Context, arg SetDatabaseBackupRemoteKeyByIDParams) error
|
||||
@@ -98,12 +116,15 @@ 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
|
||||
UpdateDatabaseServicePlanByIDAndUser(ctx context.Context, arg UpdateDatabaseServicePlanByIDAndUserParams) error
|
||||
UpdateOutboundWebhook(ctx context.Context, arg UpdateOutboundWebhookParams) error
|
||||
UpdateProjectByID(ctx context.Context, arg UpdateProjectByIDParams) (int64, error)
|
||||
UpdateUserTemplate(ctx context.Context, arg UpdateUserTemplateParams) (int64, error)
|
||||
UpdateWebhookDeliveryResult(ctx context.Context, arg UpdateWebhookDeliveryResultParams) error
|
||||
UpsertEnvironmentVariable(ctx context.Context, arg UpsertEnvironmentVariableParams) error
|
||||
UpsertServiceTemplate(ctx context.Context, arg UpsertServiceTemplateParams) error
|
||||
}
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: webhooks.sql
|
||||
|
||||
package sqlcdb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/sqlc-dev/pqtype"
|
||||
)
|
||||
|
||||
const createOutboundWebhook = `-- name: CreateOutboundWebhook :exec
|
||||
INSERT INTO outbound_webhooks (id, user_id, name, url, secret, events, headers, enabled, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
`
|
||||
|
||||
type CreateOutboundWebhookParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
Url string `json:"url"`
|
||||
Secret string `json:"secret"`
|
||||
Events json.RawMessage `json:"events"`
|
||||
Headers pqtype.NullRawMessage `json:"headers"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateOutboundWebhook(ctx context.Context, arg CreateOutboundWebhookParams) error {
|
||||
_, err := q.db.ExecContext(ctx, createOutboundWebhook,
|
||||
arg.ID,
|
||||
arg.UserID,
|
||||
arg.Name,
|
||||
arg.Url,
|
||||
arg.Secret,
|
||||
arg.Events,
|
||||
arg.Headers,
|
||||
arg.Enabled,
|
||||
arg.CreatedAt,
|
||||
arg.UpdatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const createWebhookDelivery = `-- name: CreateWebhookDelivery :exec
|
||||
INSERT INTO webhook_deliveries (id, webhook_id, event, payload, status, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
`
|
||||
|
||||
type CreateWebhookDeliveryParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
WebhookID uuid.UUID `json:"webhook_id"`
|
||||
Event string `json:"event"`
|
||||
Payload pqtype.NullRawMessage `json:"payload"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateWebhookDelivery(ctx context.Context, arg CreateWebhookDeliveryParams) error {
|
||||
_, err := q.db.ExecContext(ctx, createWebhookDelivery,
|
||||
arg.ID,
|
||||
arg.WebhookID,
|
||||
arg.Event,
|
||||
arg.Payload,
|
||||
arg.Status,
|
||||
arg.CreatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteOutboundWebhookByIDAndUser = `-- name: DeleteOutboundWebhookByIDAndUser :exec
|
||||
DELETE FROM outbound_webhooks
|
||||
WHERE id = $1 AND user_id = $2
|
||||
`
|
||||
|
||||
type DeleteOutboundWebhookByIDAndUserParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) DeleteOutboundWebhookByIDAndUser(ctx context.Context, arg DeleteOutboundWebhookByIDAndUserParams) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteOutboundWebhookByIDAndUser, arg.ID, arg.UserID)
|
||||
return err
|
||||
}
|
||||
|
||||
const getOutboundWebhookByID = `-- name: GetOutboundWebhookByID :one
|
||||
SELECT outbound_webhooks.id, outbound_webhooks.user_id, outbound_webhooks.name, outbound_webhooks.url, outbound_webhooks.secret, outbound_webhooks.events, outbound_webhooks.headers, outbound_webhooks.enabled, outbound_webhooks.created_at, outbound_webhooks.updated_at
|
||||
FROM outbound_webhooks
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetOutboundWebhookByID(ctx context.Context, id uuid.UUID) (OutboundWebhook, error) {
|
||||
row := q.db.QueryRowContext(ctx, getOutboundWebhookByID, id)
|
||||
var i OutboundWebhook
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.Name,
|
||||
&i.Url,
|
||||
&i.Secret,
|
||||
&i.Events,
|
||||
&i.Headers,
|
||||
&i.Enabled,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getOutboundWebhookByIDAndUser = `-- name: GetOutboundWebhookByIDAndUser :one
|
||||
SELECT outbound_webhooks.id, outbound_webhooks.user_id, outbound_webhooks.name, outbound_webhooks.url, outbound_webhooks.secret, outbound_webhooks.events, outbound_webhooks.headers, outbound_webhooks.enabled, outbound_webhooks.created_at, outbound_webhooks.updated_at
|
||||
FROM outbound_webhooks
|
||||
WHERE id = $1 AND user_id = $2
|
||||
`
|
||||
|
||||
type GetOutboundWebhookByIDAndUserParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetOutboundWebhookByIDAndUser(ctx context.Context, arg GetOutboundWebhookByIDAndUserParams) (OutboundWebhook, error) {
|
||||
row := q.db.QueryRowContext(ctx, getOutboundWebhookByIDAndUser, arg.ID, arg.UserID)
|
||||
var i OutboundWebhook
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.Name,
|
||||
&i.Url,
|
||||
&i.Secret,
|
||||
&i.Events,
|
||||
&i.Headers,
|
||||
&i.Enabled,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listEnabledOutboundWebhooks = `-- name: ListEnabledOutboundWebhooks :many
|
||||
SELECT outbound_webhooks.id, outbound_webhooks.user_id, outbound_webhooks.name, outbound_webhooks.url, outbound_webhooks.secret, outbound_webhooks.events, outbound_webhooks.headers, outbound_webhooks.enabled, outbound_webhooks.created_at, outbound_webhooks.updated_at
|
||||
FROM outbound_webhooks
|
||||
WHERE enabled
|
||||
`
|
||||
|
||||
func (q *Queries) ListEnabledOutboundWebhooks(ctx context.Context) ([]OutboundWebhook, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listEnabledOutboundWebhooks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []OutboundWebhook{}
|
||||
for rows.Next() {
|
||||
var i OutboundWebhook
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.Name,
|
||||
&i.Url,
|
||||
&i.Secret,
|
||||
&i.Events,
|
||||
&i.Headers,
|
||||
&i.Enabled,
|
||||
&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 listOutboundWebhooksByUser = `-- name: ListOutboundWebhooksByUser :many
|
||||
SELECT outbound_webhooks.id, outbound_webhooks.user_id, outbound_webhooks.name, outbound_webhooks.url, outbound_webhooks.secret, outbound_webhooks.events, outbound_webhooks.headers, outbound_webhooks.enabled, outbound_webhooks.created_at, outbound_webhooks.updated_at
|
||||
FROM outbound_webhooks
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
func (q *Queries) ListOutboundWebhooksByUser(ctx context.Context, userID uuid.UUID) ([]OutboundWebhook, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listOutboundWebhooksByUser, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []OutboundWebhook{}
|
||||
for rows.Next() {
|
||||
var i OutboundWebhook
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.Name,
|
||||
&i.Url,
|
||||
&i.Secret,
|
||||
&i.Events,
|
||||
&i.Headers,
|
||||
&i.Enabled,
|
||||
&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 listWebhookDeliveriesByWebhook = `-- name: ListWebhookDeliveriesByWebhook :many
|
||||
SELECT webhook_deliveries.id, webhook_deliveries.webhook_id, webhook_deliveries.event, webhook_deliveries.payload, webhook_deliveries.status, webhook_deliveries.response_status, webhook_deliveries.response_body, webhook_deliveries.attempts, webhook_deliveries.duration_ms, webhook_deliveries.created_at, webhook_deliveries.delivered_at
|
||||
FROM webhook_deliveries
|
||||
WHERE webhook_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 100
|
||||
`
|
||||
|
||||
func (q *Queries) ListWebhookDeliveriesByWebhook(ctx context.Context, webhookID uuid.UUID) ([]WebhookDelivery, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listWebhookDeliveriesByWebhook, webhookID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []WebhookDelivery{}
|
||||
for rows.Next() {
|
||||
var i WebhookDelivery
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.WebhookID,
|
||||
&i.Event,
|
||||
&i.Payload,
|
||||
&i.Status,
|
||||
&i.ResponseStatus,
|
||||
&i.ResponseBody,
|
||||
&i.Attempts,
|
||||
&i.DurationMs,
|
||||
&i.CreatedAt,
|
||||
&i.DeliveredAt,
|
||||
); 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 updateOutboundWebhook = `-- name: UpdateOutboundWebhook :exec
|
||||
UPDATE outbound_webhooks
|
||||
SET name = $1, url = $2, secret = $3, events = $4, headers = $5, enabled = $6, updated_at = $7
|
||||
WHERE id = $8 AND user_id = $9
|
||||
`
|
||||
|
||||
type UpdateOutboundWebhookParams struct {
|
||||
Name string `json:"name"`
|
||||
Url string `json:"url"`
|
||||
Secret string `json:"secret"`
|
||||
Events json.RawMessage `json:"events"`
|
||||
Headers pqtype.NullRawMessage `json:"headers"`
|
||||
Enabled bool `json:"enabled"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateOutboundWebhook(ctx context.Context, arg UpdateOutboundWebhookParams) error {
|
||||
_, err := q.db.ExecContext(ctx, updateOutboundWebhook,
|
||||
arg.Name,
|
||||
arg.Url,
|
||||
arg.Secret,
|
||||
arg.Events,
|
||||
arg.Headers,
|
||||
arg.Enabled,
|
||||
arg.UpdatedAt,
|
||||
arg.ID,
|
||||
arg.UserID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateWebhookDeliveryResult = `-- name: UpdateWebhookDeliveryResult :exec
|
||||
UPDATE webhook_deliveries
|
||||
SET status = $1, response_status = $2, response_body = $3, attempts = $4, duration_ms = $5, delivered_at = $6
|
||||
WHERE id = $7
|
||||
`
|
||||
|
||||
type UpdateWebhookDeliveryResultParams struct {
|
||||
Status string `json:"status"`
|
||||
ResponseStatus sql.NullInt32 `json:"response_status"`
|
||||
ResponseBody sql.NullString `json:"response_body"`
|
||||
Attempts int32 `json:"attempts"`
|
||||
DurationMs sql.NullInt32 `json:"duration_ms"`
|
||||
DeliveredAt sql.NullTime `json:"delivered_at"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateWebhookDeliveryResult(ctx context.Context, arg UpdateWebhookDeliveryResultParams) error {
|
||||
_, err := q.db.ExecContext(ctx, updateWebhookDeliveryResult,
|
||||
arg.Status,
|
||||
arg.ResponseStatus,
|
||||
arg.ResponseBody,
|
||||
arg.Attempts,
|
||||
arg.DurationMs,
|
||||
arg.DeliveredAt,
|
||||
arg.ID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -129,6 +129,27 @@ func (q *Queue) Cancel(serviceID, deploymentID uuid.UUID) CancelState {
|
||||
return NotFound
|
||||
}
|
||||
|
||||
// ServiceQueueState reports queue depth for one service.
|
||||
type ServiceQueueState struct {
|
||||
ServiceID uuid.UUID `json:"service_id"`
|
||||
Running bool `json:"running"`
|
||||
Queued int `json:"queued"`
|
||||
}
|
||||
|
||||
// Snapshot returns per-service queue depth for observability.
|
||||
func (q *Queue) Snapshot() []ServiceQueueState {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
out := make([]ServiceQueueState, 0, len(q.states))
|
||||
for id, st := range q.states {
|
||||
if !st.running && len(st.pending) == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, ServiceQueueState{ServiceID: id, Running: st.running, Queued: len(st.pending)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Do runs fn through the queue and waits for completion — for synchronous
|
||||
// endpoints (redeploy) that still must not overlap an in-flight deployment.
|
||||
// ctx bounds the wait, not the job; on timeout the job still runs to
|
||||
|
||||
@@ -125,6 +125,40 @@ func TestCancelActiveCancelsContext(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshot(t *testing.T) {
|
||||
q := New()
|
||||
if got := q.Snapshot(); len(got) != 0 {
|
||||
t.Fatalf("empty queue snapshot = %v, want empty", got)
|
||||
}
|
||||
|
||||
svc := uuid.New()
|
||||
release := make(chan struct{})
|
||||
q.Enqueue(svc, Job{Run: func(ctx context.Context) { <-release }})
|
||||
q.Enqueue(svc, Job{Run: func(ctx context.Context) {}})
|
||||
q.Enqueue(svc, Job{Run: func(ctx context.Context) {}})
|
||||
|
||||
deadline := time.After(2 * time.Second)
|
||||
for {
|
||||
snap := q.Snapshot()
|
||||
if len(snap) == 1 && snap[0].Running {
|
||||
if snap[0].ServiceID != svc {
|
||||
t.Fatalf("snapshot service = %v, want %v", snap[0].ServiceID, svc)
|
||||
}
|
||||
if snap[0].Queued != 2 {
|
||||
t.Fatalf("queued = %d, want 2", snap[0].Queued)
|
||||
}
|
||||
close(release)
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-deadline:
|
||||
t.Fatalf("snapshot never showed running job: %v", snap)
|
||||
default:
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnqueuePosition(t *testing.T) {
|
||||
q := New()
|
||||
svc := uuid.New()
|
||||
|
||||
@@ -140,6 +140,28 @@ func init() {
|
||||
register(spec{"containr_notifications_list", "List notifications", "GET", "/notifications", nil, nil, false})
|
||||
register(spec{"containr_notifications_read", "Mark a notification read, or all notifications with id=all", "POST", "/notifications/{id}/read", []string{"id"}, nil, false})
|
||||
|
||||
register(spec{"containr_webhooks_list", "List outbound webhooks", "GET", "/webhooks", nil, nil, false})
|
||||
register(spec{"containr_webhooks_create", "Create an outbound webhook. Args: name, url, events (list, e.g. [\"service.*\",\"*\"]), secret (auto-generated if omitted), headers, enabled.", "POST", "/webhooks", nil, []string{"name", "url", "events", "secret", "headers", "enabled"}, false})
|
||||
register(spec{"containr_webhooks_update", "Update an outbound webhook. Args: id, plus name/url/secret/events/headers/enabled to change.", "PATCH", "/webhooks/{id}", []string{"id"}, []string{"name", "url", "secret", "events", "headers", "enabled"}, false})
|
||||
register(spec{"containr_webhooks_delete", "Delete an outbound webhook. Args: id. Requires confirm=true.", "DELETE", "/webhooks/{id}", []string{"id"}, nil, true})
|
||||
register(spec{"containr_webhooks_deliveries", "List recent delivery attempts for a webhook. Args: id.", "GET", "/webhooks/{id}/deliveries", []string{"id"}, nil, false})
|
||||
register(spec{"containr_webhooks_test", "Queue a test ping delivery for a webhook. Args: id.", "POST", "/webhooks/{id}/test", []string{"id"}, nil, false})
|
||||
|
||||
register(spec{"containr_operations_get", "Cross-cutting operations view: active deployments, deploy queue depth, recent failures, cron runs, backups.", "GET", "/operations", nil, nil, false})
|
||||
|
||||
register(spec{"containr_banners_active", "List active announcement banners shown to users.", "GET", "/banners/active", nil, nil, false})
|
||||
register(spec{"containr_banners_list", "List all announcement banners (admin).", "GET", "/admin/banners", nil, nil, false})
|
||||
register(spec{"containr_banners_create", "Create an announcement banner (admin). Args: title, body, level (info|success|warning|error), active, dismissible, starts_at, ends_at.", "POST", "/admin/banners", nil, []string{"title", "body", "level", "active", "dismissible", "starts_at", "ends_at"}, false})
|
||||
register(spec{"containr_banners_update", "Update an announcement banner (admin). Args: id plus fields to change.", "PATCH", "/admin/banners/{id}", []string{"id"}, []string{"title", "body", "level", "active", "dismissible", "starts_at", "ends_at"}, false})
|
||||
register(spec{"containr_banners_delete", "Delete an announcement banner (admin). Args: id. Requires confirm=true.", "DELETE", "/admin/banners/{id}", []string{"id"}, nil, true})
|
||||
|
||||
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})
|
||||
|
||||
register(spec{"containr_invites_list", "List team invite links (admin).", "GET", "/admin/invites", nil, nil, false})
|
||||
register(spec{"containr_invites_create", "Create a team invite link (admin). Args: email (optional bind), expires_in_hours (default 168). Returns the one-time token + accept URL.", "POST", "/admin/invites", nil, []string{"email", "expires_in_hours"}, false})
|
||||
register(spec{"containr_invites_revoke", "Revoke a team invite (admin). Args: id. Requires confirm=true.", "DELETE", "/admin/invites/{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})
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
-- +goose Up
|
||||
-- Outbound webhooks: user-owned HTTP endpoints that receive signed
|
||||
-- platform events (resource.action), with a delivery log.
|
||||
CREATE TABLE IF NOT EXISTS outbound_webhooks (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name varchar(255) NOT NULL,
|
||||
url varchar(500) NOT NULL,
|
||||
secret text NOT NULL DEFAULT '',
|
||||
events jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
headers jsonb,
|
||||
enabled boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz DEFAULT now(),
|
||||
updated_at timestamptz DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS webhook_deliveries (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
webhook_id uuid NOT NULL REFERENCES outbound_webhooks(id) ON DELETE CASCADE,
|
||||
event varchar(255) NOT NULL,
|
||||
payload jsonb,
|
||||
status varchar(50) NOT NULL DEFAULT 'pending',
|
||||
response_status integer,
|
||||
response_body text,
|
||||
attempts integer NOT NULL DEFAULT 0,
|
||||
duration_ms integer,
|
||||
created_at timestamptz DEFAULT now(),
|
||||
delivered_at timestamptz
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_outbound_webhooks_user ON outbound_webhooks(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_webhook ON webhook_deliveries(webhook_id, created_at DESC);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS webhook_deliveries;
|
||||
DROP TABLE IF EXISTS outbound_webhooks;
|
||||
@@ -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,19 @@
|
||||
-- +goose Up
|
||||
-- Team invites: admin-generated single-use links that let a new user
|
||||
-- register even while public signup is closed. Tokens are sha256-hashed
|
||||
-- at rest like PATs.
|
||||
CREATE TABLE IF NOT EXISTS user_invites (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
token_hash varchar(64) NOT NULL UNIQUE,
|
||||
email varchar(255),
|
||||
created_by uuid REFERENCES users(id) ON DELETE SET NULL,
|
||||
expires_at timestamptz NOT NULL,
|
||||
used_by uuid REFERENCES users(id) ON DELETE SET NULL,
|
||||
used_at timestamptz,
|
||||
created_at timestamptz DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_invites_expires ON user_invites(expires_at);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS user_invites;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,23 @@
|
||||
-- name: CreateUserInvite :one
|
||||
INSERT INTO user_invites (token_hash, email, created_by, expires_at)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING user_invites.*;
|
||||
|
||||
-- name: GetUserInviteByTokenHash :one
|
||||
SELECT user_invites.*
|
||||
FROM user_invites
|
||||
WHERE token_hash = $1;
|
||||
|
||||
-- name: ListUserInvites :many
|
||||
SELECT user_invites.*
|
||||
FROM user_invites
|
||||
ORDER BY created_at DESC;
|
||||
|
||||
-- name: MarkUserInviteUsed :exec
|
||||
UPDATE user_invites
|
||||
SET used_by = $2, used_at = now()
|
||||
WHERE id = $1 AND used_at IS NULL;
|
||||
|
||||
-- name: DeleteUserInvite :exec
|
||||
DELETE FROM user_invites
|
||||
WHERE id = $1;
|
||||
@@ -0,0 +1,49 @@
|
||||
-- name: ListOutboundWebhooksByUser :many
|
||||
SELECT outbound_webhooks.*
|
||||
FROM outbound_webhooks
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC;
|
||||
|
||||
-- name: GetOutboundWebhookByIDAndUser :one
|
||||
SELECT outbound_webhooks.*
|
||||
FROM outbound_webhooks
|
||||
WHERE id = $1 AND user_id = $2;
|
||||
|
||||
-- name: ListEnabledOutboundWebhooks :many
|
||||
SELECT outbound_webhooks.*
|
||||
FROM outbound_webhooks
|
||||
WHERE enabled;
|
||||
|
||||
-- name: CreateOutboundWebhook :exec
|
||||
INSERT INTO outbound_webhooks (id, user_id, name, url, secret, events, headers, enabled, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10);
|
||||
|
||||
-- name: UpdateOutboundWebhook :exec
|
||||
UPDATE outbound_webhooks
|
||||
SET name = $1, url = $2, secret = $3, events = $4, headers = $5, enabled = $6, updated_at = $7
|
||||
WHERE id = $8 AND user_id = $9;
|
||||
|
||||
-- name: DeleteOutboundWebhookByIDAndUser :exec
|
||||
DELETE FROM outbound_webhooks
|
||||
WHERE id = $1 AND user_id = $2;
|
||||
|
||||
-- name: CreateWebhookDelivery :exec
|
||||
INSERT INTO webhook_deliveries (id, webhook_id, event, payload, status, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6);
|
||||
|
||||
-- name: UpdateWebhookDeliveryResult :exec
|
||||
UPDATE webhook_deliveries
|
||||
SET status = $1, response_status = $2, response_body = $3, attempts = $4, duration_ms = $5, delivered_at = $6
|
||||
WHERE id = $7;
|
||||
|
||||
-- name: ListWebhookDeliveriesByWebhook :many
|
||||
SELECT webhook_deliveries.*
|
||||
FROM webhook_deliveries
|
||||
WHERE webhook_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 100;
|
||||
|
||||
-- name: GetOutboundWebhookByID :one
|
||||
SELECT outbound_webhooks.*
|
||||
FROM outbound_webhooks
|
||||
WHERE id = $1;
|
||||
@@ -275,3 +275,55 @@ CREATE TABLE user_tokens (
|
||||
revoked_at TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE outbound_webhooks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
url VARCHAR(500) NOT NULL,
|
||||
secret TEXT NOT NULL DEFAULT '',
|
||||
events JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
headers JSONB,
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE webhook_deliveries (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
webhook_id UUID NOT NULL REFERENCES outbound_webhooks(id) ON DELETE CASCADE,
|
||||
event VARCHAR(255) NOT NULL,
|
||||
payload JSONB,
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'pending',
|
||||
response_status INTEGER,
|
||||
response_body TEXT,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
duration_ms INTEGER,
|
||||
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()
|
||||
);
|
||||
|
||||
CREATE TABLE user_invites (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
token_hash VARCHAR(64) NOT NULL UNIQUE,
|
||||
email VARCHAR(255),
|
||||
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
used_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
used_at TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
@@ -9,8 +9,11 @@ import { ServiceMetricsDashboard } from '@/features/service/pages/ServiceMetrics
|
||||
import { BuildsPage } from '@/features/builds/pages/BuildsPage';
|
||||
import { TemplatesPage } from '@/features/templates/pages/TemplatesPage';
|
||||
import { AuditLogsPage } from '@/features/audit/pages/AuditLogsPage';
|
||||
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,
|
||||
@@ -21,7 +24,7 @@ import {
|
||||
SecurityPage,
|
||||
ComponentShowcase,
|
||||
} from '@/features/secondary/pages';
|
||||
import { SignInPage, SignUpPage } from '@/features/auth/pages';
|
||||
import { AcceptInvitePage, SignInPage, SignUpPage } from '@/features/auth/pages';
|
||||
import { useAuthSession } from '@/lib/use-auth-session';
|
||||
import { useDemoMode } from '@/lib/demo-mode';
|
||||
import { getCurrentUserProfile } from '@/lib/api-client';
|
||||
@@ -124,12 +127,14 @@ function NotFoundPage() {
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
useBranding();
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<Routes>
|
||||
<Route path="/" element={<LandingPage />} />
|
||||
<Route path="/auth/sign-in" element={<SignInPage />} />
|
||||
<Route path="/auth/sign-up" element={<SignUpPage />} />
|
||||
<Route path="/auth/accept-invite" element={<AcceptInvitePage />} />
|
||||
|
||||
{/* Public browsing — approved projects, templates, docs. */}
|
||||
{/* Project workspace renders outside the shell — fullscreen canvas, no nav chrome */}
|
||||
@@ -141,6 +146,7 @@ export default function App() {
|
||||
<Route path="/templates" element={<TemplatesPage />} />
|
||||
<Route path="/databases" element={<DatabasesPage />} />
|
||||
<Route path="/ha" element={<HighAvailabilityPage />} />
|
||||
<Route path="/operations" element={<OperationsPage />} />
|
||||
<Route path="/security" element={<SecurityPage />} />
|
||||
<Route path="/usage" element={<UsagePage />} />
|
||||
<Route path="/docs" element={<DocsPage />} />
|
||||
@@ -151,6 +157,7 @@ export default function App() {
|
||||
<Route path="/people" element={<PeoplePage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/settings/audit-logs" element={<AuditLogsPage />} />
|
||||
<Route path="/settings/webhooks" element={<WebhooksPage />} />
|
||||
<Route path="/showcase" element={<ComponentShowcase />} />
|
||||
</Route>
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||
import {
|
||||
Activity,
|
||||
Bell,
|
||||
BookOpen,
|
||||
ChartBar,
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
UploadCloud,
|
||||
User,
|
||||
Users,
|
||||
Webhook,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
getCurrentUserProfile,
|
||||
@@ -39,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[] };
|
||||
@@ -56,6 +58,7 @@ const navSections: NavSection[] = [
|
||||
{
|
||||
title: 'Operate',
|
||||
items: [
|
||||
{ label: 'Operations', href: '/operations', icon: Activity, hint: 'Live jobs & failures' },
|
||||
{ label: 'Databases', href: '/databases', icon: Database, hint: 'Managed data services' },
|
||||
{ label: 'High Availability', href: '/ha', icon: ShieldCheck, hint: 'Failover & health' },
|
||||
{ label: 'Security', href: '/security', icon: Shield, hint: 'Scans & compliance' },
|
||||
@@ -67,6 +70,7 @@ const navSections: NavSection[] = [
|
||||
items: [
|
||||
{ label: 'People', href: '/people', icon: Users, hint: 'Members & access' },
|
||||
{ label: 'Audit Logs', href: '/settings/audit-logs', icon: ScrollText, hint: 'Activity trail' },
|
||||
{ label: 'Webhooks', href: '/settings/webhooks', icon: Webhook, hint: 'Signed event delivery' },
|
||||
{ label: 'Settings', href: '/settings', icon: Settings, hint: 'Platform preferences' },
|
||||
{ label: 'Docs', href: '/docs', icon: BookOpen, hint: 'Guides & reference' },
|
||||
],
|
||||
@@ -383,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={{
|
||||
@@ -393,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">
|
||||
|
||||
@@ -4,22 +4,33 @@ import { Link } from 'react-router-dom';
|
||||
import {
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Copy,
|
||||
FolderKanban,
|
||||
Megaphone,
|
||||
Pencil,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
UserPlus,
|
||||
Users,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
createBanner,
|
||||
createInvite,
|
||||
deleteBanner,
|
||||
deleteProject,
|
||||
getAdminOverview,
|
||||
listAdminBanners,
|
||||
listAdminUsers,
|
||||
listInvites,
|
||||
listProjects,
|
||||
revokeInvite,
|
||||
setProjectApproval,
|
||||
setUserAdmin,
|
||||
updateBanner,
|
||||
updateProject,
|
||||
type AdminUser,
|
||||
type Banner,
|
||||
type ProjectEntity,
|
||||
} from '@/lib/api-client';
|
||||
import { useAuthSession } from '@/lib/use-auth-session';
|
||||
@@ -50,16 +61,82 @@ 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 invitesQuery = useQuery({
|
||||
queryKey: ['admin-invites'],
|
||||
queryFn: listInvites,
|
||||
enabled: !isDemoMode,
|
||||
select: (list) =>
|
||||
list.map((invite) => ({
|
||||
...invite,
|
||||
expired: !invite.used_at && new Date(invite.expires_at).getTime() < Date.now(),
|
||||
})),
|
||||
});
|
||||
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 [inviteEmail, setInviteEmail] = useState('');
|
||||
const [inviteLink, setInviteLink] = useState<string | null>(null);
|
||||
|
||||
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: ['admin-invites'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||
};
|
||||
|
||||
const inviteCreateMutation = useMutation({
|
||||
mutationFn: () => createInvite({ email: inviteEmail.trim() || undefined }),
|
||||
onSuccess: (result) => {
|
||||
setInviteLink(`${window.location.origin}${result.url}`);
|
||||
setInviteEmail('');
|
||||
invalidate();
|
||||
},
|
||||
onError: (error) => showToast('error', 'Invite failed', error instanceof Error ? error.message : undefined),
|
||||
});
|
||||
|
||||
const inviteRevokeMutation = useMutation({
|
||||
mutationFn: (id: string) => revokeInvite(id),
|
||||
onSuccess: () => {
|
||||
showToast('success', 'Invite revoked');
|
||||
invalidate();
|
||||
},
|
||||
onError: (error) => showToast('error', 'Revoke failed', error instanceof Error ? error.message : undefined),
|
||||
});
|
||||
|
||||
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 +203,8 @@ export function AdminPage() {
|
||||
const pending = overview?.pending_projects ?? [];
|
||||
const users = usersQuery.data ?? [];
|
||||
const allProjects = projectsQuery.data ?? [];
|
||||
const banners = bannersQuery.data ?? [];
|
||||
const invites = invitesQuery.data ?? [];
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-5 py-8">
|
||||
@@ -258,6 +337,176 @@ 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">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<UserPlus size={15} className="text-[var(--accent-primary)]" />
|
||||
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Team invites</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={inviteEmail}
|
||||
onChange={(e) => setInviteEmail(e.target.value)}
|
||||
placeholder="Bind to email (optional)"
|
||||
type="email"
|
||||
className="h-9 min-w-64 flex-1 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm focus:border-[var(--accent-primary)]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inviteCreateMutation.mutate()}
|
||||
disabled={inviteCreateMutation.isPending}
|
||||
className="rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-[12px] font-semibold text-[var(--accent-on)] disabled:opacity-50"
|
||||
>
|
||||
{inviteCreateMutation.isPending ? 'Creating…' : 'Create invite link'}
|
||||
</button>
|
||||
</div>
|
||||
{inviteLink ? (
|
||||
<div className="flex items-center gap-2 border-b border-[var(--border-subtle)] bg-[var(--accent-primary-soft)] px-4 py-2.5">
|
||||
<code className="min-w-0 flex-1 truncate text-[12px] text-[var(--accent-primary)]">{inviteLink}</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
navigator.clipboard?.writeText(inviteLink);
|
||||
showToast('success', 'Link copied');
|
||||
}}
|
||||
className="inline-flex items-center gap-1 rounded-[var(--radius-md)] border border-[var(--border-default)] px-2.5 py-1 text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
>
|
||||
<Copy size={11} /> Copy
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setInviteLink(null)}
|
||||
className="text-[11px] text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{invites.map((invite) => {
|
||||
const used = Boolean(invite.used_at);
|
||||
const expired = !used && invite.expired;
|
||||
return (
|
||||
<div
|
||||
key={invite.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)]">
|
||||
{invite.email || 'Open invite'}
|
||||
</p>
|
||||
<p className="truncate text-[11.5px] text-[var(--text-tertiary)]">
|
||||
expires {new Date(invite.expires_at).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`rounded-full px-2.5 py-0.5 text-[10.5px] font-semibold ${
|
||||
used
|
||||
? 'bg-[var(--surface-muted)] text-[var(--text-tertiary)]'
|
||||
: expired
|
||||
? 'bg-[var(--error-soft)] text-[var(--error)]'
|
||||
: 'bg-[var(--success-soft)] text-[var(--success)]'
|
||||
}`}
|
||||
>
|
||||
{used ? 'used' : expired ? 'expired' : 'open'}
|
||||
</span>
|
||||
{!used ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inviteRevokeMutation.mutate(invite.id)}
|
||||
disabled={inviteRevokeMutation.isPending}
|
||||
className="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"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{invites.length === 0 ? (
|
||||
<p className="px-4 py-6 text-center text-[13px] text-[var(--text-tertiary)]">No invites yet.</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)]" />
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
signUpWithEmail,
|
||||
startGitHubSignIn,
|
||||
} from '@/lib/auth-client';
|
||||
import { acceptInvite, getInvite } from '@/lib/api-client';
|
||||
import { useAuthSession } from '@/lib/use-auth-session';
|
||||
|
||||
function sanitizeRedirect(raw: string | null): string {
|
||||
@@ -258,6 +259,139 @@ export function SignInPage() {
|
||||
);
|
||||
}
|
||||
|
||||
export function AcceptInvitePage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [searchParams] = useSearchParams();
|
||||
const token = searchParams.get('token') ?? '';
|
||||
|
||||
const sessionQuery = useAuthSession();
|
||||
const inviteQuery = useQuery({
|
||||
queryKey: ['invite', token],
|
||||
queryFn: () => getInvite(token),
|
||||
enabled: token.length > 0,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
if (sessionQuery.data) {
|
||||
return <Navigate to="/projects" replace />;
|
||||
}
|
||||
|
||||
const boundEmail = inviteQuery.data?.email ?? undefined;
|
||||
const effectiveEmail = boundEmail ?? email;
|
||||
const inviteValid = inviteQuery.data?.valid === true;
|
||||
|
||||
const submit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await acceptInvite({ token, name: name.trim(), email: effectiveEmail.trim(), password });
|
||||
// Establish the cookie session — the accept endpoint creates the
|
||||
// account, sign-in creates the browser session.
|
||||
await signInWithEmail(effectiveEmail.trim(), password);
|
||||
await queryClient.invalidateQueries({ queryKey: ['auth-session'] });
|
||||
navigate('/projects', { replace: true });
|
||||
} catch (exception) {
|
||||
setError(exception instanceof Error ? exception.message : 'Failed to accept invite');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<AuthLayout>
|
||||
<AuthCard title="Invalid invite" subtitle="This link is missing its invite token.">
|
||||
<AuthErrorNotice message="Ask your administrator for a fresh invite link." />
|
||||
</AuthCard>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<AuthCard
|
||||
title="Join the team"
|
||||
subtitle={boundEmail ? `Invited as ${boundEmail}` : 'You have been invited — create your account.'}
|
||||
>
|
||||
{error ? <AuthErrorNotice message={error} /> : null}
|
||||
{inviteQuery.isLoading ? <AuthInfoNotice message="Validating invite…" /> : null}
|
||||
{inviteQuery.isError ? <AuthErrorNotice message="This invite link is not valid." /> : null}
|
||||
{inviteQuery.data && !inviteValid ? (
|
||||
<AuthErrorNotice message={inviteQuery.data.used ? 'This invite was already used.' : 'This invite has expired.'} />
|
||||
) : null}
|
||||
|
||||
{inviteValid ? (
|
||||
<form className="space-y-3" onSubmit={submit}>
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
|
||||
Name
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
className="mt-2 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2.5 text-sm text-[var(--text-primary)] outline-none transition-colors focus:border-[var(--accent-primary)]"
|
||||
placeholder="Your name"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
|
||||
Email
|
||||
<input
|
||||
type="email"
|
||||
value={boundEmail ?? email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={Boolean(boundEmail)}
|
||||
required
|
||||
className="mt-2 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2.5 text-sm text-[var(--text-primary)] outline-none transition-colors focus:border-[var(--accent-primary)] disabled:opacity-60"
|
||||
placeholder="[email protected]"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
|
||||
Password
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
minLength={8}
|
||||
required
|
||||
className="mt-2 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2.5 text-sm text-[var(--text-primary)] outline-none transition-colors focus:border-[var(--accent-primary)]"
|
||||
placeholder="At least 8 characters"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="mt-1 inline-flex h-10 w-full items-center justify-center gap-2 rounded-[var(--radius-md)] text-sm font-semibold text-[var(--accent-on)] shadow-lg transition-all disabled:cursor-not-allowed disabled:opacity-60"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
>
|
||||
{isSubmitting ? <Loader2 size={15} className="animate-spin" /> : <User2 size={15} />}
|
||||
Accept invite
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
<div className="mt-5 flex items-center justify-between text-xs text-[var(--text-secondary)]">
|
||||
<span>Already have an account?</span>
|
||||
<Link to="/auth/sign-in" className="inline-flex items-center gap-1 text-[var(--accent-primary)] hover:underline">
|
||||
Sign in <ArrowRight size={12} />
|
||||
</Link>
|
||||
</div>
|
||||
</AuthCard>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export function SignUpPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -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]">
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { getOperations, type OperationsView } from '@/lib/api-client';
|
||||
import { useDemoMode } from '@/lib/demo-mode';
|
||||
import { formatRelative } from '@/lib/time';
|
||||
import { DemoRestricted } from '@/shared/components';
|
||||
import { Activity, AlertTriangle, Clock, Database, Loader2, Timer } from 'lucide-react';
|
||||
|
||||
function statusClass(status: string): string {
|
||||
switch (status) {
|
||||
case 'deployed':
|
||||
case 'completed':
|
||||
case 'success':
|
||||
return 'text-[var(--success)]';
|
||||
case 'failed':
|
||||
case 'cancelled':
|
||||
case 'rolled_back':
|
||||
return 'text-[var(--error)]';
|
||||
case 'building':
|
||||
case 'deploying':
|
||||
case 'in_progress':
|
||||
case 'running':
|
||||
return 'text-[var(--accent-primary)]';
|
||||
default:
|
||||
return 'text-[var(--warning)]';
|
||||
}
|
||||
}
|
||||
|
||||
function Section({ title, icon: Icon, count, children }: {
|
||||
title: string;
|
||||
icon: typeof Activity;
|
||||
count: number;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="panel p-5">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Icon size={16} className="text-[var(--accent-primary)]" />
|
||||
<h2 className="text-sm font-semibold text-[var(--text-primary)]">{title}</h2>
|
||||
<span className="text-xs text-[var(--text-muted)] mono">({count})</span>
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyLine({ text }: { text: string }) {
|
||||
return <p className="text-xs text-[var(--text-muted)]">{text}</p>;
|
||||
}
|
||||
|
||||
export function OperationsPage() {
|
||||
const isDemoMode = useDemoMode();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const opsQuery = useQuery({
|
||||
queryKey: ['operations'],
|
||||
queryFn: getOperations,
|
||||
enabled: !isDemoMode,
|
||||
refetchInterval: (query) =>
|
||||
(query.state.data?.active_deployments?.length ?? 0) > 0 ? 3000 : 15000,
|
||||
});
|
||||
|
||||
if (isDemoMode) {
|
||||
return <DemoRestricted feature="Operations" />;
|
||||
}
|
||||
|
||||
const ops: OperationsView | undefined = opsQuery.data;
|
||||
const active = ops?.active_deployments ?? [];
|
||||
const failed = ops?.recent_failures ?? [];
|
||||
const cron = ops?.cron_runs ?? [];
|
||||
const backups = ops?.backups ?? [];
|
||||
const queue = ops?.deploy_queue ?? [];
|
||||
const queuedTotal = queue.reduce((sum, q) => sum + q.queued, 0);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<div className="border-b border-[var(--border-subtle)]">
|
||||
<div className="w-full px-8 py-5">
|
||||
<h1 className="v-title">Operations<span className="v-cursor">_</span></h1>
|
||||
<p className="v-mono mt-1.5 text-[11px] text-[var(--text-tertiary)]">
|
||||
Live view of deploys, queues, cron runs, and backups
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{opsQuery.isLoading ? (
|
||||
<div className="w-full px-8 py-12 flex justify-center">
|
||||
<Loader2 size={20} className="animate-spin text-[var(--text-muted)]" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full px-8 py-6 space-y-6">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="panel-soft p-4">
|
||||
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">In flight</p>
|
||||
<p className="mt-1 text-2xl font-semibold text-[var(--accent-primary)]">{active.length}</p>
|
||||
</div>
|
||||
<div className="panel-soft p-4">
|
||||
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Queued</p>
|
||||
<p className="mt-1 text-2xl font-semibold text-[var(--warning)]">{queuedTotal}</p>
|
||||
</div>
|
||||
<div className="panel-soft p-4">
|
||||
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Failed 24h</p>
|
||||
<p className="mt-1 text-2xl font-semibold text-[var(--error)]">{failed.length}</p>
|
||||
</div>
|
||||
<div className="panel-soft p-4">
|
||||
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Backups 24h</p>
|
||||
<p className="mt-1 text-2xl font-semibold text-[var(--text-secondary)]">{backups.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||
<Section title="Active deployments" icon={Activity} count={active.length}>
|
||||
{active.length === 0 ? (
|
||||
<EmptyLine text="Nothing deploying right now." />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{active.map((d) => (
|
||||
<button
|
||||
key={d.id}
|
||||
onClick={() => navigate(`/services/${d.service_id}`)}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 rounded-[var(--radius-md)] hover:bg-[var(--surface-muted)] text-left transition-colors"
|
||||
>
|
||||
<Loader2 size={13} className="animate-spin text-[var(--accent-primary)] shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-sm text-[var(--text-primary)]">{d.service_name}</span>
|
||||
<span className="text-xs text-[var(--text-muted)]"> · {d.project_name}</span>
|
||||
</div>
|
||||
<span className={`text-xs mono ${statusClass(d.status)}`}>{d.status}</span>
|
||||
<span className="text-xs text-[var(--text-muted)]">{formatRelative(d.created_at)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="Failed — last 24h" icon={AlertTriangle} count={failed.length}>
|
||||
{failed.length === 0 ? (
|
||||
<EmptyLine text="No recent failures." />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{failed.map((d) => (
|
||||
<button
|
||||
key={d.id}
|
||||
onClick={() => navigate(`/services/${d.service_id}`)}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 rounded-[var(--radius-md)] hover:bg-[var(--surface-muted)] text-left transition-colors"
|
||||
>
|
||||
<AlertTriangle size={13} className="text-[var(--error)] shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-sm text-[var(--text-primary)]">{d.service_name}</span>
|
||||
<span className="text-xs text-[var(--text-muted)]"> · {d.project_name}</span>
|
||||
{d.error && (
|
||||
<p className="text-[11px] text-[var(--error)]/80 truncate">{d.error}</p>
|
||||
)}
|
||||
</div>
|
||||
<span className={`text-xs mono ${statusClass(d.status)}`}>{d.status}</span>
|
||||
<span className="text-xs text-[var(--text-muted)]">{formatRelative(d.created_at)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="Cron — last 24h" icon={Timer} count={cron.length}>
|
||||
{cron.length === 0 ? (
|
||||
<EmptyLine text="No cron runs in the last day." />
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{cron.map((r) => (
|
||||
<div key={r.id} className="flex items-center gap-3 px-3 py-1.5 text-xs">
|
||||
<span className={`mono ${statusClass(r.status)}`}>{r.status}</span>
|
||||
<span className="text-[var(--text-primary)]">{r.job_name}</span>
|
||||
<span className="mono text-[var(--text-muted)]">{r.schedule}</span>
|
||||
{r.error && <span className="text-[var(--error)]/80 truncate">{r.error}</span>}
|
||||
<span className="ml-auto text-[var(--text-muted)]">{formatRelative(r.started_at)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="Backups — last 24h" icon={Database} count={backups.length}>
|
||||
{backups.length === 0 ? (
|
||||
<EmptyLine text="No backups in the last day." />
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{backups.map((b) => (
|
||||
<div key={b.id} className="flex items-center gap-3 px-3 py-1.5 text-xs">
|
||||
<span className={`mono ${statusClass(b.status)}`}>{b.status}</span>
|
||||
<span className="text-[var(--text-primary)]">{b.database_name}</span>
|
||||
<span className="text-[var(--text-muted)]">{b.size}</span>
|
||||
<span className="ml-auto text-[var(--text-muted)]">{formatRelative(b.created_at)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
{queue.length > 0 && (
|
||||
<Section title="Deploy queue depth" icon={Clock} count={queue.length}>
|
||||
<div className="space-y-1.5">
|
||||
{queue.map((q) => (
|
||||
<div key={q.service_id} className="flex items-center gap-3 px-3 py-1.5 text-xs mono">
|
||||
<span className="text-[var(--text-secondary)]">{q.service_id.slice(0, 8)}</span>
|
||||
<span className={q.running ? 'text-[var(--accent-primary)]' : 'text-[var(--text-muted)]'}>
|
||||
{q.running ? 'running' : 'idle'}
|
||||
</span>
|
||||
<span className="text-[var(--warning)]">{q.queued} queued</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
createWebhook,
|
||||
deleteWebhook,
|
||||
listWebhookDeliveries,
|
||||
listWebhooks,
|
||||
testWebhook,
|
||||
updateWebhook,
|
||||
type OutboundWebhook,
|
||||
type WebhookDelivery,
|
||||
} from '@/lib/api-client';
|
||||
import { useDemoMode } from '@/lib/demo-mode';
|
||||
import { formatRelative } from '@/lib/time';
|
||||
import { DemoRestricted, useToast } from '@/shared/components';
|
||||
import {
|
||||
Webhook,
|
||||
Plus,
|
||||
Trash2,
|
||||
Send,
|
||||
Loader2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
|
||||
const inputClass =
|
||||
'h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all';
|
||||
|
||||
const eventSuggestions = ['*', 'service.*', 'project.*', 'database.*', 'deployment.*', 'template.*'];
|
||||
|
||||
function statusClass(status: WebhookDelivery['status']): string {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return 'text-[var(--success)]';
|
||||
case 'failed':
|
||||
return 'text-[var(--error)]';
|
||||
default:
|
||||
return 'text-[var(--warning)]';
|
||||
}
|
||||
}
|
||||
|
||||
function WebhookRow({
|
||||
webhook,
|
||||
onDelete,
|
||||
onToggle,
|
||||
onTest,
|
||||
testing,
|
||||
}: {
|
||||
webhook: OutboundWebhook;
|
||||
onDelete: () => void;
|
||||
onToggle: () => void;
|
||||
onTest: () => void;
|
||||
testing: boolean;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const deliveriesQuery = useQuery({
|
||||
queryKey: ['webhook-deliveries', webhook.id],
|
||||
queryFn: () => listWebhookDeliveries(webhook.id),
|
||||
enabled: expanded,
|
||||
refetchInterval: expanded ? 10000 : false,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="panel p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={() => setExpanded((v) => !v)} className="text-[var(--text-tertiary)]">
|
||||
{expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-[var(--text-primary)]">{webhook.name}</span>
|
||||
{!webhook.enabled && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--surface-muted)] text-[var(--text-muted)]">
|
||||
disabled
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-[var(--text-muted)] mono truncate">{webhook.url}</p>
|
||||
</div>
|
||||
<div className="hidden md:flex gap-1">
|
||||
{webhook.events.map((ev) => (
|
||||
<span
|
||||
key={ev}
|
||||
className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--accent-primary-soft)] text-[var(--accent-primary)] mono"
|
||||
>
|
||||
{ev}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
onClick={onTest}
|
||||
disabled={testing || !webhook.enabled}
|
||||
className="p-2 rounded-[var(--radius-md)] text-[var(--text-tertiary)] hover:text-[var(--accent-primary)] disabled:opacity-40 transition-colors"
|
||||
title="Send test ping"
|
||||
>
|
||||
{testing ? <Loader2 size={15} className="animate-spin" /> : <Send size={15} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="p-2 rounded-[var(--radius-md)] text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-colors text-xs"
|
||||
>
|
||||
{webhook.enabled ? 'Disable' : 'Enable'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="p-2 rounded-[var(--radius-md)] text-[var(--text-tertiary)] hover:text-[var(--error)] transition-colors"
|
||||
title="Delete webhook"
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className="mt-3 border-t border-[var(--border-subtle)] pt-3">
|
||||
<p className="text-[11px] uppercase tracking-wider text-[var(--text-muted)] mb-2">
|
||||
Recent deliveries
|
||||
</p>
|
||||
{deliveriesQuery.isLoading ? (
|
||||
<Loader2 size={14} className="animate-spin text-[var(--text-muted)]" />
|
||||
) : (deliveriesQuery.data ?? []).length === 0 ? (
|
||||
<p className="text-xs text-[var(--text-muted)]">No deliveries yet.</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{(deliveriesQuery.data ?? []).slice(0, 20).map((d) => (
|
||||
<div key={d.id} className="flex items-center gap-3 text-xs mono">
|
||||
<span className={statusClass(d.status)}>{d.status}</span>
|
||||
<span className="text-[var(--text-secondary)]">{d.event}</span>
|
||||
{d.response_status != null && (
|
||||
<span className="text-[var(--text-muted)]">HTTP {d.response_status}</span>
|
||||
)}
|
||||
<span className="text-[var(--text-muted)]">{d.attempts} tries</span>
|
||||
{d.duration_ms != null && (
|
||||
<span className="text-[var(--text-muted)]">{d.duration_ms}ms</span>
|
||||
)}
|
||||
<span className="text-[var(--text-muted)] ml-auto">{formatRelative(d.created_at)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WebhooksPage() {
|
||||
const isDemoMode = useDemoMode();
|
||||
const queryClient = useQueryClient();
|
||||
const toast = useToast();
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [form, setForm] = useState({ name: '', url: '', events: [] as string[] });
|
||||
const [secret, setSecret] = useState<string | null>(null);
|
||||
const [testingId, setTestingId] = useState<string | null>(null);
|
||||
|
||||
const webhooksQuery = useQuery({
|
||||
queryKey: ['webhooks'],
|
||||
queryFn: listWebhooks,
|
||||
enabled: !isDemoMode,
|
||||
});
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['webhooks'] });
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => createWebhook(form),
|
||||
onSuccess: (created) => {
|
||||
invalidate();
|
||||
setCreating(false);
|
||||
setForm({ name: '', url: '', events: [] });
|
||||
if (created.secret) {
|
||||
setSecret(created.secret);
|
||||
}
|
||||
},
|
||||
onError: (err) => toast.showToast(err instanceof Error ? err.message : 'Failed to create webhook', 'error'),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => deleteWebhook(id),
|
||||
onSuccess: invalidate,
|
||||
onError: (err) => toast.showToast(err instanceof Error ? err.message : 'Delete failed', 'error'),
|
||||
});
|
||||
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: (w: OutboundWebhook) => updateWebhook(w.id, { enabled: !w.enabled }),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const testMutation = useMutation({
|
||||
mutationFn: (id: string) => testWebhook(id),
|
||||
onMutate: (id) => setTestingId(id),
|
||||
onSettled: () => setTestingId(null),
|
||||
onSuccess: () => toast.showToast('Test ping queued', 'info'),
|
||||
onError: (err) => toast.showToast(err instanceof Error ? err.message : 'Test failed', 'error'),
|
||||
});
|
||||
|
||||
if (isDemoMode) {
|
||||
return <DemoRestricted feature="Outbound webhooks" />;
|
||||
}
|
||||
|
||||
const toggleEvent = (ev: string) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
events: f.events.includes(ev) ? f.events.filter((e) => e !== ev) : [...f.events, ev],
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<div className="border-b border-[var(--border-subtle)]">
|
||||
<div className="w-full px-8 py-5 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="v-title">Webhooks<span className="v-cursor">_</span></h1>
|
||||
<p className="v-mono mt-1.5 text-[11px] text-[var(--text-tertiary)]">
|
||||
Signed HTTP delivery for every platform event — deploys, restarts, deletes
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setCreating(true)}
|
||||
className="flex items-center gap-1.5 h-9 px-4 rounded-[var(--radius-md)] text-sm font-semibold text-[var(--accent-on)]"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
>
|
||||
<Plus size={15} />
|
||||
New webhook
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full px-8 py-6 space-y-4 max-w-4xl">
|
||||
{secret && (
|
||||
<div className="panel-soft p-4 flex items-center gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">Signing secret — shown once</p>
|
||||
<p className="text-xs text-[var(--text-muted)]">
|
||||
Verify deliveries with <span className="mono">X-Containr-Signature</span>
|
||||
</p>
|
||||
<code className="block mt-2 text-xs mono text-[var(--accent-primary)] break-all">{secret}</code>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard?.writeText(secret);
|
||||
toast.showToast('Secret copied', 'success');
|
||||
}}
|
||||
className="p-2 text-[var(--text-tertiary)] hover:text-[var(--text-primary)]"
|
||||
>
|
||||
<Copy size={15} />
|
||||
</button>
|
||||
<button onClick={() => setSecret(null)} className="p-2 text-[var(--text-tertiary)]">
|
||||
<X size={15} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{webhooksQuery.isLoading ? (
|
||||
<Loader2 size={18} className="animate-spin text-[var(--text-muted)]" />
|
||||
) : (webhooksQuery.data ?? []).length === 0 ? (
|
||||
<div className="panel p-8 text-center">
|
||||
<Webhook size={28} className="mx-auto text-[var(--text-muted)] mb-3" />
|
||||
<p className="text-sm text-[var(--text-secondary)]">No webhooks yet.</p>
|
||||
<p className="text-xs text-[var(--text-muted)] mt-1">
|
||||
Subscribe an HTTP endpoint to platform events like <span className="mono">service.*</span> or{' '}
|
||||
<span className="mono">deployment.fail</span>.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
(webhooksQuery.data ?? []).map((w) => (
|
||||
<WebhookRow
|
||||
key={w.id}
|
||||
webhook={w}
|
||||
testing={testingId === w.id}
|
||||
onTest={() => testMutation.mutate(w.id)}
|
||||
onToggle={() => toggleMutation.mutate(w)}
|
||||
onDelete={() => {
|
||||
if (window.confirm(`Delete webhook "${w.name}"?`)) {
|
||||
deleteMutation.mutate(w.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{creating && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-[var(--bg-void)]/80 backdrop-blur-sm" onClick={() => setCreating(false)} />
|
||||
<div className="relative w-full max-w-md panel p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">New webhook</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
className={`${inputClass} w-full`}
|
||||
placeholder="deploy-alerts"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
|
||||
Endpoint URL
|
||||
</label>
|
||||
<input
|
||||
value={form.url}
|
||||
onChange={(e) => setForm((f) => ({ ...f, url: e.target.value }))}
|
||||
className={`${inputClass} w-full mono`}
|
||||
placeholder="https://example.com/hooks/containr"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
|
||||
Events
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{eventSuggestions.map((ev) => (
|
||||
<button
|
||||
key={ev}
|
||||
onClick={() => toggleEvent(ev)}
|
||||
className={`px-2.5 py-1 rounded-[var(--radius-md)] border text-xs mono transition-colors ${
|
||||
form.events.includes(ev)
|
||||
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]'
|
||||
: 'border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-tertiary)]'
|
||||
}`}
|
||||
>
|
||||
{ev}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 text-[11px] text-[var(--text-muted)]">
|
||||
Events are <span className="mono">resource.action</span> — wildcards like{' '}
|
||||
<span className="mono">service.*</span> match the whole resource.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setCreating(false)}
|
||||
className="h-10 px-4 rounded-[var(--radius-md)] text-sm text-[var(--text-secondary)]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => createMutation.mutate()}
|
||||
disabled={
|
||||
createMutation.isPending ||
|
||||
!form.name.trim() ||
|
||||
!form.url.trim() ||
|
||||
form.events.length === 0
|
||||
}
|
||||
className="h-10 px-5 rounded-[var(--radius-md)] text-sm font-semibold text-[var(--accent-on)] disabled:opacity-50 flex items-center gap-2"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
>
|
||||
{createMutation.isPending && <Loader2 size={14} className="animate-spin" />}
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3507,6 +3507,766 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/webhooks": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** List outbound webhooks */
|
||||
get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Webhooks for the caller (secrets masked) */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
webhooks?: components["schemas"]["OutboundWebhook"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
put?: never;
|
||||
/**
|
||||
* Create an outbound webhook
|
||||
* @description Receives signed POST deliveries for every audited action matching
|
||||
* the event list — "resource.action" names, "resource.*" wildcards,
|
||||
* or "*" for everything. The secret is returned once; deliveries
|
||||
* carry X-Containr-Signature: sha256=<hmac> over the raw body.
|
||||
*/
|
||||
post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
name: string;
|
||||
url: string;
|
||||
/** @description Signing secret (auto-generated when omitted) */
|
||||
secret?: string;
|
||||
/**
|
||||
* @example [
|
||||
* "service.*",
|
||||
* "deployment.fail"
|
||||
* ]
|
||||
*/
|
||||
events: string[];
|
||||
headers?: {
|
||||
[key: string]: string;
|
||||
};
|
||||
/** @default true */
|
||||
enabled?: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Webhook created (secret revealed once) */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["OutboundWebhook"];
|
||||
};
|
||||
};
|
||||
/** @description Invalid name, URL, or empty event list */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/webhooks/{id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
/** Delete a webhook */
|
||||
delete: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Deleted */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["MessageResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
options?: never;
|
||||
head?: never;
|
||||
/** Update a webhook */
|
||||
patch: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
name?: string;
|
||||
url?: string;
|
||||
secret?: string;
|
||||
events?: string[];
|
||||
headers?: {
|
||||
[key: string]: string;
|
||||
};
|
||||
enabled?: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Updated webhook */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["OutboundWebhook"];
|
||||
};
|
||||
};
|
||||
/** @description Webhook not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
trace?: never;
|
||||
};
|
||||
"/webhooks/{id}/deliveries": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** List delivery attempts for a webhook */
|
||||
get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Recent deliveries (newest first, max 100) */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
deliveries?: components["schemas"]["WebhookDelivery"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/webhooks/{id}/test": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Queue a test ping delivery */
|
||||
post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Delivery queued */
|
||||
202: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["MessageResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/operations": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Cross-cutting operations view
|
||||
* @description Single snapshot of platform work scoped to the caller's projects
|
||||
* (admins see all): active deployments, in-memory deploy-queue
|
||||
* depth per service, deployments that failed in the last 24h,
|
||||
* cron executions and database backups from the last 24h.
|
||||
*/
|
||||
get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Operations snapshot */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
active_deployments?: Record<string, never>[];
|
||||
deploy_queue?: {
|
||||
service_id?: string;
|
||||
running?: boolean;
|
||||
queued?: number;
|
||||
}[];
|
||||
recent_failures?: Record<string, never>[];
|
||||
cron_runs?: Record<string, never>[];
|
||||
backups?: Record<string, never>[];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
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;
|
||||
};
|
||||
"/auth/invites/{token}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Validate an invite token (public) */
|
||||
get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Invite state */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
valid?: boolean;
|
||||
used?: boolean;
|
||||
expired?: boolean;
|
||||
email?: string | null;
|
||||
/** Format: date-time */
|
||||
expires_at?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Invite not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/auth/accept-invite": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Register via an invite link (public) */
|
||||
post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
token: string;
|
||||
name: string;
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Created session */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["LoginResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Invite used or expired */
|
||||
410: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/invites": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** List invites (admin) */
|
||||
get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description All invites */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
invites?: Record<string, never>[];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
put?: never;
|
||||
/** Create an invite link (admin) */
|
||||
post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: {
|
||||
content: {
|
||||
"application/json": {
|
||||
/** @description Optionally bind the invite to this email */
|
||||
email?: string;
|
||||
/** @default 168 */
|
||||
expires_in_hours?: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Created invite with one-time token */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
invite?: Record<string, never>;
|
||||
/** @description Shown once */
|
||||
token?: string;
|
||||
url?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/invites/{id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
/** Revoke an invite (admin) */
|
||||
delete: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Revoked */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: 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;
|
||||
@@ -4472,6 +5232,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;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -9107,6 +9876,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 */
|
||||
@@ -9923,6 +10702,43 @@ export interface components {
|
||||
notifications?: components["schemas"]["Notification"][];
|
||||
unread?: number;
|
||||
};
|
||||
OutboundWebhook: {
|
||||
/** Format: uuid */
|
||||
id?: string;
|
||||
name?: string;
|
||||
url?: string;
|
||||
/** @description Only present on create/update responses */
|
||||
secret?: string;
|
||||
events?: string[];
|
||||
headers?: {
|
||||
[key: string]: string;
|
||||
};
|
||||
enabled?: boolean;
|
||||
/** Format: date-time */
|
||||
created_at?: string;
|
||||
/** Format: date-time */
|
||||
updated_at?: string;
|
||||
};
|
||||
WebhookDelivery: {
|
||||
/** Format: uuid */
|
||||
id?: string;
|
||||
/** Format: uuid */
|
||||
webhook_id?: string;
|
||||
event?: string;
|
||||
payload?: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
/** @enum {string} */
|
||||
status?: "pending" | "success" | "failed";
|
||||
response_status?: number | null;
|
||||
response_body?: string | null;
|
||||
attempts?: number;
|
||||
duration_ms?: number | null;
|
||||
/** Format: date-time */
|
||||
created_at?: string;
|
||||
/** Format: date-time */
|
||||
delivered_at?: string | null;
|
||||
};
|
||||
AuditLogListResponse: {
|
||||
audit_logs?: components["schemas"]["AuditLog"][];
|
||||
};
|
||||
|
||||
@@ -40,7 +40,7 @@ export const bundledDocs: BundledDoc[] = [
|
||||
"path": "guides/CLI.md",
|
||||
"section": "guides",
|
||||
"title": "Containr CLI",
|
||||
"markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance — the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings → Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response — safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|update|delete|start|stop|restart|redeploy\nservices clone|move duplicate config or re-parent a service\nservices domains list|add|remove|default|check multi-domain + DNS preflight\nregistries list|add|update|remove private-image pull credentials\nvolumes list|delete docker volume inventory + cleanup (admin)\ndeploy <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs <service-id> [--follow] runtime logs (SSE follow)\nexec <service-id> -- <cmd> one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\nvariables shared list|set|unset project shared vars — referenced as ${{shared.KEY}}\ndatabases list|get|create|update|delete|action|backup|restore|download-backup|import-backup\n import-backup <id> <file.tar.gz|-> uploads an archive as a restore\n point; ships offsite too when a backup target is assigned\ndatabases list|get|create|update|delete|action|backup|restore|download-backup\n create --public exposes the port on all interfaces;\n update <id> --public/--public=false toggles it (container recreates, data persists)\ndatabases register <name> register an external database (--type --host --port --database --username --password --ssl; probed before storing)\ndatabases test-connection probe a connection — by id, or ad-hoc flags\nbackup-targets list|add|update|remove|test\n S3-compatible archive destinations; add/update probe the bucket\n first, empty --access-key/--secret-key on update keep stored keys;\n update <db-id> --backup-target assigns a target (\"\" clears it)\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy|plan|import-compose|deploy-graph\n deploy --name --var K=V; plan dry-resolves expressions;\n import-compose <compose.yml|-> converts a compose file into\n a v2 graph config; deploy-graph <project-id> <config.json|->\n deploys an ad-hoc service graph\nimport compose <project-id> <compose.yml|->\n deploys a docker-compose file straight into a project —\n no template row. DB images become managed databases\n attached under their service name; --var K=V overrides\n template variables\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|analytics (admin)\nadmin overview|users|settings|audit-logs\nnotifications list|read\nup deploy the current directory\n```\n\n## containr up\n\nRun inside a git checkout: uses `remote.origin.url` + the current branch\nas the build source, creates a service named after the directory when none\nexists, then triggers a deployment.\n\n```bash\ncontainr up --project <id>\ncontainr up --project <id> --image ghcr.io/example/app:latest # image, no build\n```\n\nSet `project_id` on the active profile to omit `-Line truncated
|
||||
"markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance — the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings → Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response — safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|update|delete|start|stop|restart|redeploy\nservices clone|move duplicate config or re-parent a service\nservices domains list|add|remove|default|check multi-domain + DNS preflight\nregistries list|add|update|remove private-image pull credentials\nvolumes list|delete docker volume inventory + cleanup (admin)\ndeploy <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs <service-id> [--follow] runtime logs (SSE follow)\nexec <service-id> -- <cmd> one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\nvariables shared list|set|unset project shared vars — referenced as ${{shared.KEY}}\ndatabases list|get|create|update|delete|action|backup|restore|download-backup|import-backup\n import-backup <id> <file.tar.gz|-> uploads an archive as a restore\n point; ships offsite too when a backup target is assigned\ndatabases list|get|create|update|delete|action|backup|restore|download-backup\n create --public exposes the port on all interfaces;\n update <id> --public/--public=false toggles it (container recreates, data persists)\ndatabases register <name> register an external database (--type --host --port --database --username --password --ssl; probed before storing)\ndatabases test-connection probe a connection — by id, or ad-hoc flags\nbackup-targets list|add|update|remove|test\n S3-compatible archive destinations; add/update probe the bucket\n first, empty --access-key/--secret-key on update keep stored keys;\n update <db-id> --backup-target assigns a target (\"\" clears it)\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy|plan|import-compose|deploy-graph\n deploy --name --var K=V; plan dry-resolves expressions;\n import-compose <compose.yml|-> converts a compose file into\n a v2 graph config; deploy-graph <project-id> <config.json|->\n deploys an ad-hoc service graph\nimport compose <project-id> <compose.yml|->\n deploys a docker-compose file straight into a project —\n no template row. DB images become managed databases\n attached under their service name; --var K=V overrides\n template variables\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|analytics (admin)\nadmin overview|users|settings|audit-logs\nnotifications list|read\nwebhooks list|create|update|delete|deliveries|test\n outbound signed webhooks — create <name> <url>\n --event 'service.*' (repeatable, '*' = all);\n deliveries are HMAC-signed (X-Containr-Signature)\noperations live ops view: active deploys, queue depth,\n 24h failures, cron runs, backups\nbanners list|creatLine truncated
|
||||
},
|
||||
{
|
||||
"path": "guides/CLOUDFLARE_SETUP.md",
|
||||
|
||||
@@ -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),
|
||||
@@ -1767,6 +1829,198 @@ export async function importComposeIntoProject(
|
||||
return { created: payload.created ?? [], warnings: payload.warnings ?? [] };
|
||||
}
|
||||
|
||||
export type OutboundWebhook = {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
secret?: string;
|
||||
events: string[];
|
||||
headers?: Record<string, string>;
|
||||
enabled: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type WebhookDelivery = {
|
||||
id: string;
|
||||
webhook_id: string;
|
||||
event: string;
|
||||
status: 'pending' | 'success' | 'failed';
|
||||
response_status?: number | null;
|
||||
response_body?: string | null;
|
||||
attempts: number;
|
||||
duration_ms?: number | null;
|
||||
created_at: string;
|
||||
delivered_at?: string | null;
|
||||
};
|
||||
|
||||
export async function listWebhooks(): Promise<OutboundWebhook[]> {
|
||||
const payload = await requestJson<{ webhooks?: OutboundWebhook[] }>('/webhooks');
|
||||
return payload.webhooks ?? [];
|
||||
}
|
||||
|
||||
export type WebhookInput = {
|
||||
name: string;
|
||||
url: string;
|
||||
events: string[];
|
||||
secret?: string;
|
||||
headers?: Record<string, string>;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export async function createWebhook(input: WebhookInput): Promise<OutboundWebhook> {
|
||||
return requestJson<OutboundWebhook>('/webhooks', { method: 'POST', body: JSON.stringify(input) });
|
||||
}
|
||||
|
||||
export async function updateWebhook(id: string, input: Partial<WebhookInput>): Promise<OutboundWebhook> {
|
||||
return requestJson<OutboundWebhook>(`/webhooks/${id}`, { method: 'PATCH', body: JSON.stringify(input) });
|
||||
}
|
||||
|
||||
export async function deleteWebhook(id: string): Promise<void> {
|
||||
await requestJson(`/webhooks/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function listWebhookDeliveries(id: string): Promise<WebhookDelivery[]> {
|
||||
const payload = await requestJson<{ deliveries?: WebhookDelivery[] }>(`/webhooks/${id}/deliveries`);
|
||||
return payload.deliveries ?? [];
|
||||
}
|
||||
|
||||
export async function testWebhook(id: string): Promise<void> {
|
||||
await requestJson(`/webhooks/${id}/test`, { method: 'POST', body: JSON.stringify({}) });
|
||||
}
|
||||
|
||||
export type OperationsView = {
|
||||
active_deployments: Array<{
|
||||
id: string;
|
||||
service_id: string;
|
||||
service_name: string;
|
||||
project_name: string;
|
||||
status: string;
|
||||
image: string;
|
||||
error?: string | null;
|
||||
started_at?: string | null;
|
||||
completed_at?: string | null;
|
||||
created_at: string;
|
||||
}>;
|
||||
deploy_queue: Array<{ service_id: string; running: boolean; queued: number }>;
|
||||
recent_failures: OperationsView['active_deployments'];
|
||||
cron_runs: Array<{
|
||||
id: string;
|
||||
job_name: string;
|
||||
schedule: string;
|
||||
status: string;
|
||||
started_at: string;
|
||||
finished_at?: string | null;
|
||||
error?: string | null;
|
||||
}>;
|
||||
backups: Array<{
|
||||
id: string;
|
||||
database_id: string;
|
||||
database_name: string;
|
||||
status: string;
|
||||
size: string;
|
||||
created_at: string;
|
||||
completed_at?: string | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
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 type Invite = {
|
||||
id: string;
|
||||
email?: string;
|
||||
expires_at: string;
|
||||
used_at?: string;
|
||||
created_at?: string;
|
||||
};
|
||||
|
||||
export type InviteState = {
|
||||
valid: boolean;
|
||||
used: boolean;
|
||||
expired: boolean;
|
||||
email?: string | null;
|
||||
expires_at: string;
|
||||
};
|
||||
|
||||
export async function getInvite(token: string): Promise<InviteState> {
|
||||
return requestJson<InviteState>(`/auth/invites/${token}`);
|
||||
}
|
||||
|
||||
export async function acceptInvite(input: {
|
||||
token: string;
|
||||
name: string;
|
||||
email: string;
|
||||
password: string;
|
||||
}): Promise<void> {
|
||||
await requestJson('/auth/accept-invite', { method: 'POST', body: JSON.stringify(input) });
|
||||
}
|
||||
|
||||
export async function listInvites(): Promise<Invite[]> {
|
||||
const payload = await requestJson<{ invites?: Invite[] }>('/admin/invites');
|
||||
return payload.invites ?? [];
|
||||
}
|
||||
|
||||
export async function createInvite(input: { email?: string; expiresInHours?: number }): Promise<{
|
||||
invite: Invite;
|
||||
token: string;
|
||||
url: string;
|
||||
}> {
|
||||
const body: Record<string, unknown> = {};
|
||||
if (input.email) body.email = input.email;
|
||||
if (input.expiresInHours) body.expires_in_hours = input.expiresInHours;
|
||||
return requestJson('/admin/invites', { method: 'POST', body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
export async function revokeInvite(id: string): Promise<void> {
|
||||
await requestJson(`/admin/invites/${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';
|
||||
@@ -2350,6 +2350,482 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MessageResponse'
|
||||
|
||||
# Outbound Webhooks
|
||||
/webhooks:
|
||||
get:
|
||||
tags:
|
||||
- Webhooks
|
||||
summary: List outbound webhooks
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: Webhooks for the caller (secrets masked)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
webhooks:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/OutboundWebhook'
|
||||
post:
|
||||
tags:
|
||||
- Webhooks
|
||||
summary: Create an outbound webhook
|
||||
description: |
|
||||
Receives signed POST deliveries for every audited action matching
|
||||
the event list — "resource.action" names, "resource.*" wildcards,
|
||||
or "*" for everything. The secret is returned once; deliveries
|
||||
carry X-Containr-Signature: sha256=<hmac> over the raw body.
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- name
|
||||
- url
|
||||
- events
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
url:
|
||||
type: string
|
||||
secret:
|
||||
type: string
|
||||
description: Signing secret (auto-generated when omitted)
|
||||
events:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
example: ["service.*", "deployment.fail"]
|
||||
headers:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
enabled:
|
||||
type: boolean
|
||||
default: true
|
||||
responses:
|
||||
'201':
|
||||
description: Webhook created (secret revealed once)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/OutboundWebhook'
|
||||
'400':
|
||||
description: Invalid name, URL, or empty event list
|
||||
|
||||
/webhooks/{id}:
|
||||
patch:
|
||||
tags:
|
||||
- Webhooks
|
||||
summary: Update a webhook
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
url:
|
||||
type: string
|
||||
secret:
|
||||
type: string
|
||||
events:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
headers:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
enabled:
|
||||
type: boolean
|
||||
responses:
|
||||
'200':
|
||||
description: Updated webhook
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/OutboundWebhook'
|
||||
'404':
|
||||
description: Webhook not found
|
||||
delete:
|
||||
tags:
|
||||
- Webhooks
|
||||
summary: Delete a webhook
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Deleted
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MessageResponse'
|
||||
|
||||
/webhooks/{id}/deliveries:
|
||||
get:
|
||||
tags:
|
||||
- Webhooks
|
||||
summary: List delivery attempts for a webhook
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Recent deliveries (newest first, max 100)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
deliveries:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/WebhookDelivery'
|
||||
|
||||
/webhooks/{id}/test:
|
||||
post:
|
||||
tags:
|
||||
- Webhooks
|
||||
summary: Queue a test ping delivery
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'202':
|
||||
description: Delivery queued
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MessageResponse'
|
||||
|
||||
/operations:
|
||||
get:
|
||||
tags:
|
||||
- Operations
|
||||
summary: Cross-cutting operations view
|
||||
description: |
|
||||
Single snapshot of platform work scoped to the caller's projects
|
||||
(admins see all): active deployments, in-memory deploy-queue
|
||||
depth per service, deployments that failed in the last 24h,
|
||||
cron executions and database backups from the last 24h.
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: Operations snapshot
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
active_deployments:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
deploy_queue:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
service_id:
|
||||
type: string
|
||||
running:
|
||||
type: boolean
|
||||
queued:
|
||||
type: integer
|
||||
recent_failures:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
cron_runs:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
backups:
|
||||
type: array
|
||||
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
|
||||
|
||||
/auth/invites/{token}:
|
||||
get:
|
||||
tags:
|
||||
- Invites
|
||||
summary: Validate an invite token (public)
|
||||
responses:
|
||||
'200':
|
||||
description: Invite state
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
valid: {type: boolean}
|
||||
used: {type: boolean}
|
||||
expired: {type: boolean}
|
||||
email: {type: string, nullable: true}
|
||||
expires_at: {type: string, format: date-time}
|
||||
'404':
|
||||
description: Invite not found
|
||||
|
||||
/auth/accept-invite:
|
||||
post:
|
||||
tags:
|
||||
- Invites
|
||||
summary: Register via an invite link (public)
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [token, name, email, password]
|
||||
properties:
|
||||
token: {type: string}
|
||||
name: {type: string}
|
||||
email: {type: string}
|
||||
password: {type: string, minLength: 8}
|
||||
responses:
|
||||
'201':
|
||||
description: Created session
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/LoginResponse'
|
||||
'410':
|
||||
description: Invite used or expired
|
||||
|
||||
/admin/invites:
|
||||
get:
|
||||
tags:
|
||||
- Invites
|
||||
summary: List invites (admin)
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: All invites
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
invites:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
post:
|
||||
tags:
|
||||
- Invites
|
||||
summary: Create an invite link (admin)
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
email: {type: string, description: Optionally bind the invite to this email}
|
||||
expires_in_hours: {type: integer, default: 168, minimum: 1, maximum: 2160}
|
||||
responses:
|
||||
'201':
|
||||
description: Created invite with one-time token
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
invite: {type: object}
|
||||
token: {type: string, description: Shown once}
|
||||
url: {type: string}
|
||||
|
||||
/admin/invites/{id}:
|
||||
delete:
|
||||
tags:
|
||||
- Invites
|
||||
summary: Revoke an invite (admin)
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- {name: id, in: path, required: true, schema: {type: string}}
|
||||
responses:
|
||||
'204':
|
||||
description: Revoked
|
||||
|
||||
/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:
|
||||
@@ -2973,6 +3449,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
|
||||
@@ -5738,6 +6224,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
|
||||
@@ -6953,6 +7458,72 @@ components:
|
||||
unread:
|
||||
type: integer
|
||||
|
||||
OutboundWebhook:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
name:
|
||||
type: string
|
||||
url:
|
||||
type: string
|
||||
secret:
|
||||
type: string
|
||||
description: Only present on create/update responses
|
||||
events:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
headers:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
enabled:
|
||||
type: boolean
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
WebhookDelivery:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
webhook_id:
|
||||
type: string
|
||||
format: uuid
|
||||
event:
|
||||
type: string
|
||||
payload:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
status:
|
||||
type: string
|
||||
enum: [pending, success, failed]
|
||||
response_status:
|
||||
type: integer
|
||||
nullable: true
|
||||
response_body:
|
||||
type: string
|
||||
nullable: true
|
||||
attempts:
|
||||
type: integer
|
||||
duration_ms:
|
||||
type: integer
|
||||
nullable: true
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
delivered_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
|
||||
AuditLogListResponse:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -78,6 +78,16 @@ security scan|vulnerabilities|metrics
|
||||
gateway services|keys|analytics (admin)
|
||||
admin overview|users|settings|audit-logs
|
||||
notifications list|read
|
||||
webhooks list|create|update|delete|deliveries|test
|
||||
outbound signed webhooks — create <name> <url>
|
||||
--event 'service.*' (repeatable, '*' = all);
|
||||
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`
|
||||
invites list|create|revoke team invite links (admin); create
|
||||
prints the one-time acceptance URL
|
||||
up deploy the current directory
|
||||
```
|
||||
|
||||
|
||||
Reference in new issue
Block a user