Compare commits

..
Author SHA1 Message Date
Tomas Dvorak 549790c611 refactor(db): port auth and security handlers to sqlc
- auth.sql: user lookups, counts, register/mirror inserts, profile update
- security.sql: access EXISTS, vulnerability list/metrics/status,
  framework list, latest scan/report, project-id guards
- NULL description/service_id rows stay filtered, matching the original
  scan-error-and-skip behavior
- auth_users probe and filtered audit-log queries stay raw: one is a
  Better Auth table outside the schema, the other is dynamically built
- TestSecurityLiveInvariants covers the access predicate, severity
  ordering, upsert conflict path, and latest-row lookups against live
  Postgres

Authored By: TDvorak <[email protected]>
2026-10-10 07:36:21 +02:00
94 changed files with 4154 additions and 7741 deletions

No files matched your search

-3
View File
@@ -124,6 +124,3 @@ ref/
.playwright-mcp/ .playwright-mcp/
canvas-*.png canvas-*.png
.vercel .vercel
# Local design previews (not shipped)
design/
+4 -14
View File
@@ -1,6 +1,6 @@
# Containr — Roadmap 2: The dflow Convergence # Containr — Roadmap 2: The dflow Convergence
Last verified: **2026-10-10** — deep pass over the full dflow codebase Last verified: **2026-10-08** — deep pass over the full dflow codebase
(`~/Desktop/PROG+HTML/dflow`, community fork of dflow-sh/dflow, frozen for (`~/Desktop/PROG+HTML/dflow`, community fork of dflow-sh/dflow, frozen for
preservation) against Containr `main` (`15b5b0e`). preservation) against Containr `main` (`15b5b0e`).
@@ -337,9 +337,8 @@ and wired refs, one click or one CLI call.
and local + tags are rejected, empty array clears. Surfaces: and local + tags are rejected, empty array clears. Surfaces:
`--placement-tags` on services create/update, `--tags` on `--placement-tags` on services create/update, `--tags` on
`nodes update`, MCP args, OpenAPI, node-detail tag editor + `nodes update`, MCP args, OpenAPI, node-detail tag editor +
service-detail placement-tags input. Build-on-node and remote service-detail placement-tags input. Remaining: build-on-node,
sleep/wake both shipped (see below). Remaining: spread weighting remote sleep/wake, spread weighting by real capacity.
by real capacity.
- [x] **Agent self-upgrade** — `POST /agents/:id/upgrade` enqueues a - [x] **Agent self-upgrade** — `POST /agents/:id/upgrade` enqueues a
`self_upgrade` command carrying platform + sha256 + server version; `self_upgrade` command carrying platform + sha256 + server version;
the agent downloads its replacement from `GET /agents/download/ the agent downloads its replacement from `GET /agents/download/
@@ -538,18 +537,9 @@ and wired refs, one click or one CLI call.
workspace, CLI `environments` + MCP parity. Per-env vars/domains ride the workspace, CLI `environments` + MCP parity. Per-env vars/domains ride the
service's own scope (one env per service); promote ships via preview service's own scope (one env per service); promote ships via preview
promote. promote.
- [x] **sqlc port** — all static `database/sql` call sites → `sqlc/queries/` + generated `sqlcdb`. Merged: metrics + domains (#75), service clone/move (#76), preview environments (#77), deployments + rollback (#78), runtime ops (#79), cron (#80), sleep/wake sweeper (#81), git providers/repos/webhooks (#82), auth + security (#83), operations/settings/audit/activity/builds/misc + apwhy gateway (#84). Raw SQL remains only for runtime-assembled statements (filtered lists, sparse updates) and Better Auth `auth_users` probes. - [ ] **sqlc port** — ~265 raw `database/sql` sites → `sqlc/queries/`. Done in tranches (dynamic queries stay raw). Tranches merged: metrics storage + service domains (#75), service clone/move ops (#76), preview environments (#77), deployments + rollback (#78). Remaining: runtime, compliance, cron, git, security, auth, sleeper, scanner, gateway, and smaller files.
- [ ] **Tests** — canvas/wizard frontend coverage, deployment/template/ - [ ] **Tests** — canvas/wizard frontend coverage, deployment/template/
webhook table tests, Playwright e2e golden path. webhook table tests, Playwright e2e golden path.
- [x] **UI/UX audit** — browser-driven pass over the whole app (PR #85):
fixed the stuck connectivity banner (`pingServer` returned
`undefined`), stale persisted sessions, fabricated observability
metrics (now real `getServiceMetrics` aggregation), mobile overflow,
Security blank state, deep-linkable service tabs, and a dozen
polish items.
- [x] **Design previews** — gitignored `/design` holds three on-brand
HTML concepts (console, service detail, landing) on the product's
token system with a live white-label accent switcher.
- [x] **First-run wizard** — public `GET /setup/status` + authed - [x] **First-run wizard** — public `GET /setup/status` + authed
`POST /setup/complete`; `/setup` page steps admin account → GitHub App `POST /setup/complete`; `/setup` page steps admin account → GitHub App
manifest flow (callback returns to the wizard via a session marker) → manifest flow (callback returns to the wizard via a session marker) →
+30 -21
View File
@@ -12,7 +12,6 @@ import (
"time" "time"
"containr/internal/database" "containr/internal/database"
"containr/internal/database/sqlcdb"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
@@ -181,13 +180,13 @@ func handleGetProjectActivity(c *gin.Context) {
} }
// Access check: approved / owner / member / admin. // Access check: approved / owner / member / admin.
userUUID, _ := uuid.Parse(userID) var visible bool
visible, err := sqlcdb.New(db.DB).CheckSecurityProjectAccess(context.Background(), sqlcdb.CheckSecurityProjectAccessParams{ if err := db.QueryRow(
ID: uuid.MustParse(projectID), `SELECT p.is_approved OR p.owner_id = $2::uuid OR $3::bool
OwnerID: userUUID, OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $2::uuid)
Column3: contextIsAdmin(c), FROM projects p WHERE p.id = $1`,
}) projectID, userID, contextIsAdmin(c),
if err != nil || !visible { ).Scan(&visible); err != nil || !visible {
respondError(c, http.StatusNotFound, "NOT_FOUND", "project not found") respondError(c, http.StatusNotFound, "NOT_FOUND", "project not found")
return return
} }
@@ -238,12 +237,12 @@ func StartAuditRetention(ctx context.Context, db *database.DB) {
} }
prune := func() { prune := func() {
n, err := sqlcdb.New(db.DB).PruneAuditLogs(context.Background(), int32(days)) res, err := db.Exec(`DELETE FROM audit_logs WHERE created_at < NOW() - ($1::int * INTERVAL '1 day')`, days)
if err != nil { if err != nil {
log.Printf("audit retention prune failed: %v", err) log.Printf("audit retention prune failed: %v", err)
return return
} }
if n > 0 { if n, _ := res.RowsAffected(); n > 0 {
log.Printf("audit retention: pruned %d rows older than %d days", n, days) log.Printf("audit retention: pruned %d rows older than %d days", n, days)
} }
} }
@@ -266,24 +265,34 @@ func StartAuditRetention(ctx context.Context, db *database.DB) {
// backfillAuditMetadata classifies rows written before severity/category/label // backfillAuditMetadata classifies rows written before severity/category/label
// existed so filters and the feed see uniform metadata. // existed so filters and the feed see uniform metadata.
func backfillAuditMetadata(db *database.DB) { func backfillAuditMetadata(db *database.DB) {
q := sqlcdb.New(db.DB) cursor := uuid.Nil.String()
cursor := uuid.Nil
for { for {
rows, err := q.ListAuditBackfillBatch(context.Background(), cursor) rows, err := db.Query(
`SELECT id::text, resource, action FROM audit_logs
WHERE label = '' AND id > $1::uuid ORDER BY id LIMIT 500`, cursor)
if err != nil { if err != nil {
log.Printf("audit backfill scan failed: %v", err) log.Printf("audit backfill scan failed: %v", err)
return return
} }
if len(rows) == 0 { type row struct{ id, resource, action string }
var batch []row
for rows.Next() {
var r row
if rows.Scan(&r.id, &r.resource, &r.action) == nil {
batch = append(batch, r)
}
}
rows.Close()
if len(batch) == 0 {
return return
} }
for _, r := range rows { for _, r := range batch {
id, _ := uuid.Parse(r.ID) cursor = r.id
cursor = id sev, cat, label := classifyAuditEvent(r.resource, r.action)
sev, cat, label := classifyAuditEvent(r.Resource, r.Action) if _, err := db.Exec(
if err := q.SetAuditLogMetadata(context.Background(), sqlcdb.SetAuditLogMetadataParams{ `UPDATE audit_logs SET severity = $1, category = $2, label = $3 WHERE id = $4::uuid`,
Severity: sev, Category: cat, Label: label, Column4: id, sev, cat, label, r.id,
}); err != nil { ); err != nil {
log.Printf("audit backfill update failed: %v", err) log.Printf("audit backfill update failed: %v", err)
} }
} }
+34 -23
View File
@@ -52,11 +52,15 @@ func EnsureAdminAccount(ctx context.Context, db *database.DB, cfg *config.Config
return return
} }
if _, err := sqlcdb.New(db.DB).UpsertAdminUser(ctx, sqlcdb.UpsertAdminUserParams{ var userID string
Email: email, err = db.QueryRowContext(ctx, `
PasswordHash: string(hashed), INSERT INTO users (email, password_hash, name, is_admin)
Name: name, VALUES ($1, $2, $3, true)
}); err != nil { ON CONFLICT (email) DO UPDATE
SET is_admin = true, updated_at = NOW()
RETURNING id
`, email, string(hashed), name).Scan(&userID)
if err != nil {
log.Printf("Admin bootstrap failed to upsert user %s: %v", email, err) log.Printf("Admin bootstrap failed to upsert user %s: %v", email, err)
return return
} }
@@ -181,18 +185,24 @@ func handleAdminOverview(c *gin.Context) {
func handleAdminListUsers(c *gin.Context) { func handleAdminListUsers(c *gin.Context) {
db := c.MustGet("db").(*database.DB) db := c.MustGet("db").(*database.DB)
rows, err := sqlcdb.New(db.DB).ListAdminUsers(c.Request.Context()) rows, err := db.QueryContext(c.Request.Context(), `
SELECT id, email, name, COALESCE(avatar_url, ''), is_admin, COALESCE(to_char(created_at, 'YYYY-MM-DD"T"HH24:MI:SS"Z"'), '')
FROM users
ORDER BY created_at ASC
`)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list users"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list users"})
return return
} }
defer rows.Close()
users := make([]adminUserRow, 0, len(rows)) users := make([]adminUserRow, 0)
for _, r := range rows { for rows.Next() {
users = append(users, adminUserRow{ var u adminUserRow
ID: r.ID.String(), Email: r.Email, Name: r.Name, if err := rows.Scan(&u.ID, &u.Email, &u.Name, &u.AvatarURL, &u.IsAdmin, &u.CreatedAt); err != nil {
AvatarURL: r.AvatarUrl, IsAdmin: r.IsAdmin, CreatedAt: r.CreatedAt, continue
}) }
users = append(users, u)
} }
c.JSON(http.StatusOK, gin.H{"users": users}) c.JSON(http.StatusOK, gin.H{"users": users})
} }
@@ -219,16 +229,15 @@ func handleAdminSetUserAdmin(c *gin.Context) {
return return
} }
targetUUID, _ := uuid.Parse(targetID) result, err := db.ExecContext(c.Request.Context(),
n, err := sqlcdb.New(db.DB).SetUserAdmin(c.Request.Context(), sqlcdb.SetUserAdminParams{ `UPDATE users SET is_admin = $1, updated_at = NOW() WHERE id = $2`,
IsAdmin: req.IsAdmin, req.IsAdmin, targetID,
ID: targetUUID, )
})
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user"})
return return
} }
if n == 0 { if n, _ := result.RowsAffected(); n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return return
} }
@@ -249,13 +258,15 @@ func handleAdminImpersonateUser(c *gin.Context) {
return return
} }
userUUID, _ := uuid.Parse(targetID) var email, name string
info, err := sqlcdb.New(db.DB).GetAdminUserInfo(c.Request.Context(), userUUID) var isAdmin bool
err := db.QueryRowContext(c.Request.Context(),
`SELECT email, COALESCE(name, ''), is_admin FROM users WHERE id = $1`, targetID).
Scan(&email, &name, &isAdmin)
if err != nil { if err != nil {
respondError(c, http.StatusNotFound, "NOT_FOUND", "user not found") respondError(c, http.StatusNotFound, "NOT_FOUND", "user not found")
return return
} }
email, name, isAdmin := info.Email, info.Name, info.IsAdmin
if isAdmin { if isAdmin {
respondError(c, http.StatusForbidden, "FORBIDDEN", "admin accounts cannot be impersonated") respondError(c, http.StatusForbidden, "FORBIDDEN", "admin accounts cannot be impersonated")
return return
@@ -275,9 +286,9 @@ func handleAdminImpersonateUser(c *gin.Context) {
} }
LogAuditWithRequest(c, "user", targetID, "impersonate", map[string]interface{}{ LogAuditWithRequest(c, "user", targetID, "impersonate", map[string]interface{}{
"actor_id": actorID, "actor_id": actorID,
"target_email": email, "target_email": email,
"expires_at": expiresAt.Format(time.RFC3339), "expires_at": expiresAt.Format(time.RFC3339),
}) })
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"token": token, "token": token,
+189 -121
View File
@@ -2,7 +2,6 @@ package api
import ( import (
"containr/internal/database" "containr/internal/database"
"containr/internal/database/sqlcdb"
"context" "context"
"crypto/rand" "crypto/rand"
"database/sql" "database/sql"
@@ -15,7 +14,6 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
"github.com/google/uuid"
"github.com/lib/pq" "github.com/lib/pq"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
@@ -162,22 +160,21 @@ func handleAPwhyServiceValidate(c *gin.Context) {
db := c.MustGet("db").(*database.DB) db := c.MustGet("db").(*database.DB)
ctx := c.Request.Context() ctx := c.Request.Context()
svcUUID, parseErr := uuid.Parse(serviceID) var upstreamURL, healthPath string
var svc sqlcdb.GetAPServiceForValidateRow var authHeader, authValue sql.NullString
var err error var timeoutMs sql.NullInt64
if parseErr == nil { err := db.QueryRowContext(ctx, `
svc, err = sqlcdb.New(db.DB).GetAPServiceForValidate(ctx, svcUUID) SELECT upstream_url, health_path, upstream_auth_header, upstream_auth_value, request_timeout_ms
} FROM api_services WHERE id = $1
if parseErr != nil || err != nil { `, serviceID).Scan(&upstreamURL, &healthPath, &authHeader, &authValue, &timeoutMs)
if err != nil {
sendErrorResponse(c, http.StatusNotFound, "SERVICE_NOT_FOUND", "Service not found", nil) sendErrorResponse(c, http.StatusNotFound, "SERVICE_NOT_FOUND", "Service not found", nil)
return return
} }
upstreamURL, healthPath := svc.UpstreamUrl, svc.HealthPath
authHeader, authValue := svc.UpstreamAuthHeader, svc.UpstreamAuthValue
timeout := 8000 timeout := 8000
if svc.RequestTimeoutMs.Valid && svc.RequestTimeoutMs.Int32 > 0 { if timeoutMs.Valid && timeoutMs.Int64 > 0 {
timeout = int(svc.RequestTimeoutMs.Int32) timeout = int(timeoutMs.Int64)
} }
target := strings.TrimRight(upstreamURL, "/") + "/" + strings.TrimLeft(healthPath, "/") target := strings.TrimRight(upstreamURL, "/") + "/" + strings.TrimLeft(healthPath, "/")
@@ -206,19 +203,17 @@ func handleAPwhyServiceValidate(c *gin.Context) {
validationStatus = "healthy" validationStatus = "healthy"
} }
q := sqlcdb.New(db.DB) _, _ = db.ExecContext(ctx, `
_ = q.SetAPServiceValidation(ctx, sqlcdb.SetAPServiceValidationParams{ UPDATE api_services
LastValidationStatus: sql.NullString{String: validationStatus, Valid: true}, SET last_validation_at = NOW(), last_validation_status = $1, last_validation_message = $2, updated_at = NOW()
LastValidationMessage: sql.NullString{String: message, Valid: true}, WHERE id = $3
ID: svcUUID, `, validationStatus, message, serviceID)
})
if !ok { if !ok {
_ = q.InsertServiceIncident(ctx, sqlcdb.InsertServiceIncidentParams{ _, _ = db.ExecContext(ctx, `
ServiceID: uuid.NullUUID{UUID: svcUUID, Valid: true}, INSERT INTO incident_events (service_id, code, message, severity, http_status, occurred_at)
Message: message, VALUES ($1, 'SERVICE_VALIDATION_FAILED', $2, 'medium', $3, NOW())
HttpStatus: sql.NullInt32{Int32: int32(status), Valid: true}, `, serviceID, message, status)
})
} }
sendSuccessResponse(c, http.StatusOK, map[string]interface{}{ sendSuccessResponse(c, http.StatusOK, map[string]interface{}{
@@ -236,24 +231,36 @@ func handleAPwhyServicesList(c *gin.Context) {
ctx := context.Background() ctx := context.Background()
// Query services from database // Query services from database
rows, err := sqlcdb.New(db.DB).ListAPServices(ctx) rows, err := db.QueryContext(ctx, `
SELECT id, name, slug, upstream_url, route_prefix, enabled, created_at, updated_at
FROM api_services
ORDER BY created_at DESC
`)
if err != nil { if err != nil {
sendErrorResponse(c, http.StatusInternalServerError, "DATABASE_ERROR", sendErrorResponse(c, http.StatusInternalServerError, "DATABASE_ERROR",
"Failed to query services", err.Error()) "Failed to query services", err.Error())
return return
} }
defer rows.Close()
services := make([]map[string]interface{}, 0)
for rows.Next() {
var id, name, slug, upstreamURL, routePrefix, createdAt, updatedAt string
var enabled int
err := rows.Scan(&id, &name, &slug, &upstreamURL, &routePrefix, &enabled, &createdAt, &updatedAt)
if err != nil {
continue // Skip malformed rows
}
services := make([]map[string]interface{}, 0, len(rows))
for _, r := range rows {
services = append(services, map[string]interface{}{ services = append(services, map[string]interface{}{
"id": r.ID.String(), "id": id,
"name": r.Name, "name": name,
"slug": r.Slug, "slug": slug,
"upstreamUrl": r.UpstreamUrl, "upstreamUrl": upstreamURL,
"routePrefix": r.RoutePrefix, "routePrefix": routePrefix,
"enabled": r.Enabled != 0, "enabled": enabled != 0,
"createdAt": r.CreatedAt, "createdAt": createdAt,
"updatedAt": r.UpdatedAt, "updatedAt": updatedAt,
}) })
} }
@@ -292,14 +299,14 @@ func handleAPwhyServicesCreate(c *gin.Context) {
} }
// id defaults to gen_random_uuid(); enabled defaults to 1. // id defaults to gen_random_uuid(); enabled defaults to 1.
svcID, err := sqlcdb.New(db.DB).InsertAPService(ctx, sqlcdb.InsertAPServiceParams{ var id string
Name: req.Name, err := db.QueryRowContext(ctx, `
Slug: slug, INSERT INTO api_services (
UpstreamUrl: req.UpstreamURL, name, slug, upstream_url, route_prefix, health_path,
RoutePrefix: req.RoutePrefix, rpm_limit, monthly_quota, created_at, updated_at
RpmLimit: sql.NullInt32{Int32: int32(rpmLimit), Valid: true}, ) VALUES ($1, $2, $3, $4, '/health', $5, $6, NOW(), NOW())
MonthlyQuota: sql.NullInt32{Int32: int32(monthlyQuota), Valid: true}, RETURNING id
}) `, req.Name, slug, req.UpstreamURL, req.RoutePrefix, rpmLimit, monthlyQuota).Scan(&id)
if err != nil { if err != nil {
if isUniqueViolation(err) { if isUniqueViolation(err) {
@@ -313,7 +320,7 @@ func handleAPwhyServicesCreate(c *gin.Context) {
} }
serviceData := map[string]interface{}{ serviceData := map[string]interface{}{
"id": svcID.String(), "id": id,
"name": req.Name, "name": req.Name,
"slug": slug, "slug": slug,
"upstreamUrl": req.UpstreamURL, "upstreamUrl": req.UpstreamURL,
@@ -349,15 +356,13 @@ func handleAPwhyServicesPatch(c *gin.Context) {
db := c.MustGet("db").(*database.DB) db := c.MustGet("db").(*database.DB)
if input.Enabled != nil { if input.Enabled != nil {
enabled := int32(0) enabled := 0
if *input.Enabled { if *input.Enabled {
enabled = 1 enabled = 1
} }
svcUUID, _ := uuid.Parse(serviceID) _, err := db.ExecContext(context.Background(),
err := sqlcdb.New(db.DB).SetAPServiceEnabled(context.Background(), sqlcdb.SetAPServiceEnabledParams{ "UPDATE api_services SET enabled = $1, updated_at = NOW() WHERE id = $2",
Enabled: enabled, enabled, serviceID)
ID: svcUUID,
})
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
@@ -378,7 +383,11 @@ func handleAPwhyServicesPatch(c *gin.Context) {
func handleAPwhyKeysList(c *gin.Context) { func handleAPwhyKeysList(c *gin.Context) {
db := c.MustGet("db").(*database.DB) db := c.MustGet("db").(*database.DB)
rows, err := sqlcdb.New(db.DB).ListAPKeys(context.Background()) rows, err := db.QueryContext(context.Background(), `
SELECT id, name, key_prefix, plan, enabled, rpm_limit, monthly_quota, created_at, updated_at
FROM api_keys
ORDER BY created_at DESC
`)
if err != nil { if err != nil {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"ok": true, "ok": true,
@@ -386,24 +395,34 @@ func handleAPwhyKeysList(c *gin.Context) {
}) })
return return
} }
defer rows.Close()
keys := make([]map[string]interface{}, 0)
for rows.Next() {
var id, name, keyPrefix, plan, createdAt, updatedAt string
var enabled int
var rpmLimit, monthlyQuota sql.NullInt64
err := rows.Scan(&id, &name, &keyPrefix, &plan, &enabled, &rpmLimit, &monthlyQuota, &createdAt, &updatedAt)
if err != nil {
continue
}
keys := make([]map[string]interface{}, 0, len(rows))
for _, r := range rows {
key := map[string]interface{}{ key := map[string]interface{}{
"id": r.ID.String(), "id": id,
"name": r.Name, "name": name,
"keyPrefix": r.KeyPrefix, "keyPrefix": keyPrefix,
"plan": r.Plan, "plan": plan,
"enabled": r.Enabled != 0, "enabled": enabled != 0,
"createdAt": r.CreatedAt, "createdAt": createdAt,
"updatedAt": r.UpdatedAt, "updatedAt": updatedAt,
} }
if r.RpmLimit.Valid { if rpmLimit.Valid {
key["rpmLimit"] = int64(r.RpmLimit.Int32) key["rpmLimit"] = rpmLimit.Int64
} }
if r.MonthlyQuota.Valid { if monthlyQuota.Valid {
key["monthlyQuota"] = int64(r.MonthlyQuota.Int32) key["monthlyQuota"] = monthlyQuota.Int64
} }
keys = append(keys, key) keys = append(keys, key)
@@ -462,14 +481,14 @@ func handleAPwhyKeysCreate(c *gin.Context) {
} }
// id defaults to gen_random_uuid(); enabled defaults to 1. // id defaults to gen_random_uuid(); enabled defaults to 1.
keyID, err := sqlcdb.New(db.DB).InsertAPKey(context.Background(), sqlcdb.InsertAPKeyParams{ var id string
Name: input.Name, err = db.QueryRowContext(context.Background(), `
KeyHash: keyHash, INSERT INTO api_keys (
KeyPrefix: keyPrefix, name, key_hash, key_prefix, plan, allowed_service_ids,
Plan: plan, rpm_limit, monthly_quota, created_at, updated_at
RpmLimit: sql.NullInt32{Int32: int32(rpmLimit), Valid: true}, ) VALUES ($1, $2, $3, $4, '[]', $5, $6, NOW(), NOW())
MonthlyQuota: sql.NullInt32{Int32: int32(monthlyQuota), Valid: true}, RETURNING id
}) `, input.Name, keyHash, keyPrefix, plan, rpmLimit, monthlyQuota).Scan(&id)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
@@ -482,7 +501,7 @@ func handleAPwhyKeysCreate(c *gin.Context) {
c.JSON(http.StatusCreated, gin.H{ c.JSON(http.StatusCreated, gin.H{
"ok": true, "ok": true,
"data": gin.H{ "data": gin.H{
"id": keyID.String(), "id": id,
"name": input.Name, "name": input.Name,
"plan": plan, "plan": plan,
"key": apiKey, // Only return the actual key once "key": apiKey, // Only return the actual key once
@@ -513,15 +532,13 @@ func handleAPwhyKeysPatch(c *gin.Context) {
db := c.MustGet("db").(*database.DB) db := c.MustGet("db").(*database.DB)
if input.Enabled != nil { if input.Enabled != nil {
enabled := int32(0) enabled := 0
if *input.Enabled { if *input.Enabled {
enabled = 1 enabled = 1
} }
keyUUID, _ := uuid.Parse(keyID) _, err := db.ExecContext(context.Background(),
err := sqlcdb.New(db.DB).SetAPKeyEnabled(context.Background(), sqlcdb.SetAPKeyEnabledParams{ "UPDATE api_keys SET enabled = $1, updated_at = NOW() WHERE id = $2",
Enabled: enabled, enabled, keyID)
ID: keyUUID,
})
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
@@ -543,27 +560,23 @@ func handleAPwhyAnalyticsOps(c *gin.Context) {
db := c.MustGet("db").(*database.DB) db := c.MustGet("db").(*database.DB)
// Get counts from database // Get counts from database
q := sqlcdb.New(db.DB) var totalServices, totalKeys, totalUsers int
ctx := context.Background()
totalServices, err := q.CountAPServices(ctx) if err := db.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM api_services").Scan(&totalServices); err != nil {
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"ok": false, "ok": false,
"error": "Failed to count services: " + err.Error(), "error": "Failed to count services: " + err.Error(),
}) })
return return
} }
totalKeys, err := q.CountAPKeys(ctx) if err := db.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM api_keys").Scan(&totalKeys); err != nil {
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"ok": false, "ok": false,
"error": "Failed to count api keys: " + err.Error(), "error": "Failed to count api keys: " + err.Error(),
}) })
return return
} }
totalUsers, err := q.CountUsers(ctx) if err := db.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM users").Scan(&totalUsers); err != nil {
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"ok": false, "ok": false,
"error": "Failed to count users: " + err.Error(), "error": "Failed to count users: " + err.Error(),
@@ -571,8 +584,8 @@ func handleAPwhyAnalyticsOps(c *gin.Context) {
return return
} }
totalRequests, err := q.SumUsageCounters(ctx) var totalRequests int
if err != nil { if err := db.QueryRowContext(context.Background(), "SELECT COALESCE(SUM(request_count), 0) FROM usage_counters").Scan(&totalRequests); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"ok": false, "ok": false,
"error": "Failed to aggregate request counters: " + err.Error(), "error": "Failed to aggregate request counters: " + err.Error(),
@@ -580,8 +593,12 @@ func handleAPwhyAnalyticsOps(c *gin.Context) {
return return
} }
requestsToday, err := q.SumRequestMetricSince(ctx, "day") var requestsToday int
if err != nil { if err := db.QueryRowContext(context.Background(), `
SELECT COALESCE(SUM(value), 0)::int
FROM metrics_timeseries
WHERE metric = 'request_total' AND occurred_at >= DATE_TRUNC('day', NOW())
`).Scan(&requestsToday); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"ok": false, "ok": false,
"error": "Failed to aggregate today's requests: " + err.Error(), "error": "Failed to aggregate today's requests: " + err.Error(),
@@ -589,8 +606,12 @@ func handleAPwhyAnalyticsOps(c *gin.Context) {
return return
} }
requestsThisMonth, err := q.SumRequestMetricSince(ctx, "month") var requestsThisMonth int
if err != nil { if err := db.QueryRowContext(context.Background(), `
SELECT COALESCE(SUM(value), 0)::int
FROM metrics_timeseries
WHERE metric = 'request_total' AND occurred_at >= DATE_TRUNC('month', NOW())
`).Scan(&requestsThisMonth); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"ok": false, "ok": false,
"error": "Failed to aggregate monthly requests: " + err.Error(), "error": "Failed to aggregate monthly requests: " + err.Error(),
@@ -615,47 +636,94 @@ func handleAPwhyAnalyticsOps(c *gin.Context) {
func handleAPwhyAnalyticsTraffic(c *gin.Context) { func handleAPwhyAnalyticsTraffic(c *gin.Context) {
db := c.MustGet("db").(*database.DB) db := c.MustGet("db").(*database.DB)
q := sqlcdb.New(db.DB)
ctx := context.Background()
topServices := make([]map[string]interface{}, 0) topServices := make([]map[string]interface{}, 0)
if serviceRows, err := q.ListTopAPServices(ctx); err == nil { serviceRows, err := db.QueryContext(context.Background(), `
for _, r := range serviceRows { SELECT s.id, s.name, COALESCE(SUM(u.request_count), 0) AS total_requests
topServices = append(topServices, map[string]interface{}{ FROM api_services s
"service_id": r.ID.String(), LEFT JOIN usage_counters u ON u.service_id = s.id
"name": r.Name, GROUP BY s.id, s.name
"requests": r.TotalRequests, ORDER BY total_requests DESC, s.name ASC
}) LIMIT 10
`)
if err == nil {
defer serviceRows.Close()
for serviceRows.Next() {
var serviceID, serviceName string
var totalRequests int
if scanErr := serviceRows.Scan(&serviceID, &serviceName, &totalRequests); scanErr == nil {
topServices = append(topServices, map[string]interface{}{
"service_id": serviceID,
"name": serviceName,
"requests": totalRequests,
})
}
} }
} }
requestsByDay := make([]map[string]interface{}, 0) requestsByDay := make([]map[string]interface{}, 0)
if trafficRows, err := q.ListRequestsByDay(ctx); err == nil { trafficRows, err := db.QueryContext(context.Background(), `
for _, r := range trafficRows { SELECT TO_CHAR(DATE_TRUNC('day', occurred_at), 'YYYY-MM-DD') AS day_bucket, COUNT(*) AS total
requestsByDay = append(requestsByDay, map[string]interface{}{ FROM metrics_timeseries
"day": r.DayBucket, WHERE metric = 'request_total' AND occurred_at >= NOW() - INTERVAL '7 days'
"requests": r.Total, GROUP BY DATE_TRUNC('day', occurred_at)
}) ORDER BY DATE_TRUNC('day', occurred_at) ASC
`)
if err == nil {
defer trafficRows.Close()
for trafficRows.Next() {
var day string
var count int
if scanErr := trafficRows.Scan(&day, &count); scanErr == nil {
requestsByDay = append(requestsByDay, map[string]interface{}{
"day": day,
"requests": count,
})
}
} }
} }
statusCodes := make([]map[string]interface{}, 0) statusCodes := make([]map[string]interface{}, 0)
if statusRows, err := q.ListIncidentStatusCodes(ctx); err == nil { statusRows, err := db.QueryContext(context.Background(), `
for _, r := range statusRows { SELECT COALESCE(http_status, 0) AS status_code, COALESCE(SUM(count), 0) AS total
statusCodes = append(statusCodes, map[string]interface{}{ FROM incident_events
"status_code": r.StatusCode, WHERE occurred_at >= NOW() - INTERVAL '7 days'
"count": r.Total, GROUP BY COALESCE(http_status, 0)
}) ORDER BY total DESC, status_code ASC
`)
if err == nil {
defer statusRows.Close()
for statusRows.Next() {
var code int
var total int
if scanErr := statusRows.Scan(&code, &total); scanErr == nil {
statusCodes = append(statusCodes, map[string]interface{}{
"status_code": code,
"count": total,
})
}
} }
} }
clientEvents := make([]map[string]interface{}, 0) clientEvents := make([]map[string]interface{}, 0)
if eventRows, err := q.ListClientEventPaths(ctx); err == nil { eventRows, err := db.QueryContext(context.Background(), `
for _, r := range eventRows { SELECT COALESCE((labels_json::jsonb ->> 'path'), 'unknown') AS path, COUNT(*) AS total
clientEvents = append(clientEvents, map[string]interface{}{ FROM metrics_timeseries
"path": r.Path, WHERE metric = 'client_event' AND occurred_at >= NOW() - INTERVAL '7 days'
"count": r.Total, GROUP BY COALESCE((labels_json::jsonb ->> 'path'), 'unknown')
}) ORDER BY total DESC, path ASC
LIMIT 20
`)
if err == nil {
defer eventRows.Close()
for eventRows.Next() {
var path string
var total int
if scanErr := eventRows.Scan(&path, &total); scanErr == nil {
clientEvents = append(clientEvents, map[string]interface{}{
"path": path,
"count": total,
})
}
} }
} }
+34 -54
View File
@@ -2,9 +2,6 @@ package api
import ( import (
"containr/internal/database" "containr/internal/database"
"containr/internal/database/sqlcdb"
"context"
"database/sql"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
@@ -14,7 +11,6 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/sqlc-dev/pqtype"
) )
type AuditLog struct { type AuditLog struct {
@@ -102,20 +98,16 @@ func LogAudit(userID, resource, resourceID, action string, details map[string]in
} }
detailsJSON, _ := json.Marshal(details) detailsJSON, _ := json.Marshal(details)
resourceUUID := parseUUIDOrNil(resourceID)
userUUID := parseUUIDOrNil(userID)
severity, category, label := classifyAuditEvent(resource, action) severity, category, label := classifyAuditEvent(resource, action)
err := sqlcdb.New(db.DB).InsertAuditLog(context.Background(), sqlcdb.InsertAuditLogParams{ auditID := uuid.New().String()
ID: uuid.New(), _, err := db.Exec(
UserID: nullUUIDOrNil(userID), `INSERT INTO audit_logs (id, user_id, resource, resource_id, action, details, severity, category, label, created_at)
Resource: resource, VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
ResourceID: nullUUIDOrNil(resourceID), auditID, userUUID, resource, resourceUUID, action, string(detailsJSON), severity, category, label, time.Now().UTC(),
Action: action, )
Details: pqtype.NullRawMessage{RawMessage: detailsJSON, Valid: true},
Severity: severity,
Category: category,
Label: label,
CreatedAt: time.Now().UTC(),
})
if err != nil { if err != nil {
} }
@@ -139,24 +131,16 @@ func LogAuditWithRequest(c *gin.Context, resource, resourceID, action string, de
if uid, ok := userID.(string); ok { if uid, ok := userID.(string); ok {
userIDStr = uid userIDStr = uid
} }
userUUID := parseUUIDOrNil(userIDStr)
resourceUUID := parseUUIDOrNil(resourceID)
severity, category, label := classifyAuditEvent(resource, action) severity, category, label := classifyAuditEvent(resource, action)
var inet pqtype.Inet auditID := uuid.New().String()
_ = inet.Scan(c.ClientIP()) _, err := db.Exec(
err := sqlcdb.New(db.DB).InsertAuditLogWithRequest(context.Background(), sqlcdb.InsertAuditLogWithRequestParams{ `INSERT INTO audit_logs (id, user_id, resource, resource_id, action, details, ip_address, user_agent, severity, category, label, created_at)
ID: uuid.New(), VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9, $10, $11, $12)`,
UserID: nullUUIDOrNil(userIDStr), auditID, userUUID, resource, resourceUUID, action, string(detailsJSON), c.ClientIP(), c.GetHeader("User-Agent"), severity, category, label, time.Now().UTC(),
Resource: resource, )
ResourceID: nullUUIDOrNil(resourceID),
Action: action,
Column6: json.RawMessage(detailsJSON),
Column7: inet,
UserAgent: sql.NullString{String: c.GetHeader("User-Agent"), Valid: true},
Severity: severity,
Category: category,
Label: label,
CreatedAt: time.Now().UTC(),
})
if err != nil { if err != nil {
} }
@@ -271,29 +255,30 @@ func handleGetResourceAuditLogs(c *gin.Context) {
resource := c.Param("resource") resource := c.Param("resource")
resourceID := c.Param("id") resourceID := c.Param("id")
rows, err := sqlcdb.New(db.DB).ListResourceAuditLogs(context.Background(), sqlcdb.ListResourceAuditLogsParams{ rows, err := db.Query(
UserID: nullUUIDOrNil(userID), `SELECT id, COALESCE(user_id::text, ''), resource, COALESCE(resource_id::text, ''), action, COALESCE(details::text, '{}'),
Resource: resource, COALESCE(ip_address::text, ''), COALESCE(user_agent, ''), created_at
ResourceID: nullUUIDOrNil(resourceID), FROM audit_logs
}) WHERE user_id::text = $1 AND resource = $2 AND resource_id::text = $3
ORDER BY created_at DESC
LIMIT 100`,
userID, resource, resourceID,
)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch audit logs"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch audit logs"})
return return
} }
defer rows.Close()
var logs []AuditLog var logs []AuditLog
for _, row := range rows { for rows.Next() {
logs = append(logs, AuditLog{ var log AuditLog
ID: row.ID.String(), err := rows.Scan(&log.ID, &log.UserID, &log.Resource, &log.ResourceID, &log.Action, &log.Details, &log.IPAddress, &log.UserAgent, &log.CreatedAt)
UserID: row.UserID, if err != nil {
Resource: row.Resource, continue
ResourceID: row.ResourceID, }
Action: row.Action, logs = append(logs, log)
Details: row.Details,
IPAddress: row.IpAddress,
UserAgent: row.UserAgent,
CreatedAt: row.CreatedAt,
})
} }
c.JSON(http.StatusOK, gin.H{"audit_logs": logs}) c.JSON(http.StatusOK, gin.H{"audit_logs": logs})
@@ -317,8 +302,3 @@ func parseUUIDOrNil(raw string) interface{} {
} }
return trimmed return trimmed
} }
func nullUUIDOrNil(raw string) uuid.NullUUID {
parsed, err := uuid.Parse(strings.TrimSpace(raw))
return uuid.NullUUID{UUID: parsed, Valid: err == nil}
}
+65 -55
View File
@@ -16,7 +16,6 @@ import (
"containr/internal/build" "containr/internal/build"
"containr/internal/database" "containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/docker" "containr/internal/docker"
"containr/internal/types" "containr/internal/types"
@@ -670,22 +669,39 @@ func (h *BuildHandler) upsertBuild(status *BuildStatusResponse) error {
} }
now := time.Now().UTC() now := time.Now().UTC()
err := sqlcdb.New(h.db.DB).UpsertBuild(context.Background(), sqlcdb.UpsertBuildParams{ _, err := h.db.Exec(
ID: status.ID, `INSERT INTO builds
ProjectID: nsNullIfEmpty(status.ProjectID), (id, project_id, service_id, status, progress, started_at, completed_at, image_name, image_tag, size, error, log, metadata, created_at, updated_at)
ServiceID: nsNullIfEmpty(status.ServiceID), VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb, $14, $14)
Status: status.Status, ON CONFLICT (id) DO UPDATE SET
Progress: int32(status.Progress), project_id = EXCLUDED.project_id,
StartedAt: status.StartedAt, service_id = EXCLUDED.service_id,
CompletedAt: ntPtr(status.CompletedAt), status = EXCLUDED.status,
ImageName: status.ImageName, progress = EXCLUDED.progress,
ImageTag: status.ImageTag, started_at = EXCLUDED.started_at,
Size: status.Size, completed_at = EXCLUDED.completed_at,
Error: nsNullIfEmpty(status.Error), image_name = EXCLUDED.image_name,
Log: status.Log, image_tag = EXCLUDED.image_tag,
Column13: json.RawMessage(jsonOrEmptyObject(metadataRaw)), size = EXCLUDED.size,
CreatedAt: now, error = EXCLUDED.error,
}) log = EXCLUDED.log,
metadata = EXCLUDED.metadata,
updated_at = EXCLUDED.updated_at`,
status.ID,
nullIfEmptyString(status.ProjectID),
nullIfEmptyString(status.ServiceID),
status.Status,
status.Progress,
status.StartedAt,
status.CompletedAt,
status.ImageName,
status.ImageTag,
status.Size,
nullIfEmptyString(status.Error),
status.Log,
jsonOrEmptyObject(metadataRaw),
now,
)
if err != nil && h.isMissingBuildsTable(err) { if err != nil && h.isMissingBuildsTable(err) {
return nil return nil
} }
@@ -697,37 +713,18 @@ func (h *BuildHandler) getBuildFromDB(buildID string) (BuildStatusResponse, bool
return BuildStatusResponse{}, false, nil return BuildStatusResponse{}, false, nil
} }
row, err := sqlcdb.New(h.db.DB).GetBuild(context.Background(), buildID) row := h.db.QueryRow(
if err != nil { `SELECT id, project_id, service_id, status, progress, started_at, completed_at, image_name, image_tag, size, error, log, metadata
if errors.Is(err, sql.ErrNoRows) || h.isMissingBuildsTable(err) { FROM builds
return BuildStatusResponse{}, false, nil WHERE id = $1`,
} buildID,
return BuildStatusResponse{}, false, err )
build, found, err := scanBuildRow(row.Scan)
if err != nil && h.isMissingBuildsTable(err) {
return BuildStatusResponse{}, false, nil
} }
build := BuildStatusResponse{ return build, found, err
ID: row.ID,
ProjectID: row.ProjectID.String,
ServiceID: row.ServiceID.String,
Status: row.Status,
Progress: int(row.Progress),
StartedAt: row.StartedAt.UTC(),
ImageName: row.ImageName,
ImageTag: row.ImageTag,
Size: row.Size,
Error: row.Error.String,
Log: row.Log,
}
if t := timePtrNT(row.CompletedAt); t != nil {
u := t.UTC()
build.CompletedAt = &u
}
if len(row.Metadata) > 0 {
metadata := map[string]string{}
if err := json.Unmarshal(row.Metadata, &metadata); err == nil {
build.Metadata = metadata
}
}
return build, true, nil
} }
func (h *BuildHandler) listBuildsFromDB(projectID, serviceID, status string, page, limit int) ([]BuildStatusResponse, int, error) { func (h *BuildHandler) listBuildsFromDB(projectID, serviceID, status string, page, limit int) ([]BuildStatusResponse, int, error) {
@@ -811,11 +808,19 @@ func (h *BuildHandler) cancelBuildInDB(buildID string) bool {
return false return false
} }
now := time.Now().UTC() now := time.Now().UTC()
affected, err := sqlcdb.New(h.db.DB).CancelBuild(context.Background(), sqlcdb.CancelBuildParams{ result, err := h.db.Exec(
CompletedAt: sql.NullTime{Time: now, Valid: true}, `UPDATE builds
Log: h.formatLogLine("Cancellation requested") + "\n", SET status = 'cancelled',
ID: buildID, progress = 100,
}) completed_at = $1,
log = COALESCE(log, '') || $2,
updated_at = $1
WHERE id = $3
AND status NOT IN ('success', 'failed', 'cancelled')`,
now,
h.formatLogLine("Cancellation requested")+"\n",
buildID,
)
if err != nil { if err != nil {
if h.isMissingBuildsTable(err) { if h.isMissingBuildsTable(err) {
return false return false
@@ -823,7 +828,9 @@ func (h *BuildHandler) cancelBuildInDB(buildID string) bool {
log.Printf("failed to cancel build %s in database: %v", buildID, err) log.Printf("failed to cancel build %s in database: %v", buildID, err)
return false return false
} }
if affected == 0 {
affected, err := result.RowsAffected()
if err != nil || affected == 0 {
return false return false
} }
@@ -902,9 +909,12 @@ func scanBuildRow(scan func(dest ...interface{}) error) (BuildStatusResponse, bo
return parsed, true, nil return parsed, true, nil
} }
func nsNullIfEmpty(value string) sql.NullString { func nullIfEmptyString(value string) interface{} {
trimmed := strings.TrimSpace(value) trimmed := strings.TrimSpace(value)
return sql.NullString{String: trimmed, Valid: trimmed != ""} if trimmed == "" {
return nil
}
return trimmed
} }
func jsonOrEmptyObject(raw []byte) string { func jsonOrEmptyObject(raw []byte) string {
+23 -18
View File
@@ -1,11 +1,9 @@
package api package api
import ( import (
"context"
"net/http" "net/http"
"containr/internal/database" "containr/internal/database"
"containr/internal/database/sqlcdb"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
@@ -59,12 +57,15 @@ func contextIsAdmin(c *gin.Context) bool {
// public; unapproved ones are visible to the owner, project members, and // public; unapproved ones are visible to the owner, project members, and
// platform admins only. // platform admins only.
func projectReadAccess(c *gin.Context, db *database.DB, projectID uuid.UUID) (bool, bool) { func projectReadAccess(c *gin.Context, db *database.DB, projectID uuid.UUID) (bool, bool) {
q := sqlcdb.New(db.DB) var isApproved bool
row, err := q.GetProjectReadAccess(context.Background(), projectID) var ownerID string
err := db.QueryRow(
`SELECT is_approved, owner_id::text FROM projects WHERE id = $1`,
projectID,
).Scan(&isApproved, &ownerID)
if err != nil { if err != nil {
return false, false return false, false
} }
isApproved, ownerID := row.IsApproved, row.OwnerID
if isApproved || contextIsAdmin(c) { if isApproved || contextIsAdmin(c) {
return true, true return true, true
} }
@@ -75,11 +76,11 @@ func projectReadAccess(c *gin.Context, db *database.DB, projectID uuid.UUID) (bo
if ownerID == userID { if ownerID == userID {
return true, true return true, true
} }
userUUID, _ := uuid.Parse(userID) var member bool
if member, err := q.ProjectMemberExists(context.Background(), sqlcdb.ProjectMemberExistsParams{ if err := db.QueryRow(
ProjectID: projectID, `SELECT EXISTS(SELECT 1 FROM project_members WHERE project_id = $1 AND user_id = $2)`,
UserID: userUUID, projectID, userID,
}); err == nil && member { ).Scan(&member); err == nil && member {
return true, true return true, true
} }
return true, false return true, false
@@ -90,8 +91,11 @@ func projectReadAccess(c *gin.Context, db *database.DB, projectID uuid.UUID) (bo
// project members, and platform admins may reach secret-adjacent data // project members, and platform admins may reach secret-adjacent data
// such as variables and runtime logs. // such as variables and runtime logs.
func projectManageAccess(c *gin.Context, db *database.DB, projectID uuid.UUID) (bool, bool) { func projectManageAccess(c *gin.Context, db *database.DB, projectID uuid.UUID) (bool, bool) {
q := sqlcdb.New(db.DB) var ownerID string
ownerID, err := q.GetProjectOwnerText(context.Background(), projectID) err := db.QueryRow(
`SELECT owner_id::text FROM projects WHERE id = $1`,
projectID,
).Scan(&ownerID)
if err != nil { if err != nil {
return false, false return false, false
} }
@@ -105,11 +109,11 @@ func projectManageAccess(c *gin.Context, db *database.DB, projectID uuid.UUID) (
if ownerID == userID { if ownerID == userID {
return true, true return true, true
} }
userUUID, _ := uuid.Parse(userID) var member bool
if member, err := q.ProjectMemberExists(context.Background(), sqlcdb.ProjectMemberExistsParams{ if err := db.QueryRow(
ProjectID: projectID, `SELECT EXISTS(SELECT 1 FROM project_members WHERE project_id = $1 AND user_id = $2)`,
UserID: userUUID, projectID, userID,
}); err == nil && member { ).Scan(&member); err == nil && member {
return true, true return true, true
} }
return true, false return true, false
@@ -117,7 +121,8 @@ func projectManageAccess(c *gin.Context, db *database.DB, projectID uuid.UUID) (
// serviceProjectID resolves the project that owns a service. // serviceProjectID resolves the project that owns a service.
func serviceProjectID(db *database.DB, serviceID uuid.UUID) (uuid.UUID, bool) { func serviceProjectID(db *database.DB, serviceID uuid.UUID) (uuid.UUID, bool) {
projectID, err := sqlcdb.New(db.DB).GetServiceProjectID(context.Background(), serviceID) var projectID uuid.UUID
err := db.QueryRow(`SELECT project_id FROM services WHERE id = $1`, serviceID).Scan(&projectID)
if err != nil { if err != nil {
return uuid.Nil, false return uuid.Nil, false
} }
+1 -1
View File
@@ -116,7 +116,7 @@ func handleDeleteEnvironment(c *gin.Context) {
return return
} }
var projectID uuid.UUID var projectID uuid.UUID
if projectID, err = sqlcdb.New(db.DB).GetEnvironmentProjectID(context.Background(), envID); err != nil { if err := db.QueryRow(`SELECT project_id FROM environments WHERE id = $1`, envID).Scan(&projectID); err != nil {
respondError(c, http.StatusNotFound, "NOT_FOUND", "Environment not found") respondError(c, http.StatusNotFound, "NOT_FOUND", "Environment not found")
return return
} }
+52 -65
View File
@@ -17,11 +17,9 @@ import (
"time" "time"
"containr/internal/database" "containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/gateway" "containr/internal/gateway"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
@@ -109,28 +107,23 @@ func extractGatewayKey(c *gin.Context) string {
// lookupGatewayService resolves the slug to an enabled service row. // lookupGatewayService resolves the slug to an enabled service row.
func lookupGatewayService(ctx context.Context, db *database.DB, slug string) (*gatewayService, error) { func lookupGatewayService(ctx context.Context, db *database.DB, slug string) (*gatewayService, error) {
r, err := sqlcdb.New(db.DB).GetAPServiceBySlug(ctx, slug) var s gatewayService
var enabled int
err := db.QueryRowContext(ctx, `
SELECT id, upstream_url, route_prefix, upstream_auth_header,
upstream_auth_value, enabled, rpm_limit, monthly_quota,
request_timeout_ms
FROM api_services WHERE slug = $1
`, slug).Scan(&s.ID, &s.UpstreamURL, &s.RoutePrefix,
&s.UpstreamAuthHeader, &s.UpstreamAuthValue, &enabled,
&s.RPMLimit, &s.MonthlyQuota, &s.RequestTimeoutMS)
if err != nil { if err != nil {
return nil, err return nil, err
} }
s := gatewayService{ s.Enabled = enabled != 0
ID: r.ID.String(),
UpstreamURL: r.UpstreamUrl,
RoutePrefix: r.RoutePrefix,
UpstreamAuthHeader: r.UpstreamAuthHeader,
UpstreamAuthValue: r.UpstreamAuthValue,
Enabled: r.Enabled != 0,
RPMLimit: nullInt32To64(r.RpmLimit),
MonthlyQuota: nullInt32To64(r.MonthlyQuota),
RequestTimeoutMS: int(r.RequestTimeoutMs.Int32),
}
return &s, nil return &s, nil
} }
func nullInt32To64(v sql.NullInt32) sql.NullInt64 {
return sql.NullInt64{Int64: int64(v.Int32), Valid: v.Valid}
}
// authenticateGatewayKey resolves a presented key by its indexed prefix and // authenticateGatewayKey resolves a presented key by its indexed prefix and
// bcrypt-verifies candidates. Returns nil when nothing matches. // bcrypt-verifies candidates. Returns nil when nothing matches.
func authenticateGatewayKey(ctx context.Context, db *database.DB, presented string) (*gatewayKey, error) { func authenticateGatewayKey(ctx context.Context, db *database.DB, presented string) (*gatewayKey, error) {
@@ -138,24 +131,26 @@ func authenticateGatewayKey(ctx context.Context, db *database.DB, presented stri
if len(prefix) > 8 { if len(prefix) > 8 {
prefix = prefix[:8] prefix = prefix[:8]
} }
rows, err := sqlcdb.New(db.DB).ListAPKeysByPrefix(ctx, prefix) rows, err := db.QueryContext(ctx, `
SELECT id, key_hash, enabled, rpm_limit, monthly_quota, allowed_service_ids
FROM api_keys WHERE key_prefix = $1
`, prefix)
if err != nil { if err != nil {
return nil, err return nil, err
} }
for _, r := range rows { defer rows.Close()
k := gatewayKey{ for rows.Next() {
ID: r.ID.String(), var k gatewayKey
Hash: r.KeyHash, var enabled int
Enabled: r.Enabled != 0, if err := rows.Scan(&k.ID, &k.Hash, &enabled, &k.RPMLimit, &k.MonthlyQuota, &k.AllowedServiceIDs); err != nil {
RPMLimit: nullInt32To64(r.RpmLimit), return nil, err
MonthlyQuota: nullInt32To64(r.MonthlyQuota),
AllowedServiceIDs: r.AllowedServiceIds,
} }
k.Enabled = enabled != 0
if bcrypt.CompareHashAndPassword([]byte(k.Hash), []byte(presented)) == nil { if bcrypt.CompareHashAndPassword([]byte(k.Hash), []byte(presented)) == nil {
return &k, nil return &k, nil
} }
} }
return nil, nil return nil, rows.Err()
} }
// keyAllowsService applies the allowed_service_ids JSON allowlist. An empty // keyAllowsService applies the allowed_service_ids JSON allowlist. An empty
@@ -173,56 +168,48 @@ func keyAllowsService(k *gatewayKey, serviceID string) bool {
return false return false
} }
// monthlyKeyUsage / monthlyServiceUsage sum a counter dimension for the // monthlyUsage sums a counter dimension for the current calendar month.
// current calendar month. func monthlyUsage(ctx context.Context, db *database.DB, column, id, period string) (int, error) {
func monthlyKeyUsage(ctx context.Context, db *database.DB, id, period string) (int64, error) { var total int
keyUUID, _ := uuid.Parse(id) err := db.QueryRowContext(ctx, `
return sqlcdb.New(db.DB).SumUsageByKey(ctx, sqlcdb.SumUsageByKeyParams{ SELECT COALESCE(SUM(request_count), 0) FROM usage_counters
ApiKeyID: keyUUID, PeriodMonth: period, WHERE `+column+` = $1 AND period_month = $2
}) `, id, period).Scan(&total)
} return total, err
func monthlyServiceUsage(ctx context.Context, db *database.DB, id, period string) (int64, error) {
svcUUID, _ := uuid.Parse(id)
return sqlcdb.New(db.DB).SumUsageByService(ctx, sqlcdb.SumUsageByServiceParams{
ServiceID: svcUUID, PeriodMonth: period,
})
} }
// recordGatewayRequest persists usage, metrics, and failure incidents for a // recordGatewayRequest persists usage, metrics, and failure incidents for a
// proxied call. Runs in the request path but is three small writes. // proxied call. Runs in the request path but is three small writes.
func recordGatewayRequest(ctx context.Context, db *database.DB, svc *gatewayService, k *gatewayKey, status int, period string) { func recordGatewayRequest(ctx context.Context, db *database.DB, svc *gatewayService, k *gatewayKey, status int, period string) {
q := sqlcdb.New(db.DB) _, _ = db.ExecContext(ctx, `
keyUUID, _ := uuid.Parse(k.ID) INSERT INTO usage_counters (api_key_id, service_id, period_month, request_count, updated_at)
svcUUID, _ := uuid.Parse(svc.ID) VALUES ($1, $2, $3, 1, NOW())
_ = q.UpsertUsageCounter(ctx, sqlcdb.UpsertUsageCounterParams{ ON CONFLICT (api_key_id, service_id, period_month)
ApiKeyID: keyUUID, ServiceID: svcUUID, PeriodMonth: period, DO UPDATE SET request_count = usage_counters.request_count + 1, updated_at = NOW()
}) `, k.ID, svc.ID, period)
_ = q.TouchAPKeyLastUsed(ctx, keyUUID) _, _ = db.ExecContext(ctx, `
UPDATE api_keys SET last_used_at = NOW() WHERE id = $1
`, k.ID)
labels, _ := json.Marshal(map[string]interface{}{ labels, _ := json.Marshal(map[string]interface{}{
"service_id": svc.ID, "service_id": svc.ID,
"key_id": k.ID, "key_id": k.ID,
"status": status, "status": status,
}) })
_ = q.InsertMetricPoint(ctx, sql.NullString{String: string(labels), Valid: true}) _, _ = db.ExecContext(ctx, `
INSERT INTO metrics_timeseries (metric, value, labels_json, occurred_at)
VALUES ('gateway_request', 1, $1, NOW())
`, string(labels))
} }
func recordGatewayIncident(ctx context.Context, db *database.DB, svcID string, keyID *string, code, msg, severity string, status int) { func recordGatewayIncident(ctx context.Context, db *database.DB, svcID string, keyID *string, code, msg, severity string, status int) {
var kid uuid.NullUUID var kid interface{}
if keyID != nil { if keyID != nil {
if u, err := uuid.Parse(*keyID); err == nil { kid = *keyID
kid = uuid.NullUUID{UUID: u, Valid: true}
}
} }
svcUUID, _ := uuid.Parse(svcID) _, _ = db.ExecContext(ctx, `
_ = sqlcdb.New(db.DB).InsertGatewayIncident(ctx, sqlcdb.InsertGatewayIncidentParams{ INSERT INTO incident_events (service_id, api_key_id, code, message, severity, http_status, occurred_at)
ServiceID: uuid.NullUUID{UUID: svcUUID, Valid: svcUUID != uuid.Nil}, VALUES ($1, $2, $3, $4, $5, $6, NOW())
ApiKeyID: kid, `, svcID, kid, code, msg, severity, status)
Code: code,
Message: msg,
Severity: severity,
HttpStatus: sql.NullInt32{Int32: int32(status), Valid: true},
})
} }
// handleGatewayProxy is mounted at /g/:slug and /g/:slug/*path on the root // handleGatewayProxy is mounted at /g/:slug and /g/:slug/*path on the root
@@ -280,13 +267,13 @@ func handleGatewayProxy(c *gin.Context) {
period := now.Format("2006-01") period := now.Format("2006-01")
if key.MonthlyQuota.Valid && key.MonthlyQuota.Int64 > 0 { if key.MonthlyQuota.Valid && key.MonthlyQuota.Int64 > 0 {
if used, err := monthlyKeyUsage(ctx, db, key.ID, period); err == nil && used >= key.MonthlyQuota.Int64 { if used, err := monthlyUsage(ctx, db, "api_key_id", key.ID, period); err == nil && int64(used) >= key.MonthlyQuota.Int64 {
respondError(c, http.StatusTooManyRequests, "QUOTA_EXCEEDED", "Monthly request quota exceeded") respondError(c, http.StatusTooManyRequests, "QUOTA_EXCEEDED", "Monthly request quota exceeded")
return return
} }
} }
if svc.MonthlyQuota.Valid && svc.MonthlyQuota.Int64 > 0 { if svc.MonthlyQuota.Valid && svc.MonthlyQuota.Int64 > 0 {
if used, err := monthlyServiceUsage(ctx, db, svc.ID, period); err == nil && used >= svc.MonthlyQuota.Int64 { if used, err := monthlyUsage(ctx, db, "service_id", svc.ID, period); err == nil && int64(used) >= svc.MonthlyQuota.Int64 {
respondError(c, http.StatusTooManyRequests, "QUOTA_EXCEEDED", "Service monthly quota exceeded") respondError(c, http.StatusTooManyRequests, "QUOTA_EXCEEDED", "Service monthly quota exceeded")
return return
} }
+18 -27
View File
@@ -1,16 +1,12 @@
package api package api
import ( import (
"context"
"fmt" "fmt"
"strconv" "strconv"
"strings" "strings"
"containr/internal/database" "containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/source" "containr/internal/source"
"github.com/google/uuid"
) )
// resolveGitCloneSpec turns a service's git_repo (clone URL, ssh remote, or // resolveGitCloneSpec turns a service's git_repo (clone URL, ssh remote, or
@@ -26,31 +22,26 @@ func resolveGitCloneSpec(db *database.DB, ownerID, gitRepo, branch, commit, buil
} }
spec := source.Spec{Branch: branch, Commit: commit, BuildPath: buildPath} spec := source.Spec{Branch: branch, Commit: commit, BuildPath: buildPath}
q := sqlcdb.New(db.DB)
ctx := context.Background()
ownerUUID, _ := uuid.Parse(ownerID) var providerID string
var providerID uuid.UUID
if !strings.Contains(cloneURL, "://") { if !strings.Contains(cloneURL, "://") {
// Bare owner/repo — the connected repository row knows the host. // Bare owner/repo — the connected repository row knows the host.
row, err := q.GetGitRepoCloneByFullName(ctx, sqlcdb.GetGitRepoCloneByFullNameParams{ err = db.QueryRow(
FullName: cloneURL, `SELECT clone_url, provider_id FROM git_repositories
UserID: ownerUUID, WHERE full_name = $1 AND user_id = $2 LIMIT 1`,
}) cloneURL, ownerID,
).Scan(&cloneURL, &providerID)
if err != nil { if err != nil {
return source.Spec{}, fmt.Errorf("repository %s is not connected — connect it under Git settings or store a clone URL", raw) return source.Spec{}, fmt.Errorf("repository %s is not connected — connect it under Git settings or store a clone URL", raw)
} }
cloneURL = row.CloneUrl
providerID = row.ProviderID
} else { } else {
// URL form — match a connected repo for its provider when possible; // URL form — match a connected repo for its provider when possible;
// an unmatched public URL still clones, just without credentials. // an unmatched public URL still clones, just without credentials.
if pid, err := q.GetGitRepoProviderByCloneURL(ctx, sqlcdb.GetGitRepoProviderByCloneURLParams{ _ = db.QueryRow(
CloneUrl: cloneURL, `SELECT provider_id FROM git_repositories
UserID: ownerUUID, WHERE clone_url = $1 AND user_id = $2 LIMIT 1`,
}); err == nil { cloneURL, ownerID,
providerID = pid ).Scan(&providerID)
}
} }
if err := source.ValidateRemoteURL(cloneURL); err != nil { if err := source.ValidateRemoteURL(cloneURL); err != nil {
@@ -58,17 +49,17 @@ func resolveGitCloneSpec(db *database.DB, ownerID, gitRepo, branch, commit, buil
} }
spec.CloneURL = cloneURL spec.CloneURL = cloneURL
if providerID == uuid.Nil { if providerID == "" {
return spec, nil return spec, nil
} }
prov, err := q.GetGitProviderForFetch(ctx, sqlcdb.GetGitProviderForFetchParams{ var name, apiURL, token string
ID: providerID, if err := db.QueryRow(
UserID: ownerUUID, `SELECT name, api_url, access_token FROM git_providers
}) WHERE id = $1 AND user_id = $2`,
if err != nil { providerID, ownerID,
).Scan(&name, &apiURL, &token); err != nil {
return spec, nil // provider row gone — anonymous clone still works for public repos return spec, nil // provider row gone — anonymous clone still works for public repos
} }
name, apiURL, token := prov.Name, prov.ApiUrl, prov.AccessToken
switch strings.ToLower(name) { switch strings.ToLower(name) {
case "github_app": case "github_app":
+4 -4
View File
@@ -15,7 +15,6 @@ import (
"time" "time"
"containr/internal/database" "containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/deployment" "containr/internal/deployment"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -89,8 +88,8 @@ func handleAdminGitHubAppManifest(c *gin.Context) {
callbackURL := base + "/git/github-app/callback" callbackURL := base + "/git/github-app/callback"
manifest, err := json.Marshal(map[string]interface{}{ manifest, err := json.Marshal(map[string]interface{}{
"name": name, "name": name,
"url": base, "url": base,
"hook_attributes": map[string]interface{}{ "hook_attributes": map[string]interface{}{
"url": base + "/api/git/github-app/webhook", "url": base + "/api/git/github-app/webhook",
"active": true, "active": true,
@@ -207,8 +206,9 @@ func handleAdminGitHubAppConvert(c *gin.Context) {
// GET /admin/git/github-app — provisioning status for the settings UI. // GET /admin/git/github-app — provisioning status for the settings UI.
func handleAdminGetGitHubApp(c *gin.Context) { func handleAdminGetGitHubApp(c *gin.Context) {
db := c.MustGet("db").(*database.DB) db := c.MustGet("db").(*database.DB)
var stored string
source := "none" source := "none"
if v, err := sqlcdb.New(db.DB).GetAppSetting(c.Request.Context(), settingGitHubAppID); err == nil && strings.TrimSpace(v) != "" { if err := db.QueryRow(`SELECT value FROM app_settings WHERE key = $1`, settingGitHubAppID).Scan(&stored); err == nil && strings.TrimSpace(stored) != "" {
source = "settings" source = "settings"
} else if strings.TrimSpace(os.Getenv("GITHUB_APP_ID")) != "" { } else if strings.TrimSpace(os.Getenv("GITHUB_APP_ID")) != "" {
source = "env" source = "env"
+9 -15
View File
@@ -193,8 +193,8 @@ func handleAcceptInvite(c *gin.Context) {
return return
} }
existing, err := q.CountUsersByEmail(c.Request.Context(), req.Email) var existing int
if err != nil { if err := db.QueryRow(`SELECT COUNT(*) FROM users WHERE email = $1`, req.Email).Scan(&existing); err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "database error") respondError(c, http.StatusInternalServerError, "INTERNAL", "database error")
return return
} }
@@ -214,23 +214,17 @@ func handleAcceptInvite(c *gin.Context) {
return return
} }
row, err := q.InsertUser(c.Request.Context(), sqlcdb.InsertUserParams{ var user User
Email: req.Email, err = db.QueryRow(`
PasswordHash: string(hashedPassword), INSERT INTO users (email, password_hash, name)
Name: strings.TrimSpace(req.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 { if err != nil {
respondError(c, http.StatusConflict, "CONFLICT", "user already exists") respondError(c, http.StatusConflict, "CONFLICT", "user already exists")
return return
} }
user := User{
ID: row.ID.String(),
Email: row.Email,
Name: row.Name,
AvatarURL: row.AvatarUrl,
IsAdmin: row.IsAdmin,
CreatedAt: row.CreatedAt.Time.String(),
}
userUUID, _ := uuid.Parse(user.ID) userUUID, _ := uuid.Parse(user.ID)
if err := q.MarkUserInviteUsed(c.Request.Context(), sqlcdb.MarkUserInviteUsedParams{ if err := q.MarkUserInviteUsed(c.Request.Context(), sqlcdb.MarkUserInviteUsedParams{
+7 -3
View File
@@ -9,7 +9,6 @@ import (
"time" "time"
"containr/internal/database" "containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/docker" "containr/internal/docker"
containertypes "github.com/docker/docker/api/types/container" containertypes "github.com/docker/docker/api/types/container"
@@ -61,12 +60,17 @@ func handleGetServiceMetrics(c *gin.Context) {
return return
} }
svc, err := sqlcdb.New(db.DB).GetServiceProjectAndStatus(context.Background(), serviceID) var projectID uuid.UUID
var status string
err = db.QueryRow(
`SELECT s.project_id, COALESCE(s.status, '')
FROM services s WHERE s.id = $1`,
serviceID,
).Scan(&projectID, &status)
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return return
} }
projectID := svc.ProjectID
if _, allowed := projectReadAccess(c, db, projectID); !allowed { if _, allowed := projectReadAccess(c, db, projectID); !allowed {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return return
+4 -3
View File
@@ -315,9 +315,10 @@ func (r *agentNodeRunner) replicaPayload(spec deployment.RuntimeSpec, agentID st
// pullCredentials resolves the project owner's registry auth for the image // pullCredentials resolves the project owner's registry auth for the image
// host so private images pull on remote nodes. Empty map → anonymous pull. // host so private images pull on remote nodes. Empty map → anonymous pull.
func (r *agentNodeRunner) pullCredentials(projectID, imageRef string) map[string]interface{} { func (r *agentNodeRunner) pullCredentials(projectID, imageRef string) map[string]interface{} {
pid, _ := uuid.Parse(projectID) var ownerID string
ownerID, err := sqlcdb.New(r.db.DB).GetProjectOwnerText(context.Background(), pid) if err := r.db.QueryRow(
if err != nil { `SELECT owner_id FROM projects WHERE id = $1`, projectID,
).Scan(&ownerID); err != nil {
return map[string]interface{}{} return map[string]interface{}{}
} }
auth := registryAuthFor(r.db, ownerID, imageRef) auth := registryAuthFor(r.db, ownerID, imageRef)
@@ -9,7 +9,6 @@ import (
"containr/internal/database" "containr/internal/database"
"containr/internal/database/sqlcdb" "containr/internal/database/sqlcdb"
"context" "context"
"database/sql"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log" "log"
@@ -30,13 +29,15 @@ func notifyAdmins(db *database.DB, kind, title, body, resourceType, resourceID s
if db == nil { if db == nil {
return return
} }
adminIDs, err := sqlcdb.New(db.DB).ListAdminUserIDs(context.Background()) rows, err := db.Query(`SELECT id FROM users WHERE is_admin`)
if err != nil { if err != nil {
log.Printf("containr: notify admins lookup failed: %v", err) log.Printf("containr: notify admins lookup failed: %v", err)
return return
} }
for _, id := range adminIDs { defer rows.Close()
{ for rows.Next() {
var id uuid.UUID
if err := rows.Scan(&id); err == nil {
insertUserNotification(db, id.String(), kind, title, body, resourceType, resourceID) insertUserNotification(db, id.String(), kind, title, body, resourceType, resourceID)
} }
} }
@@ -72,32 +73,34 @@ type agentHealthRow struct {
func reconcileAgentHealth(db *database.DB, staleAfter time.Duration) { func reconcileAgentHealth(db *database.DB, staleAfter time.Duration) {
cutoff := time.Now().Add(-staleAfter) cutoff := time.Now().Add(-staleAfter)
var agents []agentHealthRow var agents []agentHealthRow
q := sqlcdb.New(db.DB) rows, err := db.Query(
agentRows, err := q.ListAgentHealthRows(context.Background(), sql.NullTime{Time: cutoff, Valid: true}) `SELECT id, COALESCE(name, hostname, id::text), COALESCE(status, ''),
COALESCE(last_heartbeat < $1, TRUE), auto_prune
FROM node_agents`, cutoff)
if err != nil { if err != nil {
log.Printf("containr: agent health sweep: %v", err) log.Printf("containr: agent health sweep: %v", err)
return return
} }
for _, r := range agentRows { for rows.Next() {
agents = append(agents, agentHealthRow{ var a agentHealthRow
id: r.ID, name: r.Name, status: r.Status, stale: r.Stale, autoPrune: r.AutoPrune, if err := rows.Scan(&a.id, &a.name, &a.status, &a.stale, &a.autoPrune); err == nil {
}) agents = append(agents, a)
}
} }
rows.Close()
for _, a := range agents { for _, a := range agents {
switch { switch {
case a.stale && a.status != "offline": case a.stale && a.status != "offline":
if err := q.SetAgentStatus(context.Background(), sqlcdb.SetAgentStatusParams{ if _, err := db.Exec(
Status: sql.NullString{String: "offline", Valid: true}, ID: a.id, `UPDATE node_agents SET status = 'offline', updated_at = NOW() WHERE id = $1`, a.id); err != nil {
}); err != nil {
continue continue
} }
notifyAdmins(db, "agent", "Node agent offline", notifyAdmins(db, "agent", "Node agent offline",
fmt.Sprintf("Agent %s missed its heartbeat for over %s.", a.name, staleAfter), "agent", a.id) fmt.Sprintf("Agent %s missed its heartbeat for over %s.", a.name, staleAfter), "agent", a.id)
case !a.stale && a.status == "offline": case !a.stale && a.status == "offline":
if err := q.SetAgentStatus(context.Background(), sqlcdb.SetAgentStatusParams{ if _, err := db.Exec(
Status: sql.NullString{String: "online", Valid: true}, ID: a.id, `UPDATE node_agents SET status = 'online', updated_at = NOW() WHERE id = $1`, a.id); err != nil {
}); err != nil {
continue continue
} }
notifyAdmins(db, "agent", "Node agent back online", notifyAdmins(db, "agent", "Node agent back online",
@@ -183,17 +186,17 @@ func checkForUpgrade(db *database.DB, currentVersion string) {
return return
} }
lastNotified, _ := sqlcdb.New(db.DB).GetAppSetting(context.Background(), "upgrade_notified_tag") var lastNotified string
_ = db.QueryRow(`SELECT value FROM app_settings WHERE key = 'upgrade_notified_tag'`).Scan(&lastNotified)
if lastNotified == release.TagName { if lastNotified == release.TagName {
return return
} }
notifyAdmins(db, "upgrade", "Containr update available", notifyAdmins(db, "upgrade", "Containr update available",
fmt.Sprintf("Release %s is available (running %s). %s", release.TagName, currentVersion, release.HTMLURL), fmt.Sprintf("Release %s is available (running %s). %s", release.TagName, currentVersion, release.HTMLURL),
"release", release.TagName) "release", release.TagName)
_ = sqlcdb.New(db.DB).UpsertAppSettingValue(context.Background(), sqlcdb.UpsertAppSettingValueParams{ _, _ = db.Exec(
Key: "upgrade_notified_tag", `INSERT INTO app_settings (key, value, updated_at) VALUES ('upgrade_notified_tag', $1, NOW())
Value: release.TagName, ON CONFLICT (key) DO UPDATE SET value = $1, updated_at = NOW()`, release.TagName)
})
} }
// semverNewer reports whether candidate is a higher semver than current. // semverNewer reports whether candidate is a higher semver than current.
+69 -40
View File
@@ -1,16 +1,13 @@
package api package api
import ( import (
"context"
"net/http" "net/http"
"time" "time"
"containr/internal/database" "containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/deployqueue" "containr/internal/deployqueue"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid"
) )
// GET /operations — a single cross-cutting view of platform work: active // GET /operations — a single cross-cutting view of platform work: active
@@ -58,59 +55,91 @@ func handleGetOperations(c *gin.Context) {
db := c.MustGet("db").(*database.DB) db := c.MustGet("db").(*database.DB)
isAdmin := contextIsAdmin(c) isAdmin := contextIsAdmin(c)
userUUID, _ := uuid.Parse(userID) // Same visibility rule as the deployment list endpoint.
q := sqlcdb.New(db.DB) visibility := `p.is_approved OR p.owner_id = $1 OR $2::bool
visibility := sqlcdb.ListActiveOperationDeploymentsParams{OwnerID: userUUID, Column2: isAdmin} OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1)`
active := []operationDeployment{} active := []operationDeployment{}
if rows, err := q.ListActiveOperationDeployments(context.Background(), visibility); err == nil { rows, err := db.Query(
for _, r := range rows { `SELECT d.id, d.service_id, s.name, p.name, d.status,
active = append(active, operationDeployment{ COALESCE(d.image_name, ''), d.error, d.started_at, d.completed_at, d.created_at
ID: r.ID.String(), ServiceID: r.ServiceID.String(), ServiceName: r.ServiceName, FROM deployments d
ProjectName: r.ProjectName, Status: r.Status.String, Image: r.Image, JOIN services s ON s.id = d.service_id
Error: strPtrNS(r.Error), StartedAt: timePtrNT(r.StartedAt), JOIN projects p ON p.id = s.project_id
CompletedAt: timePtrNT(r.CompletedAt), CreatedAt: r.CreatedAt.Time, 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{} recentFailed := []operationDeployment{}
if rows, err := q.ListRecentFailedDeployments(context.Background(), sqlcdb.ListRecentFailedDeploymentsParams{ rows2, err2 := db.Query(
OwnerID: userUUID, Column2: isAdmin, `SELECT d.id, d.service_id, s.name, p.name, d.status,
}); err == nil { COALESCE(d.image_name, ''), d.error, d.started_at, d.completed_at, d.created_at
for _, r := range rows { FROM deployments d
recentFailed = append(recentFailed, operationDeployment{ JOIN services s ON s.id = d.service_id
ID: r.ID.String(), ServiceID: r.ServiceID.String(), ServiceName: r.ServiceName, JOIN projects p ON p.id = s.project_id
ProjectName: r.ProjectName, Status: r.Status.String, Image: r.Image, WHERE d.status IN ('failed','cancelled','rolled_back')
Error: strPtrNS(r.Error), StartedAt: timePtrNT(r.StartedAt), AND d.updated_at > NOW() - INTERVAL '24 hours'
CompletedAt: timePtrNT(r.CompletedAt), CreatedAt: r.CreatedAt.Time, 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{} cronRuns := []operationCronRun{}
if rows, err := q.ListRecentCronRuns(context.Background(), sqlcdb.ListRecentCronRunsParams{ rows3, err3 := db.Query(
OwnerID: userUUID, Column2: isAdmin, `SELECT e.id, j.name, j.schedule, e.status, e.started_at, e.finished_at, e.error
}); err == nil { FROM cron_executions e
for _, r := range rows { JOIN cron_jobs j ON j.id = e.cron_job_id
cronRuns = append(cronRuns, operationCronRun{ JOIN projects p ON p.id = j.project_id
ID: r.ID.String(), JobName: r.JobName, Schedule: r.Schedule, Status: r.Status.String, WHERE e.started_at > NOW() - INTERVAL '24 hours'
StartedAt: r.StartedAt, FinishedAt: timePtrNT(r.FinishedAt), Error: strPtrNS(r.Error), 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{} backups := []operationBackup{}
if rows, err := q.ListRecentOperationBackups(context.Background(), sqlcdb.ListRecentOperationBackupsParams{ rows4, err4 := db.Query(
UserID: userID, Column2: isAdmin, `SELECT b.id, b.database_id, ds.name, b.status, b.size, b.created_at, b.completed_at
}); err == nil { FROM database_backups b
for _, r := range rows { JOIN database_services ds ON ds.id = b.database_id
backups = append(backups, operationBackup{ WHERE b.created_at > NOW() - INTERVAL '24 hours'
ID: r.ID, DatabaseID: r.DatabaseID, DBName: r.DbName, Status: r.Status, AND (ds.user_id = $1 OR $2::bool)
Size: r.Size, CreatedAt: r.CreatedAt.Time, Completed: timePtrNT(r.CompletedAt), 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 var queue []deployqueue.ServiceQueueState
if queueValue, exists := c.Get("deploy_queue"); exists && queueValue != nil { if queueValue, exists := c.Get("deploy_queue"); exists && queueValue != nil {
queue = queueValue.(*deployqueue.Queue).Snapshot() queue = queueValue.(*deployqueue.Queue).Snapshot()
+11 -17
View File
@@ -1,12 +1,10 @@
package api package api
import ( import (
"context"
"net/http" "net/http"
"time" "time"
"containr/internal/database" "containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/secrets" "containr/internal/secrets"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -114,8 +112,7 @@ func handleUpdateProjectVariables(c *gin.Context) {
} }
defer tx.Rollback() defer tx.Rollback()
txq := sqlcdb.New(tx) if _, err = tx.Exec(`DELETE FROM project_variables WHERE project_id = $1`, projectID); err != nil {
if err = txq.DeleteProjectVariables(c.Request.Context(), projectID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clear existing variables"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clear existing variables"})
return return
} }
@@ -130,15 +127,11 @@ func handleUpdateProjectVariables(c *gin.Context) {
if v.IsSecret { if v.IsSecret {
value = secrets.Encrypt(value) value = secrets.Encrypt(value)
} }
if err = txq.InsertProjectVariable(c.Request.Context(), sqlcdb.InsertProjectVariableParams{ if _, err = tx.Exec(
ID: uuid.New(), `INSERT INTO project_variables (id, project_id, key, value, is_secret, created_at, updated_at)
ProjectID: projectID, VALUES ($1, $2, $3, $4, $5, $6, $7)`,
Key: v.Key, uuid.New(), projectID, v.Key, value, v.IsSecret, now, now,
Value: value, ); err != nil {
IsSecret: v.IsSecret,
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to insert variable: " + v.Key}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to insert variable: " + v.Key})
return return
} }
@@ -175,10 +168,11 @@ func handleUpdateProjectVariables(c *gin.Context) {
// resolveSharedVar returns the decrypted value of a project variable. // resolveSharedVar returns the decrypted value of a project variable.
func resolveSharedVar(db *database.DB, projectID uuid.UUID, key string) (string, bool) { func resolveSharedVar(db *database.DB, projectID uuid.UUID, key string) (string, bool) {
value, err := sqlcdb.New(db.DB).GetProjectVariableValue(context.Background(), sqlcdb.GetProjectVariableValueParams{ var value string
ProjectID: projectID, err := db.QueryRow(
Key: key, `SELECT value FROM project_variables WHERE project_id = $1 AND key = $2`,
}) projectID, key,
).Scan(&value)
if err != nil { if err != nil {
return "", false return "", false
} }
+7 -9
View File
@@ -1,13 +1,11 @@
package api package api
import ( import (
"context"
"net/http" "net/http"
"strings" "strings"
"time" "time"
"containr/internal/database" "containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/secrets" "containr/internal/secrets"
"github.com/docker/docker/api/types/registry" "github.com/docker/docker/api/types/registry"
@@ -179,17 +177,17 @@ func handleDeleteRegistry(c *gin.Context) {
// bare image names (nginx:latest). // bare image names (nginx:latest).
func registryAuthFor(db *database.DB, ownerID, imageRef string) registry.AuthConfig { func registryAuthFor(db *database.DB, ownerID, imageRef string) registry.AuthConfig {
host := registryHost(imageRef) host := registryHost(imageRef)
ownerUUID, _ := uuid.Parse(ownerID) var username, password string
row, err := sqlcdb.New(db.DB).GetRegistryAuth(context.Background(), sqlcdb.GetRegistryAuthParams{ err := db.QueryRow(
OwnerID: ownerUUID, `SELECT username, password FROM registries WHERE owner_id = $1 AND host = $2`,
Host: host, ownerID, host,
}) ).Scan(&username, &password)
if err != nil { if err != nil {
return registry.AuthConfig{} return registry.AuthConfig{}
} }
return registry.AuthConfig{ return registry.AuthConfig{
Username: row.Username, Username: username,
Password: secrets.Decrypt(row.Password), Password: secrets.Decrypt(password),
ServerAddress: host, ServerAddress: host,
} }
} }
+28 -23
View File
@@ -11,7 +11,6 @@ import (
"containr/internal/build" "containr/internal/build"
"containr/internal/config" "containr/internal/config"
"containr/internal/database" "containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/deployment" "containr/internal/deployment"
"containr/internal/deployqueue" "containr/internal/deployqueue"
"containr/internal/docker" "containr/internal/docker"
@@ -22,7 +21,6 @@ import (
"containr/internal/secrets" "containr/internal/secrets"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid"
) )
func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg *config.Config) { func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg *config.Config) {
@@ -85,18 +83,19 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
// 127.0.0.1 fallback stays for node-bound checks. // 127.0.0.1 fallback stays for node-bound checks.
docker := dockerClient docker := dockerClient
haManager.WithServiceResolver(func(ctx context.Context, serviceID string) (string, int, error) { haManager.WithServiceResolver(func(ctx context.Context, serviceID string) (string, int, error) {
sid, err := uuid.Parse(serviceID) var name, projectID string
var port int
err := db.QueryRowContext(ctx,
`SELECT name, project_id::text, COALESCE(port, 0) FROM services WHERE id = $1`,
serviceID,
).Scan(&name, &projectID, &port)
if err != nil { if err != nil {
return "", 0, err return "", 0, err
} }
svc, err := sqlcdb.New(db.DB).GetServiceNameProjectPort(ctx, sid) if err := docker.ConnectSelfToNetwork(ctx, deployment.ProjectNetworkName(projectID)); err != nil {
if err != nil {
return "", 0, err return "", 0, err
} }
if err := docker.ConnectSelfToNetwork(ctx, deployment.ProjectNetworkName(svc.ProjectID)); err != nil { return name, port, nil
return "", 0, err
}
return svc.Name, int(svc.Port), nil
}) })
} }
haAPIManager := NewHAManager(haManager) haAPIManager := NewHAManager(haManager)
@@ -138,25 +137,31 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
// Boot sweep: in-memory engine state dies on restart, so anything that // Boot sweep: in-memory engine state dies on restart, so anything that
// looked in-flight is actually orphaned — mark it failed rather than // looked in-flight is actually orphaned — mark it failed rather than
// leaving phantom 'building'/'queued' rows. // leaving phantom 'building'/'queued' rows.
bootQ := sqlcdb.New(db.DB) _, _ = db.Exec(
_ = bootQ.FailInterruptedDeployments(context.Background()) `UPDATE deployments SET status = 'failed', error = 'Interrupted by server restart', completed_at = NOW(), updated_at = NOW()
WHERE status IN ('queued', 'pending', 'building', 'deploying', 'rolling_back')`,
)
// Boot sweep: encrypt any plaintext secret values left over from before // Boot sweep: encrypt any plaintext secret values left over from before
// encryption-at-rest landed. Transparent and idempotent. // encryption-at-rest landed. Transparent and idempotent.
if encRows, err := bootQ.ListSecretEnvVars(context.Background()); err == nil { if encRows, err := db.Query(`SELECT id, value FROM environment_variables WHERE is_secret = TRUE`); err == nil {
toEncrypt := 0 type encRow struct {
for _, r := range encRows { id string
if secrets.IsEncrypted(r.Value) { value string
continue }
} var toEncrypt []encRow
if err := bootQ.UpdateEnvVarValue(context.Background(), sqlcdb.UpdateEnvVarValueParams{ for encRows.Next() {
Value: secrets.Encrypt(r.Value), ID: r.ID, var r encRow
}); err == nil { if err := encRows.Scan(&r.id, &r.value); err == nil && !secrets.IsEncrypted(r.value) {
toEncrypt++ toEncrypt = append(toEncrypt, r)
} }
} }
if toEncrypt > 0 { encRows.Close()
log.Printf("secrets: encrypted %d plaintext secret variable(s)", toEncrypt) for _, r := range toEncrypt {
_, _ = db.Exec(`UPDATE environment_variables SET value = $1, updated_at = NOW() WHERE id = $2`, secrets.Encrypt(r.value), r.id)
}
if len(toEncrypt) > 0 {
log.Printf("secrets: encrypted %d plaintext secret variable(s)", len(toEncrypt))
} }
} }
+29 -22
View File
@@ -2,7 +2,6 @@ package api
import ( import (
"containr/internal/database" "containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/deployment" "containr/internal/deployment"
"containr/internal/deployqueue" "containr/internal/deployqueue"
"context" "context"
@@ -71,18 +70,20 @@ type Service struct {
// loadServiceSleep fills the sleep-mode columns, which live outside the // loadServiceSleep fills the sleep-mode columns, which live outside the
// wide service SELECTs. Missing columns (pre-migration) degrade to off. // wide service SELECTs. Missing columns (pre-migration) degrade to off.
func loadServiceSleep(db *database.DB, s *Service) { func loadServiceSleep(db *database.DB, s *Service) {
row, err := sqlcdb.New(db.DB).GetServiceSleep(context.Background(), s.ID) var idle int
if err == nil { if err := db.QueryRow(
s.SleepEnabled = row.SleepEnabled `SELECT COALESCE(sleep_enabled, false), COALESCE(sleep_idle_minutes, 15) FROM services WHERE id = $1`,
s.SleepIdleMinutes = int(row.SleepIdleMinutes) s.ID,
).Scan(&s.SleepEnabled, &idle); err == nil {
s.SleepIdleMinutes = idle
} }
} }
// serviceNodeID reads the pin column directly — kept out of the wide service // serviceNodeID reads the pin column directly — kept out of the wide service
// SELECTs so pre-migration schemas keep working. // SELECTs so pre-migration schemas keep working.
func serviceNodeID(db *database.DB, serviceID uuid.UUID) string { func serviceNodeID(db *database.DB, serviceID uuid.UUID) string {
id, err := sqlcdb.New(db.DB).GetServiceNodeID(context.Background(), serviceID) var id sql.NullString
if err == nil { if err := db.QueryRow(`SELECT node_id FROM services WHERE id = $1`, serviceID).Scan(&id); err == nil {
return id.String return id.String
} }
return "" return ""
@@ -90,14 +91,17 @@ func serviceNodeID(db *database.DB, serviceID uuid.UUID) string {
// serviceSpread reads the spread column with the same lazy pattern. // serviceSpread reads the spread column with the same lazy pattern.
func serviceSpread(db *database.DB, serviceID uuid.UUID) bool { func serviceSpread(db *database.DB, serviceID uuid.UUID) bool {
spread, err := sqlcdb.New(db.DB).GetServiceSpread(context.Background(), serviceID) var spread bool
return err == nil && spread if err := db.QueryRow(`SELECT COALESCE(spread, false) FROM services WHERE id = $1`, serviceID).Scan(&spread); err == nil {
return spread
}
return false
} }
// servicePlacementTags reads the placement_tags column with the lazy pattern. // servicePlacementTags reads the placement_tags column with the lazy pattern.
func servicePlacementTags(db *database.DB, serviceID uuid.UUID) []string { func servicePlacementTags(db *database.DB, serviceID uuid.UUID) []string {
raw, err := sqlcdb.New(db.DB).GetServicePlacementTags(context.Background(), serviceID) var raw []byte
if err != nil { if err := db.QueryRow(`SELECT placement_tags FROM services WHERE id = $1`, serviceID).Scan(&raw); err != nil {
return nil return nil
} }
var tags []string var tags []string
@@ -175,8 +179,8 @@ func normalizeTraefikLabels(raw map[string]string) (map[string]string, error) {
// serviceTraefikLabels lazy-reads the traefik_labels column. // serviceTraefikLabels lazy-reads the traefik_labels column.
func serviceTraefikLabels(db *database.DB, serviceID uuid.UUID) map[string]string { func serviceTraefikLabels(db *database.DB, serviceID uuid.UUID) map[string]string {
raw, err := sqlcdb.New(db.DB).GetServiceTraefikLabels(context.Background(), serviceID) var raw []byte
if err != nil { if err := db.QueryRow(`SELECT traefik_labels FROM services WHERE id = $1`, serviceID).Scan(&raw); err != nil {
return nil return nil
} }
var labels map[string]string var labels map[string]string
@@ -207,6 +211,7 @@ func tagsJSON(tags []string) []byte {
// loadServiceNode fills the pin + spread + placement tags + the agent's // loadServiceNode fills the pin + spread + placement tags + the agent's
// display name for responses. // display name for responses.
func loadServiceNode(db *database.DB, s *Service) { func loadServiceNode(db *database.DB, s *Service) {
var name sql.NullString
s.NodeID = serviceNodeID(db, s.ID) s.NodeID = serviceNodeID(db, s.ID)
s.Spread = serviceSpread(db, s.ID) s.Spread = serviceSpread(db, s.ID)
s.PlacementTags = servicePlacementTags(db, s.ID) s.PlacementTags = servicePlacementTags(db, s.ID)
@@ -214,8 +219,8 @@ func loadServiceNode(db *database.DB, s *Service) {
if s.NodeID == "" { if s.NodeID == "" {
return return
} }
if name, err := sqlcdb.New(db.DB).GetNodeAgentName(context.Background(), s.NodeID); err == nil { if err := db.QueryRow(`SELECT name FROM node_agents WHERE id = $1`, s.NodeID).Scan(&name); err == nil {
s.NodeName = name s.NodeName = name.String
} }
} }
@@ -230,8 +235,8 @@ type ServiceVolume struct {
// loadServiceVolumes reads the JSONB column; absent/invalid data degrades // loadServiceVolumes reads the JSONB column; absent/invalid data degrades
// to no mounts rather than failing the request. // to no mounts rather than failing the request.
func loadServiceVolumes(db *database.DB, serviceID uuid.UUID) []deployment.VolumeMount { func loadServiceVolumes(db *database.DB, serviceID uuid.UUID) []deployment.VolumeMount {
raw, err := sqlcdb.New(db.DB).GetServiceVolumes(context.Background(), serviceID) var raw []byte
if err != nil { if err := db.QueryRow(`SELECT volumes FROM services WHERE id = $1`, serviceID).Scan(&raw); err != nil {
return nil return nil
} }
var stored []ServiceVolume var stored []ServiceVolume
@@ -698,11 +703,13 @@ func handleCreateService(c *gin.Context) {
} }
func getProjectEnvironmentID(db *database.DB, projectID uuid.UUID, environment string) (uuid.UUID, error) { func getProjectEnvironmentID(db *database.DB, projectID uuid.UUID, environment string) (uuid.UUID, error) {
row, err := sqlcdb.New(db.DB).GetProjectEnvironmentByName(context.Background(), sqlcdb.GetProjectEnvironmentByNameParams{ var environmentID uuid.UUID
ProjectID: projectID, err := db.QueryRow(
Name: environment, "SELECT id FROM environments WHERE project_id = $1 AND name = $2",
}) projectID,
return row.ID, err environment,
).Scan(&environmentID)
return environmentID, err
} }
func inferServiceSourceType(service Service) string { func inferServiceSourceType(service Service) string {
+16 -14
View File
@@ -10,13 +10,11 @@ import (
"time" "time"
"containr/internal/database" "containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/docker" "containr/internal/docker"
"github.com/docker/docker/api/types/network" "github.com/docker/docker/api/types/network"
"github.com/docker/docker/api/types/registry" "github.com/docker/docker/api/types/registry"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid"
) )
// Platform setting keys. Values stored in app_settings take precedence over // Platform setting keys. Values stored in app_settings take precedence over
@@ -72,7 +70,8 @@ func handleGetBranding(c *gin.Context) {
// then the caller's default. // then the caller's default.
func settingValue(db *database.DB, key, envKey, fallback string) string { func settingValue(db *database.DB, key, envKey, fallback string) string {
if db != nil && db.DB != nil { if db != nil && db.DB != nil {
value, err := sqlcdb.New(db.DB).GetAppSetting(context.Background(), key) var value string
err := db.QueryRow(`SELECT value FROM app_settings WHERE key = $1`, key).Scan(&value)
if err == nil { if err == nil {
return value return value
} }
@@ -89,15 +88,18 @@ func settingValue(db *database.DB, key, envKey, fallback string) string {
} }
func setSetting(db *database.DB, key, value string, secret bool) error { func setSetting(db *database.DB, key, value string, secret bool) error {
return sqlcdb.New(db.DB).UpsertAppSetting(context.Background(), sqlcdb.UpsertAppSettingParams{ _, err := db.Exec(`
Key: key, INSERT INTO app_settings (key, value, is_secret, updated_at)
Value: value, VALUES ($1, $2, $3, NOW())
IsSecret: secret, ON CONFLICT (key) DO UPDATE
}) SET value = EXCLUDED.value, is_secret = EXCLUDED.is_secret, updated_at = NOW()
`, key, value, secret)
return err
} }
func deleteSetting(db *database.DB, key string) error { func deleteSetting(db *database.DB, key string) error {
return sqlcdb.New(db.DB).DeleteAppSetting(context.Background(), key) _, err := db.Exec(`DELETE FROM app_settings WHERE key = $1`, key)
return err
} }
// signupEnabled controls whether public registration stays open after the // signupEnabled controls whether public registration stays open after the
@@ -108,12 +110,11 @@ func signupEnabled(db *database.DB) bool {
} }
func isAdminUser(db *database.DB, userID string) bool { func isAdminUser(db *database.DB, userID string) bool {
parsed, err := uuid.Parse(userID) var isAdmin bool
if err != nil { if err := db.QueryRow(`SELECT is_admin FROM users WHERE id = $1`, userID).Scan(&isAdmin); err != nil {
return false return false
} }
isAdmin, err := sqlcdb.New(db.DB).GetUserIsAdmin(context.Background(), parsed) return isAdmin
return err == nil && isAdmin
} }
func requireAdmin(c *gin.Context) (*database.DB, bool) { func requireAdmin(c *gin.Context) (*database.DB, bool) {
@@ -241,7 +242,8 @@ func handleUpdateSettings(c *gin.Context) {
// resolvedTunnelToken prefers the in-app value and falls back to the env. // resolvedTunnelToken prefers the in-app value and falls back to the env.
func resolvedTunnelToken(db *database.DB) (string, string) { func resolvedTunnelToken(db *database.DB) (string, string) {
if db != nil && db.DB != nil { if db != nil && db.DB != nil {
if value, err := sqlcdb.New(db.DB).GetAppSetting(context.Background(), settingCloudflareTunnelToken); err == nil && strings.TrimSpace(value) != "" { var value string
if err := db.QueryRow(`SELECT value FROM app_settings WHERE key = $1`, settingCloudflareTunnelToken).Scan(&value); err == nil && strings.TrimSpace(value) != "" {
return strings.TrimSpace(value), "app" return strings.TrimSpace(value), "app"
} }
} }
+2 -3
View File
@@ -5,12 +5,10 @@ package api
// lives in app_settings so it survives restarts. // lives in app_settings so it survives restarts.
import ( import (
"context"
"net/http" "net/http"
"strings" "strings"
"containr/internal/database" "containr/internal/database"
"containr/internal/database/sqlcdb"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -27,7 +25,8 @@ func setupComplete(db *database.DB, hasUsers bool) bool {
if !hasUsers { if !hasUsers {
return false return false
} }
if projects, err := sqlcdb.New(db.DB).CountProjects(context.Background()); err == nil && projects > 0 { var projects int
if err := db.QueryRow(`SELECT COUNT(*) FROM projects`).Scan(&projects); err == nil && projects > 0 {
return true return true
} }
return false return false
+18 -33
View File
@@ -640,20 +640,19 @@ func deployTemplateGraph(
createdServiceIDs := []uuid.UUID{} createdServiceIDs := []uuid.UUID{}
createdDatabaseIDs := []string{} createdDatabaseIDs := []string{}
cleanup := func() { cleanup := func() {
q := sqlcdb.New(db.DB)
for _, sid := range createdServiceIDs { for _, sid := range createdServiceIDs {
if engine != nil { if engine != nil {
removeServiceRuntime(context.Background(), db, engine, sid) removeServiceRuntime(context.Background(), db, engine, sid)
} }
_ = q.DeleteEnvVarsForService(context.Background(), sid) _, _ = db.Exec(`DELETE FROM environment_variables WHERE service_id = $1`, sid)
_ = q.DeleteServiceByID(context.Background(), sid) _, _ = db.Exec(`DELETE FROM services WHERE id = $1`, sid)
} }
for _, did := range createdDatabaseIDs { for _, did := range createdDatabaseIDs {
if dbHandler != nil && dbHandler.dockerClient != nil { if dbHandler != nil && dbHandler.dockerClient != nil {
_ = dbHandler.dockerClient.RemoveContainer(context.Background(), managedDatabaseContainerName(did), true) _ = dbHandler.dockerClient.RemoveContainer(context.Background(), managedDatabaseContainerName(did), true)
_ = dbHandler.dockerClient.RemoveVolume(context.Background(), managedDatabaseVolumeName(did), true) _ = dbHandler.dockerClient.RemoveVolume(context.Background(), managedDatabaseVolumeName(did), true)
} }
_ = q.DeleteDatabaseServiceByID(context.Background(), did) _, _ = db.Exec(`DELETE FROM database_services WHERE id = $1`, did)
} }
} }
@@ -773,19 +772,14 @@ func deployTemplateGraph(
if builder == "" { if builder == "" {
builder = "auto" builder = "auto"
} }
var volumesRaw json.RawMessage if _, err = tx.ExecContext(ctx,
if s, ok := volumesJSON.(string); ok { `UPDATE services SET port = $1, healthcheck_path = $2, volumes = COALESCE($3::jsonb, '[]'::jsonb),
volumesRaw = json.RawMessage(s) git_repo = NULLIF($4, ''), git_branch = NULLIF($5, ''), builder = $6
} WHERE id = $7`,
if err = sqlcdb.New(tx).UpdateServiceDeployMeta(ctx, sqlcdb.UpdateServiceDeployMetaParams{ m.spec.Port, strings.TrimSpace(m.spec.HealthCheck), volumesJSON,
Port: int32(m.spec.Port), strings.TrimSpace(m.spec.Repo), strings.TrimSpace(m.spec.Branch), builder,
HealthcheckPath: strings.TrimSpace(m.spec.HealthCheck), serviceID,
Column3: volumesRaw, ); err != nil {
Column4: strings.TrimSpace(m.spec.Repo),
Column5: strings.TrimSpace(m.spec.Branch),
Builder: builder,
ID: serviceID,
}); err != nil {
tx.Rollback() tx.Rollback()
cleanup() cleanup()
return nil, &gin.H{"error": "Failed to configure service"} return nil, &gin.H{"error": "Failed to configure service"}
@@ -867,24 +861,15 @@ func queueTemplateDeployment(c *gin.Context, db *database.DB, engine *deployment
CreatedAt: now, CreatedAt: now,
UpdatedAt: now, UpdatedAt: now,
} }
q := sqlcdb.New(db.DB) if _, err := db.Exec(
if err := q.InsertDeployment(context.Background(), sqlcdb.InsertDeploymentParams{ `INSERT INTO deployments
ID: dep.ID, (id, service_id, version, commit_hash, status, image_name, image_tag, created_at, updated_at)
ServiceID: dep.ServiceID, VALUES ($1, $2, $3, NULL, $4, '', '', $5, $6)`,
Version: fmt.Sprintf("v%d", now.Unix()), dep.ID, dep.ServiceID, fmt.Sprintf("v%d", now.Unix()), dep.Status, dep.CreatedAt, dep.UpdatedAt,
Status: sql.NullString{String: dep.Status, Valid: true}, ); err != nil {
ImageName: sql.NullString{String: "", Valid: true},
ImageTag: sql.NullString{String: "", Valid: true},
CreatedAt: sql.NullTime{Time: dep.CreatedAt, Valid: true},
UpdatedAt: sql.NullTime{Time: dep.UpdatedAt, Valid: true},
}); err != nil {
return uuid.Nil return uuid.Nil
} }
_ = q.SetServiceStatus(context.Background(), sqlcdb.SetServiceStatusParams{ _, _ = db.Exec(`UPDATE services SET status = 'queued', updated_at = $1 WHERE id = $2`, time.Now(), service.ID)
Status: sql.NullString{String: "queued", Valid: true},
UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true},
ID: service.ID,
})
getDeployQueue(c).Enqueue(service.ID, deployqueue.Job{ getDeployQueue(c).Enqueue(service.ID, deployqueue.Job{
DeploymentID: dep.ID, DeploymentID: dep.ID,
+6 -5
View File
@@ -411,11 +411,12 @@ func handleCreateFromTemplate(c *gin.Context) {
return return
} }
if err = sqlcdb.New(tx).UpdateServiceRuntimeMeta(ctx, sqlcdb.UpdateServiceRuntimeMetaParams{ // sqlc's schema predates the runtime columns; port and healthcheck_path are
Port: int32(config.Port), // written via raw SQL like every other service mutation path.
HealthcheckPath: strings.TrimSpace(config.HealthCheck), if _, err = tx.ExecContext(ctx,
ID: serviceID, `UPDATE services SET port = $1, healthcheck_path = $2 WHERE id = $3`,
}); err != nil { config.Port, strings.TrimSpace(config.HealthCheck), serviceID,
); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create service from template"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create service from template"})
return return
} }
+9 -16
View File
@@ -1,14 +1,11 @@
package api package api
import ( import (
"database/sql" "containr/internal/database"
"containr/internal/secrets"
"net/http" "net/http"
"time" "time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/secrets"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
) )
@@ -150,8 +147,7 @@ func handleUpdateVariables(c *gin.Context) {
} }
defer tx.Rollback() defer tx.Rollback()
txq := sqlcdb.New(tx) _, err = tx.Exec("DELETE FROM environment_variables WHERE service_id = $1", serviceID)
err = txq.DeleteEnvVarsForService(c.Request.Context(), serviceID)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clear existing variables"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clear existing variables"})
return return
@@ -168,15 +164,12 @@ func handleUpdateVariables(c *gin.Context) {
if v.IsSecret { if v.IsSecret {
value = secrets.Encrypt(value) value = secrets.Encrypt(value)
} }
err = txq.InsertEnvVar(c.Request.Context(), sqlcdb.InsertEnvVarParams{ varID := uuid.New()
ID: uuid.New(), _, err = tx.Exec(
ServiceID: serviceID, `INSERT INTO environment_variables (id, service_id, key, value, is_secret, created_at, updated_at)
Key: v.Key, VALUES ($1, $2, $3, $4, $5, $6, $7)`,
Value: value, varID, serviceID, v.Key, value, v.IsSecret, now, now,
IsSecret: sql.NullBool{Bool: v.IsSecret, Valid: true}, )
CreatedAt: sql.NullTime{Time: now, Valid: true},
UpdatedAt: sql.NullTime{Time: now, Valid: true},
})
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to insert variable: " + v.Key}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to insert variable: " + v.Key})
return return
@@ -1,636 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: apwhy.sql
package sqlcdb
import (
"context"
"database/sql"
"github.com/google/uuid"
)
const countAPKeys = `-- name: CountAPKeys :one
SELECT COUNT(*) FROM api_keys
`
func (q *Queries) CountAPKeys(ctx context.Context) (int64, error) {
row := q.db.QueryRowContext(ctx, countAPKeys)
var count int64
err := row.Scan(&count)
return count, err
}
const countAPServices = `-- name: CountAPServices :one
SELECT COUNT(*) FROM api_services
`
func (q *Queries) CountAPServices(ctx context.Context) (int64, error) {
row := q.db.QueryRowContext(ctx, countAPServices)
var count int64
err := row.Scan(&count)
return count, err
}
const getAPServiceBySlug = `-- name: GetAPServiceBySlug :one
SELECT id, upstream_url, route_prefix, upstream_auth_header,
upstream_auth_value, enabled, rpm_limit, monthly_quota,
request_timeout_ms
FROM api_services WHERE slug = $1
`
type GetAPServiceBySlugRow struct {
ID uuid.UUID `json:"id"`
UpstreamUrl string `json:"upstream_url"`
RoutePrefix string `json:"route_prefix"`
UpstreamAuthHeader sql.NullString `json:"upstream_auth_header"`
UpstreamAuthValue sql.NullString `json:"upstream_auth_value"`
Enabled int32 `json:"enabled"`
RpmLimit sql.NullInt32 `json:"rpm_limit"`
MonthlyQuota sql.NullInt32 `json:"monthly_quota"`
RequestTimeoutMs sql.NullInt32 `json:"request_timeout_ms"`
}
func (q *Queries) GetAPServiceBySlug(ctx context.Context, slug string) (GetAPServiceBySlugRow, error) {
row := q.db.QueryRowContext(ctx, getAPServiceBySlug, slug)
var i GetAPServiceBySlugRow
err := row.Scan(
&i.ID,
&i.UpstreamUrl,
&i.RoutePrefix,
&i.UpstreamAuthHeader,
&i.UpstreamAuthValue,
&i.Enabled,
&i.RpmLimit,
&i.MonthlyQuota,
&i.RequestTimeoutMs,
)
return i, err
}
const getAPServiceForValidate = `-- name: GetAPServiceForValidate :one
SELECT upstream_url, health_path, upstream_auth_header, upstream_auth_value, request_timeout_ms
FROM api_services WHERE id = $1
`
type GetAPServiceForValidateRow struct {
UpstreamUrl string `json:"upstream_url"`
HealthPath string `json:"health_path"`
UpstreamAuthHeader sql.NullString `json:"upstream_auth_header"`
UpstreamAuthValue sql.NullString `json:"upstream_auth_value"`
RequestTimeoutMs sql.NullInt32 `json:"request_timeout_ms"`
}
func (q *Queries) GetAPServiceForValidate(ctx context.Context, id uuid.UUID) (GetAPServiceForValidateRow, error) {
row := q.db.QueryRowContext(ctx, getAPServiceForValidate, id)
var i GetAPServiceForValidateRow
err := row.Scan(
&i.UpstreamUrl,
&i.HealthPath,
&i.UpstreamAuthHeader,
&i.UpstreamAuthValue,
&i.RequestTimeoutMs,
)
return i, err
}
const insertAPKey = `-- name: InsertAPKey :one
INSERT INTO api_keys (
name, key_hash, key_prefix, plan, allowed_service_ids,
rpm_limit, monthly_quota, created_at, updated_at
) VALUES ($1, $2, $3, $4, '[]', $5, $6, NOW(), NOW())
RETURNING id
`
type InsertAPKeyParams struct {
Name string `json:"name"`
KeyHash string `json:"key_hash"`
KeyPrefix string `json:"key_prefix"`
Plan string `json:"plan"`
RpmLimit sql.NullInt32 `json:"rpm_limit"`
MonthlyQuota sql.NullInt32 `json:"monthly_quota"`
}
func (q *Queries) InsertAPKey(ctx context.Context, arg InsertAPKeyParams) (uuid.UUID, error) {
row := q.db.QueryRowContext(ctx, insertAPKey,
arg.Name,
arg.KeyHash,
arg.KeyPrefix,
arg.Plan,
arg.RpmLimit,
arg.MonthlyQuota,
)
var id uuid.UUID
err := row.Scan(&id)
return id, err
}
const insertAPService = `-- name: InsertAPService :one
INSERT INTO api_services (
name, slug, upstream_url, route_prefix, health_path,
rpm_limit, monthly_quota, created_at, updated_at
) VALUES ($1, $2, $3, $4, '/health', $5, $6, NOW(), NOW())
RETURNING id
`
type InsertAPServiceParams struct {
Name string `json:"name"`
Slug string `json:"slug"`
UpstreamUrl string `json:"upstream_url"`
RoutePrefix string `json:"route_prefix"`
RpmLimit sql.NullInt32 `json:"rpm_limit"`
MonthlyQuota sql.NullInt32 `json:"monthly_quota"`
}
func (q *Queries) InsertAPService(ctx context.Context, arg InsertAPServiceParams) (uuid.UUID, error) {
row := q.db.QueryRowContext(ctx, insertAPService,
arg.Name,
arg.Slug,
arg.UpstreamUrl,
arg.RoutePrefix,
arg.RpmLimit,
arg.MonthlyQuota,
)
var id uuid.UUID
err := row.Scan(&id)
return id, err
}
const insertGatewayIncident = `-- name: InsertGatewayIncident :exec
INSERT INTO incident_events (service_id, api_key_id, code, message, severity, http_status, occurred_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW())
`
type InsertGatewayIncidentParams struct {
ServiceID uuid.NullUUID `json:"service_id"`
ApiKeyID uuid.NullUUID `json:"api_key_id"`
Code string `json:"code"`
Message string `json:"message"`
Severity string `json:"severity"`
HttpStatus sql.NullInt32 `json:"http_status"`
}
func (q *Queries) InsertGatewayIncident(ctx context.Context, arg InsertGatewayIncidentParams) error {
_, err := q.db.ExecContext(ctx, insertGatewayIncident,
arg.ServiceID,
arg.ApiKeyID,
arg.Code,
arg.Message,
arg.Severity,
arg.HttpStatus,
)
return err
}
const insertMetricPoint = `-- name: InsertMetricPoint :exec
INSERT INTO metrics_timeseries (metric, value, labels_json, occurred_at)
VALUES ('gateway_request', 1, $1, NOW())
`
func (q *Queries) InsertMetricPoint(ctx context.Context, labelsJson sql.NullString) error {
_, err := q.db.ExecContext(ctx, insertMetricPoint, labelsJson)
return err
}
const insertServiceIncident = `-- name: InsertServiceIncident :exec
INSERT INTO incident_events (service_id, code, message, severity, http_status, occurred_at)
VALUES ($1, 'SERVICE_VALIDATION_FAILED', $2, 'medium', $3, NOW())
`
type InsertServiceIncidentParams struct {
ServiceID uuid.NullUUID `json:"service_id"`
Message string `json:"message"`
HttpStatus sql.NullInt32 `json:"http_status"`
}
func (q *Queries) InsertServiceIncident(ctx context.Context, arg InsertServiceIncidentParams) error {
_, err := q.db.ExecContext(ctx, insertServiceIncident, arg.ServiceID, arg.Message, arg.HttpStatus)
return err
}
const listAPKeys = `-- name: ListAPKeys :many
SELECT id, name, key_prefix, plan, enabled, rpm_limit, monthly_quota,
to_char(created_at, 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS created_at,
to_char(updated_at, 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS updated_at
FROM api_keys
ORDER BY created_at DESC
`
type ListAPKeysRow struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
KeyPrefix string `json:"key_prefix"`
Plan string `json:"plan"`
Enabled int32 `json:"enabled"`
RpmLimit sql.NullInt32 `json:"rpm_limit"`
MonthlyQuota sql.NullInt32 `json:"monthly_quota"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func (q *Queries) ListAPKeys(ctx context.Context) ([]ListAPKeysRow, error) {
rows, err := q.db.QueryContext(ctx, listAPKeys)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListAPKeysRow{}
for rows.Next() {
var i ListAPKeysRow
if err := rows.Scan(
&i.ID,
&i.Name,
&i.KeyPrefix,
&i.Plan,
&i.Enabled,
&i.RpmLimit,
&i.MonthlyQuota,
&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 listAPKeysByPrefix = `-- name: ListAPKeysByPrefix :many
SELECT id, key_hash, enabled, rpm_limit, monthly_quota, allowed_service_ids
FROM api_keys WHERE key_prefix = $1
`
type ListAPKeysByPrefixRow struct {
ID uuid.UUID `json:"id"`
KeyHash string `json:"key_hash"`
Enabled int32 `json:"enabled"`
RpmLimit sql.NullInt32 `json:"rpm_limit"`
MonthlyQuota sql.NullInt32 `json:"monthly_quota"`
AllowedServiceIds string `json:"allowed_service_ids"`
}
func (q *Queries) ListAPKeysByPrefix(ctx context.Context, keyPrefix string) ([]ListAPKeysByPrefixRow, error) {
rows, err := q.db.QueryContext(ctx, listAPKeysByPrefix, keyPrefix)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListAPKeysByPrefixRow{}
for rows.Next() {
var i ListAPKeysByPrefixRow
if err := rows.Scan(
&i.ID,
&i.KeyHash,
&i.Enabled,
&i.RpmLimit,
&i.MonthlyQuota,
&i.AllowedServiceIds,
); 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 listAPServices = `-- name: ListAPServices :many
SELECT id, name, slug, upstream_url, route_prefix, enabled,
to_char(created_at, 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS created_at,
to_char(updated_at, 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS updated_at
FROM api_services
ORDER BY created_at DESC
`
type ListAPServicesRow struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
UpstreamUrl string `json:"upstream_url"`
RoutePrefix string `json:"route_prefix"`
Enabled int32 `json:"enabled"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func (q *Queries) ListAPServices(ctx context.Context) ([]ListAPServicesRow, error) {
rows, err := q.db.QueryContext(ctx, listAPServices)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListAPServicesRow{}
for rows.Next() {
var i ListAPServicesRow
if err := rows.Scan(
&i.ID,
&i.Name,
&i.Slug,
&i.UpstreamUrl,
&i.RoutePrefix,
&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 listClientEventPaths = `-- name: ListClientEventPaths :many
SELECT COALESCE((labels_json::jsonb ->> 'path'), 'unknown') AS path, COUNT(*) AS total
FROM metrics_timeseries
WHERE metric = 'client_event' AND occurred_at >= NOW() - INTERVAL '7 days'
GROUP BY COALESCE((labels_json::jsonb ->> 'path'), 'unknown')
ORDER BY total DESC, path ASC
LIMIT 20
`
type ListClientEventPathsRow struct {
Path interface{} `json:"path"`
Total int64 `json:"total"`
}
func (q *Queries) ListClientEventPaths(ctx context.Context) ([]ListClientEventPathsRow, error) {
rows, err := q.db.QueryContext(ctx, listClientEventPaths)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListClientEventPathsRow{}
for rows.Next() {
var i ListClientEventPathsRow
if err := rows.Scan(&i.Path, &i.Total); 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 listIncidentStatusCodes = `-- name: ListIncidentStatusCodes :many
SELECT COALESCE(http_status, 0) AS status_code, COALESCE(SUM(count), 0) AS total
FROM incident_events
WHERE occurred_at >= NOW() - INTERVAL '7 days'
GROUP BY COALESCE(http_status, 0)
ORDER BY total DESC, status_code ASC
`
type ListIncidentStatusCodesRow struct {
StatusCode int32 `json:"status_code"`
Total interface{} `json:"total"`
}
func (q *Queries) ListIncidentStatusCodes(ctx context.Context) ([]ListIncidentStatusCodesRow, error) {
rows, err := q.db.QueryContext(ctx, listIncidentStatusCodes)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListIncidentStatusCodesRow{}
for rows.Next() {
var i ListIncidentStatusCodesRow
if err := rows.Scan(&i.StatusCode, &i.Total); 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 listRequestsByDay = `-- name: ListRequestsByDay :many
SELECT TO_CHAR(DATE_TRUNC('day', occurred_at), 'YYYY-MM-DD') AS day_bucket, COUNT(*) AS total
FROM metrics_timeseries
WHERE metric = 'request_total' AND occurred_at >= NOW() - INTERVAL '7 days'
GROUP BY DATE_TRUNC('day', occurred_at)
ORDER BY DATE_TRUNC('day', occurred_at) ASC
`
type ListRequestsByDayRow struct {
DayBucket string `json:"day_bucket"`
Total int64 `json:"total"`
}
func (q *Queries) ListRequestsByDay(ctx context.Context) ([]ListRequestsByDayRow, error) {
rows, err := q.db.QueryContext(ctx, listRequestsByDay)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListRequestsByDayRow{}
for rows.Next() {
var i ListRequestsByDayRow
if err := rows.Scan(&i.DayBucket, &i.Total); 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 listTopAPServices = `-- name: ListTopAPServices :many
SELECT s.id, s.name, COALESCE(SUM(u.request_count), 0) AS total_requests
FROM api_services s
LEFT JOIN usage_counters u ON u.service_id = s.id
GROUP BY s.id, s.name
ORDER BY total_requests DESC, s.name ASC
LIMIT 10
`
type ListTopAPServicesRow struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
TotalRequests interface{} `json:"total_requests"`
}
func (q *Queries) ListTopAPServices(ctx context.Context) ([]ListTopAPServicesRow, error) {
rows, err := q.db.QueryContext(ctx, listTopAPServices)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListTopAPServicesRow{}
for rows.Next() {
var i ListTopAPServicesRow
if err := rows.Scan(&i.ID, &i.Name, &i.TotalRequests); 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 setAPKeyEnabled = `-- name: SetAPKeyEnabled :exec
UPDATE api_keys SET enabled = $1, updated_at = NOW() WHERE id = $2
`
type SetAPKeyEnabledParams struct {
Enabled int32 `json:"enabled"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) SetAPKeyEnabled(ctx context.Context, arg SetAPKeyEnabledParams) error {
_, err := q.db.ExecContext(ctx, setAPKeyEnabled, arg.Enabled, arg.ID)
return err
}
const setAPServiceEnabled = `-- name: SetAPServiceEnabled :exec
UPDATE api_services SET enabled = $1, updated_at = NOW() WHERE id = $2
`
type SetAPServiceEnabledParams struct {
Enabled int32 `json:"enabled"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) SetAPServiceEnabled(ctx context.Context, arg SetAPServiceEnabledParams) error {
_, err := q.db.ExecContext(ctx, setAPServiceEnabled, arg.Enabled, arg.ID)
return err
}
const setAPServiceValidation = `-- name: SetAPServiceValidation :exec
UPDATE api_services
SET last_validation_at = NOW(), last_validation_status = $1, last_validation_message = $2, updated_at = NOW()
WHERE id = $3
`
type SetAPServiceValidationParams struct {
LastValidationStatus sql.NullString `json:"last_validation_status"`
LastValidationMessage sql.NullString `json:"last_validation_message"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) SetAPServiceValidation(ctx context.Context, arg SetAPServiceValidationParams) error {
_, err := q.db.ExecContext(ctx, setAPServiceValidation, arg.LastValidationStatus, arg.LastValidationMessage, arg.ID)
return err
}
const sumRequestMetricSince = `-- name: SumRequestMetricSince :one
SELECT COALESCE(SUM(value), 0)::int
FROM metrics_timeseries
WHERE metric = 'request_total' AND occurred_at >= DATE_TRUNC($1::text, NOW())
`
func (q *Queries) SumRequestMetricSince(ctx context.Context, dollar_1 string) (int32, error) {
row := q.db.QueryRowContext(ctx, sumRequestMetricSince, dollar_1)
var column_1 int32
err := row.Scan(&column_1)
return column_1, err
}
const sumUsageByKey = `-- name: SumUsageByKey :one
SELECT COALESCE(SUM(request_count), 0)::bigint FROM usage_counters
WHERE api_key_id = $1 AND period_month = $2
`
type SumUsageByKeyParams struct {
ApiKeyID uuid.UUID `json:"api_key_id"`
PeriodMonth string `json:"period_month"`
}
func (q *Queries) SumUsageByKey(ctx context.Context, arg SumUsageByKeyParams) (int64, error) {
row := q.db.QueryRowContext(ctx, sumUsageByKey, arg.ApiKeyID, arg.PeriodMonth)
var column_1 int64
err := row.Scan(&column_1)
return column_1, err
}
const sumUsageByService = `-- name: SumUsageByService :one
SELECT COALESCE(SUM(request_count), 0)::bigint FROM usage_counters
WHERE service_id = $1 AND period_month = $2
`
type SumUsageByServiceParams struct {
ServiceID uuid.UUID `json:"service_id"`
PeriodMonth string `json:"period_month"`
}
func (q *Queries) SumUsageByService(ctx context.Context, arg SumUsageByServiceParams) (int64, error) {
row := q.db.QueryRowContext(ctx, sumUsageByService, arg.ServiceID, arg.PeriodMonth)
var column_1 int64
err := row.Scan(&column_1)
return column_1, err
}
const sumUsageCounters = `-- name: SumUsageCounters :one
SELECT COALESCE(SUM(request_count), 0) FROM usage_counters
`
func (q *Queries) SumUsageCounters(ctx context.Context) (interface{}, error) {
row := q.db.QueryRowContext(ctx, sumUsageCounters)
var coalesce interface{}
err := row.Scan(&coalesce)
return coalesce, err
}
const touchAPKeyLastUsed = `-- name: TouchAPKeyLastUsed :exec
UPDATE api_keys SET last_used_at = NOW() WHERE id = $1
`
func (q *Queries) TouchAPKeyLastUsed(ctx context.Context, id uuid.UUID) error {
_, err := q.db.ExecContext(ctx, touchAPKeyLastUsed, id)
return err
}
const upsertUsageCounter = `-- name: UpsertUsageCounter :exec
INSERT INTO usage_counters (api_key_id, service_id, period_month, request_count, updated_at)
VALUES ($1, $2, $3, 1, NOW())
ON CONFLICT (api_key_id, service_id, period_month)
DO UPDATE SET request_count = usage_counters.request_count + 1, updated_at = NOW()
`
type UpsertUsageCounterParams struct {
ApiKeyID uuid.UUID `json:"api_key_id"`
ServiceID uuid.UUID `json:"service_id"`
PeriodMonth string `json:"period_month"`
}
func (q *Queries) UpsertUsageCounter(ctx context.Context, arg UpsertUsageCounterParams) error {
_, err := q.db.ExecContext(ctx, upsertUsageCounter, arg.ApiKeyID, arg.ServiceID, arg.PeriodMonth)
return err
}
@@ -1,218 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: audit.sql
package sqlcdb
import (
"context"
"database/sql"
"encoding/json"
"time"
"github.com/google/uuid"
"github.com/sqlc-dev/pqtype"
)
const insertAuditLog = `-- name: InsertAuditLog :exec
INSERT INTO audit_logs (id, user_id, resource, resource_id, action, details, severity, category, label, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
`
type InsertAuditLogParams struct {
ID uuid.UUID `json:"id"`
UserID uuid.NullUUID `json:"user_id"`
Resource string `json:"resource"`
ResourceID uuid.NullUUID `json:"resource_id"`
Action string `json:"action"`
Details pqtype.NullRawMessage `json:"details"`
Severity string `json:"severity"`
Category string `json:"category"`
Label string `json:"label"`
CreatedAt time.Time `json:"created_at"`
}
func (q *Queries) InsertAuditLog(ctx context.Context, arg InsertAuditLogParams) error {
_, err := q.db.ExecContext(ctx, insertAuditLog,
arg.ID,
arg.UserID,
arg.Resource,
arg.ResourceID,
arg.Action,
arg.Details,
arg.Severity,
arg.Category,
arg.Label,
arg.CreatedAt,
)
return err
}
const insertAuditLogWithRequest = `-- name: InsertAuditLogWithRequest :exec
INSERT INTO audit_logs (id, user_id, resource, resource_id, action, details, ip_address, user_agent, severity, category, label, created_at)
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7::inet, $8, $9, $10, $11, $12)
`
type InsertAuditLogWithRequestParams struct {
ID uuid.UUID `json:"id"`
UserID uuid.NullUUID `json:"user_id"`
Resource string `json:"resource"`
ResourceID uuid.NullUUID `json:"resource_id"`
Action string `json:"action"`
Column6 json.RawMessage `json:"column_6"`
Column7 pqtype.Inet `json:"column_7"`
UserAgent sql.NullString `json:"user_agent"`
Severity string `json:"severity"`
Category string `json:"category"`
Label string `json:"label"`
CreatedAt time.Time `json:"created_at"`
}
func (q *Queries) InsertAuditLogWithRequest(ctx context.Context, arg InsertAuditLogWithRequestParams) error {
_, err := q.db.ExecContext(ctx, insertAuditLogWithRequest,
arg.ID,
arg.UserID,
arg.Resource,
arg.ResourceID,
arg.Action,
arg.Column6,
arg.Column7,
arg.UserAgent,
arg.Severity,
arg.Category,
arg.Label,
arg.CreatedAt,
)
return err
}
const listAuditBackfillBatch = `-- name: ListAuditBackfillBatch :many
SELECT id::text, resource, action FROM audit_logs
WHERE label = '' AND id > $1::uuid ORDER BY id LIMIT 500
`
type ListAuditBackfillBatchRow struct {
ID string `json:"id"`
Resource string `json:"resource"`
Action string `json:"action"`
}
func (q *Queries) ListAuditBackfillBatch(ctx context.Context, dollar_1 uuid.UUID) ([]ListAuditBackfillBatchRow, error) {
rows, err := q.db.QueryContext(ctx, listAuditBackfillBatch, dollar_1)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListAuditBackfillBatchRow{}
for rows.Next() {
var i ListAuditBackfillBatchRow
if err := rows.Scan(&i.ID, &i.Resource, &i.Action); 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 listResourceAuditLogs = `-- name: ListResourceAuditLogs :many
SELECT id, COALESCE(user_id::text, '')::text AS user_id, resource,
COALESCE(resource_id::text, '')::text AS resource_id, action,
COALESCE(details::text, '{}')::text AS details,
COALESCE(ip_address::text, '')::text AS ip_address,
COALESCE(user_agent, '') AS user_agent, created_at
FROM audit_logs
WHERE user_id::text = $1 AND resource = $2 AND resource_id::text = $3
ORDER BY created_at DESC
LIMIT 100
`
type ListResourceAuditLogsParams struct {
UserID uuid.NullUUID `json:"user_id"`
Resource string `json:"resource"`
ResourceID uuid.NullUUID `json:"resource_id"`
}
type ListResourceAuditLogsRow struct {
ID uuid.UUID `json:"id"`
UserID string `json:"user_id"`
Resource string `json:"resource"`
ResourceID string `json:"resource_id"`
Action string `json:"action"`
Details string `json:"details"`
IpAddress string `json:"ip_address"`
UserAgent string `json:"user_agent"`
CreatedAt time.Time `json:"created_at"`
}
func (q *Queries) ListResourceAuditLogs(ctx context.Context, arg ListResourceAuditLogsParams) ([]ListResourceAuditLogsRow, error) {
rows, err := q.db.QueryContext(ctx, listResourceAuditLogs, arg.UserID, arg.Resource, arg.ResourceID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListResourceAuditLogsRow{}
for rows.Next() {
var i ListResourceAuditLogsRow
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.Resource,
&i.ResourceID,
&i.Action,
&i.Details,
&i.IpAddress,
&i.UserAgent,
&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 pruneAuditLogs = `-- name: PruneAuditLogs :execrows
DELETE FROM audit_logs WHERE created_at < NOW() - ($1::int * INTERVAL '1 day')
`
func (q *Queries) PruneAuditLogs(ctx context.Context, dollar_1 int32) (int64, error) {
result, err := q.db.ExecContext(ctx, pruneAuditLogs, dollar_1)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const setAuditLogMetadata = `-- name: SetAuditLogMetadata :exec
UPDATE audit_logs SET severity = $1, category = $2, label = $3 WHERE id = $4::uuid
`
type SetAuditLogMetadataParams struct {
Severity string `json:"severity"`
Category string `json:"category"`
Label string `json:"label"`
Column4 uuid.UUID `json:"column_4"`
}
func (q *Queries) SetAuditLogMetadata(ctx context.Context, arg SetAuditLogMetadataParams) error {
_, err := q.db.ExecContext(ctx, setAuditLogMetadata,
arg.Severity,
arg.Category,
arg.Label,
arg.Column4,
)
return err
}
@@ -34,24 +34,6 @@ func (q *Queries) CountUsersByEmail(ctx context.Context, email string) (int64, e
return count, err return count, err
} }
const getAdminUserInfo = `-- name: GetAdminUserInfo :one
SELECT email, COALESCE(name, '') AS name, is_admin
FROM users WHERE id = $1
`
type GetAdminUserInfoRow struct {
Email string `json:"email"`
Name string `json:"name"`
IsAdmin bool `json:"is_admin"`
}
func (q *Queries) GetAdminUserInfo(ctx context.Context, id uuid.UUID) (GetAdminUserInfoRow, error) {
row := q.db.QueryRowContext(ctx, getAdminUserInfo, id)
var i GetAdminUserInfoRow
err := row.Scan(&i.Email, &i.Name, &i.IsAdmin)
return i, err
}
const getUserByEmailForAuth = `-- name: GetUserByEmailForAuth :one const getUserByEmailForAuth = `-- name: GetUserByEmailForAuth :one
SELECT id, email, password_hash, name, COALESCE(avatar_url, '') AS avatar_url, is_admin, created_at SELECT id, email, password_hash, name, COALESCE(avatar_url, '') AS avatar_url, is_admin, created_at
FROM users WHERE email = $1 FROM users WHERE email = $1
@@ -186,69 +168,6 @@ func (q *Queries) InsertUserAdmin(ctx context.Context, arg InsertUserAdminParams
return i, err return i, err
} }
const listAdminUsers = `-- name: ListAdminUsers :many
SELECT id, email, name, COALESCE(avatar_url, '') AS avatar_url, is_admin,
COALESCE(to_char(created_at, 'YYYY-MM-DD"T"HH24:MI:SS"Z"'), '')::text AS created_at
FROM users
ORDER BY created_at ASC
`
type ListAdminUsersRow struct {
ID uuid.UUID `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
AvatarUrl string `json:"avatar_url"`
IsAdmin bool `json:"is_admin"`
CreatedAt string `json:"created_at"`
}
func (q *Queries) ListAdminUsers(ctx context.Context) ([]ListAdminUsersRow, error) {
rows, err := q.db.QueryContext(ctx, listAdminUsers)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListAdminUsersRow{}
for rows.Next() {
var i ListAdminUsersRow
if err := rows.Scan(
&i.ID,
&i.Email,
&i.Name,
&i.AvatarUrl,
&i.IsAdmin,
&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 setUserAdmin = `-- name: SetUserAdmin :execrows
UPDATE users SET is_admin = $1, updated_at = NOW() WHERE id = $2
`
type SetUserAdminParams struct {
IsAdmin bool `json:"is_admin"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) SetUserAdmin(ctx context.Context, arg SetUserAdminParams) (int64, error) {
result, err := q.db.ExecContext(ctx, setUserAdmin, arg.IsAdmin, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const updateUserProfile = `-- name: UpdateUserProfile :exec const updateUserProfile = `-- name: UpdateUserProfile :exec
UPDATE users UPDATE users
SET name = COALESCE($1::varchar, name), avatar_url = COALESCE($2::varchar, avatar_url) SET name = COALESCE($1::varchar, name), avatar_url = COALESCE($2::varchar, avatar_url)
@@ -266,27 +185,6 @@ func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfilePa
return err return err
} }
const upsertAdminUser = `-- name: UpsertAdminUser :one
INSERT INTO users (email, password_hash, name, is_admin)
VALUES ($1, $2, $3, true)
ON CONFLICT (email) DO UPDATE
SET is_admin = true, updated_at = NOW()
RETURNING id
`
type UpsertAdminUserParams struct {
Email string `json:"email"`
PasswordHash string `json:"password_hash"`
Name string `json:"name"`
}
func (q *Queries) UpsertAdminUser(ctx context.Context, arg UpsertAdminUserParams) (uuid.UUID, error) {
row := q.db.QueryRowContext(ctx, upsertAdminUser, arg.Email, arg.PasswordHash, arg.Name)
var id uuid.UUID
err := row.Scan(&id)
return id, err
}
const upsertLocalUser = `-- name: UpsertLocalUser :one const upsertLocalUser = `-- name: UpsertLocalUser :one
INSERT INTO users (email, password_hash, name, avatar_url, is_admin) INSERT INTO users (email, password_hash, name, avatar_url, is_admin)
VALUES ($1, $2, $3, NULLIF($4::varchar, ''), $5) VALUES ($1, $2, $3, NULLIF($4::varchar, ''), $5)
@@ -1,138 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: builds.sql
package sqlcdb
import (
"context"
"database/sql"
"encoding/json"
"time"
)
const cancelBuild = `-- name: CancelBuild :execrows
UPDATE builds
SET status = 'cancelled',
progress = 100,
completed_at = $1,
log = COALESCE(log, '') || $2,
updated_at = $1
WHERE id = $3
AND status NOT IN ('success', 'failed', 'cancelled')
`
type CancelBuildParams struct {
CompletedAt sql.NullTime `json:"completed_at"`
Log string `json:"log"`
ID string `json:"id"`
}
func (q *Queries) CancelBuild(ctx context.Context, arg CancelBuildParams) (int64, error) {
result, err := q.db.ExecContext(ctx, cancelBuild, arg.CompletedAt, arg.Log, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const getBuild = `-- name: GetBuild :one
SELECT id, project_id, service_id, status, progress, started_at, completed_at, image_name, image_tag, size, error, log, metadata
FROM builds
WHERE id = $1
`
type GetBuildRow struct {
ID string `json:"id"`
ProjectID sql.NullString `json:"project_id"`
ServiceID sql.NullString `json:"service_id"`
Status string `json:"status"`
Progress int32 `json:"progress"`
StartedAt time.Time `json:"started_at"`
CompletedAt sql.NullTime `json:"completed_at"`
ImageName string `json:"image_name"`
ImageTag string `json:"image_tag"`
Size int64 `json:"size"`
Error sql.NullString `json:"error"`
Log string `json:"log"`
Metadata json.RawMessage `json:"metadata"`
}
func (q *Queries) GetBuild(ctx context.Context, id string) (GetBuildRow, error) {
row := q.db.QueryRowContext(ctx, getBuild, id)
var i GetBuildRow
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.ServiceID,
&i.Status,
&i.Progress,
&i.StartedAt,
&i.CompletedAt,
&i.ImageName,
&i.ImageTag,
&i.Size,
&i.Error,
&i.Log,
&i.Metadata,
)
return i, err
}
const upsertBuild = `-- name: UpsertBuild :exec
INSERT INTO builds
(id, project_id, service_id, status, progress, started_at, completed_at, image_name, image_tag, size, error, log, metadata, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb, $14, $14)
ON CONFLICT (id) DO UPDATE SET
project_id = EXCLUDED.project_id,
service_id = EXCLUDED.service_id,
status = EXCLUDED.status,
progress = EXCLUDED.progress,
started_at = EXCLUDED.started_at,
completed_at = EXCLUDED.completed_at,
image_name = EXCLUDED.image_name,
image_tag = EXCLUDED.image_tag,
size = EXCLUDED.size,
error = EXCLUDED.error,
log = EXCLUDED.log,
metadata = EXCLUDED.metadata,
updated_at = EXCLUDED.updated_at
`
type UpsertBuildParams struct {
ID string `json:"id"`
ProjectID sql.NullString `json:"project_id"`
ServiceID sql.NullString `json:"service_id"`
Status string `json:"status"`
Progress int32 `json:"progress"`
StartedAt time.Time `json:"started_at"`
CompletedAt sql.NullTime `json:"completed_at"`
ImageName string `json:"image_name"`
ImageTag string `json:"image_tag"`
Size int64 `json:"size"`
Error sql.NullString `json:"error"`
Log string `json:"log"`
Column13 json.RawMessage `json:"column_13"`
CreatedAt time.Time `json:"created_at"`
}
func (q *Queries) UpsertBuild(ctx context.Context, arg UpsertBuildParams) error {
_, err := q.db.ExecContext(ctx, upsertBuild,
arg.ID,
arg.ProjectID,
arg.ServiceID,
arg.Status,
arg.Progress,
arg.StartedAt,
arg.CompletedAt,
arg.ImageName,
arg.ImageTag,
arg.Size,
arg.Error,
arg.Log,
arg.Column13,
arg.CreatedAt,
)
return err
}
@@ -157,28 +157,6 @@ func (q *Queries) GetGitProviderTimestamps(ctx context.Context, id uuid.UUID) (G
return i, err return i, err
} }
const getGitRepoCloneByFullName = `-- name: GetGitRepoCloneByFullName :one
SELECT clone_url, provider_id FROM git_repositories
WHERE full_name = $1 AND user_id = $2 LIMIT 1
`
type GetGitRepoCloneByFullNameParams struct {
FullName string `json:"full_name"`
UserID uuid.UUID `json:"user_id"`
}
type GetGitRepoCloneByFullNameRow struct {
CloneUrl string `json:"clone_url"`
ProviderID uuid.UUID `json:"provider_id"`
}
func (q *Queries) GetGitRepoCloneByFullName(ctx context.Context, arg GetGitRepoCloneByFullNameParams) (GetGitRepoCloneByFullNameRow, error) {
row := q.db.QueryRowContext(ctx, getGitRepoCloneByFullName, arg.FullName, arg.UserID)
var i GetGitRepoCloneByFullNameRow
err := row.Scan(&i.CloneUrl, &i.ProviderID)
return i, err
}
const getGitRepoForPush = `-- name: GetGitRepoForPush :one const getGitRepoForPush = `-- name: GetGitRepoForPush :one
SELECT clone_url, full_name, user_id FROM git_repositories WHERE id = $1 SELECT clone_url, full_name, user_id FROM git_repositories WHERE id = $1
` `
@@ -196,23 +174,6 @@ func (q *Queries) GetGitRepoForPush(ctx context.Context, id uuid.UUID) (GetGitRe
return i, err return i, err
} }
const getGitRepoProviderByCloneURL = `-- name: GetGitRepoProviderByCloneURL :one
SELECT provider_id FROM git_repositories
WHERE clone_url = $1 AND user_id = $2 LIMIT 1
`
type GetGitRepoProviderByCloneURLParams struct {
CloneUrl string `json:"clone_url"`
UserID uuid.UUID `json:"user_id"`
}
func (q *Queries) GetGitRepoProviderByCloneURL(ctx context.Context, arg GetGitRepoProviderByCloneURLParams) (uuid.UUID, error) {
row := q.db.QueryRowContext(ctx, getGitRepoProviderByCloneURL, arg.CloneUrl, arg.UserID)
var provider_id uuid.UUID
err := row.Scan(&provider_id)
return provider_id, err
}
const getGitRepositoryByFullName = `-- name: GetGitRepositoryByFullName :one const getGitRepositoryByFullName = `-- name: GetGitRepositoryByFullName :one
SELECT id, provider_id, name, full_name, COALESCE(description, '') AS description, SELECT id, provider_id, name, full_name, COALESCE(description, '') AS description,
clone_url, COALESCE(webhook_url, '') AS webhook_url, clone_url, COALESCE(webhook_url, '') AS webhook_url,
@@ -1,174 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: misc.sql
package sqlcdb
import (
"context"
"database/sql"
"encoding/json"
"time"
"github.com/google/uuid"
)
const countProjects = `-- name: CountProjects :one
SELECT COUNT(*) FROM projects
`
func (q *Queries) CountProjects(ctx context.Context) (int64, error) {
row := q.db.QueryRowContext(ctx, countProjects)
var count int64
err := row.Scan(&count)
return count, err
}
const deleteProjectVariables = `-- name: DeleteProjectVariables :exec
DELETE FROM project_variables WHERE project_id = $1
`
func (q *Queries) DeleteProjectVariables(ctx context.Context, projectID uuid.UUID) error {
_, err := q.db.ExecContext(ctx, deleteProjectVariables, projectID)
return err
}
const getProjectVariableValue = `-- name: GetProjectVariableValue :one
SELECT value FROM project_variables WHERE project_id = $1 AND key = $2
`
type GetProjectVariableValueParams struct {
ProjectID uuid.UUID `json:"project_id"`
Key string `json:"key"`
}
func (q *Queries) GetProjectVariableValue(ctx context.Context, arg GetProjectVariableValueParams) (string, error) {
row := q.db.QueryRowContext(ctx, getProjectVariableValue, arg.ProjectID, arg.Key)
var value string
err := row.Scan(&value)
return value, err
}
const getRegistryAuth = `-- name: GetRegistryAuth :one
SELECT username, password FROM registries
WHERE owner_id::text = $1 AND host = $2
`
type GetRegistryAuthParams struct {
OwnerID uuid.UUID `json:"owner_id"`
Host string `json:"host"`
}
type GetRegistryAuthRow struct {
Username string `json:"username"`
Password string `json:"password"`
}
func (q *Queries) GetRegistryAuth(ctx context.Context, arg GetRegistryAuthParams) (GetRegistryAuthRow, error) {
row := q.db.QueryRowContext(ctx, getRegistryAuth, arg.OwnerID, arg.Host)
var i GetRegistryAuthRow
err := row.Scan(&i.Username, &i.Password)
return i, err
}
const insertEnvVar = `-- name: InsertEnvVar :exec
INSERT INTO environment_variables (id, service_id, key, value, is_secret, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`
type InsertEnvVarParams struct {
ID uuid.UUID `json:"id"`
ServiceID uuid.UUID `json:"service_id"`
Key string `json:"key"`
Value string `json:"value"`
IsSecret sql.NullBool `json:"is_secret"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
func (q *Queries) InsertEnvVar(ctx context.Context, arg InsertEnvVarParams) error {
_, err := q.db.ExecContext(ctx, insertEnvVar,
arg.ID,
arg.ServiceID,
arg.Key,
arg.Value,
arg.IsSecret,
arg.CreatedAt,
arg.UpdatedAt,
)
return err
}
const insertProjectVariable = `-- name: InsertProjectVariable :exec
INSERT INTO project_variables (id, project_id, key, value, is_secret, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`
type InsertProjectVariableParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Key string `json:"key"`
Value string `json:"value"`
IsSecret bool `json:"is_secret"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (q *Queries) InsertProjectVariable(ctx context.Context, arg InsertProjectVariableParams) error {
_, err := q.db.ExecContext(ctx, insertProjectVariable,
arg.ID,
arg.ProjectID,
arg.Key,
arg.Value,
arg.IsSecret,
arg.CreatedAt,
arg.UpdatedAt,
)
return err
}
const updateServiceDeployMeta = `-- name: UpdateServiceDeployMeta :exec
UPDATE services SET port = $1, healthcheck_path = $2,
volumes = COALESCE($3::jsonb, '[]'::jsonb),
git_repo = NULLIF($4::text, ''), git_branch = NULLIF($5::text, ''), builder = $6
WHERE id = $7
`
type UpdateServiceDeployMetaParams struct {
Port int32 `json:"port"`
HealthcheckPath string `json:"healthcheck_path"`
Column3 json.RawMessage `json:"column_3"`
Column4 string `json:"column_4"`
Column5 string `json:"column_5"`
Builder string `json:"builder"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) UpdateServiceDeployMeta(ctx context.Context, arg UpdateServiceDeployMetaParams) error {
_, err := q.db.ExecContext(ctx, updateServiceDeployMeta,
arg.Port,
arg.HealthcheckPath,
arg.Column3,
arg.Column4,
arg.Column5,
arg.Builder,
arg.ID,
)
return err
}
const updateServiceRuntimeMeta = `-- name: UpdateServiceRuntimeMeta :exec
UPDATE services SET port = $1, healthcheck_path = $2 WHERE id = $3
`
type UpdateServiceRuntimeMetaParams struct {
Port int32 `json:"port"`
HealthcheckPath string `json:"healthcheck_path"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) UpdateServiceRuntimeMeta(ctx context.Context, arg UpdateServiceRuntimeMetaParams) error {
_, err := q.db.ExecContext(ctx, updateServiceRuntimeMeta, arg.Port, arg.HealthcheckPath, arg.ID)
return err
}
@@ -49,66 +49,6 @@ type AgentHeartbeat struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
type ApiKey struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
KeyHash string `json:"key_hash"`
KeyPrefix string `json:"key_prefix"`
Plan string `json:"plan"`
AllowedServiceIds string `json:"allowed_service_ids"`
Enabled int32 `json:"enabled"`
RpmLimit sql.NullInt32 `json:"rpm_limit"`
MonthlyQuota sql.NullInt32 `json:"monthly_quota"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
LastUsedAt sql.NullTime `json:"last_used_at"`
}
type ApiService struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
UpstreamUrl string `json:"upstream_url"`
RoutePrefix string `json:"route_prefix"`
HealthPath string `json:"health_path"`
UpstreamAuthHeader sql.NullString `json:"upstream_auth_header"`
UpstreamAuthValue sql.NullString `json:"upstream_auth_value"`
InternalToken sql.NullString `json:"internal_token"`
Enabled int32 `json:"enabled"`
RpmLimit sql.NullInt32 `json:"rpm_limit"`
MonthlyQuota sql.NullInt32 `json:"monthly_quota"`
RequestTimeoutMs sql.NullInt32 `json:"request_timeout_ms"`
LastValidationAt sql.NullTime `json:"last_validation_at"`
LastValidationStatus sql.NullString `json:"last_validation_status"`
LastValidationMessage sql.NullString `json:"last_validation_message"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type AppSetting struct {
Key string `json:"key"`
Value string `json:"value"`
IsSecret bool `json:"is_secret"`
UpdatedAt time.Time `json:"updated_at"`
}
type AuditLog struct {
ID uuid.UUID `json:"id"`
Timestamp time.Time `json:"timestamp"`
UserID uuid.NullUUID `json:"user_id"`
Action string `json:"action"`
Resource string `json:"resource"`
ResourceID uuid.NullUUID `json:"resource_id"`
Details pqtype.NullRawMessage `json:"details"`
IpAddress pqtype.Inet `json:"ip_address"`
UserAgent sql.NullString `json:"user_agent"`
Success bool `json:"success"`
CreatedAt time.Time `json:"created_at"`
Severity string `json:"severity"`
Category string `json:"category"`
Label string `json:"label"`
}
type BackupTarget struct { type BackupTarget struct {
ID string `json:"id"` ID string `json:"id"`
UserID string `json:"user_id"` UserID string `json:"user_id"`
@@ -138,24 +78,6 @@ type Banner struct {
UpdatedAt sql.NullTime `json:"updated_at"` UpdatedAt sql.NullTime `json:"updated_at"`
} }
type Build struct {
ID string `json:"id"`
ProjectID sql.NullString `json:"project_id"`
ServiceID sql.NullString `json:"service_id"`
Status string `json:"status"`
Progress int32 `json:"progress"`
StartedAt time.Time `json:"started_at"`
CompletedAt sql.NullTime `json:"completed_at"`
ImageName string `json:"image_name"`
ImageTag string `json:"image_tag"`
Size int64 `json:"size"`
Error sql.NullString `json:"error"`
Log string `json:"log"`
Metadata json.RawMessage `json:"metadata"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ComplianceFramework struct { type ComplianceFramework struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -340,18 +262,6 @@ type GitWebhook struct {
UpdatedAt sql.NullTime `json:"updated_at"` UpdatedAt sql.NullTime `json:"updated_at"`
} }
type IncidentEvent struct {
ID int32 `json:"id"`
ServiceID uuid.NullUUID `json:"service_id"`
ApiKeyID uuid.NullUUID `json:"api_key_id"`
Code string `json:"code"`
Message string `json:"message"`
Severity string `json:"severity"`
HttpStatus sql.NullInt32 `json:"http_status"`
Count int32 `json:"count"`
OccurredAt time.Time `json:"occurred_at"`
}
type InstanceMetric struct { type InstanceMetric struct {
ServiceID string `json:"service_id"` ServiceID string `json:"service_id"`
Timestamp time.Time `json:"timestamp"` Timestamp time.Time `json:"timestamp"`
@@ -377,14 +287,6 @@ type InstanceMetric struct {
CreatedAt sql.NullTime `json:"created_at"` CreatedAt sql.NullTime `json:"created_at"`
} }
type MetricsTimeseries struct {
ID int32 `json:"id"`
Metric string `json:"metric"`
Value float32 `json:"value"`
LabelsJson sql.NullString `json:"labels_json"`
OccurredAt time.Time `json:"occurred_at"`
}
type NodeAgent struct { type NodeAgent struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -506,16 +408,6 @@ type ProjectMember struct {
CreatedAt sql.NullTime `json:"created_at"` CreatedAt sql.NullTime `json:"created_at"`
} }
type ProjectVariable struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Key string `json:"key"`
Value string `json:"value"`
IsSecret bool `json:"is_secret"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Registry struct { type Registry struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
OwnerID uuid.UUID `json:"owner_id"` OwnerID uuid.UUID `json:"owner_id"`
@@ -643,15 +535,6 @@ type ServiceTemplate struct {
UpdatedAt sql.NullTime `json:"updated_at"` UpdatedAt sql.NullTime `json:"updated_at"`
} }
type UsageCounter struct {
ID int32 `json:"id"`
ApiKeyID uuid.UUID `json:"api_key_id"`
ServiceID uuid.UUID `json:"service_id"`
PeriodMonth string `json:"period_month"`
RequestCount int32 `json:"request_count"`
UpdatedAt time.Time `json:"updated_at"`
}
type User struct { type User struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
Email string `json:"email"` Email string `json:"email"`
@@ -1,258 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: operations.sql
package sqlcdb
import (
"context"
"database/sql"
"time"
"github.com/google/uuid"
)
const listActiveOperationDeployments = `-- name: ListActiveOperationDeployments :many
SELECT d.id, d.service_id, s.name AS service_name, p.name AS project_name, d.status,
COALESCE(d.image_name, '') AS image, 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 (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))
ORDER BY d.created_at ASC
`
type ListActiveOperationDeploymentsParams struct {
OwnerID uuid.UUID `json:"owner_id"`
Column2 bool `json:"column_2"`
}
type ListActiveOperationDeploymentsRow struct {
ID uuid.UUID `json:"id"`
ServiceID uuid.UUID `json:"service_id"`
ServiceName string `json:"service_name"`
ProjectName string `json:"project_name"`
Status sql.NullString `json:"status"`
Image string `json:"image"`
Error sql.NullString `json:"error"`
StartedAt sql.NullTime `json:"started_at"`
CompletedAt sql.NullTime `json:"completed_at"`
CreatedAt sql.NullTime `json:"created_at"`
}
func (q *Queries) ListActiveOperationDeployments(ctx context.Context, arg ListActiveOperationDeploymentsParams) ([]ListActiveOperationDeploymentsRow, error) {
rows, err := q.db.QueryContext(ctx, listActiveOperationDeployments, arg.OwnerID, arg.Column2)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListActiveOperationDeploymentsRow{}
for rows.Next() {
var i ListActiveOperationDeploymentsRow
if err := rows.Scan(
&i.ID,
&i.ServiceID,
&i.ServiceName,
&i.ProjectName,
&i.Status,
&i.Image,
&i.Error,
&i.StartedAt,
&i.CompletedAt,
&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 listRecentCronRuns = `-- name: ListRecentCronRuns :many
SELECT e.id, j.name AS job_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 (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))
ORDER BY e.started_at DESC
LIMIT 30
`
type ListRecentCronRunsParams struct {
OwnerID uuid.UUID `json:"owner_id"`
Column2 bool `json:"column_2"`
}
type ListRecentCronRunsRow struct {
ID uuid.UUID `json:"id"`
JobName string `json:"job_name"`
Schedule string `json:"schedule"`
Status sql.NullString `json:"status"`
StartedAt time.Time `json:"started_at"`
FinishedAt sql.NullTime `json:"finished_at"`
Error sql.NullString `json:"error"`
}
func (q *Queries) ListRecentCronRuns(ctx context.Context, arg ListRecentCronRunsParams) ([]ListRecentCronRunsRow, error) {
rows, err := q.db.QueryContext(ctx, listRecentCronRuns, arg.OwnerID, arg.Column2)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListRecentCronRunsRow{}
for rows.Next() {
var i ListRecentCronRunsRow
if err := rows.Scan(
&i.ID,
&i.JobName,
&i.Schedule,
&i.Status,
&i.StartedAt,
&i.FinishedAt,
&i.Error,
); 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 listRecentFailedDeployments = `-- name: ListRecentFailedDeployments :many
SELECT d.id, d.service_id, s.name AS service_name, p.name AS project_name, d.status,
COALESCE(d.image_name, '') AS image, 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 (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))
ORDER BY d.updated_at DESC
LIMIT 20
`
type ListRecentFailedDeploymentsParams struct {
OwnerID uuid.UUID `json:"owner_id"`
Column2 bool `json:"column_2"`
}
type ListRecentFailedDeploymentsRow struct {
ID uuid.UUID `json:"id"`
ServiceID uuid.UUID `json:"service_id"`
ServiceName string `json:"service_name"`
ProjectName string `json:"project_name"`
Status sql.NullString `json:"status"`
Image string `json:"image"`
Error sql.NullString `json:"error"`
StartedAt sql.NullTime `json:"started_at"`
CompletedAt sql.NullTime `json:"completed_at"`
CreatedAt sql.NullTime `json:"created_at"`
}
func (q *Queries) ListRecentFailedDeployments(ctx context.Context, arg ListRecentFailedDeploymentsParams) ([]ListRecentFailedDeploymentsRow, error) {
rows, err := q.db.QueryContext(ctx, listRecentFailedDeployments, arg.OwnerID, arg.Column2)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListRecentFailedDeploymentsRow{}
for rows.Next() {
var i ListRecentFailedDeploymentsRow
if err := rows.Scan(
&i.ID,
&i.ServiceID,
&i.ServiceName,
&i.ProjectName,
&i.Status,
&i.Image,
&i.Error,
&i.StartedAt,
&i.CompletedAt,
&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 listRecentOperationBackups = `-- name: ListRecentOperationBackups :many
SELECT b.id, b.database_id, ds.name AS db_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
`
type ListRecentOperationBackupsParams struct {
UserID string `json:"user_id"`
Column2 bool `json:"column_2"`
}
type ListRecentOperationBackupsRow struct {
ID string `json:"id"`
DatabaseID string `json:"database_id"`
DbName string `json:"db_name"`
Status string `json:"status"`
Size string `json:"size"`
CreatedAt sql.NullTime `json:"created_at"`
CompletedAt sql.NullTime `json:"completed_at"`
}
func (q *Queries) ListRecentOperationBackups(ctx context.Context, arg ListRecentOperationBackupsParams) ([]ListRecentOperationBackupsRow, error) {
rows, err := q.db.QueryContext(ctx, listRecentOperationBackups, arg.UserID, arg.Column2)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListRecentOperationBackupsRow{}
for rows.Next() {
var i ListRecentOperationBackupsRow
if err := rows.Scan(
&i.ID,
&i.DatabaseID,
&i.DbName,
&i.Status,
&i.Size,
&i.CreatedAt,
&i.CompletedAt,
); 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
}
@@ -16,7 +16,6 @@ type Querier interface {
// Portable bucket bucketing (no TimescaleDB): floor epoch seconds into // Portable bucket bucketing (no TimescaleDB): floor epoch seconds into
// bucket-sized buckets, then back to timestamptz. // bucket-sized buckets, then back to timestamptz.
AggregateNodeMetrics(ctx context.Context, arg AggregateNodeMetricsParams) ([]AggregateNodeMetricsRow, error) AggregateNodeMetrics(ctx context.Context, arg AggregateNodeMetricsParams) ([]AggregateNodeMetricsRow, error)
CancelBuild(ctx context.Context, arg CancelBuildParams) (int64, error)
CheckSecurityProjectAccess(ctx context.Context, arg CheckSecurityProjectAccessParams) (bool, error) CheckSecurityProjectAccess(ctx context.Context, arg CheckSecurityProjectAccessParams) (bool, error)
ClearServiceDomainDefault(ctx context.Context, serviceID uuid.UUID) error ClearServiceDomainDefault(ctx context.Context, serviceID uuid.UUID) error
ClearServiceNodePins(ctx context.Context, nodeID sql.NullString) (int64, error) ClearServiceNodePins(ctx context.Context, nodeID sql.NullString) (int64, error)
@@ -26,8 +25,6 @@ type Querier interface {
CompleteCommand(ctx context.Context, arg CompleteCommandParams) (AgentCommand, error) CompleteCommand(ctx context.Context, arg CompleteCommandParams) (AgentCommand, error)
CompleteDeployment(ctx context.Context, arg CompleteDeploymentParams) error CompleteDeployment(ctx context.Context, arg CompleteDeploymentParams) error
ComplianceFrameworkExists(ctx context.Context, id uuid.UUID) (bool, error) ComplianceFrameworkExists(ctx context.Context, id uuid.UUID) (bool, error)
CountAPKeys(ctx context.Context) (int64, error)
CountAPServices(ctx context.Context) (int64, error)
CountAccessibleDeployments(ctx context.Context, arg CountAccessibleDeploymentsParams) (int64, error) CountAccessibleDeployments(ctx context.Context, arg CountAccessibleDeploymentsParams) (int64, error)
CountActivePreviewsForBranch(ctx context.Context, arg CountActivePreviewsForBranchParams) (int64, error) CountActivePreviewsForBranch(ctx context.Context, arg CountActivePreviewsForBranchParams) (int64, error)
CountDatabaseServicesByUserAndName(ctx context.Context, arg CountDatabaseServicesByUserAndNameParams) (int64, error) CountDatabaseServicesByUserAndName(ctx context.Context, arg CountDatabaseServicesByUserAndNameParams) (int64, error)
@@ -35,7 +32,6 @@ type Querier interface {
CountDeploymentsForService(ctx context.Context, serviceID uuid.UUID) (int64, error) CountDeploymentsForService(ctx context.Context, serviceID uuid.UUID) (int64, error)
CountEnvironmentServices(ctx context.Context, environmentID uuid.UUID) (int64, error) CountEnvironmentServices(ctx context.Context, environmentID uuid.UUID) (int64, error)
CountGitRepositories(ctx context.Context, arg CountGitRepositoriesParams) (int64, error) CountGitRepositories(ctx context.Context, arg CountGitRepositoriesParams) (int64, error)
CountProjects(ctx context.Context) (int64, error)
CountProjectsByUser(ctx context.Context, arg CountProjectsByUserParams) (int64, error) CountProjectsByUser(ctx context.Context, arg CountProjectsByUserParams) (int64, error)
CountServiceDomains(ctx context.Context, serviceID uuid.UUID) (int64, error) CountServiceDomains(ctx context.Context, serviceID uuid.UUID) (int64, error)
CountServicesByName(ctx context.Context, arg CountServicesByNameParams) (int64, error) CountServicesByName(ctx context.Context, arg CountServicesByNameParams) (int64, error)
@@ -67,39 +63,30 @@ type Querier interface {
DatabaseServiceExistsByIDAndUser(ctx context.Context, arg DatabaseServiceExistsByIDAndUserParams) (bool, error) DatabaseServiceExistsByIDAndUser(ctx context.Context, arg DatabaseServiceExistsByIDAndUserParams) (bool, error)
DeleteAgent(ctx context.Context, id string) error DeleteAgent(ctx context.Context, id string) error
DeleteAllServiceDomains(ctx context.Context, serviceID uuid.UUID) error DeleteAllServiceDomains(ctx context.Context, serviceID uuid.UUID) error
DeleteAppSetting(ctx context.Context, key string) error
DeleteBackupTargetByIDAndUser(ctx context.Context, arg DeleteBackupTargetByIDAndUserParams) error DeleteBackupTargetByIDAndUser(ctx context.Context, arg DeleteBackupTargetByIDAndUserParams) error
DeleteBanner(ctx context.Context, id uuid.UUID) error DeleteBanner(ctx context.Context, id uuid.UUID) error
DeleteCronJob(ctx context.Context, id uuid.UUID) error DeleteCronJob(ctx context.Context, id uuid.UUID) error
DeleteDatabaseServiceByID(ctx context.Context, id string) error DeleteDatabaseServiceByID(ctx context.Context, id string) error
DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) error DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) error
DeleteEnvVarsForService(ctx context.Context, serviceID uuid.UUID) error
DeleteGitProvider(ctx context.Context, arg DeleteGitProviderParams) (int64, error) DeleteGitProvider(ctx context.Context, arg DeleteGitProviderParams) (int64, error)
DeleteNotificationChannel(ctx context.Context, arg DeleteNotificationChannelParams) (int64, error) DeleteNotificationChannel(ctx context.Context, arg DeleteNotificationChannelParams) (int64, error)
DeleteOutboundWebhookByIDAndUser(ctx context.Context, arg DeleteOutboundWebhookByIDAndUserParams) error DeleteOutboundWebhookByIDAndUser(ctx context.Context, arg DeleteOutboundWebhookByIDAndUserParams) error
DeletePreviewEnvironment(ctx context.Context, id uuid.UUID) error DeletePreviewEnvironment(ctx context.Context, id uuid.UUID) error
DeleteProjectByID(ctx context.Context, projectID uuid.UUID) (int64, error) DeleteProjectByID(ctx context.Context, projectID uuid.UUID) (int64, error)
DeleteProjectEnvironment(ctx context.Context, arg DeleteProjectEnvironmentParams) error DeleteProjectEnvironment(ctx context.Context, arg DeleteProjectEnvironmentParams) error
DeleteProjectVariables(ctx context.Context, projectID uuid.UUID) error
DeleteServiceByID(ctx context.Context, id uuid.UUID) error DeleteServiceByID(ctx context.Context, id uuid.UUID) error
DeleteServiceContainersOnAgent(ctx context.Context, arg DeleteServiceContainersOnAgentParams) error DeleteServiceContainersOnAgent(ctx context.Context, arg DeleteServiceContainersOnAgentParams) error
DeleteServiceDomain(ctx context.Context, arg DeleteServiceDomainParams) (int64, error) DeleteServiceDomain(ctx context.Context, arg DeleteServiceDomainParams) (int64, error)
DeleteUserInvite(ctx context.Context, id uuid.UUID) error DeleteUserInvite(ctx context.Context, id uuid.UUID) error
DeleteUserTemplate(ctx context.Context, arg DeleteUserTemplateParams) (int64, error) DeleteUserTemplate(ctx context.Context, arg DeleteUserTemplateParams) (int64, error)
FailDeployment(ctx context.Context, arg FailDeploymentParams) error FailDeployment(ctx context.Context, arg FailDeploymentParams) error
FailInterruptedDeployments(ctx context.Context) error
FinishCronExecution(ctx context.Context, arg FinishCronExecutionParams) error FinishCronExecution(ctx context.Context, arg FinishCronExecutionParams) error
GetAPServiceBySlug(ctx context.Context, slug string) (GetAPServiceBySlugRow, error)
GetAPServiceForValidate(ctx context.Context, id uuid.UUID) (GetAPServiceForValidateRow, error)
GetActiveAgentAuthTokenByHash(ctx context.Context, tokenHash string) (AgentAuthToken, error) GetActiveAgentAuthTokenByHash(ctx context.Context, tokenHash string) (AgentAuthToken, error)
GetAdminUserInfo(ctx context.Context, id uuid.UUID) (GetAdminUserInfoRow, error)
GetAgent(ctx context.Context, id string) (NodeAgent, error) GetAgent(ctx context.Context, id string) (NodeAgent, error)
GetAgentByHostAndIP(ctx context.Context, arg GetAgentByHostAndIPParams) (NodeAgent, error) GetAgentByHostAndIP(ctx context.Context, arg GetAgentByHostAndIPParams) (NodeAgent, error)
GetAppSetting(ctx context.Context, key string) (string, error)
GetBackupTargetByID(ctx context.Context, id string) (BackupTarget, error) GetBackupTargetByID(ctx context.Context, id string) (BackupTarget, error)
GetBackupTargetByIDAndUser(ctx context.Context, arg GetBackupTargetByIDAndUserParams) (BackupTarget, error) GetBackupTargetByIDAndUser(ctx context.Context, arg GetBackupTargetByIDAndUserParams) (BackupTarget, error)
GetBannerByID(ctx context.Context, id uuid.UUID) (Banner, error) GetBannerByID(ctx context.Context, id uuid.UUID) (Banner, error)
GetBuild(ctx context.Context, id string) (GetBuildRow, error)
GetCommandForAgent(ctx context.Context, arg GetCommandForAgentParams) (AgentCommand, error) GetCommandForAgent(ctx context.Context, arg GetCommandForAgentParams) (AgentCommand, error)
GetComplianceReportProjectID(ctx context.Context, id uuid.UUID) (uuid.UUID, error) GetComplianceReportProjectID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
GetContainer(ctx context.Context, id string) (ContainerInstance, error) GetContainer(ctx context.Context, id string) (ContainerInstance, error)
@@ -116,15 +103,12 @@ type Querier interface {
GetDeploymentAccess(ctx context.Context, id uuid.UUID) (GetDeploymentAccessRow, error) GetDeploymentAccess(ctx context.Context, id uuid.UUID) (GetDeploymentAccessRow, error)
GetDeploymentForRollback(ctx context.Context, id uuid.UUID) (GetDeploymentForRollbackRow, error) GetDeploymentForRollback(ctx context.Context, id uuid.UUID) (GetDeploymentForRollbackRow, error)
GetDeploymentWithProject(ctx context.Context, id uuid.UUID) (GetDeploymentWithProjectRow, error) GetDeploymentWithProject(ctx context.Context, id uuid.UUID) (GetDeploymentWithProjectRow, error)
GetEnvironmentProjectID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
GetGitProviderForFetch(ctx context.Context, arg GetGitProviderForFetchParams) (GetGitProviderForFetchRow, error) GetGitProviderForFetch(ctx context.Context, arg GetGitProviderForFetchParams) (GetGitProviderForFetchRow, error)
GetGitProviderForRepos(ctx context.Context, arg GetGitProviderForReposParams) (GetGitProviderForReposRow, error) GetGitProviderForRepos(ctx context.Context, arg GetGitProviderForReposParams) (GetGitProviderForReposRow, error)
GetGitProviderName(ctx context.Context, arg GetGitProviderNameParams) (string, error) GetGitProviderName(ctx context.Context, arg GetGitProviderNameParams) (string, error)
GetGitProviderNameByID(ctx context.Context, id uuid.UUID) (string, error) GetGitProviderNameByID(ctx context.Context, id uuid.UUID) (string, error)
GetGitProviderTimestamps(ctx context.Context, id uuid.UUID) (GetGitProviderTimestampsRow, error) GetGitProviderTimestamps(ctx context.Context, id uuid.UUID) (GetGitProviderTimestampsRow, error)
GetGitRepoCloneByFullName(ctx context.Context, arg GetGitRepoCloneByFullNameParams) (GetGitRepoCloneByFullNameRow, error)
GetGitRepoForPush(ctx context.Context, id uuid.UUID) (GetGitRepoForPushRow, error) GetGitRepoForPush(ctx context.Context, id uuid.UUID) (GetGitRepoForPushRow, error)
GetGitRepoProviderByCloneURL(ctx context.Context, arg GetGitRepoProviderByCloneURLParams) (uuid.UUID, error)
GetGitRepositoryByFullName(ctx context.Context, arg GetGitRepositoryByFullNameParams) (GetGitRepositoryByFullNameRow, error) GetGitRepositoryByFullName(ctx context.Context, arg GetGitRepositoryByFullNameParams) (GetGitRepositoryByFullNameRow, error)
GetGitRepositoryProviderID(ctx context.Context, arg GetGitRepositoryProviderIDParams) (uuid.UUID, error) GetGitRepositoryProviderID(ctx context.Context, arg GetGitRepositoryProviderIDParams) (uuid.UUID, error)
GetGitWebhookForPush(ctx context.Context, id uuid.UUID) (GetGitWebhookForPushRow, error) GetGitWebhookForPush(ctx context.Context, id uuid.UUID) (GetGitWebhookForPushRow, error)
@@ -132,7 +116,6 @@ type Querier interface {
GetLastDeployedImage(ctx context.Context, serviceID uuid.UUID) (string, error) GetLastDeployedImage(ctx context.Context, serviceID uuid.UUID) (string, error)
GetLatestComplianceReport(ctx context.Context, projectID uuid.UUID) (GetLatestComplianceReportRow, error) GetLatestComplianceReport(ctx context.Context, projectID uuid.UUID) (GetLatestComplianceReportRow, error)
GetLatestSecurityScan(ctx context.Context, projectID uuid.UUID) (GetLatestSecurityScanRow, error) GetLatestSecurityScan(ctx context.Context, projectID uuid.UUID) (GetLatestSecurityScanRow, error)
GetNodeAgentName(ctx context.Context, id string) (string, error)
GetOutboundWebhookByID(ctx context.Context, id uuid.UUID) (OutboundWebhook, error) GetOutboundWebhookByID(ctx context.Context, id uuid.UUID) (OutboundWebhook, error)
GetOutboundWebhookByIDAndUser(ctx context.Context, arg GetOutboundWebhookByIDAndUserParams) (OutboundWebhook, error) GetOutboundWebhookByIDAndUser(ctx context.Context, arg GetOutboundWebhookByIDAndUserParams) (OutboundWebhook, error)
GetPreviewEnvironment(ctx context.Context, arg GetPreviewEnvironmentParams) (GetPreviewEnvironmentRow, error) GetPreviewEnvironment(ctx context.Context, arg GetPreviewEnvironmentParams) (GetPreviewEnvironmentRow, error)
@@ -147,10 +130,7 @@ type Querier interface {
GetProjectOwnerByID(ctx context.Context, projectID uuid.UUID) (uuid.UUID, error) GetProjectOwnerByID(ctx context.Context, projectID uuid.UUID) (uuid.UUID, error)
GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUID, error) GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
GetProjectOwnerText(ctx context.Context, id uuid.UUID) (string, error) GetProjectOwnerText(ctx context.Context, id uuid.UUID) (string, error)
GetProjectReadAccess(ctx context.Context, id uuid.UUID) (GetProjectReadAccessRow, error)
GetProjectRoleForUser(ctx context.Context, arg GetProjectRoleForUserParams) (string, error) GetProjectRoleForUser(ctx context.Context, arg GetProjectRoleForUserParams) (string, error)
GetProjectVariableValue(ctx context.Context, arg GetProjectVariableValueParams) (string, error)
GetRegistryAuth(ctx context.Context, arg GetRegistryAuthParams) (GetRegistryAuthRow, error)
GetSecurityScanProjectID(ctx context.Context, id uuid.UUID) (uuid.UUID, error) GetSecurityScanProjectID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
GetServiceAccess(ctx context.Context, id uuid.UUID) (GetServiceAccessRow, error) GetServiceAccess(ctx context.Context, id uuid.UUID) (GetServiceAccessRow, error)
GetServiceDomainDefault(ctx context.Context, serviceID uuid.UUID) (string, error) GetServiceDomainDefault(ctx context.Context, serviceID uuid.UUID) (string, error)
@@ -159,67 +139,39 @@ type Querier interface {
GetServiceEnvironment(ctx context.Context, id uuid.UUID) (string, error) GetServiceEnvironment(ctx context.Context, id uuid.UUID) (string, error)
GetServiceForDeploy(ctx context.Context, id uuid.UUID) (GetServiceForDeployRow, error) GetServiceForDeploy(ctx context.Context, id uuid.UUID) (GetServiceForDeployRow, error)
GetServiceForDeployWithOwner(ctx context.Context, id uuid.UUID) (GetServiceForDeployWithOwnerRow, error) GetServiceForDeployWithOwner(ctx context.Context, id uuid.UUID) (GetServiceForDeployWithOwnerRow, error)
GetServiceNameProjectPort(ctx context.Context, id uuid.UUID) (GetServiceNameProjectPortRow, error)
GetServiceNodeID(ctx context.Context, id uuid.UUID) (sql.NullString, error)
GetServicePlacementBrief(ctx context.Context, id uuid.UUID) (GetServicePlacementBriefRow, error) GetServicePlacementBrief(ctx context.Context, id uuid.UUID) (GetServicePlacementBriefRow, error)
GetServicePlacementTags(ctx context.Context, id uuid.UUID) (json.RawMessage, error)
GetServicePort(ctx context.Context, id uuid.UUID) (int32, error) GetServicePort(ctx context.Context, id uuid.UUID) (int32, error)
GetServiceProjectAndStatus(ctx context.Context, id uuid.UUID) (GetServiceProjectAndStatusRow, error)
GetServiceProjectID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
GetServicePublishedPort(ctx context.Context, id uuid.UUID) (int32, error) GetServicePublishedPort(ctx context.Context, id uuid.UUID) (int32, error)
GetServiceRuntimeWithOwner(ctx context.Context, id uuid.UUID) (GetServiceRuntimeWithOwnerRow, error) GetServiceRuntimeWithOwner(ctx context.Context, id uuid.UUID) (GetServiceRuntimeWithOwnerRow, error)
GetServiceSleep(ctx context.Context, id uuid.UUID) (GetServiceSleepRow, error)
GetServiceSpread(ctx context.Context, id uuid.UUID) (bool, error)
GetServiceStatusText(ctx context.Context, id uuid.UUID) (string, error) GetServiceStatusText(ctx context.Context, id uuid.UUID) (string, error)
GetServiceTemplateByID(ctx context.Context, id string) (ServiceTemplate, error) GetServiceTemplateByID(ctx context.Context, id string) (ServiceTemplate, error)
GetServiceTraefikLabels(ctx context.Context, id uuid.UUID) (json.RawMessage, error)
GetServiceTypeBrief(ctx context.Context, arg GetServiceTypeBriefParams) (GetServiceTypeBriefRow, error) GetServiceTypeBrief(ctx context.Context, arg GetServiceTypeBriefParams) (GetServiceTypeBriefRow, error)
GetServiceVariableValue(ctx context.Context, arg GetServiceVariableValueParams) (string, error) GetServiceVariableValue(ctx context.Context, arg GetServiceVariableValueParams) (string, error)
GetServiceVolumes(ctx context.Context, id uuid.UUID) (json.RawMessage, error)
GetUserByEmailForAuth(ctx context.Context, email string) (GetUserByEmailForAuthRow, error) GetUserByEmailForAuth(ctx context.Context, email string) (GetUserByEmailForAuthRow, error)
GetUserByID(ctx context.Context, id uuid.UUID) (GetUserByIDRow, error) GetUserByID(ctx context.Context, id uuid.UUID) (GetUserByIDRow, error)
GetUserInviteByTokenHash(ctx context.Context, tokenHash string) (UserInvite, error) GetUserInviteByTokenHash(ctx context.Context, tokenHash string) (UserInvite, error)
GetUserIsAdmin(ctx context.Context, id uuid.UUID) (bool, error)
GetUserTokenByHash(ctx context.Context, tokenHash string) (UserToken, error) GetUserTokenByHash(ctx context.Context, tokenHash string) (UserToken, error)
GetVulnerabilityMetrics(ctx context.Context, projectID uuid.UUID) (GetVulnerabilityMetricsRow, error) GetVulnerabilityMetrics(ctx context.Context, projectID uuid.UUID) (GetVulnerabilityMetricsRow, error)
GetVulnerabilityProjectID(ctx context.Context, id uuid.UUID) (uuid.UUID, error) GetVulnerabilityProjectID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
InsertAPKey(ctx context.Context, arg InsertAPKeyParams) (uuid.UUID, error)
InsertAPService(ctx context.Context, arg InsertAPServiceParams) (uuid.UUID, error)
InsertAgentHeartbeat(ctx context.Context, arg InsertAgentHeartbeatParams) error InsertAgentHeartbeat(ctx context.Context, arg InsertAgentHeartbeatParams) error
InsertAuditLog(ctx context.Context, arg InsertAuditLogParams) error
InsertAuditLogWithRequest(ctx context.Context, arg InsertAuditLogWithRequestParams) error
InsertCronExecution(ctx context.Context, arg InsertCronExecutionParams) error InsertCronExecution(ctx context.Context, arg InsertCronExecutionParams) error
InsertCronJob(ctx context.Context, arg InsertCronJobParams) error InsertCronJob(ctx context.Context, arg InsertCronJobParams) error
InsertDeployment(ctx context.Context, arg InsertDeploymentParams) error InsertDeployment(ctx context.Context, arg InsertDeploymentParams) error
InsertEnvVar(ctx context.Context, arg InsertEnvVarParams) error
InsertGatewayIncident(ctx context.Context, arg InsertGatewayIncidentParams) error
InsertGitProvider(ctx context.Context, arg InsertGitProviderParams) error InsertGitProvider(ctx context.Context, arg InsertGitProviderParams) error
InsertGitRepository(ctx context.Context, arg InsertGitRepositoryParams) (InsertGitRepositoryRow, error) InsertGitRepository(ctx context.Context, arg InsertGitRepositoryParams) (InsertGitRepositoryRow, error)
InsertMetricPoint(ctx context.Context, labelsJson sql.NullString) error
InsertNotificationChannel(ctx context.Context, arg InsertNotificationChannelParams) (NotificationChannel, error) InsertNotificationChannel(ctx context.Context, arg InsertNotificationChannelParams) (NotificationChannel, error)
InsertPendingDeployment(ctx context.Context, arg InsertPendingDeploymentParams) error InsertPendingDeployment(ctx context.Context, arg InsertPendingDeploymentParams) error
InsertProjectEnvironment(ctx context.Context, arg InsertProjectEnvironmentParams) error InsertProjectEnvironment(ctx context.Context, arg InsertProjectEnvironmentParams) error
InsertProjectVariable(ctx context.Context, arg InsertProjectVariableParams) error
InsertServiceIncident(ctx context.Context, arg InsertServiceIncidentParams) error
InsertUser(ctx context.Context, arg InsertUserParams) (InsertUserRow, error) InsertUser(ctx context.Context, arg InsertUserParams) (InsertUserRow, error)
InsertUserAdmin(ctx context.Context, arg InsertUserAdminParams) (InsertUserAdminRow, error) InsertUserAdmin(ctx context.Context, arg InsertUserAdminParams) (InsertUserAdminRow, error)
ListAPKeys(ctx context.Context) ([]ListAPKeysRow, error)
ListAPKeysByPrefix(ctx context.Context, keyPrefix string) ([]ListAPKeysByPrefixRow, error)
ListAPServices(ctx context.Context) ([]ListAPServicesRow, error)
ListActiveBanners(ctx context.Context) ([]Banner, error) ListActiveBanners(ctx context.Context) ([]Banner, error)
ListActiveOperationDeployments(ctx context.Context, arg ListActiveOperationDeploymentsParams) ([]ListActiveOperationDeploymentsRow, error)
ListAdminUserIDs(ctx context.Context) ([]uuid.UUID, error)
ListAdminUsers(ctx context.Context) ([]ListAdminUsersRow, error)
ListAgentAuthTokens(ctx context.Context) ([]AgentAuthToken, error) ListAgentAuthTokens(ctx context.Context) ([]AgentAuthToken, error)
ListAgentHealthRows(ctx context.Context, lastHeartbeat sql.NullTime) ([]ListAgentHealthRowsRow, error)
ListAgentHeartbeatsSince(ctx context.Context, arg ListAgentHeartbeatsSinceParams) ([]AgentHeartbeat, error) ListAgentHeartbeatsSince(ctx context.Context, arg ListAgentHeartbeatsSinceParams) ([]AgentHeartbeat, error)
ListAgents(ctx context.Context) ([]NodeAgent, error) ListAgents(ctx context.Context) ([]NodeAgent, error)
ListAllDatabaseServices(ctx context.Context) ([]DatabaseService, error) ListAllDatabaseServices(ctx context.Context) ([]DatabaseService, error)
ListAllNotificationChannelsByUser(ctx context.Context, userID uuid.UUID) ([]NotificationChannel, error) ListAllNotificationChannelsByUser(ctx context.Context, userID uuid.UUID) ([]NotificationChannel, error)
ListAuditBackfillBatch(ctx context.Context, dollar_1 uuid.UUID) ([]ListAuditBackfillBatchRow, error)
ListBackupTargetsByUser(ctx context.Context, userID string) ([]BackupTarget, error) ListBackupTargetsByUser(ctx context.Context, userID string) ([]BackupTarget, error)
ListBanners(ctx context.Context) ([]Banner, error) ListBanners(ctx context.Context) ([]Banner, error)
ListClientEventPaths(ctx context.Context) ([]ListClientEventPathsRow, error)
ListCommandsForAgent(ctx context.Context, nodeAgentID string) ([]AgentCommand, error) ListCommandsForAgent(ctx context.Context, nodeAgentID string) ([]AgentCommand, error)
ListComplianceFrameworks(ctx context.Context) ([]ListComplianceFrameworksRow, error) ListComplianceFrameworks(ctx context.Context) ([]ListComplianceFrameworksRow, error)
ListConnectedRepositories(ctx context.Context, arg ListConnectedRepositoriesParams) ([]ListConnectedRepositoriesRow, error) ListConnectedRepositories(ctx context.Context, arg ListConnectedRepositoriesParams) ([]ListConnectedRepositoriesRow, error)
@@ -235,7 +187,6 @@ type Querier interface {
ListExpiredPreviewsForUser(ctx context.Context, arg ListExpiredPreviewsForUserParams) ([]ListExpiredPreviewsForUserRow, error) ListExpiredPreviewsForUser(ctx context.Context, arg ListExpiredPreviewsForUserParams) ([]ListExpiredPreviewsForUserRow, error)
ListGitProviders(ctx context.Context, userID uuid.UUID) ([]ListGitProvidersRow, error) ListGitProviders(ctx context.Context, userID uuid.UUID) ([]ListGitProvidersRow, error)
ListGitRepositories(ctx context.Context, arg ListGitRepositoriesParams) ([]ListGitRepositoriesRow, error) ListGitRepositories(ctx context.Context, arg ListGitRepositoriesParams) ([]ListGitRepositoriesRow, error)
ListIncidentStatusCodes(ctx context.Context) ([]ListIncidentStatusCodesRow, error)
ListInstanceMetrics(ctx context.Context, arg ListInstanceMetricsParams) ([]ListInstanceMetricsRow, error) ListInstanceMetrics(ctx context.Context, arg ListInstanceMetricsParams) ([]ListInstanceMetricsRow, error)
ListNodeAgentsForService(ctx context.Context, serviceID string) ([]string, error) ListNodeAgentsForService(ctx context.Context, serviceID string) ([]string, error)
ListNodeMetrics(ctx context.Context, arg ListNodeMetricsParams) ([]ListNodeMetricsRow, error) ListNodeMetrics(ctx context.Context, arg ListNodeMetricsParams) ([]ListNodeMetricsRow, error)
@@ -252,18 +203,12 @@ type Querier interface {
// platform admin. Anonymous callers pass user_id = uuid.Nil, is_admin = false. // platform admin. Anonymous callers pass user_id = uuid.Nil, is_admin = false.
ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error) ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error)
ListRecentAccessibleDeployments(ctx context.Context, arg ListRecentAccessibleDeploymentsParams) ([]ListRecentAccessibleDeploymentsRow, error) ListRecentAccessibleDeployments(ctx context.Context, arg ListRecentAccessibleDeploymentsParams) ([]ListRecentAccessibleDeploymentsRow, error)
ListRecentCronRuns(ctx context.Context, arg ListRecentCronRunsParams) ([]ListRecentCronRunsRow, error)
ListRecentFailedDeployments(ctx context.Context, arg ListRecentFailedDeploymentsParams) ([]ListRecentFailedDeploymentsRow, error)
ListRecentOperationBackups(ctx context.Context, arg ListRecentOperationBackupsParams) ([]ListRecentOperationBackupsRow, error)
ListRequestsByDay(ctx context.Context) ([]ListRequestsByDayRow, error)
ListResourceAuditLogs(ctx context.Context, arg ListResourceAuditLogsParams) ([]ListResourceAuditLogsRow, error)
// Base domains of every online, schedulable agent carrying the required // Base domains of every online, schedulable agent carrying the required
// placement tags — auto-domain candidates for spread services. // placement tags — auto-domain candidates for spread services.
ListSchedulableAgentDomains(ctx context.Context, dollar_1 json.RawMessage) ([]string, error) ListSchedulableAgentDomains(ctx context.Context, dollar_1 json.RawMessage) ([]string, error)
ListSchedulableAgents(ctx context.Context) ([]ListSchedulableAgentsRow, error) ListSchedulableAgents(ctx context.Context) ([]ListSchedulableAgentsRow, error)
// Only agents carrying every required placement tag (jsonb array containment). // Only agents carrying every required placement tag (jsonb array containment).
ListSchedulableAgentsMatching(ctx context.Context, dollar_1 json.RawMessage) ([]ListSchedulableAgentsMatchingRow, error) ListSchedulableAgentsMatching(ctx context.Context, dollar_1 json.RawMessage) ([]ListSchedulableAgentsMatchingRow, error)
ListSecretEnvVars(ctx context.Context) ([]ListSecretEnvVarsRow, error)
// Distinct agents holding inventory rows for a service. // Distinct agents holding inventory rows for a service.
ListServiceAgents(ctx context.Context, serviceID string) ([]string, error) ListServiceAgents(ctx context.Context, serviceID string) ([]string, error)
ListServiceContainers(ctx context.Context, serviceID string) ([]ListServiceContainersRow, error) ListServiceContainers(ctx context.Context, serviceID string) ([]ListServiceContainersRow, error)
@@ -276,7 +221,6 @@ type Querier interface {
ListServicesForPush(ctx context.Context, arg ListServicesForPushParams) ([]ListServicesForPushRow, error) ListServicesForPush(ctx context.Context, arg ListServicesForPushParams) ([]ListServicesForPushRow, error)
ListSleepCandidates(ctx context.Context) ([]ListSleepCandidatesRow, error) ListSleepCandidates(ctx context.Context) ([]ListSleepCandidatesRow, error)
ListSweepablePreviews(ctx context.Context) ([]ListSweepablePreviewsRow, error) ListSweepablePreviews(ctx context.Context) ([]ListSweepablePreviewsRow, error)
ListTopAPServices(ctx context.Context) ([]ListTopAPServicesRow, error)
ListUserInvites(ctx context.Context) ([]UserInvite, error) ListUserInvites(ctx context.Context) ([]UserInvite, error)
ListUserTokens(ctx context.Context, userID uuid.UUID) ([]UserToken, error) ListUserTokens(ctx context.Context, userID uuid.UUID) ([]UserToken, error)
ListVulnerabilities(ctx context.Context, projectID uuid.UUID) ([]ListVulnerabilitiesRow, error) ListVulnerabilities(ctx context.Context, projectID uuid.UUID) ([]ListVulnerabilitiesRow, error)
@@ -292,21 +236,14 @@ type Querier interface {
// Same resource-aware ordering as PickLeastLoadedAgent, restricted to agents // Same resource-aware ordering as PickLeastLoadedAgent, restricted to agents
// carrying every required placement tag. // carrying every required placement tag.
PickLeastLoadedAgentMatching(ctx context.Context, dollar_1 json.RawMessage) (string, error) PickLeastLoadedAgentMatching(ctx context.Context, dollar_1 json.RawMessage) (string, error)
ProjectMemberExists(ctx context.Context, arg ProjectMemberExistsParams) (bool, error)
PruneAuditLogs(ctx context.Context, dollar_1 int32) (int64, error)
RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error) RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error)
RevokeUserToken(ctx context.Context, arg RevokeUserTokenParams) (int64, error) RevokeUserToken(ctx context.Context, arg RevokeUserTokenParams) (int64, error)
ScrubCommandPayload(ctx context.Context, arg ScrubCommandPayloadParams) error ScrubCommandPayload(ctx context.Context, arg ScrubCommandPayloadParams) error
ServiceExistsInProject(ctx context.Context, arg ServiceExistsInProjectParams) (bool, error) ServiceExistsInProject(ctx context.Context, arg ServiceExistsInProjectParams) (bool, error)
ServiceWriteAccess(ctx context.Context, arg ServiceWriteAccessParams) (bool, error) ServiceWriteAccess(ctx context.Context, arg ServiceWriteAccessParams) (bool, error)
SetAPKeyEnabled(ctx context.Context, arg SetAPKeyEnabledParams) error
SetAPServiceEnabled(ctx context.Context, arg SetAPServiceEnabledParams) error
SetAPServiceValidation(ctx context.Context, arg SetAPServiceValidationParams) error
SetAgentDefaultDomain(ctx context.Context, arg SetAgentDefaultDomainParams) error SetAgentDefaultDomain(ctx context.Context, arg SetAgentDefaultDomainParams) error
SetAgentSchedulable(ctx context.Context, arg SetAgentSchedulableParams) error SetAgentSchedulable(ctx context.Context, arg SetAgentSchedulableParams) error
SetAgentStatus(ctx context.Context, arg SetAgentStatusParams) error
SetAgentTags(ctx context.Context, arg SetAgentTagsParams) error SetAgentTags(ctx context.Context, arg SetAgentTagsParams) error
SetAuditLogMetadata(ctx context.Context, arg SetAuditLogMetadataParams) error
SetDatabaseBackupRemoteKeyByID(ctx context.Context, arg SetDatabaseBackupRemoteKeyByIDParams) error SetDatabaseBackupRemoteKeyByID(ctx context.Context, arg SetDatabaseBackupRemoteKeyByIDParams) error
SetDatabaseBackupScheduleByIDAndUser(ctx context.Context, arg SetDatabaseBackupScheduleByIDAndUserParams) error SetDatabaseBackupScheduleByIDAndUser(ctx context.Context, arg SetDatabaseBackupScheduleByIDAndUserParams) error
SetDatabaseBackupStatusByID(ctx context.Context, arg SetDatabaseBackupStatusByIDParams) error SetDatabaseBackupStatusByID(ctx context.Context, arg SetDatabaseBackupStatusByIDParams) error
@@ -323,14 +260,8 @@ type Querier interface {
SetServicePublishedPort(ctx context.Context, arg SetServicePublishedPortParams) error SetServicePublishedPort(ctx context.Context, arg SetServicePublishedPortParams) error
SetServiceReplicas(ctx context.Context, arg SetServiceReplicasParams) error SetServiceReplicas(ctx context.Context, arg SetServiceReplicasParams) error
SetServiceStatus(ctx context.Context, arg SetServiceStatusParams) error SetServiceStatus(ctx context.Context, arg SetServiceStatusParams) error
SetUserAdmin(ctx context.Context, arg SetUserAdminParams) (int64, error)
SumRequestMetricSince(ctx context.Context, dollar_1 string) (int32, error)
SumUsageByKey(ctx context.Context, arg SumUsageByKeyParams) (int64, error)
SumUsageByService(ctx context.Context, arg SumUsageByServiceParams) (int64, error)
SumUsageCounters(ctx context.Context) (interface{}, error)
SyncDeploymentProgress(ctx context.Context, arg SyncDeploymentProgressParams) error SyncDeploymentProgress(ctx context.Context, arg SyncDeploymentProgressParams) error
SyncPreviewStatuses(ctx context.Context) error SyncPreviewStatuses(ctx context.Context) error
TouchAPKeyLastUsed(ctx context.Context, id uuid.UUID) error
TouchAgentAuthToken(ctx context.Context, tokenHash string) error TouchAgentAuthToken(ctx context.Context, tokenHash string) error
TouchServiceDomainChecked(ctx context.Context, id uuid.UUID) error TouchServiceDomainChecked(ctx context.Context, id uuid.UUID) error
TouchUserToken(ctx context.Context, id uuid.UUID) error TouchUserToken(ctx context.Context, id uuid.UUID) error
@@ -351,21 +282,14 @@ type Querier interface {
UpdateDatabaseServiceNameAndPlanByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameAndPlanByIDAndUserParams) error UpdateDatabaseServiceNameAndPlanByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameAndPlanByIDAndUserParams) error
UpdateDatabaseServiceNameByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameByIDAndUserParams) error UpdateDatabaseServiceNameByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameByIDAndUserParams) error
UpdateDatabaseServicePlanByIDAndUser(ctx context.Context, arg UpdateDatabaseServicePlanByIDAndUserParams) error UpdateDatabaseServicePlanByIDAndUser(ctx context.Context, arg UpdateDatabaseServicePlanByIDAndUserParams) error
UpdateEnvVarValue(ctx context.Context, arg UpdateEnvVarValueParams) error
UpdateOutboundWebhook(ctx context.Context, arg UpdateOutboundWebhookParams) error UpdateOutboundWebhook(ctx context.Context, arg UpdateOutboundWebhookParams) error
UpdatePreviewEnvironment(ctx context.Context, arg UpdatePreviewEnvironmentParams) error UpdatePreviewEnvironment(ctx context.Context, arg UpdatePreviewEnvironmentParams) error
UpdateProjectByID(ctx context.Context, arg UpdateProjectByIDParams) (int64, error) UpdateProjectByID(ctx context.Context, arg UpdateProjectByIDParams) (int64, error)
UpdateServiceDeployMeta(ctx context.Context, arg UpdateServiceDeployMetaParams) error
UpdateServicePreviewBranch(ctx context.Context, arg UpdateServicePreviewBranchParams) error UpdateServicePreviewBranch(ctx context.Context, arg UpdateServicePreviewBranchParams) error
UpdateServiceRuntimeMeta(ctx context.Context, arg UpdateServiceRuntimeMetaParams) error
UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) error UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) error
UpdateUserTemplate(ctx context.Context, arg UpdateUserTemplateParams) (int64, error) UpdateUserTemplate(ctx context.Context, arg UpdateUserTemplateParams) (int64, error)
UpdateVulnerabilityStatus(ctx context.Context, arg UpdateVulnerabilityStatusParams) error UpdateVulnerabilityStatus(ctx context.Context, arg UpdateVulnerabilityStatusParams) error
UpdateWebhookDeliveryResult(ctx context.Context, arg UpdateWebhookDeliveryResultParams) error UpdateWebhookDeliveryResult(ctx context.Context, arg UpdateWebhookDeliveryResultParams) error
UpsertAdminUser(ctx context.Context, arg UpsertAdminUserParams) (uuid.UUID, error)
UpsertAppSetting(ctx context.Context, arg UpsertAppSettingParams) error
UpsertAppSettingValue(ctx context.Context, arg UpsertAppSettingValueParams) error
UpsertBuild(ctx context.Context, arg UpsertBuildParams) error
UpsertEnvironmentVariable(ctx context.Context, arg UpsertEnvironmentVariableParams) error UpsertEnvironmentVariable(ctx context.Context, arg UpsertEnvironmentVariableParams) error
UpsertGitHubAppProvider(ctx context.Context, arg UpsertGitHubAppProviderParams) (UpsertGitHubAppProviderRow, error) UpsertGitHubAppProvider(ctx context.Context, arg UpsertGitHubAppProviderParams) (UpsertGitHubAppProviderRow, error)
UpsertGitRepository(ctx context.Context, arg UpsertGitRepositoryParams) error UpsertGitRepository(ctx context.Context, arg UpsertGitRepositoryParams) error
@@ -376,7 +300,6 @@ type Querier interface {
UpsertServiceContainer(ctx context.Context, arg UpsertServiceContainerParams) error UpsertServiceContainer(ctx context.Context, arg UpsertServiceContainerParams) error
UpsertServiceMetrics(ctx context.Context, arg UpsertServiceMetricsParams) error UpsertServiceMetrics(ctx context.Context, arg UpsertServiceMetricsParams) error
UpsertServiceTemplate(ctx context.Context, arg UpsertServiceTemplateParams) error UpsertServiceTemplate(ctx context.Context, arg UpsertServiceTemplateParams) error
UpsertUsageCounter(ctx context.Context, arg UpsertUsageCounterParams) error
} }
var _ Querier = (*Queries)(nil) var _ Querier = (*Queries)(nil)
@@ -1,308 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: services_meta.sql
package sqlcdb
import (
"context"
"database/sql"
"encoding/json"
"github.com/google/uuid"
)
const deleteEnvVarsForService = `-- name: DeleteEnvVarsForService :exec
DELETE FROM environment_variables WHERE service_id = $1
`
func (q *Queries) DeleteEnvVarsForService(ctx context.Context, serviceID uuid.UUID) error {
_, err := q.db.ExecContext(ctx, deleteEnvVarsForService, serviceID)
return err
}
const failInterruptedDeployments = `-- name: FailInterruptedDeployments :exec
UPDATE deployments SET status = 'failed', error = 'Interrupted by server restart', completed_at = NOW(), updated_at = NOW()
WHERE status IN ('queued', 'pending', 'building', 'deploying', 'rolling_back')
`
func (q *Queries) FailInterruptedDeployments(ctx context.Context) error {
_, err := q.db.ExecContext(ctx, failInterruptedDeployments)
return err
}
const getEnvironmentProjectID = `-- name: GetEnvironmentProjectID :one
SELECT project_id FROM environments WHERE id = $1
`
func (q *Queries) GetEnvironmentProjectID(ctx context.Context, id uuid.UUID) (uuid.UUID, error) {
row := q.db.QueryRowContext(ctx, getEnvironmentProjectID, id)
var project_id uuid.UUID
err := row.Scan(&project_id)
return project_id, err
}
const getNodeAgentName = `-- name: GetNodeAgentName :one
SELECT name FROM node_agents WHERE id = $1
`
func (q *Queries) GetNodeAgentName(ctx context.Context, id string) (string, error) {
row := q.db.QueryRowContext(ctx, getNodeAgentName, id)
var name string
err := row.Scan(&name)
return name, err
}
const getProjectReadAccess = `-- name: GetProjectReadAccess :one
SELECT is_approved, owner_id::text FROM projects WHERE id = $1
`
type GetProjectReadAccessRow struct {
IsApproved bool `json:"is_approved"`
OwnerID string `json:"owner_id"`
}
func (q *Queries) GetProjectReadAccess(ctx context.Context, id uuid.UUID) (GetProjectReadAccessRow, error) {
row := q.db.QueryRowContext(ctx, getProjectReadAccess, id)
var i GetProjectReadAccessRow
err := row.Scan(&i.IsApproved, &i.OwnerID)
return i, err
}
const getServiceNameProjectPort = `-- name: GetServiceNameProjectPort :one
SELECT name, project_id::text, COALESCE(port, 0)::int AS port
FROM services WHERE id::text = $1
`
type GetServiceNameProjectPortRow struct {
Name string `json:"name"`
ProjectID string `json:"project_id"`
Port int32 `json:"port"`
}
func (q *Queries) GetServiceNameProjectPort(ctx context.Context, id uuid.UUID) (GetServiceNameProjectPortRow, error) {
row := q.db.QueryRowContext(ctx, getServiceNameProjectPort, id)
var i GetServiceNameProjectPortRow
err := row.Scan(&i.Name, &i.ProjectID, &i.Port)
return i, err
}
const getServiceNodeID = `-- name: GetServiceNodeID :one
SELECT node_id FROM services WHERE id = $1
`
func (q *Queries) GetServiceNodeID(ctx context.Context, id uuid.UUID) (sql.NullString, error) {
row := q.db.QueryRowContext(ctx, getServiceNodeID, id)
var node_id sql.NullString
err := row.Scan(&node_id)
return node_id, err
}
const getServicePlacementTags = `-- name: GetServicePlacementTags :one
SELECT placement_tags FROM services WHERE id = $1
`
func (q *Queries) GetServicePlacementTags(ctx context.Context, id uuid.UUID) (json.RawMessage, error) {
row := q.db.QueryRowContext(ctx, getServicePlacementTags, id)
var placement_tags json.RawMessage
err := row.Scan(&placement_tags)
return placement_tags, err
}
const getServiceProjectAndStatus = `-- name: GetServiceProjectAndStatus :one
SELECT s.project_id, COALESCE(s.status, '') AS status
FROM services s WHERE s.id = $1
`
type GetServiceProjectAndStatusRow struct {
ProjectID uuid.UUID `json:"project_id"`
Status string `json:"status"`
}
func (q *Queries) GetServiceProjectAndStatus(ctx context.Context, id uuid.UUID) (GetServiceProjectAndStatusRow, error) {
row := q.db.QueryRowContext(ctx, getServiceProjectAndStatus, id)
var i GetServiceProjectAndStatusRow
err := row.Scan(&i.ProjectID, &i.Status)
return i, err
}
const getServiceProjectID = `-- name: GetServiceProjectID :one
SELECT project_id FROM services WHERE id = $1
`
func (q *Queries) GetServiceProjectID(ctx context.Context, id uuid.UUID) (uuid.UUID, error) {
row := q.db.QueryRowContext(ctx, getServiceProjectID, id)
var project_id uuid.UUID
err := row.Scan(&project_id)
return project_id, err
}
const getServiceSleep = `-- name: GetServiceSleep :one
SELECT COALESCE(sleep_enabled, false) AS sleep_enabled, COALESCE(sleep_idle_minutes, 15) AS sleep_idle_minutes
FROM services WHERE id = $1
`
type GetServiceSleepRow struct {
SleepEnabled bool `json:"sleep_enabled"`
SleepIdleMinutes int32 `json:"sleep_idle_minutes"`
}
func (q *Queries) GetServiceSleep(ctx context.Context, id uuid.UUID) (GetServiceSleepRow, error) {
row := q.db.QueryRowContext(ctx, getServiceSleep, id)
var i GetServiceSleepRow
err := row.Scan(&i.SleepEnabled, &i.SleepIdleMinutes)
return i, err
}
const getServiceSpread = `-- name: GetServiceSpread :one
SELECT COALESCE(spread, false) FROM services WHERE id = $1
`
func (q *Queries) GetServiceSpread(ctx context.Context, id uuid.UUID) (bool, error) {
row := q.db.QueryRowContext(ctx, getServiceSpread, id)
var spread bool
err := row.Scan(&spread)
return spread, err
}
const getServiceTraefikLabels = `-- name: GetServiceTraefikLabels :one
SELECT traefik_labels FROM services WHERE id = $1
`
func (q *Queries) GetServiceTraefikLabels(ctx context.Context, id uuid.UUID) (json.RawMessage, error) {
row := q.db.QueryRowContext(ctx, getServiceTraefikLabels, id)
var traefik_labels json.RawMessage
err := row.Scan(&traefik_labels)
return traefik_labels, err
}
const getServiceVolumes = `-- name: GetServiceVolumes :one
SELECT volumes FROM services WHERE id = $1
`
func (q *Queries) GetServiceVolumes(ctx context.Context, id uuid.UUID) (json.RawMessage, error) {
row := q.db.QueryRowContext(ctx, getServiceVolumes, id)
var volumes json.RawMessage
err := row.Scan(&volumes)
return volumes, err
}
const listAgentHealthRows = `-- name: ListAgentHealthRows :many
SELECT id, COALESCE(name, hostname, id::text) AS name, COALESCE(status, '') AS status,
COALESCE(last_heartbeat < $1, TRUE)::bool AS stale, auto_prune
FROM node_agents
`
type ListAgentHealthRowsRow struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Stale bool `json:"stale"`
AutoPrune bool `json:"auto_prune"`
}
func (q *Queries) ListAgentHealthRows(ctx context.Context, lastHeartbeat sql.NullTime) ([]ListAgentHealthRowsRow, error) {
rows, err := q.db.QueryContext(ctx, listAgentHealthRows, lastHeartbeat)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListAgentHealthRowsRow{}
for rows.Next() {
var i ListAgentHealthRowsRow
if err := rows.Scan(
&i.ID,
&i.Name,
&i.Status,
&i.Stale,
&i.AutoPrune,
); 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 listSecretEnvVars = `-- name: ListSecretEnvVars :many
SELECT id, value FROM environment_variables WHERE is_secret = TRUE
`
type ListSecretEnvVarsRow struct {
ID uuid.UUID `json:"id"`
Value string `json:"value"`
}
func (q *Queries) ListSecretEnvVars(ctx context.Context) ([]ListSecretEnvVarsRow, error) {
rows, err := q.db.QueryContext(ctx, listSecretEnvVars)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListSecretEnvVarsRow{}
for rows.Next() {
var i ListSecretEnvVarsRow
if err := rows.Scan(&i.ID, &i.Value); 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 projectMemberExists = `-- name: ProjectMemberExists :one
SELECT EXISTS(SELECT 1 FROM project_members WHERE project_id = $1 AND user_id = $2)
`
type ProjectMemberExistsParams struct {
ProjectID uuid.UUID `json:"project_id"`
UserID uuid.UUID `json:"user_id"`
}
func (q *Queries) ProjectMemberExists(ctx context.Context, arg ProjectMemberExistsParams) (bool, error) {
row := q.db.QueryRowContext(ctx, projectMemberExists, arg.ProjectID, arg.UserID)
var exists bool
err := row.Scan(&exists)
return exists, err
}
const setAgentStatus = `-- name: SetAgentStatus :exec
UPDATE node_agents SET status = $1, updated_at = NOW() WHERE id = $2
`
type SetAgentStatusParams struct {
Status sql.NullString `json:"status"`
ID string `json:"id"`
}
func (q *Queries) SetAgentStatus(ctx context.Context, arg SetAgentStatusParams) error {
_, err := q.db.ExecContext(ctx, setAgentStatus, arg.Status, arg.ID)
return err
}
const updateEnvVarValue = `-- name: UpdateEnvVarValue :exec
UPDATE environment_variables SET value = $1, updated_at = NOW() WHERE id = $2
`
type UpdateEnvVarValueParams struct {
Value string `json:"value"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) UpdateEnvVarValue(ctx context.Context, arg UpdateEnvVarValueParams) error {
_, err := q.db.ExecContext(ctx, updateEnvVarValue, arg.Value, arg.ID)
return err
}
@@ -1,103 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: settings.sql
package sqlcdb
import (
"context"
"github.com/google/uuid"
)
const deleteAppSetting = `-- name: DeleteAppSetting :exec
DELETE FROM app_settings WHERE key = $1
`
func (q *Queries) DeleteAppSetting(ctx context.Context, key string) error {
_, err := q.db.ExecContext(ctx, deleteAppSetting, key)
return err
}
const getAppSetting = `-- name: GetAppSetting :one
SELECT value FROM app_settings WHERE key = $1
`
func (q *Queries) GetAppSetting(ctx context.Context, key string) (string, error) {
row := q.db.QueryRowContext(ctx, getAppSetting, key)
var value string
err := row.Scan(&value)
return value, err
}
const getUserIsAdmin = `-- name: GetUserIsAdmin :one
SELECT is_admin FROM users WHERE id = $1
`
func (q *Queries) GetUserIsAdmin(ctx context.Context, id uuid.UUID) (bool, error) {
row := q.db.QueryRowContext(ctx, getUserIsAdmin, id)
var is_admin bool
err := row.Scan(&is_admin)
return is_admin, err
}
const listAdminUserIDs = `-- name: ListAdminUserIDs :many
SELECT id FROM users WHERE is_admin
`
func (q *Queries) ListAdminUserIDs(ctx context.Context) ([]uuid.UUID, error) {
rows, err := q.db.QueryContext(ctx, listAdminUserIDs)
if err != nil {
return nil, err
}
defer rows.Close()
items := []uuid.UUID{}
for rows.Next() {
var id uuid.UUID
if err := rows.Scan(&id); err != nil {
return nil, err
}
items = append(items, id)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const upsertAppSetting = `-- name: UpsertAppSetting :exec
INSERT INTO app_settings (key, value, is_secret, updated_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (key) DO UPDATE
SET value = EXCLUDED.value, is_secret = EXCLUDED.is_secret, updated_at = NOW()
`
type UpsertAppSettingParams struct {
Key string `json:"key"`
Value string `json:"value"`
IsSecret bool `json:"is_secret"`
}
func (q *Queries) UpsertAppSetting(ctx context.Context, arg UpsertAppSettingParams) error {
_, err := q.db.ExecContext(ctx, upsertAppSetting, arg.Key, arg.Value, arg.IsSecret)
return err
}
const upsertAppSettingValue = `-- name: UpsertAppSettingValue :exec
INSERT INTO app_settings (key, value, updated_at) VALUES ($1, $2, NOW())
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()
`
type UpsertAppSettingValueParams struct {
Key string `json:"key"`
Value string `json:"value"`
}
func (q *Queries) UpsertAppSettingValue(ctx context.Context, arg UpsertAppSettingValueParams) error {
_, err := q.db.ExecContext(ctx, upsertAppSettingValue, arg.Key, arg.Value)
return err
}
-125
View File
@@ -1,125 +0,0 @@
-- name: GetAPServiceForValidate :one
SELECT upstream_url, health_path, upstream_auth_header, upstream_auth_value, request_timeout_ms
FROM api_services WHERE id = $1;
-- name: SetAPServiceValidation :exec
UPDATE api_services
SET last_validation_at = NOW(), last_validation_status = $1, last_validation_message = $2, updated_at = NOW()
WHERE id = $3;
-- name: InsertServiceIncident :exec
INSERT INTO incident_events (service_id, code, message, severity, http_status, occurred_at)
VALUES ($1, 'SERVICE_VALIDATION_FAILED', $2, 'medium', $3, NOW());
-- name: InsertGatewayIncident :exec
INSERT INTO incident_events (service_id, api_key_id, code, message, severity, http_status, occurred_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW());
-- name: ListAPServices :many
SELECT id, name, slug, upstream_url, route_prefix, enabled,
to_char(created_at, 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS created_at,
to_char(updated_at, 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS updated_at
FROM api_services
ORDER BY created_at DESC;
-- name: InsertAPService :one
INSERT INTO api_services (
name, slug, upstream_url, route_prefix, health_path,
rpm_limit, monthly_quota, created_at, updated_at
) VALUES ($1, $2, $3, $4, '/health', $5, $6, NOW(), NOW())
RETURNING id;
-- name: SetAPServiceEnabled :exec
UPDATE api_services SET enabled = $1, updated_at = NOW() WHERE id = $2;
-- name: ListAPKeys :many
SELECT id, name, key_prefix, plan, enabled, rpm_limit, monthly_quota,
to_char(created_at, 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS created_at,
to_char(updated_at, 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS updated_at
FROM api_keys
ORDER BY created_at DESC;
-- name: InsertAPKey :one
INSERT INTO api_keys (
name, key_hash, key_prefix, plan, allowed_service_ids,
rpm_limit, monthly_quota, created_at, updated_at
) VALUES ($1, $2, $3, $4, '[]', $5, $6, NOW(), NOW())
RETURNING id;
-- name: SetAPKeyEnabled :exec
UPDATE api_keys SET enabled = $1, updated_at = NOW() WHERE id = $2;
-- name: CountAPServices :one
SELECT COUNT(*) FROM api_services;
-- name: CountAPKeys :one
SELECT COUNT(*) FROM api_keys;
-- name: SumUsageCounters :one
SELECT COALESCE(SUM(request_count), 0) FROM usage_counters;
-- name: SumRequestMetricSince :one
SELECT COALESCE(SUM(value), 0)::int
FROM metrics_timeseries
WHERE metric = 'request_total' AND occurred_at >= DATE_TRUNC($1::text, NOW());
-- name: ListTopAPServices :many
SELECT s.id, s.name, COALESCE(SUM(u.request_count), 0) AS total_requests
FROM api_services s
LEFT JOIN usage_counters u ON u.service_id = s.id
GROUP BY s.id, s.name
ORDER BY total_requests DESC, s.name ASC
LIMIT 10;
-- name: ListRequestsByDay :many
SELECT TO_CHAR(DATE_TRUNC('day', occurred_at), 'YYYY-MM-DD') AS day_bucket, COUNT(*) AS total
FROM metrics_timeseries
WHERE metric = 'request_total' AND occurred_at >= NOW() - INTERVAL '7 days'
GROUP BY DATE_TRUNC('day', occurred_at)
ORDER BY DATE_TRUNC('day', occurred_at) ASC;
-- name: ListIncidentStatusCodes :many
SELECT COALESCE(http_status, 0) AS status_code, COALESCE(SUM(count), 0) AS total
FROM incident_events
WHERE occurred_at >= NOW() - INTERVAL '7 days'
GROUP BY COALESCE(http_status, 0)
ORDER BY total DESC, status_code ASC;
-- name: ListClientEventPaths :many
SELECT COALESCE((labels_json::jsonb ->> 'path'), 'unknown') AS path, COUNT(*) AS total
FROM metrics_timeseries
WHERE metric = 'client_event' AND occurred_at >= NOW() - INTERVAL '7 days'
GROUP BY COALESCE((labels_json::jsonb ->> 'path'), 'unknown')
ORDER BY total DESC, path ASC
LIMIT 20;
-- name: GetAPServiceBySlug :one
SELECT id, upstream_url, route_prefix, upstream_auth_header,
upstream_auth_value, enabled, rpm_limit, monthly_quota,
request_timeout_ms
FROM api_services WHERE slug = $1;
-- name: ListAPKeysByPrefix :many
SELECT id, key_hash, enabled, rpm_limit, monthly_quota, allowed_service_ids
FROM api_keys WHERE key_prefix = $1;
-- name: SumUsageByKey :one
SELECT COALESCE(SUM(request_count), 0)::bigint FROM usage_counters
WHERE api_key_id = $1 AND period_month = $2;
-- name: SumUsageByService :one
SELECT COALESCE(SUM(request_count), 0)::bigint FROM usage_counters
WHERE service_id = $1 AND period_month = $2;
-- name: UpsertUsageCounter :exec
INSERT INTO usage_counters (api_key_id, service_id, period_month, request_count, updated_at)
VALUES ($1, $2, $3, 1, NOW())
ON CONFLICT (api_key_id, service_id, period_month)
DO UPDATE SET request_count = usage_counters.request_count + 1, updated_at = NOW();
-- name: TouchAPKeyLastUsed :exec
UPDATE api_keys SET last_used_at = NOW() WHERE id = $1;
-- name: InsertMetricPoint :exec
INSERT INTO metrics_timeseries (metric, value, labels_json, occurred_at)
VALUES ('gateway_request', 1, $1, NOW());
-28
View File
@@ -1,28 +0,0 @@
-- name: InsertAuditLog :exec
INSERT INTO audit_logs (id, user_id, resource, resource_id, action, details, severity, category, label, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10);
-- name: InsertAuditLogWithRequest :exec
INSERT INTO audit_logs (id, user_id, resource, resource_id, action, details, ip_address, user_agent, severity, category, label, created_at)
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7::inet, $8, $9, $10, $11, $12);
-- name: PruneAuditLogs :execrows
DELETE FROM audit_logs WHERE created_at < NOW() - ($1::int * INTERVAL '1 day');
-- name: ListAuditBackfillBatch :many
SELECT id::text, resource, action FROM audit_logs
WHERE label = '' AND id > $1::uuid ORDER BY id LIMIT 500;
-- name: SetAuditLogMetadata :exec
UPDATE audit_logs SET severity = $1, category = $2, label = $3 WHERE id = $4::uuid;
-- name: ListResourceAuditLogs :many
SELECT id, COALESCE(user_id::text, '')::text AS user_id, resource,
COALESCE(resource_id::text, '')::text AS resource_id, action,
COALESCE(details::text, '{}')::text AS details,
COALESCE(ip_address::text, '')::text AS ip_address,
COALESCE(user_agent, '') AS user_agent, created_at
FROM audit_logs
WHERE user_id::text = $1 AND resource = $2 AND resource_id::text = $3
ORDER BY created_at DESC
LIMIT 100;
-20
View File
@@ -35,23 +35,3 @@ FROM users WHERE id = $1;
UPDATE users UPDATE users
SET name = COALESCE($1::varchar, name), avatar_url = COALESCE($2::varchar, avatar_url) SET name = COALESCE($1::varchar, name), avatar_url = COALESCE($2::varchar, avatar_url)
WHERE id = $3; WHERE id = $3;
-- name: UpsertAdminUser :one
INSERT INTO users (email, password_hash, name, is_admin)
VALUES ($1, $2, $3, true)
ON CONFLICT (email) DO UPDATE
SET is_admin = true, updated_at = NOW()
RETURNING id;
-- name: ListAdminUsers :many
SELECT id, email, name, COALESCE(avatar_url, '') AS avatar_url, is_admin,
COALESCE(to_char(created_at, 'YYYY-MM-DD"T"HH24:MI:SS"Z"'), '')::text AS created_at
FROM users
ORDER BY created_at ASC;
-- name: SetUserAdmin :execrows
UPDATE users SET is_admin = $1, updated_at = NOW() WHERE id = $2;
-- name: GetAdminUserInfo :one
SELECT email, COALESCE(name, '') AS name, is_admin
FROM users WHERE id = $1;
-33
View File
@@ -1,33 +0,0 @@
-- name: UpsertBuild :exec
INSERT INTO builds
(id, project_id, service_id, status, progress, started_at, completed_at, image_name, image_tag, size, error, log, metadata, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb, $14, $14)
ON CONFLICT (id) DO UPDATE SET
project_id = EXCLUDED.project_id,
service_id = EXCLUDED.service_id,
status = EXCLUDED.status,
progress = EXCLUDED.progress,
started_at = EXCLUDED.started_at,
completed_at = EXCLUDED.completed_at,
image_name = EXCLUDED.image_name,
image_tag = EXCLUDED.image_tag,
size = EXCLUDED.size,
error = EXCLUDED.error,
log = EXCLUDED.log,
metadata = EXCLUDED.metadata,
updated_at = EXCLUDED.updated_at;
-- name: GetBuild :one
SELECT id, project_id, service_id, status, progress, started_at, completed_at, image_name, image_tag, size, error, log, metadata
FROM builds
WHERE id = $1;
-- name: CancelBuild :execrows
UPDATE builds
SET status = 'cancelled',
progress = 100,
completed_at = $1,
log = COALESCE(log, '') || $2,
updated_at = $1
WHERE id = $3
AND status NOT IN ('success', 'failed', 'cancelled');
-8
View File
@@ -127,11 +127,3 @@ SELECT s.id, s.project_id, s.name,
s.created_at, s.updated_at s.created_at, s.updated_at
FROM services s FROM services s
WHERE (s.git_branch = $1 OR $1 = '') AND (s.git_repo = $2 OR s.git_repo = $3); WHERE (s.git_branch = $1 OR $1 = '') AND (s.git_repo = $2 OR s.git_repo = $3);
-- name: GetGitRepoCloneByFullName :one
SELECT clone_url, provider_id FROM git_repositories
WHERE full_name = $1 AND user_id = $2 LIMIT 1;
-- name: GetGitRepoProviderByCloneURL :one
SELECT provider_id FROM git_repositories
WHERE clone_url = $1 AND user_id = $2 LIMIT 1;
-29
View File
@@ -1,29 +0,0 @@
-- name: CountProjects :one
SELECT COUNT(*) FROM projects;
-- name: GetRegistryAuth :one
SELECT username, password FROM registries
WHERE owner_id::text = $1 AND host = $2;
-- name: UpdateServiceRuntimeMeta :exec
UPDATE services SET port = $1, healthcheck_path = $2 WHERE id = $3;
-- name: UpdateServiceDeployMeta :exec
UPDATE services SET port = $1, healthcheck_path = $2,
volumes = COALESCE($3::jsonb, '[]'::jsonb),
git_repo = NULLIF($4::text, ''), git_branch = NULLIF($5::text, ''), builder = $6
WHERE id = $7;
-- name: InsertEnvVar :exec
INSERT INTO environment_variables (id, service_id, key, value, is_secret, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7);
-- name: DeleteProjectVariables :exec
DELETE FROM project_variables WHERE project_id = $1;
-- name: InsertProjectVariable :exec
INSERT INTO project_variables (id, project_id, key, value, is_secret, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7);
-- name: GetProjectVariableValue :one
SELECT value FROM project_variables WHERE project_id = $1 AND key = $2;
-43
View File
@@ -1,43 +0,0 @@
-- name: ListActiveOperationDeployments :many
SELECT d.id, d.service_id, s.name AS service_name, p.name AS project_name, d.status,
COALESCE(d.image_name, '') AS image, 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 (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))
ORDER BY d.created_at ASC;
-- name: ListRecentFailedDeployments :many
SELECT d.id, d.service_id, s.name AS service_name, p.name AS project_name, d.status,
COALESCE(d.image_name, '') AS image, 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 (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))
ORDER BY d.updated_at DESC
LIMIT 20;
-- name: ListRecentCronRuns :many
SELECT e.id, j.name AS job_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 (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))
ORDER BY e.started_at DESC
LIMIT 30;
-- name: ListRecentOperationBackups :many
SELECT b.id, b.database_id, ds.name AS db_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;
@@ -1,62 +0,0 @@
-- name: GetServiceSleep :one
SELECT COALESCE(sleep_enabled, false) AS sleep_enabled, COALESCE(sleep_idle_minutes, 15) AS sleep_idle_minutes
FROM services WHERE id = $1;
-- name: GetServiceNodeID :one
SELECT node_id FROM services WHERE id = $1;
-- name: GetServiceSpread :one
SELECT COALESCE(spread, false) FROM services WHERE id = $1;
-- name: GetServicePlacementTags :one
SELECT placement_tags FROM services WHERE id = $1;
-- name: GetServiceTraefikLabels :one
SELECT traefik_labels FROM services WHERE id = $1;
-- name: GetServiceVolumes :one
SELECT volumes FROM services WHERE id = $1;
-- name: GetNodeAgentName :one
SELECT name FROM node_agents WHERE id = $1;
-- name: GetServiceProjectID :one
SELECT project_id FROM services WHERE id = $1;
-- name: GetServiceProjectAndStatus :one
SELECT s.project_id, COALESCE(s.status, '') AS status
FROM services s WHERE s.id = $1;
-- name: GetEnvironmentProjectID :one
SELECT project_id FROM environments WHERE id = $1;
-- name: GetProjectReadAccess :one
SELECT is_approved, owner_id::text FROM projects WHERE id = $1;
-- name: ProjectMemberExists :one
SELECT EXISTS(SELECT 1 FROM project_members WHERE project_id = $1 AND user_id = $2);
-- name: DeleteEnvVarsForService :exec
DELETE FROM environment_variables WHERE service_id = $1;
-- name: FailInterruptedDeployments :exec
UPDATE deployments SET status = 'failed', error = 'Interrupted by server restart', completed_at = NOW(), updated_at = NOW()
WHERE status IN ('queued', 'pending', 'building', 'deploying', 'rolling_back');
-- name: ListSecretEnvVars :many
SELECT id, value FROM environment_variables WHERE is_secret = TRUE;
-- name: UpdateEnvVarValue :exec
UPDATE environment_variables SET value = $1, updated_at = NOW() WHERE id = $2;
-- name: ListAgentHealthRows :many
SELECT id, COALESCE(name, hostname, id::text) AS name, COALESCE(status, '') AS status,
COALESCE(last_heartbeat < $1, TRUE)::bool AS stale, auto_prune
FROM node_agents;
-- name: SetAgentStatus :exec
UPDATE node_agents SET status = $1, updated_at = NOW() WHERE id = $2;
-- name: GetServiceNameProjectPort :one
SELECT name, project_id::text, COALESCE(port, 0)::int AS port
FROM services WHERE id::text = $1;
-21
View File
@@ -1,21 +0,0 @@
-- name: GetAppSetting :one
SELECT value FROM app_settings WHERE key = $1;
-- name: UpsertAppSetting :exec
INSERT INTO app_settings (key, value, is_secret, updated_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (key) DO UPDATE
SET value = EXCLUDED.value, is_secret = EXCLUDED.is_secret, updated_at = NOW();
-- name: UpsertAppSettingValue :exec
INSERT INTO app_settings (key, value, updated_at) VALUES ($1, $2, NOW())
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW();
-- name: DeleteAppSetting :exec
DELETE FROM app_settings WHERE key = $1;
-- name: GetUserIsAdmin :one
SELECT is_admin FROM users WHERE id = $1;
-- name: ListAdminUserIDs :many
SELECT id FROM users WHERE is_admin;
-120
View File
@@ -144,17 +144,6 @@ CREATE TABLE environment_variables (
UNIQUE(service_id, key) UNIQUE(service_id, key)
); );
CREATE TABLE project_variables (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
key VARCHAR(255) NOT NULL,
value TEXT NOT NULL DEFAULT '',
is_secret BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
UNIQUE(project_id, key)
);
CREATE TABLE service_templates ( CREATE TABLE service_templates (
id VARCHAR(50) PRIMARY KEY, id VARCHAR(50) PRIMARY KEY,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
@@ -601,112 +590,3 @@ CREATE TABLE vulnerabilities (
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
); );
CREATE TABLE app_settings (
key VARCHAR(255) PRIMARY KEY,
value TEXT NOT NULL DEFAULT '',
is_secret BOOLEAN NOT NULL DEFAULT false,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
CREATE TABLE audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
timestamp TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
user_id UUID,
action VARCHAR(100) NOT NULL,
resource VARCHAR(100) NOT NULL,
resource_id UUID,
details JSONB DEFAULT '{}',
ip_address INET,
user_agent TEXT,
success BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
severity TEXT NOT NULL DEFAULT 'info',
category TEXT NOT NULL DEFAULT '',
label TEXT NOT NULL DEFAULT ''
);
CREATE TABLE builds (
id VARCHAR(255) PRIMARY KEY,
project_id VARCHAR(255),
service_id VARCHAR(255),
status VARCHAR(50) NOT NULL DEFAULT 'pending',
progress INTEGER NOT NULL DEFAULT 0,
started_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
completed_at TIMESTAMP WITH TIME ZONE,
image_name VARCHAR(500) NOT NULL DEFAULT '',
image_tag VARCHAR(200) NOT NULL DEFAULT '',
size BIGINT NOT NULL DEFAULT 0,
error TEXT,
log TEXT NOT NULL DEFAULT '',
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- APwhy API gateway (api_services/api_keys + usage/metrics/incident planes)
CREATE TABLE api_services (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
upstream_url TEXT NOT NULL,
route_prefix TEXT NOT NULL UNIQUE,
health_path TEXT NOT NULL DEFAULT '/health',
upstream_auth_header TEXT,
upstream_auth_value TEXT,
internal_token TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
rpm_limit INTEGER,
monthly_quota INTEGER,
request_timeout_ms INTEGER DEFAULT 8000,
last_validation_at TIMESTAMP WITHOUT TIME ZONE,
last_validation_status TEXT,
last_validation_message TEXT,
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT now(),
updated_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT now()
);
CREATE TABLE api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
key_hash TEXT NOT NULL,
key_prefix TEXT NOT NULL,
plan TEXT NOT NULL DEFAULT 'free',
allowed_service_ids TEXT NOT NULL DEFAULT '[]',
enabled INTEGER NOT NULL DEFAULT 1,
rpm_limit INTEGER DEFAULT 60,
monthly_quota INTEGER DEFAULT 1000,
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT now(),
updated_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT now(),
last_used_at TIMESTAMP WITHOUT TIME ZONE
);
CREATE TABLE usage_counters (
id INTEGER PRIMARY KEY,
api_key_id UUID NOT NULL REFERENCES api_keys(id) ON DELETE CASCADE,
service_id UUID NOT NULL REFERENCES api_services(id) ON DELETE CASCADE,
period_month TEXT NOT NULL,
request_count INTEGER NOT NULL DEFAULT 0,
updated_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT now(),
UNIQUE (api_key_id, service_id, period_month)
);
CREATE TABLE metrics_timeseries (
id INTEGER PRIMARY KEY,
metric TEXT NOT NULL,
value REAL NOT NULL,
labels_json TEXT,
occurred_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT now()
);
CREATE TABLE incident_events (
id INTEGER PRIMARY KEY,
service_id UUID REFERENCES api_services(id) ON DELETE SET NULL,
api_key_id UUID,
code TEXT NOT NULL,
message TEXT NOT NULL,
severity TEXT NOT NULL DEFAULT 'medium',
http_status INTEGER,
count INTEGER NOT NULL DEFAULT 1,
occurred_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT now()
);
-6
View File
@@ -5,12 +5,6 @@
<link rel="icon" type="image/svg+xml" href="/containr.svg" /> <link rel="icon" type="image/svg+xml" href="/containr.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>containr</title> <title>containr</title>
<script>
try {
document.documentElement.dataset.theme =
localStorage.getItem('containr.theme') === 'light' ? 'light' : 'dark';
} catch { document.documentElement.dataset.theme = 'dark'; }
</script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+3 -3
View File
@@ -1,12 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs> <defs>
<linearGradient id="bg" x1="0" y1="0" x2="64" y2="64" gradientUnits="userSpaceOnUse"> <linearGradient id="bg" x1="0" y1="0" x2="64" y2="64" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#56c9f8"/> <stop offset="0" stop-color="#cdf06f"/>
<stop offset="1" stop-color="#0e86c9"/> <stop offset="1" stop-color="#7fb524"/>
</linearGradient> </linearGradient>
</defs> </defs>
<rect width="64" height="64" rx="15" fill="url(#bg)"/> <rect width="64" height="64" rx="15" fill="url(#bg)"/>
<g fill="none" stroke="#061018" stroke-width="4.2" stroke-linecap="round" stroke-linejoin="round"> <g fill="none" stroke="#131507" stroke-width="4.2" stroke-linecap="round" stroke-linejoin="round">
<rect x="15" y="18" width="34" height="26" rx="4"/> <rect x="15" y="18" width="34" height="26" rx="4"/>
<path d="M23 18v26M31 18v26M39 18v26"/> <path d="M23 18v26M31 18v26M39 18v26"/>
<path d="M20 51h24"/> <path d="M20 51h24"/>

Before

Width:  |  Height:  |  Size: 582 B

After

Width:  |  Height:  |  Size: 582 B

+54 -66
View File
@@ -1,50 +1,39 @@
import { lazy, Suspense, type ReactNode } from 'react';
import { Link, Navigate, Outlet, Route, Routes, useLocation } from 'react-router-dom'; import { Link, Navigate, Outlet, Route, Routes, useLocation } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Compass, ShieldCheck } from 'lucide-react'; import { Compass, ShieldCheck } from 'lucide-react';
import { PlatformShell } from './layout/PlatformShell'; import { PlatformShell } from './layout/PlatformShell';
import { ProjectsPage } from '@/features/projects/pages/ProjectsPage';
import { ProjectWorkspacePage } from '@/features/workspace/pages/ProjectWorkspacePage';
import { ServiceDetailPage } from '@/features/service/pages/ServiceDetailPage';
import { ServiceMetricsDashboard } from '@/features/service/pages/ServiceMetricsDashboard';
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 { ActivityPage } from '@/features/activity/pages/ActivityPage';
import { AdminPage } from '@/features/admin/pages/AdminPage';
import { LandingPage } from '@/features/landing/LandingPage';
import { useBranding } from '@/lib/use-branding'; import { useBranding } from '@/lib/use-branding';
import {
DocsPage,
PeoplePage,
SettingsPage,
UsagePage,
DatabasesPage,
HighAvailabilityPage,
SecurityPage,
ComponentShowcase,
GitHubAppCallbackPage,
} from '@/features/secondary/pages';
import { NodeDetailPage } from '@/features/nodes/NodeDetailPage';
import { AcceptInvitePage, SignInPage, SignUpPage } from '@/features/auth/pages';
import { SetupWizardPage } from '@/features/setup/SetupWizardPage';
import { useAuthSession } from '@/lib/use-auth-session'; import { useAuthSession } from '@/lib/use-auth-session';
import { useDemoMode } from '@/lib/demo-mode'; import { useDemoMode } from '@/lib/demo-mode';
import { getCurrentUserProfile } from '@/lib/api-client'; import { getCurrentUserProfile } from '@/lib/api-client';
import { ErrorBoundary, LoadingState } from '@/shared/components'; import { ErrorBoundary, LoadingState } from '@/shared/components';
const LandingPage = lazy(() => import('@/features/landing/LandingPage').then((m) => ({ default: m.LandingPage })));
const SignInPage = lazy(() => import('@/features/auth/pages').then((m) => ({ default: m.SignInPage })));
const SignUpPage = lazy(() => import('@/features/auth/pages').then((m) => ({ default: m.SignUpPage })));
const AcceptInvitePage = lazy(() => import('@/features/auth/pages').then((m) => ({ default: m.AcceptInvitePage })));
const SetupWizardPage = lazy(() => import('@/features/setup/SetupWizardPage').then((m) => ({ default: m.SetupWizardPage })));
const ProjectWorkspacePage = lazy(() => import('@/features/workspace/pages/ProjectWorkspacePage').then((m) => ({ default: m.ProjectWorkspacePage })));
const ServiceDetailPage = lazy(() => import('@/features/service/pages/ServiceDetailPage').then((m) => ({ default: m.ServiceDetailPage })));
const ServiceMetricsDashboard = lazy(() => import('@/features/service/pages/ServiceMetricsDashboard').then((m) => ({ default: m.ServiceMetricsDashboard })));
const DashboardPage = lazy(() => import('@/features/dashboard/pages/DashboardPage').then((m) => ({ default: m.DashboardPage })));
const ProjectsPage = lazy(() => import('@/features/projects/pages/ProjectsPage').then((m) => ({ default: m.ProjectsPage })));
const TemplatesPage = lazy(() => import('@/features/templates/pages/TemplatesPage').then((m) => ({ default: m.TemplatesPage })));
const DatabasesPage = lazy(() => import('@/features/secondary/pages').then((m) => ({ default: m.DatabasesPage })));
const HighAvailabilityPage = lazy(() => import('@/features/secondary/pages').then((m) => ({ default: m.HighAvailabilityPage })));
const OperationsPage = lazy(() => import('@/features/operations/pages/OperationsPage').then((m) => ({ default: m.OperationsPage })));
const ActivityPage = lazy(() => import('@/features/activity/pages/ActivityPage').then((m) => ({ default: m.ActivityPage })));
const SecurityPage = lazy(() => import('@/features/secondary/pages').then((m) => ({ default: m.SecurityPage })));
const UsagePage = lazy(() => import('@/features/secondary/pages').then((m) => ({ default: m.UsagePage })));
const DocsPage = lazy(() => import('@/features/secondary/pages').then((m) => ({ default: m.DocsPage })));
const BuildsPage = lazy(() => import('@/features/builds/pages/BuildsPage').then((m) => ({ default: m.BuildsPage })));
const PeoplePage = lazy(() => import('@/features/secondary/pages').then((m) => ({ default: m.PeoplePage })));
const SettingsPage = lazy(() => import('@/features/secondary/pages').then((m) => ({ default: m.SettingsPage })));
const GitHubAppCallbackPage = lazy(() => import('@/features/secondary/pages').then((m) => ({ default: m.GitHubAppCallbackPage })));
const AuditLogsPage = lazy(() => import('@/features/audit/pages/AuditLogsPage').then((m) => ({ default: m.AuditLogsPage })));
const WebhooksPage = lazy(() => import('@/features/webhooks/pages/WebhooksPage').then((m) => ({ default: m.WebhooksPage })));
const ComponentShowcase = lazy(() => import('@/features/secondary/pages').then((m) => ({ default: m.ComponentShowcase })));
const AdminPage = lazy(() => import('@/features/admin/pages/AdminPage').then((m) => ({ default: m.AdminPage })));
const NodeDetailPage = lazy(() => import('@/features/nodes/NodeDetailPage').then((m) => ({ default: m.NodeDetailPage })));
function Page({ children }: { children: ReactNode }) {
return (
<Suspense fallback={<LoadingState message="Loading..." className="min-h-[60vh]" />}>
{children}
</Suspense>
);
}
function AuthRequired() { function AuthRequired() {
const location = useLocation(); const location = useLocation();
const isDemoMode = useDemoMode(); const isDemoMode = useDemoMode();
@@ -103,7 +92,7 @@ function AdminRequired() {
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-[var(--bg-void)] px-6 text-center"> <div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-[var(--bg-void)] px-6 text-center">
<ShieldCheck size={32} className="text-[var(--error)]" /> <ShieldCheck size={32} className="text-[var(--error)]" />
<div> <div>
<h1 className="font-headline text-lg font-semibold text-[var(--text-primary)]">Admin access required</h1> <h1 className="text-lg font-semibold text-[var(--text-primary)]">Admin access required</h1>
<p className="mt-1 max-w-sm text-sm text-[var(--text-secondary)]"> <p className="mt-1 max-w-sm text-sm text-[var(--text-secondary)]">
This area is restricted to the platform administrator. This area is restricted to the platform administrator.
</p> </p>
@@ -126,7 +115,7 @@ function NotFoundPage() {
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-4 px-6 text-center"> <div className="flex min-h-[60vh] flex-col items-center justify-center gap-4 px-6 text-center">
<Compass size={32} className="text-[var(--text-tertiary)]" /> <Compass size={32} className="text-[var(--text-tertiary)]" />
<div> <div>
<h1 className="font-headline text-lg font-semibold text-[var(--text-primary)]">Page not found</h1> <h1 className="text-lg font-semibold text-[var(--text-primary)]">Page not found</h1>
<p className="mt-1 max-w-sm text-sm text-[var(--text-secondary)]"> <p className="mt-1 max-w-sm text-sm text-[var(--text-secondary)]">
The page you are looking for does not exist or was moved. The page you are looking for does not exist or was moved.
</p> </p>
@@ -146,44 +135,43 @@ export default function App() {
return ( return (
<ErrorBoundary> <ErrorBoundary>
<Routes> <Routes>
<Route path="/landing" element={<Page><LandingPage /></Page>} /> <Route path="/" element={<LandingPage />} />
<Route path="/auth/sign-in" element={<Page><SignInPage /></Page>} /> <Route path="/auth/sign-in" element={<SignInPage />} />
<Route path="/auth/sign-up" element={<Page><SignUpPage /></Page>} /> <Route path="/auth/sign-up" element={<SignUpPage />} />
<Route path="/auth/accept-invite" element={<Page><AcceptInvitePage /></Page>} /> <Route path="/auth/accept-invite" element={<AcceptInvitePage />} />
<Route path="/setup" element={<Page><SetupWizardPage /></Page>} /> <Route path="/setup" element={<SetupWizardPage />} />
{/* Public browsing — approved projects, templates, docs. */} {/* Public browsing — approved projects, templates, docs. */}
{/* Project workspace renders outside the shell — fullscreen canvas, no nav chrome */} {/* Project workspace renders outside the shell — fullscreen canvas, no nav chrome */}
<Route path="/projects/:projectId" element={<Page><ProjectWorkspacePage /></Page>} /> <Route path="/projects/:projectId" element={<ProjectWorkspacePage />} />
<Route path="/projects/:projectId/services/:serviceId" element={<Page><ServiceDetailPage /></Page>} /> <Route path="/projects/:projectId/services/:serviceId" element={<ServiceDetailPage />} />
<Route element={<PlatformShell />}> <Route element={<PlatformShell />}>
<Route path="/" element={<Page><DashboardPage /></Page>} /> <Route path="/projects" element={<ProjectsPage />} />
<Route path="/projects" element={<Page><ProjectsPage /></Page>} /> <Route path="/templates" element={<TemplatesPage />} />
<Route path="/templates" element={<Page><TemplatesPage /></Page>} /> <Route path="/databases" element={<DatabasesPage />} />
<Route path="/databases" element={<Page><DatabasesPage /></Page>} /> <Route path="/ha" element={<HighAvailabilityPage />} />
<Route path="/ha" element={<Page><HighAvailabilityPage /></Page>} /> <Route path="/operations" element={<OperationsPage />} />
<Route path="/operations" element={<Page><OperationsPage /></Page>} /> <Route path="/activity" element={<ActivityPage />} />
<Route path="/activity" element={<Page><ActivityPage /></Page>} /> <Route path="/security" element={<SecurityPage />} />
<Route path="/security" element={<Page><SecurityPage /></Page>} /> <Route path="/usage" element={<UsagePage />} />
<Route path="/usage" element={<Page><UsagePage /></Page>} /> <Route path="/docs" element={<DocsPage />} />
<Route path="/docs" element={<Page><DocsPage /></Page>} />
{/* Authenticated areas */} {/* Authenticated areas */}
<Route element={<AuthRequired />}> <Route element={<AuthRequired />}>
<Route path="/builds" element={<Page><BuildsPage /></Page>} /> <Route path="/builds" element={<BuildsPage />} />
<Route path="/people" element={<Page><PeoplePage /></Page>} /> <Route path="/people" element={<PeoplePage />} />
<Route path="/settings" element={<Page><SettingsPage /></Page>} /> <Route path="/settings" element={<SettingsPage />} />
<Route path="/git/github-app/callback" element={<Page><GitHubAppCallbackPage /></Page>} /> <Route path="/git/github-app/callback" element={<GitHubAppCallbackPage />} />
<Route path="/settings/audit-logs" element={<Page><AuditLogsPage /></Page>} /> <Route path="/settings/audit-logs" element={<AuditLogsPage />} />
<Route path="/settings/webhooks" element={<Page><WebhooksPage /></Page>} /> <Route path="/settings/webhooks" element={<WebhooksPage />} />
<Route path="/showcase" element={<Page><ComponentShowcase /></Page>} /> <Route path="/showcase" element={<ComponentShowcase />} />
</Route> </Route>
{/* Platform owner only */} {/* Platform owner only */}
<Route element={<AdminRequired />}> <Route element={<AdminRequired />}>
<Route path="/admin" element={<Page><AdminPage /></Page>} /> <Route path="/admin" element={<AdminPage />} />
<Route path="/nodes/:id" element={<Page><NodeDetailPage /></Page>} /> <Route path="/nodes/:id" element={<NodeDetailPage />} />
</Route> </Route>
<Route path="*" element={<NotFoundPage />} /> <Route path="*" element={<NotFoundPage />} />
@@ -191,7 +179,7 @@ export default function App() {
{/* Full-bleed design demo — renders its own chrome, no app shell */} {/* Full-bleed design demo — renders its own chrome, no app shell */}
<Route element={<AuthRequired />}> <Route element={<AuthRequired />}>
<Route path="/metrics-demo" element={<Page><ServiceMetricsDashboard /></Page>} /> <Route path="/metrics-demo" element={<ServiceMetricsDashboard />} />
</Route> </Route>
</Routes> </Routes>
</ErrorBoundary> </ErrorBoundary>
+119 -141
View File
@@ -9,13 +9,11 @@ import {
ChartBar, ChartBar,
CheckCircle2, CheckCircle2,
ChevronsLeft, ChevronsLeft,
ChevronsUpDown,
Clock, Clock,
Container, Container,
Database, Database,
FileText, FileText,
FolderKanban, FolderKanban,
LayoutDashboard,
LayoutTemplate, LayoutTemplate,
LogIn, LogIn,
LogOut, LogOut,
@@ -43,26 +41,22 @@ import {
markAllNotificationsRead, markAllNotificationsRead,
markNotificationRead, markNotificationRead,
pullUpgradeImage, pullUpgradeImage,
listProjects,
} from '@/lib/api-client'; } from '@/lib/api-client';
import { signOutAuthSession } from '@/lib/auth-client'; import { signOutAuthSession } from '@/lib/auth-client';
import { isDemoSearch } from '@/lib/demo-mode'; import { isDemoSearch } from '@/lib/demo-mode';
import { demoProjects } from '@/lib/demo-data';
import { useAuthSession } from '@/lib/use-auth-session'; import { useAuthSession } from '@/lib/use-auth-session';
import { BannerBar, BrandWordmark, CommandPalette, useToast } from '@/shared/components'; import { BannerBar, BrandWordmark, useToast } from '@/shared/components';
type NavItem = { label: string; href: string; icon: typeof FolderKanban; hint: string }; type NavItem = { label: string; href: string; icon: typeof FolderKanban; hint: string };
type NavSection = { title: string; items: NavItem[] }; type NavSection = { title: string; items: NavItem[] };
const navSections: NavSection[] = [ const navSections: NavSection[] = [
{ {
title: 'Platform', title: 'Build',
items: [ items: [
{ label: 'Dashboard', href: '/', icon: LayoutDashboard, hint: 'Fleet overview' },
{ label: 'Projects', href: '/projects', icon: FolderKanban, hint: 'Services & deployments' }, { label: 'Projects', href: '/projects', icon: FolderKanban, hint: 'Services & deployments' },
{ label: 'Templates', href: '/templates', icon: LayoutTemplate, hint: 'Deployable presets' }, { label: 'Templates', href: '/templates', icon: LayoutTemplate, hint: 'Deployable presets' },
{ label: 'Builds', href: '/builds', icon: FileText, hint: 'Build history' }, { label: 'Builds', href: '/builds', icon: FileText, hint: 'Build history' },
{ label: 'Databases', href: '/databases', icon: Database, hint: 'Managed data services' },
], ],
}, },
{ {
@@ -70,6 +64,7 @@ const navSections: NavSection[] = [
items: [ items: [
{ label: 'Operations', href: '/operations', icon: Activity, hint: 'Live jobs & failures' }, { label: 'Operations', href: '/operations', icon: Activity, hint: 'Live jobs & failures' },
{ label: 'Activity', href: '/activity', icon: Clock, hint: 'Platform event feed' }, { label: 'Activity', href: '/activity', icon: Clock, hint: 'Platform event feed' },
{ label: 'Databases', href: '/databases', icon: Database, hint: 'Managed data services' },
{ label: 'High Availability', href: '/ha', icon: ShieldCheck, hint: 'Failover & health' }, { label: 'High Availability', href: '/ha', icon: ShieldCheck, hint: 'Failover & health' },
{ label: 'Security', href: '/security', icon: Shield, hint: 'Scans & compliance' }, { label: 'Security', href: '/security', icon: Shield, hint: 'Scans & compliance' },
{ label: 'Usage', href: '/usage', icon: ChartBar, hint: 'Resource consumption' }, { label: 'Usage', href: '/usage', icon: ChartBar, hint: 'Resource consumption' },
@@ -169,18 +164,6 @@ export function PlatformShell() {
const [theme, setTheme] = useState<ThemeMode>(() => getInitialTheme()); const [theme, setTheme] = useState<ThemeMode>(() => getInitialTheme());
const [sidebarExpanded, setSidebarExpanded] = useState<boolean>(() => getInitialSidebar()); const [sidebarExpanded, setSidebarExpanded] = useState<boolean>(() => getInitialSidebar());
const [notificationsOpen, setNotificationsOpen] = useState(false); const [notificationsOpen, setNotificationsOpen] = useState(false);
const [paletteOpen, setPaletteOpen] = useState(false);
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setPaletteOpen((open) => !open);
}
};
document.addEventListener('keydown', down);
return () => document.removeEventListener('keydown', down);
}, []);
const sessionQuery = useAuthSession({ enabled: !isDemoMode }); const sessionQuery = useAuthSession({ enabled: !isDemoMode });
const signedIn = isDemoMode || Boolean(sessionQuery.data); const signedIn = isDemoMode || Boolean(sessionQuery.data);
@@ -251,19 +234,8 @@ export function PlatformShell() {
} }
}; };
// Longest matching href wins so /settings/audit-logs lights only const isActiveRoute = (itemHref: string) =>
// "Audit Logs", not "Settings" as well. location.pathname === itemHref || location.pathname.startsWith(`${itemHref}/`);
const activeHref = location.pathname === '/'
? '/'
: visibleNavSections
.flatMap((section) => section.items.map((item) => item.href))
.filter(
(itemHref) =>
itemHref !== '/' &&
(location.pathname === itemHref || location.pathname.startsWith(`${itemHref}/`)),
)
.sort((a, b) => b.length - a.length)[0] ?? null;
const isActiveRoute = (itemHref: string) => itemHref === activeHref;
const userName = isDemoMode ? 'Demo workspace' : profileQuery.data?.name ?? sessionQuery.data?.user.name ?? 'Account'; const userName = isDemoMode ? 'Demo workspace' : profileQuery.data?.name ?? sessionQuery.data?.user.name ?? 'Account';
const userEmail = isDemoMode ? 'Sample data · read-only' : profileQuery.data?.email ?? sessionQuery.data?.user.email ?? 'Local session'; const userEmail = isDemoMode ? 'Sample data · read-only' : profileQuery.data?.email ?? sessionQuery.data?.user.email ?? 'Local session';
@@ -274,20 +246,14 @@ export function PlatformShell() {
.slice(0, 2) .slice(0, 2)
.toUpperCase() || 'C'; .toUpperCase() || 'C';
const paletteNavigate = (target: string) => { const avatar = (
navigate(href(target)); <div
}; className="flex h-[30px] w-[30px] shrink-0 items-center justify-center rounded-full text-[11px] font-bold"
style={{ background: 'linear-gradient(135deg, var(--accent-primary), var(--accent-secondary))', color: 'var(--accent-on)' }}
// Project deep-links for the palette — only fetched once it opens. >
const paletteProjectsQuery = useQuery({ {initials}
queryKey: ['palette-projects'], </div>
queryFn: () => listProjects(), );
enabled: paletteOpen && !isDemoMode,
staleTime: 60_000,
});
const paletteProjects = isDemoMode
? demoProjects
: paletteProjectsQuery.data ?? [];
if (!isDemoMode && signedIn && setupQuery.data?.needs_setup) { if (!isDemoMode && signedIn && setupQuery.data?.needs_setup) {
return <Navigate to="/setup" replace />; return <Navigate to="/setup" replace />;
@@ -295,46 +261,40 @@ export function PlatformShell() {
return ( return (
<div className="app-shell min-h-screen"> <div className="app-shell min-h-screen">
<div className="ambient-glow" />
<div className="relative flex min-h-screen"> <div className="relative flex min-h-screen">
{/* ── Sidebar — fixed rail per design/23-sentry ── */}
<aside <aside
className="shell-side hidden h-screen shrink-0 flex-col md:flex" className="hidden h-screen shrink-0 flex-col border-r py-4 transition-[width] duration-200 md:flex"
style={{ width: sidebarExpanded ? '252px' : '64px' }} style={{
width: sidebarExpanded ? '218px' : '62px',
background: 'var(--bg-base)',
borderRightColor: 'var(--border-subtle)',
}}
> >
<div <div className={`flex items-center ${sidebarExpanded ? 'justify-between px-3' : 'flex-col items-center gap-2'}`}>
className={`flex items-center ${ <NavLink to={href('/projects')} title="Containr">
sidebarExpanded ? 'gap-2.5 px-[18px] pb-5 pt-4' : 'flex-col gap-3 px-2 pb-4 pt-4' <img src="/containr.svg" alt="Containr" className="h-[38px] w-[38px] rounded-xl" />
}`}
>
<NavLink to={href('/')} title="Containr" className="flex items-center gap-2.5">
<span className="shell-mark grid h-[26px] w-[26px] shrink-0 place-items-center">
<Container size={14} />
</span>
{sidebarExpanded ? (
<span className="font-headline text-[15px] font-semibold tracking-[-0.01em] text-[var(--text-primary)]">
<BrandWordmark />
</span>
) : null}
</NavLink> </NavLink>
<button <button
type="button" type="button"
onClick={() => setSidebarExpanded((v) => !v)} onClick={() => setSidebarExpanded((v) => !v)}
title={sidebarExpanded ? 'Collapse sidebar' : 'Expand sidebar'} title={sidebarExpanded ? 'Collapse sidebar' : 'Expand sidebar'}
className={`shell-collapse flex items-center justify-center ${ className="flex h-7 w-7 items-center justify-center rounded-[var(--radius-sm)] text-[var(--text-tertiary)] transition-colors hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)]"
sidebarExpanded ? 'ml-auto h-[22px] w-[22px]' : 'h-7 w-7'
}`}
> >
{sidebarExpanded ? <ChevronsLeft size={14} /> : <PanelLeft size={15} />} {sidebarExpanded ? <ChevronsLeft size={15} /> : <PanelLeft size={15} />}
</button> </button>
</div> </div>
<nav className={`flex flex-1 flex-col overflow-y-auto ${sidebarExpanded ? 'px-3' : 'gap-4 px-2'}`}> <nav className="mt-3 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
{visibleNavSections.map((section, si) => ( {visibleNavSections.map((section) => (
<div key={section.title} className={si > 0 && sidebarExpanded ? 'mt-[26px]' : ''}> <div key={section.title}>
{sidebarExpanded ? ( {sidebarExpanded ? (
<p className="shell-nav-group">{section.title}</p> <p className="mb-1 px-2 text-[10px] font-semibold uppercase tracking-[0.14em] text-[var(--text-tertiary)]">
{section.title}
</p>
) : null} ) : null}
<div className={`flex flex-col ${sidebarExpanded ? '' : 'items-center gap-1'}`}> <div className={`flex flex-col gap-0.5 ${sidebarExpanded ? '' : 'items-center'}`}>
{section.items.map((item) => { {section.items.map((item) => {
const Icon = item.icon; const Icon = item.icon;
const isActive = isActiveRoute(item.href); const isActive = isActiveRoute(item.href);
@@ -343,12 +303,27 @@ export function PlatformShell() {
key={item.href} key={item.href}
to={href(item.href)} to={href(item.href)}
title={sidebarExpanded ? item.hint : `${item.label} — ${item.hint}`} title={sidebarExpanded ? item.hint : `${item.label} — ${item.hint}`}
className={`shell-ni ${isActive ? 'on' : ''} ${ className={`relative flex items-center rounded-[11px] transition-colors ${
sidebarExpanded ? '' : 'h-10 w-10 justify-center !px-0' sidebarExpanded ? 'gap-2.5 px-2 py-2' : 'h-10 w-10 justify-center'
} ${
isActive
? 'bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]'
: 'text-[var(--text-tertiary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-secondary)]'
}`} }`}
> >
<Icon size={16} className="shrink-0" /> {isActive ? (
{sidebarExpanded ? <span className="truncate">{item.label}</span> : null} <span
className="absolute -left-2 h-4 w-[2.5px] rounded-full"
style={{ background: 'var(--accent-primary)' }}
/>
) : null}
<Icon size={17} className="shrink-0" />
{sidebarExpanded ? (
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] font-medium leading-tight">{item.label}</span>
<span className="block truncate text-[10px] text-[var(--text-tertiary)]">{item.hint}</span>
</span>
) : null}
</NavLink> </NavLink>
); );
})} })}
@@ -357,26 +332,23 @@ export function PlatformShell() {
))} ))}
</nav> </nav>
<div className={`mt-auto ${sidebarExpanded ? 'px-3 pb-3.5' : 'flex justify-center px-2 pb-3'}`}> <div className={`flex flex-col gap-2 px-2 pt-2 ${sidebarExpanded ? '' : 'items-center'}`}>
{signedIn ? ( {signedIn ? (
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger asChild> <DropdownMenu.Trigger asChild>
<button <button
type="button" type="button"
title={sidebarExpanded ? undefined : userEmail} title={sidebarExpanded ? undefined : userEmail}
className={`shell-user ${ className={`flex items-center gap-2.5 rounded-[11px] text-left transition-colors hover:bg-[var(--surface-muted)] ${
sidebarExpanded ? 'w-full' : 'h-10 w-10 justify-center border-transparent bg-transparent' sidebarExpanded ? 'w-full px-2 py-2' : 'h-10 w-10 justify-center'
}`} }`}
> >
<span className="shell-avatar">{initials}</span> {avatar}
{sidebarExpanded ? ( {sidebarExpanded ? (
<> <span className="min-w-0 flex-1">
<span className="min-w-0 flex-1 text-left"> <span className="block truncate text-[12.5px] font-medium text-[var(--text-primary)]">{userName}</span>
<span className="block truncate text-[13px] font-medium text-[var(--text-primary)]">{userName}</span> <span className="block truncate text-[10.5px] text-[var(--text-tertiary)]">{userEmail}</span>
<span className="block truncate text-[10.5px] text-[var(--text-tertiary)]">{userEmail}</span> </span>
</span>
<ChevronsUpDown size={13} className="shrink-0 text-[var(--text-tertiary)]" />
</>
) : null} ) : null}
</button> </button>
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
@@ -455,12 +427,12 @@ export function PlatformShell() {
<NavLink <NavLink
to="/auth/sign-in" to="/auth/sign-in"
title="Sign in" title="Sign in"
className={`shell-ni ${ className={`flex items-center gap-2.5 rounded-[11px] text-[var(--text-tertiary)] transition-colors hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] ${
sidebarExpanded ? 'w-full' : 'h-10 w-10 justify-center !px-0' sidebarExpanded ? 'w-full px-2 py-2' : 'h-10 w-10 justify-center'
}`} }`}
> >
<LogIn size={16} className="shrink-0" /> <LogIn size={16} className="shrink-0" />
{sidebarExpanded ? <span className="text-[13px] font-medium">Sign in</span> : null} {sidebarExpanded ? <span className="text-[12.5px] font-medium">Sign in</span> : null}
</NavLink> </NavLink>
)} )}
</div> </div>
@@ -468,46 +440,71 @@ export function PlatformShell() {
<div className="relative z-10 flex min-h-screen min-w-0 flex-1 flex-col overflow-hidden"> <div className="relative z-10 flex min-h-screen min-w-0 flex-1 flex-col overflow-hidden">
<BannerBar /> <BannerBar />
{/* ── Topbar — search left, utility icons right ── */} <header
<header className="shell-top hidden h-[56px] shrink-0 items-center gap-3.5 md:flex"> className="hidden h-[46px] shrink-0 items-center border-b px-5 md:flex"
<button style={{
type="button" background: 'var(--bg-base)',
onClick={() => setPaletteOpen(true)} borderBottomColor: 'var(--border-subtle)',
className="shell-search" fontFamily: "'JetBrains Mono', monospace",
> fontSize: '11px',
<Search size={13} className="shrink-0" /> }}
Search anything >
<kbd>⌘ K</kbd> <NavLink to={href('/projects')} className="mr-5 text-sm font-extrabold tracking-tight text-[var(--text-primary)]" style={{ fontFamily: 'Inter, sans-serif' }}>
</button> <BrandWordmark />
</NavLink>
<span className="v-env">{isDemoMode ? 'env:demo' : `env:${import.meta.env.MODE}`}</span> <div className="flex items-center gap-5">
<span className="v-tick"><i className="sig" /><b>{isDemoMode ? 'demo' : 'nominal'}</b></span>
<span className="v-tick"><i className="sig" style={{ background: 'var(--text-tertiary)' }} />node <b>local</b></span>
</div>
<div className="flex-1" /> <div className="search-box ml-6 max-w-[300px]" style={{ height: 30 }}>
<Search size={13} />
<input type="text" placeholder="search…" className="v-mono" style={{ fontSize: 11.5 }} />
</div>
{isAdmin && !isDemoMode ? ( <div className="ml-auto flex items-center gap-4">
<span className="v-env">{isDemoMode ? 'env:demo' : 'env:production'}</span>
<button <button
type="button" type="button"
onClick={() => pullMutation.mutate()} onClick={() => setTheme((current) => (current === 'dark' ? 'light' : 'dark'))}
disabled={pullMutation.isPending || !upgradeQuery.data?.imageRef} className="flex h-8 w-8 items-center justify-center rounded-[var(--radius-md)] border text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
className="s-btn-accent" style={{ borderColor: 'var(--border-subtle)' }}
title={upgradeQuery.data?.message || 'Pull latest configured image'} title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
> >
{pullMutation.isPending ? <RefreshCw size={13} className="animate-spin" /> : <UploadCloud size={13} />} {theme === 'dark' ? <Sun size={15} /> : <Moon size={15} />}
Upgrade
</button> </button>
) : null}
{signedIn && !isDemoMode ? ( {isAdmin && !isDemoMode ? (
<div className="relative">
<button <button
type="button" type="button"
onClick={() => setNotificationsOpen((open) => !open)} onClick={() => pullMutation.mutate()}
className="shell-tic relative" disabled={pullMutation.isPending || !upgradeQuery.data?.imageRef}
aria-label="Notifications" className="inline-flex h-8 items-center gap-2 rounded-[var(--radius-md)] px-3 text-xs font-semibold text-[var(--accent-on)] disabled:cursor-not-allowed disabled:opacity-50"
style={{ background: 'var(--accent-primary)' }}
title={upgradeQuery.data?.message || 'Pull latest configured image'}
> >
<Bell size={16} /> {pullMutation.isPending ? <RefreshCw size={14} className="animate-spin" /> : <UploadCloud size={14} />}
{unreadCount > 0 ? <span className="shell-tic-dot" /> : null} Upgrade
</button> </button>
) : null}
{signedIn && !isDemoMode ? (
<div className="relative">
<button
type="button"
onClick={() => setNotificationsOpen((open) => !open)}
className="relative flex h-8 w-8 items-center justify-center rounded-[var(--radius-md)] border text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
style={{ borderColor: 'var(--border-subtle)' }}
aria-label="Notifications"
>
<Bell size={15} />
{unreadCount > 0 ? (
<span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-[var(--accent-primary)] px-1 text-[10px] font-semibold text-[var(--accent-on)]">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
) : null}
</button>
{notificationsOpen ? ( {notificationsOpen ? (
<div className="absolute right-0 top-10 z-50 w-80 rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)] p-3 shadow-2xl"> <div className="absolute right-0 top-10 z-50 w-80 rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)] p-3 shadow-2xl">
@@ -569,21 +566,13 @@ export function PlatformShell() {
) : null} ) : null}
</div> </div>
) : null} ) : null}
</div>
<button
type="button"
onClick={() => setTheme((current) => (current === 'dark' ? 'light' : 'dark'))}
className="shell-tic"
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
>
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
</button>
</header> </header>
<header className="md:hidden sticky top-0 z-50 border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/85 backdrop-blur-2xl"> <header className="md:hidden sticky top-0 z-50 border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/85 backdrop-blur-2xl">
<div className="flex items-center justify-between p-4"> <div className="flex items-center justify-between p-4">
<NavLink to={href('/')} className="flex items-center gap-3"> <NavLink to={href('/projects')} className="flex items-center gap-3">
<div className="shell-mark flex h-9 w-9 items-center justify-center"> <div className="flex h-9 w-9 items-center justify-center rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)]">
<Container size={16} /> <Container size={16} />
</div> </div>
<span className="font-headline font-semibold text-[var(--text-primary)]">Containr</span> <span className="font-headline font-semibold text-[var(--text-primary)]">Containr</span>
@@ -624,17 +613,6 @@ export function PlatformShell() {
</main> </main>
</div> </div>
</div> </div>
<CommandPalette
open={paletteOpen}
onClose={() => setPaletteOpen(false)}
onNavigate={paletteNavigate}
openTargets={paletteProjects.map((p) => ({
label: p.name,
target: `/projects/${p.id}`,
description: 'Project workspace',
}))}
/>
</div> </div>
); );
} }
@@ -6,7 +6,6 @@ import { type FilterConfig } from '@/lib/dynamic-filter';
import { useDynamicFilter, useFilterState } from '@/lib/use-dynamic-filter'; import { useDynamicFilter, useFilterState } from '@/lib/use-dynamic-filter';
import { formatRelative } from '@/lib/time'; import { formatRelative } from '@/lib/time';
import { DemoRestricted, FilterBar } from '@/shared/components'; import { DemoRestricted, FilterBar } from '@/shared/components';
import { QuietBtn, SCard, SPageHead, SPill } from '@/shared/components/sentry';
import { import {
Activity, AlertCircle, CheckCircle2, Database, GitBranch, Info, Key, Activity, AlertCircle, CheckCircle2, Database, GitBranch, Info, Key,
Loader2, Package, Projector, Rocket, Shield, User, Users, Wrench, Loader2, Package, Projector, Rocket, Shield, User, Users, Wrench,
@@ -25,12 +24,8 @@ const SEVERITY_STYLE: Record<string, { icon: typeof Info; cls: string; dot: stri
info: { icon: Info, cls: 'text-[var(--accent-primary)]', dot: 'bg-[var(--accent-primary)]' }, info: { icon: Info, cls: 'text-[var(--accent-primary)]', dot: 'bg-[var(--accent-primary)]' },
}; };
const SEV_TONE: Record<string, 'err' | 'warn' | 'ok' | 'info'> = {
error: 'err', warning: 'warn', success: 'ok', info: 'info',
};
const SCHEMA: FilterConfig<ActivityEntry>[] = [ const SCHEMA: FilterConfig<ActivityEntry>[] = [
{ key: 'q', label: 'events', type: 'search', searchFields: ['label', 'action', 'user_email'] }, { key: 'q', label: 'Search', type: 'search', searchFields: ['label', 'action', 'user_email'] },
{ {
key: 'severity', label: 'Severity', type: 'select', key: 'severity', label: 'Severity', type: 'select',
options: [ options: [
@@ -63,33 +58,46 @@ export function ActivityPage() {
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<div className="w-full px-4 pt-6 sm:px-8"> <div className="border-b border-[var(--border-subtle)]">
<SPageHead <div className="w-full px-8 py-5">
title="Activity" <h1 className="v-title">Activity<span className="v-cursor">_</span></h1>
titleAccent="_" <p className="v-mono mt-1.5 text-[11px] text-[var(--text-tertiary)]">
sub="Platform events across your visible projects" platform events across your visible projects
actions={query.isFetching ? <Loader2 size={14} className="animate-spin text-[var(--text-muted)]" /> : undefined} </p>
/> </div>
</div> </div>
<div className="px-4 sm:px-8 space-y-4"> <div className="px-8 py-6 space-y-4">
<SCard icon={<Activity />} title="Event Feed" accent={`${entries.length} events`} pad={false}> <div className="flex items-start gap-3">
<div className="px-4 pt-1 pb-3"> <div className="flex-1">
<FilterBar {...filter} /> <FilterBar {...filter} />
</div> </div>
<div className="divide-y divide-[var(--border-subtle)] border-t border-[var(--border-subtle)]"> {query.isFetching && <Loader2 size={14} className="animate-spin text-[var(--text-muted)] mt-3" />}
{query.isLoading && <p className="px-5 py-8 text-xs text-[var(--text-muted)]">Loading…</p>} </div>
{!query.isLoading && entries.length === 0 && (
<p className="px-5 py-8 text-xs text-[var(--text-muted)]">No events yet — activity appears here as you deploy, configure, and manage services.</p>
)}
{entries.map((e) => <Entry key={e.id} entry={e} />)}
</div>
</SCard>
<div className="flex items-center gap-2.5"> <div className="panel p-0 divide-y divide-[var(--border-subtle)]">
<QuietBtn disabled={page <= 1} onClick={() => setPage(page - 1)}>← newer</QuietBtn> {query.isLoading && <p className="px-5 py-8 text-xs text-[var(--text-muted)]">Loading…</p>}
<QuietBtn disabled={!query.data?.has_more} onClick={() => setPage(page + 1)}>older →</QuietBtn> {!query.isLoading && entries.length === 0 && (
<span className="v-mono text-[10.5px] text-[var(--text-muted)]">page {page}</span> <p className="px-5 py-8 text-xs text-[var(--text-muted)]">No events yet — activity appears here as you deploy, configure, and manage services.</p>
)}
{entries.map((e) => <Entry key={e.id} entry={e} />)}
</div>
<div className="flex items-center gap-3">
<button
disabled={page <= 1}
onClick={() => setPage(page - 1)}
className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--border-subtle)] text-[var(--text-muted)] disabled:opacity-40 hover:text-[var(--text-primary)]"
>
newer
</button>
<button
disabled={!query.data?.has_more}
onClick={() => setPage(page + 1)}
className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--border-subtle)] text-[var(--text-muted)] disabled:opacity-40 hover:text-[var(--text-primary)]"
>
older
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -100,14 +108,14 @@ function Entry({ entry }: { entry: ActivityEntry }) {
const Cat = CATEGORY_ICONS[entry.category] ?? Activity; const Cat = CATEGORY_ICONS[entry.category] ?? Activity;
const sev = SEVERITY_STYLE[entry.severity] ?? SEVERITY_STYLE.info; const sev = SEVERITY_STYLE[entry.severity] ?? SEVERITY_STYLE.info;
return ( return (
<div className="flex items-center gap-3 px-4 py-3 hover:bg-[var(--tint-03)] transition-colors"> <div className="flex items-center gap-3 px-5 py-3">
<div className={`s-ibox ${sev.cls}`} style={{ width: 30, height: 30 }}> <div className={`w-8 h-8 rounded-md bg-[var(--bg-elevated)] flex items-center justify-center ${sev.cls}`}>
<Cat size={14} /> <Cat size={15} />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-[13px] font-medium text-[var(--text-primary)] truncate">{entry.label || entry.action}</span> <span className="text-[13px] text-[var(--text-primary)] truncate">{entry.label || entry.action}</span>
<SPill tone={SEV_TONE[entry.severity] ?? 'info'}>{entry.severity}</SPill> <span className={`v-mono text-[9px] uppercase px-1.5 py-0.5 rounded ${sev.cls} bg-[var(--bg-elevated)]`}>{entry.severity}</span>
</div> </div>
<div className="v-mono text-[10px] text-[var(--text-muted)] mt-0.5"> <div className="v-mono text-[10px] text-[var(--text-muted)] mt-0.5">
{entry.category} · {entry.action}{entry.user_email ? ` · ${entry.user_email}` : ''} {entry.category} · {entry.action}{entry.user_email ? ` · ${entry.user_email}` : ''}
@@ -38,7 +38,6 @@ import {
import { useAuthSession } from '@/lib/use-auth-session'; import { useAuthSession } from '@/lib/use-auth-session';
import { useDemoMode } from '@/lib/demo-mode'; import { useDemoMode } from '@/lib/demo-mode';
import { DemoRestricted, LoadingState, useToast } from '@/shared/components'; import { DemoRestricted, LoadingState, useToast } from '@/shared/components';
import { GhostBtn, QuietBtn, SPageHead, SPill } from '@/shared/components/sentry';
const statLabels: Record<string, string> = { const statLabels: Record<string, string> = {
users: 'Users', users: 'Users',
@@ -200,7 +199,7 @@ export function AdminPage() {
return ( return (
<div className="mx-auto max-w-xl px-5 py-24 text-center"> <div className="mx-auto max-w-xl px-5 py-24 text-center">
<ShieldCheck size={28} className="mx-auto text-[var(--error)]" /> <ShieldCheck size={28} className="mx-auto text-[var(--error)]" />
<h1 className="mt-4 font-headline text-lg font-semibold text-[var(--text-primary)]">Admin access required</h1> <h1 className="mt-4 text-lg font-semibold text-[var(--text-primary)]">Admin access required</h1>
<p className="mt-2 text-sm text-[var(--text-secondary)]"> <p className="mt-2 text-sm text-[var(--text-secondary)]">
This area is restricted to platform administrators. This area is restricted to platform administrators.
</p> </p>
@@ -217,74 +216,74 @@ export function AdminPage() {
const invites = invitesQuery.data ?? []; const invites = invitesQuery.data ?? [];
return ( return (
<div className="w-full px-4 py-6 sm:px-8"> <div className="mx-auto max-w-6xl px-5 py-8">
<SPageHead <div className="mb-8">
title="Admin Console" <h1 className="text-[24px] font-bold tracking-tight text-[var(--text-primary)]">Admin console</h1>
titleAccent="_" <p className="mt-1 text-sm text-[var(--text-secondary)]">
sub="Platform overview, project approvals, and user management." Platform overview, project approvals, and user management.
/> </p>
</div>
<div className="grid grid-cols-4 gap-3.5 max-lg:grid-cols-3 max-sm:grid-cols-2"> <div className="grid grid-cols-4 gap-3 max-lg:grid-cols-3 max-sm:grid-cols-2">
{Object.entries(statLabels).map(([key, label]) => ( {Object.entries(statLabels).map(([key, label]) => (
<div key={key} className="s-stat"> <div
<div className="px-4 pb-1 pt-4"> key={key}
<p className="text-[10.5px] font-medium uppercase tracking-[0.08em] text-[var(--text-tertiary)]">{label}</p> className="rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)] px-4 py-3.5"
<p className="font-headline mt-3 text-[28px] font-bold leading-none tabular-nums text-[var(--text-primary)]"> >
{stats[key] ?? 0} <p className="text-[20px] font-bold tabular-nums text-[var(--text-primary)]">{stats[key] ?? 0}</p>
</p> <p className="mt-0.5 text-[10.5px] uppercase tracking-wider text-[var(--text-tertiary)]">{label}</p>
</div>
<div className="s-stat-foot mt-3">
<span>total</span>
</div>
</div> </div>
))} ))}
</div> </div>
<section className="s-card !p-0 mt-6 overflow-hidden"> <section className="mt-8">
<div className="s-cardhead border-b border-[var(--border-subtle)]"> <div className="mb-3 flex items-center gap-2">
<span className="s-ibox"><Clock /></span> <Clock size={15} className="text-[var(--warning)]" />
<h2 className="s-t">Pending Approvals</h2> <h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Pending approvals</h2>
<span className="s-trail"> {pending.length > 0 ? (
{pending.length > 0 ? <SPill tone="warn">{pending.length}</SPill> : null} <span className="rounded-full bg-[var(--warning-soft)] px-2 py-0.5 text-[10.5px] font-semibold text-[var(--warning)]">
</span> {pending.length}
</span>
) : null}
</div> </div>
{pending.length > 0 ? ( {pending.length > 0 ? (
<div> <div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)]">
{pending.map((project) => ( {pending.map((project) => (
<div <div
key={project.id} key={project.id}
className="flex items-center gap-4 border-b border-[var(--border-subtle)] px-4 py-3 last:border-b-0" className="flex items-center gap-4 border-b border-[var(--border-subtle)] px-4 py-3 last:border-b-0"
> >
<span className="s-ibox !h-7 !w-7"><FolderKanban /></span> <FolderKanban size={15} className="shrink-0 text-[var(--text-tertiary)]" />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<Link to={`/projects/${project.id}`} className="text-[13.5px] font-medium text-[var(--text-primary)] hover:underline"> <Link to={`/projects/${project.id}`} className="text-[13.5px] font-medium text-[var(--text-primary)] hover:underline">
{project.name} {project.name}
</Link> </Link>
<p className="truncate text-[11.5px] text-[var(--text-tertiary)]">{project.description || 'No description'}</p> <p className="truncate text-[11.5px] text-[var(--text-tertiary)]">{project.description || 'No description'}</p>
</div> </div>
<GhostBtn <button
type="button"
onClick={() => approvalMutation.mutate({ id: project.id, approved: true })} onClick={() => approvalMutation.mutate({ id: project.id, approved: true })}
disabled={approvalMutation.isPending} disabled={approvalMutation.isPending}
className="inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-3 py-1.5 text-[12px] font-semibold text-[var(--accent-on)] disabled:opacity-50"
> >
<CheckCircle2 size={13} /> Approve <CheckCircle2 size={13} /> Approve
</GhostBtn> </button>
</div> </div>
))} ))}
</div> </div>
) : ( ) : (
<p className="px-4 py-6 text-center text-[13px] text-[var(--text-tertiary)]"> <div className="rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)] px-4 py-6 text-center text-[13px] text-[var(--text-tertiary)]">
Nothing waiting for approval. Nothing waiting for approval.
</p> </div>
)} )}
</section> </section>
<section className="s-card !p-0 mt-6 overflow-hidden"> <section className="mt-8">
<div className="s-cardhead border-b border-[var(--border-subtle)]"> <div className="mb-3 flex items-center gap-2">
<span className="s-ibox"><FolderKanban /></span> <FolderKanban size={15} className="text-[var(--accent-primary)]" />
<h2 className="s-t">All Projects</h2> <h2 className="text-[15px] font-semibold text-[var(--text-primary)]">All projects</h2>
<span className="s-trail"><span className="s-chip" style={{ cursor: 'default' }}>{allProjects.length}</span></span>
</div> </div>
<div> <div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)]">
{allProjects.map((project) => ( {allProjects.map((project) => (
<div <div
key={project.id} key={project.id}
@@ -298,16 +297,22 @@ export function AdminPage() {
{project.stats?.service_count ?? 0} services · {project.stats?.deployment_count ?? 0} deploys {project.stats?.service_count ?? 0} services · {project.stats?.deployment_count ?? 0} deploys
</p> </p>
</div> </div>
<SPill tone={project.isApproved ? 'ok' : 'warn'}> <span
className={`rounded-full px-2.5 py-0.5 text-[10.5px] font-semibold ${
project.isApproved
? 'bg-[var(--success-soft)] text-[var(--success)]'
: 'bg-[var(--warning-soft)] text-[var(--warning)]'
}`}
>
{project.isApproved ? 'public' : 'pending'} {project.isApproved ? 'public' : 'pending'}
</SPill> </span>
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
setEditingProject(project); setEditingProject(project);
setEditForm({ name: project.name, description: project.description ?? '' }); setEditForm({ name: project.name, description: project.description ?? '' });
}} }}
className="s-icon-btn" className="inline-flex items-center gap-1 rounded-[var(--radius-md)] border border-[var(--border-default)] px-2.5 py-1.5 text-[12px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
title="Edit project" title="Edit project"
> >
<Pencil size={12} /> <Pencil size={12} />
@@ -320,17 +325,19 @@ export function AdminPage() {
} }
}} }}
disabled={deleteMutation.isPending} disabled={deleteMutation.isPending}
className="s-icon-btn !text-[var(--text-tertiary)] hover:!text-[var(--error)] disabled:opacity-40" 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 project" title="Delete project"
> >
<Trash2 size={12} /> <Trash2 size={12} />
</button> </button>
<QuietBtn <button
type="button"
onClick={() => approvalMutation.mutate({ id: project.id, approved: !project.isApproved })} onClick={() => approvalMutation.mutate({ id: project.id, approved: !project.isApproved })}
disabled={approvalMutation.isPending} disabled={approvalMutation.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"
> >
{project.isApproved ? 'Unapprove' : 'Approve'} {project.isApproved ? 'Unapprove' : 'Approve'}
</QuietBtn> </button>
</div> </div>
))} ))}
{allProjects.length === 0 ? ( {allProjects.length === 0 ? (
@@ -339,13 +346,12 @@ export function AdminPage() {
</div> </div>
</section> </section>
<section className="s-card !p-0 mt-6 overflow-hidden"> <section className="mt-8">
<div className="s-cardhead border-b border-[var(--border-subtle)]"> <div className="mb-3 flex items-center gap-2">
<span className="s-ibox"><Megaphone /></span> <Megaphone size={15} className="text-[var(--accent-primary)]" />
<h2 className="s-t">Announcements</h2> <h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Announcements</h2>
<span className="s-trail"><span className="s-chip" style={{ cursor: 'default' }}>{banners.length}</span></span>
</div> </div>
<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"> <div className="flex flex-wrap items-center gap-2 border-b border-[var(--border-subtle)] px-4 py-3">
<input <input
value={bannerForm.title} value={bannerForm.title}
@@ -369,12 +375,14 @@ export function AdminPage() {
<option value="warning">warning</option> <option value="warning">warning</option>
<option value="error">error</option> <option value="error">error</option>
</select> </select>
<GhostBtn <button
type="button"
onClick={() => bannerCreateMutation.mutate()} onClick={() => bannerCreateMutation.mutate()}
disabled={bannerCreateMutation.isPending || bannerForm.title.trim().length === 0} 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'} {bannerCreateMutation.isPending ? 'Publishing…' : 'Publish'}
</GhostBtn> </button>
</div> </div>
{banners.map((banner) => ( {banners.map((banner) => (
<div <div
@@ -388,12 +396,14 @@ export function AdminPage() {
{banner.body ? ` · ${banner.body}` : ''} {banner.body ? ` · ${banner.body}` : ''}
</p> </p>
</div> </div>
<QuietBtn <button
type="button"
onClick={() => bannerToggleMutation.mutate({ id: banner.id, active: !banner.active })} onClick={() => bannerToggleMutation.mutate({ id: banner.id, active: !banner.active })}
disabled={bannerToggleMutation.isPending} 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'} {banner.active ? 'Deactivate' : 'Activate'}
</QuietBtn> </button>
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
@@ -402,7 +412,7 @@ export function AdminPage() {
} }
}} }}
disabled={bannerDeleteMutation.isPending} disabled={bannerDeleteMutation.isPending}
className="s-icon-btn !text-[var(--text-tertiary)] hover:!text-[var(--error)] disabled:opacity-40" 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" title="Delete banner"
> >
<Trash2 size={12} /> <Trash2 size={12} />
@@ -415,13 +425,12 @@ export function AdminPage() {
</div> </div>
</section> </section>
<section className="s-card !p-0 mt-6 overflow-hidden"> <section className="mt-8">
<div className="s-cardhead border-b border-[var(--border-subtle)]"> <div className="mb-3 flex items-center gap-2">
<span className="s-ibox"><UserPlus /></span> <UserPlus size={15} className="text-[var(--accent-primary)]" />
<h2 className="s-t">Team Invites</h2> <h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Team invites</h2>
<span className="s-trail"><span className="s-chip" style={{ cursor: 'default' }}>{invites.length}</span></span>
</div> </div>
<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"> <div className="flex flex-wrap items-center gap-2 border-b border-[var(--border-subtle)] px-4 py-3">
<input <input
value={inviteEmail} value={inviteEmail}
@@ -430,25 +439,35 @@ export function AdminPage() {
type="email" 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)]" 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)]"
/> />
<GhostBtn <button
type="button"
onClick={() => inviteCreateMutation.mutate()} onClick={() => inviteCreateMutation.mutate()}
disabled={inviteCreateMutation.isPending} 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'} {inviteCreateMutation.isPending ? 'Creating…' : 'Create invite link'}
</GhostBtn> </button>
</div> </div>
{inviteLink ? ( {inviteLink ? (
<div className="flex items-center gap-2 border-b border-[var(--border-subtle)] bg-[var(--accent-primary-soft)] px-4 py-2.5"> <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="v-mono min-w-0 flex-1 truncate text-[12px] text-[var(--accent-primary)]">{inviteLink}</code> <code className="min-w-0 flex-1 truncate text-[12px] text-[var(--accent-primary)]">{inviteLink}</code>
<QuietBtn <button
type="button"
onClick={() => { onClick={() => {
navigator.clipboard?.writeText(inviteLink); navigator.clipboard?.writeText(inviteLink);
showToast('success', 'Link copied'); 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 <Copy size={11} /> Copy
</QuietBtn> </button>
<QuietBtn onClick={() => setInviteLink(null)}>Dismiss</QuietBtn> <button
type="button"
onClick={() => setInviteLink(null)}
className="text-[11px] text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]"
>
Dismiss
</button>
</div> </div>
) : null} ) : null}
{invites.map((invite) => { {invites.map((invite) => {
@@ -467,17 +486,26 @@ export function AdminPage() {
expires {new Date(invite.expires_at).toLocaleString()} expires {new Date(invite.expires_at).toLocaleString()}
</p> </p>
</div> </div>
<SPill tone={used ? 'off' : expired ? 'err' : 'ok'}> <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'} {used ? 'used' : expired ? 'expired' : 'open'}
</SPill> </span>
{!used ? ( {!used ? (
<QuietBtn <button
type="button"
onClick={() => inviteRevokeMutation.mutate(invite.id)} onClick={() => inviteRevokeMutation.mutate(invite.id)}
disabled={inviteRevokeMutation.isPending} disabled={inviteRevokeMutation.isPending}
className="hover:!text-[var(--error)] hover:!border-[var(--error)]" 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 Revoke
</QuietBtn> </button>
) : null} ) : null}
</div> </div>
); );
@@ -488,13 +516,12 @@ export function AdminPage() {
</div> </div>
</section> </section>
<section className="s-card !p-0 mt-6 mb-10 overflow-hidden"> <section className="mt-8 pb-10">
<div className="s-cardhead border-b border-[var(--border-subtle)]"> <div className="mb-3 flex items-center gap-2">
<span className="s-ibox"><Users /></span> <Users size={15} className="text-[var(--accent-tertiary)]" />
<h2 className="s-t">Users</h2> <h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Users</h2>
<span className="s-trail"><span className="s-chip" style={{ cursor: 'default' }}>{users.length}</span></span>
</div> </div>
<div> <div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)]">
{users.map((user) => ( {users.map((user) => (
<UserRow <UserRow
key={user.id} key={user.id}
@@ -513,29 +540,30 @@ export function AdminPage() {
{impersonation ? ( {impersonation ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
<div className="w-full max-w-lg s-card !p-0 overflow-hidden"> <div className="w-full max-w-lg overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--bg-base)] shadow-2xl">
<div className="s-cardhead border-b border-[var(--border-subtle)]"> <div className="border-b border-[var(--border-subtle)] px-5 py-4">
<span className="s-ibox"><ShieldCheck /></span> <h2 className="text-[15px] font-semibold text-[var(--text-primary)]">
<h2 className="s-t">Impersonating {impersonation.user.email}</h2> Impersonating {impersonation.user.email}
<span className="s-trail"> </h2>
<span className="v-mono text-[10.5px] text-[var(--text-muted)]"> <p className="text-xs text-[var(--text-tertiary)]">
expires {new Date(impersonation.expires_at).toLocaleString()} Token valid until {new Date(impersonation.expires_at).toLocaleString()} — audit-logged.
</span> </p>
</span>
</div> </div>
<div className="space-y-3 px-5 py-4"> <div className="space-y-3 px-5 py-4">
<div className="s-inset flex items-center gap-2 px-3 py-2.5"> <div className="flex items-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2.5">
<code className="v-mono min-w-0 flex-1 truncate text-[12px] text-[var(--text-primary)]"> <code className="min-w-0 flex-1 truncate text-[12px] text-[var(--text-primary)]">
{impersonation.token} {impersonation.token}
</code> </code>
<QuietBtn <button
type="button"
onClick={() => { onClick={() => {
navigator.clipboard?.writeText(impersonation.token); navigator.clipboard?.writeText(impersonation.token);
showToast('success', 'Token copied'); showToast('success', 'Token copied');
}} }}
className="inline-flex shrink-0 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 <Copy size={11} /> Copy
</QuietBtn> </button>
</div> </div>
<p className="text-[12px] leading-relaxed text-[var(--text-tertiary)]"> <p className="text-[12px] leading-relaxed text-[var(--text-tertiary)]">
Use it as a bearer token — e.g. <code className="text-[var(--text-secondary)]">CONTAINR_TOKEN=&lt;token&gt; Use it as a bearer token — e.g. <code className="text-[var(--text-secondary)]">CONTAINR_TOKEN=&lt;token&gt;
@@ -544,7 +572,13 @@ export function AdminPage() {
</p> </p>
</div> </div>
<div className="flex items-center justify-end gap-2 border-t border-[var(--border-subtle)] px-5 py-3.5"> <div className="flex items-center justify-end gap-2 border-t border-[var(--border-subtle)] px-5 py-3.5">
<GhostBtn onClick={() => setImpersonation(null)}>Done</GhostBtn> <button
type="button"
onClick={() => setImpersonation(null)}
className="rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-xs font-semibold text-[var(--accent-on)]"
>
Done
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -552,13 +586,10 @@ export function AdminPage() {
{editingProject ? ( {editingProject ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
<div className="w-full max-w-md s-card !p-0 overflow-hidden"> <div className="w-full max-w-md overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--bg-base)] shadow-2xl">
<div className="s-cardhead border-b border-[var(--border-subtle)]"> <div className="border-b border-[var(--border-subtle)] px-5 py-4">
<span className="s-ibox"><Pencil /></span> <h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Edit project</h2>
<h2 className="s-t">Edit Project</h2> <p className="text-xs text-[var(--text-tertiary)]">Admin edit — changes apply immediately.</p>
<span className="s-trail">
<span className="v-mono text-[10.5px] text-[var(--text-muted)]">admin edit</span>
</span>
</div> </div>
<div className="space-y-4 px-5 py-4"> <div className="space-y-4 px-5 py-4">
<div> <div>
@@ -580,13 +611,21 @@ export function AdminPage() {
</div> </div>
</div> </div>
<div className="flex items-center justify-end gap-2 border-t border-[var(--border-subtle)] px-5 py-3.5"> <div className="flex items-center justify-end gap-2 border-t border-[var(--border-subtle)] px-5 py-3.5">
<QuietBtn onClick={() => setEditingProject(null)}>Cancel</QuietBtn> <button
<GhostBtn type="button"
onClick={() => setEditingProject(null)}
className="rounded-[var(--radius-md)] px-3.5 py-2 text-xs font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-muted)]"
>
Cancel
</button>
<button
type="button"
onClick={() => editMutation.mutate()} onClick={() => editMutation.mutate()}
disabled={editMutation.isPending || editForm.name.trim().length < 2} disabled={editMutation.isPending || editForm.name.trim().length < 2}
className="rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-xs font-semibold text-[var(--accent-on)] disabled:opacity-50"
> >
{editMutation.isPending ? 'Saving…' : 'Save'} {editMutation.isPending ? 'Saving…' : 'Save'}
</GhostBtn> </button>
</div> </div>
</div> </div>
</div> </div>
@@ -621,22 +660,32 @@ function UserRow({
<p className="truncate text-[11.5px] text-[var(--text-tertiary)]">{user.email}</p> <p className="truncate text-[11.5px] text-[var(--text-tertiary)]">{user.email}</p>
</div> </div>
{user.is_admin ? ( {user.is_admin ? (
<SPill tone="info"><ShieldCheck size={10} /> admin</SPill> <span className="inline-flex items-center gap-1 rounded-full bg-[var(--accent-primary-soft)] px-2.5 py-0.5 text-[10.5px] font-semibold text-[var(--accent-primary)]">
<ShieldCheck size={11} /> admin
</span>
) : null} ) : null}
{onImpersonate ? ( {onImpersonate ? (
<QuietBtn onClick={onImpersonate}>Impersonate</QuietBtn> <button
type="button"
onClick={onImpersonate}
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)]"
>
Impersonate
</button>
) : null} ) : null}
<QuietBtn <button
type="button"
onClick={() => onToggle(!user.is_admin)} onClick={() => onToggle(!user.is_admin)}
disabled={pending || isSelf} disabled={pending || isSelf}
title={isSelf ? 'You cannot change your own admin flag' : undefined} title={isSelf ? 'You cannot change your own admin flag' : undefined}
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:cursor-not-allowed disabled:opacity-40"
> >
{user.is_admin ? ( {user.is_admin ? (
<span className="inline-flex items-center gap-1"><XCircle size={12} /> Revoke</span> <span className="inline-flex items-center gap-1"><XCircle size={12} /> Revoke</span>
) : ( ) : (
'Make admin' 'Make admin'
)} )}
</QuietBtn> </button>
</div> </div>
); );
} }
@@ -6,9 +6,7 @@ import { type FilterConfig } from '@/lib/dynamic-filter';
import { useDynamicFilter } from '@/lib/use-dynamic-filter'; import { useDynamicFilter } from '@/lib/use-dynamic-filter';
import { formatDate, formatRelative } from '@/lib/time'; import { formatDate, formatRelative } from '@/lib/time';
import { DemoRestricted, FilterBar } from '@/shared/components'; import { DemoRestricted, FilterBar } from '@/shared/components';
import { QuietBtn, SCard, SPageHead, SPill, STable, type SCol } from '@/shared/components/sentry';
import { ScrollText, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'; import { ScrollText, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';
import type { AuditLogEntity } from '@/lib/api-client';
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
@@ -74,101 +72,109 @@ export function AuditLogsPage() {
const hasNext = logs.length === PAGE_SIZE; const hasNext = logs.length === PAGE_SIZE;
const hasFilters = filter.activeCount > 0; const hasFilters = filter.activeCount > 0;
const cols: SCol<AuditLogEntity>[] = [
{
key: 'time', label: 'Time', width: '100px', sortable: false,
render: (log) => (
<span className="v-mono text-[11px] text-[var(--text-tertiary)]" title={log.createdAt ? formatDate(log.createdAt) : undefined}>
{log.createdAt ? formatRelative(log.createdAt) : '—'}
</span>
),
},
{
key: 'actor', label: 'Actor', width: 'minmax(140px,1.1fr)',
sortValue: (log) => log.userEmail ?? log.userId ?? 'system',
render: (log) => (
<span className="text-[12px] text-[var(--text-primary)] truncate">
{log.userEmail || (log.userId ? log.userId.slice(0, 8) : 'system')}
</span>
),
},
{
key: 'resource', label: 'Resource', width: 'minmax(120px,1fr)',
sortValue: (log) => log.resource ?? '',
render: (log) => <span className="v-mono text-[11.5px] text-[var(--text-secondary)]">{log.resource}</span>,
},
{
key: 'action', label: 'Action', width: 'minmax(130px,1fr)',
sortValue: (log) => log.action ?? '',
render: (log) => <SPill tone="info">{log.action}</SPill>,
},
{
key: 'rid', label: 'Resource ID', width: 'minmax(110px,0.8fr)', sortable: false,
render: (log) => (
<span className="v-mono text-[10.5px] text-[var(--text-tertiary)] truncate block" title={log.resourceId}>
{log.resourceId || '—'}
</span>
),
},
{
key: 'ip', label: 'IP', width: '110px', sortable: false,
render: (log) => <span className="v-mono text-[10.5px] text-[var(--text-tertiary)]">{log.ipAddress?.replace(/\/\d+$/, '') || '—'}</span>,
},
{
key: 'details', label: 'Details', width: 'minmax(160px,1.2fr)', sortable: false,
render: (log) => (
<span className="v-mono text-[10.5px] text-[var(--text-tertiary)] truncate block" title={log.details}>
{log.details && log.details !== '{}' ? log.details : '—'}
</span>
),
},
];
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<div className="w-full px-4 pt-6 sm:px-8"> <div className="border-b border-[var(--border-subtle)]">
<SPageHead <div className="w-full px-8 py-5">
title="Audit Logs" <h1 className="v-title">
titleAccent="_" Audit Logs<span className="v-cursor">_</span>
sub="Every authenticated action recorded by the platform" </h1>
/> <p className="v-mono mt-1.5 text-[11px] text-[var(--text-tertiary)]">
Every authenticated action recorded by the platform
</p>
</div>
</div> </div>
<div className="w-full px-4 sm:px-8"> <div className="w-full px-8 py-6">
<SCard icon={<ScrollText />} title="Event Log" accent={`page ${page}`} pad={false}> <section className="panel p-6">
<div className="px-4 pt-1 pb-3"> <FilterBar {...filter} />
<FilterBar {...filter} />
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--border-subtle)] text-left">
<th className="px-3 py-2.5 font-medium uppercase tracking-wider text-xs text-[var(--text-muted)]">Time</th>
<th className="px-3 py-2.5 font-medium uppercase tracking-wider text-xs text-[var(--text-muted)]">Actor</th>
<th className="px-3 py-2.5 font-medium uppercase tracking-wider text-xs text-[var(--text-muted)]">Resource</th>
<th className="px-3 py-2.5 font-medium uppercase tracking-wider text-xs text-[var(--text-muted)]">Action</th>
<th className="px-3 py-2.5 font-medium uppercase tracking-wider text-xs text-[var(--text-muted)]">Resource ID</th>
<th className="px-3 py-2.5 font-medium uppercase tracking-wider text-xs text-[var(--text-muted)]">IP</th>
<th className="px-3 py-2.5 font-medium uppercase tracking-wider text-xs text-[var(--text-muted)]">Details</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border-subtle)]">
{logsQuery.isLoading ? (
<tr>
<td colSpan={7} className="px-4 py-10 text-center text-[var(--text-muted)]">
<Loader2 size={18} className="animate-spin inline-block" />
</td>
</tr>
) : logsQuery.isError ? (
<tr>
<td colSpan={7} className="px-4 py-10 text-center text-[var(--error)]">
Failed to load audit logs.
</td>
</tr>
) : logs.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-10 text-center">
<ScrollText size={20} className="inline-block mb-2 text-[var(--text-muted)]" />
<p className="text-[var(--text-secondary)]">No audit events{hasFilters ? ' match these filters' : ' recorded yet'}.</p>
</td>
</tr>
) : (
logs.map((log) => (
<tr key={log.id} className="hover:bg-[var(--surface-muted)]/50 transition-colors">
<td className="px-3 py-2.5 whitespace-nowrap text-[var(--text-tertiary)]" title={log.createdAt ? formatDate(log.createdAt) : undefined}>
{log.createdAt ? formatRelative(log.createdAt) : '—'}
</td>
<td className="px-3 py-2.5 text-[var(--text-primary)]">
{log.userEmail || (log.userId ? log.userId.slice(0, 8) : 'system')}
</td>
<td className="px-3 py-2.5 mono text-[var(--text-secondary)]">{log.resource}</td>
<td className="px-3 py-2.5">
<span className="px-2 py-0.5 rounded-[var(--radius-sm)] bg-[var(--accent-primary-soft)] text-xs font-medium text-[var(--accent-primary)]">
{log.action}
</span>
</td>
<td className="px-3 py-2.5 mono text-xs text-[var(--text-tertiary)] max-w-[140px] truncate" title={log.resourceId}>
{log.resourceId || '—'}
</td>
<td className="px-3 py-2.5 mono text-xs text-[var(--text-tertiary)]">{log.ipAddress || '—'}</td>
<td className="px-3 py-2.5 mono text-xs text-[var(--text-tertiary)] max-w-[220px] truncate" title={log.details}>
{log.details && log.details !== '{}' ? log.details : '—'}
</td>
</tr>
))
)}
</tbody>
</table>
</div> </div>
<div className="border-t border-[var(--border-subtle)] px-4 overflow-x-auto">
{logsQuery.isLoading ? ( <div className="mt-4 flex items-center justify-between">
<div className="py-10 text-center text-[var(--text-muted)]"> <p className="text-xs text-[var(--text-muted)]">
<Loader2 size={18} className="animate-spin inline-block" />
</div>
) : logsQuery.isError ? (
<div className="py-10 text-center text-[var(--error)]">Failed to load audit logs.</div>
) : logs.length === 0 ? (
<div className="py-10 text-center">
<div className="s-ibox mx-auto mb-2"><ScrollText /></div>
<p className="text-[var(--text-secondary)] text-sm">No audit events{hasFilters ? ' match these filters' : ' recorded yet'}.</p>
</div>
) : (
<STable cols={cols} rows={logs} rowKey={(l) => l.id} />
)}
</div>
<div className="flex items-center justify-between px-4 py-3 border-t border-[var(--border-subtle)]">
<p className="v-mono text-[10.5px] text-[var(--text-muted)]">
Page {page}{logs.length > 0 ? ` — ${logs.length} entries` : ''} Page {page}{logs.length > 0 ? ` — ${logs.length} entries` : ''}
</p> </p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<QuietBtn onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page <= 1}> <button
<ChevronLeft size={12} /> Prev onClick={() => setPage((p) => Math.max(1, p - 1))}
</QuietBtn> disabled={page <= 1}
<QuietBtn onClick={() => setPage((p) => p + 1)} disabled={!hasNext}> className="flex items-center gap-1 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] disabled:opacity-40 transition-colors"
Next <ChevronRight size={12} /> >
</QuietBtn> <ChevronLeft size={12} />
Prev
</button>
<button
onClick={() => setPage((p) => p + 1)}
disabled={!hasNext}
className="flex items-center gap-1 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] disabled:opacity-40 transition-colors"
>
Next
<ChevronRight size={12} />
</button>
</div> </div>
</div> </div>
</SCard> </section>
</div> </div>
</div> </div>
); );
+46 -27
View File
@@ -13,16 +13,15 @@ import {
} from '@/lib/auth-client'; } from '@/lib/auth-client';
import { acceptInvite, getInvite } from '@/lib/api-client'; import { acceptInvite, getInvite } from '@/lib/api-client';
import { useAuthSession } from '@/lib/use-auth-session'; import { useAuthSession } from '@/lib/use-auth-session';
import { GhostBtn, QuietBtn } from '@/shared/components/sentry';
function sanitizeRedirect(raw: string | null): string { function sanitizeRedirect(raw: string | null): string {
if (!raw) { if (!raw) {
return '/'; return '/projects';
} }
const value = raw.trim(); const value = raw.trim();
if (!value.startsWith('/') || value.startsWith('//')) { if (!value.startsWith('/') || value.startsWith('//')) {
return '/'; return '/projects';
} }
return value; return value;
@@ -55,11 +54,9 @@ function AuthCard({
}) { }) {
return ( return (
<div className="w-full max-w-[400px]"> <div className="w-full max-w-[400px]">
<div className="s-card shadow-2xl shadow-black/50"> <div className="rounded-[var(--radius-xl)] border border-[var(--border-subtle)] bg-[var(--surface-card)]/95 p-8 shadow-2xl shadow-black/50 backdrop-blur-xl">
<div className="s-cardhead mb-1"> <h2 className="font-headline text-xl font-semibold text-[var(--text-primary)]">{title}</h2>
<h2 className="font-headline text-[17px] font-semibold text-[var(--text-primary)]">{title}</h2> <p className="mb-6 mt-1.5 text-sm text-[var(--text-secondary)]">{subtitle}</p>
</div>
<p className="mb-6 v-mono text-[11.5px] text-[var(--text-tertiary)]">{subtitle}</p>
{children} {children}
</div> </div>
</div> </div>
@@ -70,14 +67,19 @@ function AuthLayout({ children }: { children: React.ReactNode }) {
return ( return (
<div className="relative min-h-screen overflow-hidden bg-[var(--bg-void)]"> <div className="relative min-h-screen overflow-hidden bg-[var(--bg-void)]">
<div className="subtle-grid absolute inset-0" /> <div className="subtle-grid absolute inset-0" />
<div className="ambient-glow" />
<div
className="absolute left-1/2 top-0 h-[420px] w-[720px] -translate-x-1/2 -translate-y-1/2 rounded-full"
style={{ background: 'radial-gradient(ellipse at center, rgba(180,227,74,0.10) 0%, transparent 65%)' }}
/>
<div className="relative flex min-h-screen flex-col items-center justify-center px-5 py-10"> <div className="relative flex min-h-screen flex-col items-center justify-center px-5 py-10">
<div className="mb-8 flex flex-col items-center"> <div className="mb-8 flex flex-col items-center">
<img src="/containr.svg" alt="Containr" className="h-14 w-14" /> <img src="/containr.svg" alt="Containr" className="h-14 w-14" />
<span className="mt-4 font-headline text-2xl font-bold tracking-tight text-[var(--text-primary)]">Containr</span> <span className="mt-4 font-headline text-2xl font-bold tracking-tight text-[var(--text-primary)]">Containr</span>
<span className="mt-1 v-mono text-[11px] uppercase tracking-[0.14em] text-[var(--text-muted)]">Self-hosted container platform</span> <span className="mt-1 text-xs text-[var(--text-muted)]">Self-hosted container platform</span>
</div> </div>
{children} {children}
<p className="mt-8 text-center v-mono text-[10.5px] text-[var(--text-muted)]"> <p className="mt-8 text-center text-[11px] text-[var(--text-muted)]">
Sessions are cookie-based · first account becomes platform owner Sessions are cookie-based · first account becomes platform owner
</p> </p>
</div> </div>
@@ -87,8 +89,8 @@ function AuthLayout({ children }: { children: React.ReactNode }) {
function AuthErrorNotice({ message }: { message: string }) { function AuthErrorNotice({ message }: { message: string }) {
return ( return (
<div className="mb-4 s-inset flex items-start gap-2 !border-[var(--error)]/30 px-3 py-2.5 text-[12.5px] text-[var(--error)]"> <div className="mb-4 flex items-start gap-2 rounded-[var(--radius-md)] border border-[var(--error-soft)] bg-[var(--error-soft)] px-3 py-2.5 text-sm text-[var(--error)]">
<AlertCircle size={15} className="mt-0.5 shrink-0" /> <AlertCircle size={16} className="mt-0.5 shrink-0" />
<span>{message}</span> <span>{message}</span>
</div> </div>
); );
@@ -96,7 +98,7 @@ function AuthErrorNotice({ message }: { message: string }) {
function AuthInfoNotice({ message }: { message: string }) { function AuthInfoNotice({ message }: { message: string }) {
return ( return (
<div className="mb-4 s-inset !border-[var(--success)]/30 px-3 py-2.5 text-[12.5px] text-[var(--success)]"> <div className="mb-4 rounded-[var(--radius-md)] border border-[var(--success-soft)] bg-[var(--success-soft)] px-3 py-2.5 text-sm text-[var(--success)]">
{message} {message}
</div> </div>
); );
@@ -204,10 +206,15 @@ export function SignInPage() {
/> />
</label> </label>
<GhostBtn type="submit" disabled={isSubmitting || sessionQuery.isPending} className="!h-10 w-full justify-center"> <button
type="submit"
disabled={isSubmitting || sessionQuery.isPending}
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" /> : <Mail size={15} />} {isSubmitting ? <Loader2 size={15} className="animate-spin" /> : <Mail size={15} />}
Continue Continue
</GhostBtn> </button>
</form> </form>
{providers.length > 0 ? ( {providers.length > 0 ? (
@@ -220,22 +227,24 @@ export function SignInPage() {
<div className="grid gap-2 sm:grid-cols-2"> <div className="grid gap-2 sm:grid-cols-2">
{providers.includes('github') ? ( {providers.includes('github') ? (
<QuietBtn <button
type="button"
onClick={() => void signInWithGitHubProvider()} onClick={() => void signInWithGitHubProvider()}
className="!h-10 justify-center text-sm" className="inline-flex h-10 items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm font-medium text-[var(--text-primary)] transition-colors hover:border-[var(--border-default)]"
> >
<Github size={15} /> <Github size={15} />
GitHub GitHub
</QuietBtn> </button>
) : null} ) : null}
{providers.includes('google') ? ( {providers.includes('google') ? (
<QuietBtn <button
type="button"
onClick={() => void signInWithGoogleProvider()} onClick={() => void signInWithGoogleProvider()}
className="!h-10 justify-center text-sm" className="inline-flex h-10 items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm font-medium text-[var(--text-primary)] transition-colors hover:border-[var(--border-default)]"
> >
<GoogleMark /> <GoogleMark />
Google Google
</QuietBtn> </button>
) : null} ) : null}
</div> </div>
</> </>
@@ -271,7 +280,7 @@ export function AcceptInvitePage() {
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
if (sessionQuery.data) { if (sessionQuery.data) {
return <Navigate to="/" replace />; return <Navigate to="/projects" replace />;
} }
const boundEmail = inviteQuery.data?.email ?? undefined; const boundEmail = inviteQuery.data?.email ?? undefined;
@@ -288,7 +297,7 @@ export function AcceptInvitePage() {
// account, sign-in creates the browser session. // account, sign-in creates the browser session.
await signInWithEmail(effectiveEmail.trim(), password); await signInWithEmail(effectiveEmail.trim(), password);
await queryClient.invalidateQueries({ queryKey: ['auth-session'] }); await queryClient.invalidateQueries({ queryKey: ['auth-session'] });
navigate('/', { replace: true }); navigate('/projects', { replace: true });
} catch (exception) { } catch (exception) {
setError(exception instanceof Error ? exception.message : 'Failed to accept invite'); setError(exception instanceof Error ? exception.message : 'Failed to accept invite');
} finally { } finally {
@@ -360,10 +369,15 @@ export function AcceptInvitePage() {
/> />
</label> </label>
<GhostBtn type="submit" disabled={isSubmitting} className="!h-10 w-full justify-center"> <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} />} {isSubmitting ? <Loader2 size={15} className="animate-spin" /> : <User2 size={15} />}
Accept invite Accept invite
</GhostBtn> </button>
</form> </form>
) : null} ) : null}
@@ -473,10 +487,15 @@ export function SignUpPage() {
/> />
</label> </label>
<GhostBtn type="submit" disabled={isSubmitting || sessionQuery.isPending} className="!h-10 w-full justify-center"> <button
type="submit"
disabled={isSubmitting || sessionQuery.isPending}
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} />} {isSubmitting ? <Loader2 size={15} className="animate-spin" /> : <User2 size={15} />}
Create Account Create Account
</GhostBtn> </button>
</form> </form>
<div className="mt-5 flex items-center justify-between text-xs text-[var(--text-secondary)]"> <div className="mt-5 flex items-center justify-between text-xs text-[var(--text-secondary)]">
@@ -14,18 +14,6 @@ import { useDynamicFilter, useFilterState } from '@/lib/use-dynamic-filter';
import { FilterBar } from '@/shared/components'; import { FilterBar } from '@/shared/components';
import { demoBuilds } from '@/lib/demo-data'; import { demoBuilds } from '@/lib/demo-data';
import { formatRelative } from '@/lib/time'; import { formatRelative } from '@/lib/time';
import {
SCard,
SPageHead,
SPill,
SStat,
STable,
IconBtn,
QuietBtn,
Ticks,
type SCol,
} from '@/shared/components/sentry';
import { statusTone } from '@/shared/components/sentry-utils';
import { import {
Check, Check,
X, X,
@@ -36,6 +24,8 @@ import {
Box, Box,
Sparkles, Sparkles,
Filter, Filter,
Circle,
HardDrive,
} from 'lucide-react'; } from 'lucide-react';
const statusOptions: Array<{ value: '' | BuildStatus; label: string }> = [ const statusOptions: Array<{ value: '' | BuildStatus; label: string }> = [
@@ -60,11 +50,26 @@ const BUILD_SCHEMA: FilterConfig<BuildEntity>[] = [
}, },
]; ];
function BuildIcon({ status }: { status: BuildStatus }) { function StatusBadge({ status }: { status: BuildStatus }) {
if (status === 'success') return <Check size={14} className="text-[var(--success)]" />; const config = {
if (status === 'failed') return <X size={14} className="text-[var(--error)]" />; success: { color: 'var(--success)', bg: 'var(--success-soft)', Icon: Check, animate: false },
if (status === 'running') return <Loader2 size={14} className="text-[var(--warning)] animate-spin" />; failed: { color: 'var(--error)', bg: 'var(--error-soft)', Icon: X, animate: false },
return <Clock size={14} className="text-[var(--text-tertiary)]" />; running: { color: 'var(--warning)', bg: 'var(--warning-soft)', Icon: Loader2, animate: true },
pending: { color: 'var(--text-tertiary)', bg: 'var(--surface-muted)', Icon: Clock, animate: false },
cancelled: { color: 'var(--text-tertiary)', bg: 'var(--surface-muted)', Icon: X, animate: false },
}[status] || { color: 'var(--text-tertiary)', bg: 'var(--surface-muted)', Icon: Circle, animate: false };
const { Icon } = config;
return (
<div
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium"
style={{ background: config.bg, color: config.color }}
>
<Icon size={12} className={config.animate ? 'animate-spin' : ''} />
{status}
</div>
);
} }
function bytesToHumanReadable(bytes: number): string { function bytesToHumanReadable(bytes: number): string {
@@ -144,113 +149,109 @@ export function BuildsPage() {
return { running, success, failed, total: builds.length }; return { running, success, failed, total: builds.length };
}, [builds]); }, [builds]);
const buildCols: SCol<BuildEntity>[] = [
{
key: 'build', label: 'Build', width: 'minmax(220px,1.4fr)',
sortValue: (b) => b.id,
render: (b) => (
<span className="flex items-center gap-2.5 min-w-0">
<span className="s-ibox"><BuildIcon status={b.status} /></span>
<span className="min-w-0">
<span className="block v-mono text-[12px] font-medium text-[var(--text-primary)] truncate">{b.id}</span>
<span className="block v-mono text-[10.5px] text-[var(--text-tertiary)] truncate">
{b.imageName || '—'}{b.imageTag ? `:${b.imageTag}` : ''}
</span>
</span>
</span>
),
},
{
key: 'status', label: 'Status', width: '110px',
sortValue: (b) => b.status,
render: (b) => <SPill tone={statusTone(b.status)}>{b.status}</SPill>,
},
{
key: 'progress', label: 'Progress', width: '1fr',
sortValue: (b) => b.progress,
render: (b) =>
b.status === 'running' || b.status === 'pending' ? (
<span className="flex items-center gap-2.5">
<Ticks pct={b.progress} count={40} />
<span className="v-mono text-[10.5px] text-[var(--text-tertiary)]">{b.progress}%</span>
</span>
) : (
<span className="v-mono text-[10.5px] text-[var(--text-muted)]">—</span>
),
},
{
key: 'size', label: 'Size', width: '90px',
sortValue: (b) => b.size,
render: (b) => <span className="v-mono text-[11.5px] text-[var(--text-secondary)]">{bytesToHumanReadable(b.size)}</span>,
},
{
key: 'service', label: 'Service', width: '1fr',
sortValue: (b) => b.serviceId ?? '',
render: (b) => <span className="v-mono text-[11px] text-[var(--text-tertiary)] truncate">{b.serviceId || '—'}</span>,
},
{
key: 'when', label: 'Started', width: '90px',
sortValue: (b) => b.startedAt ?? '',
render: (b) => <span className="v-mono text-[11px] text-[var(--text-tertiary)]">{b.startedAt ? formatRelative(b.startedAt) : '—'}</span>,
},
];
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<div className="w-full px-4 pt-6 sm:px-8"> {/* Header */}
<SPageHead <div className="border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/50 backdrop-blur-sm">
title="Build Pipeline" <div className="w-full px-8 py-4">
titleAccent="_" <div className="flex items-center justify-between gap-4">
sub="Monitor build progress and manage jobs" <div>
actions={ <h1 className="v-title">Build Pipeline<span className="v-cursor">_</span></h1>
<> <p className="text-sm text-[var(--text-secondary)]">Monitor build progress and manage jobs</p>
<div className="flex items-center gap-2 text-[11px] v-mono"> </div>
<div className={`w-1.5 h-1.5 rounded-full ${liveStatus === 'live' ? 'bg-[var(--success)] animate-pulse' : 'bg-[var(--text-muted)]'}`} />
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 text-sm">
<div className={`w-2 h-2 rounded-full ${liveStatus === 'live' ? 'bg-[var(--success)] animate-pulse' : 'bg-[var(--text-muted)]'}`} />
<span className={liveStatus === 'live' ? 'text-[var(--success)]' : 'text-[var(--text-muted)]'}> <span className={liveStatus === 'live' ? 'text-[var(--success)]' : 'text-[var(--text-muted)]'}>
{liveStatus === 'live' ? 'live' : liveStatus === 'offline' ? 'reconnecting' : 'polling'} {liveStatus === 'live' ? 'Live' : liveStatus === 'offline' ? 'Reconnecting...' : 'Polling'}
</span> </span>
</div> </div>
<IconBtn onClick={() => queryClient.invalidateQueries({ queryKey: ['builds-page'] })} title="Refresh builds"> <button
onClick={() => queryClient.invalidateQueries({ queryKey: ['builds-page'] })}
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm font-medium hover:border-[var(--border-default)] transition-colors"
>
<RefreshCw size={14} /> <RefreshCw size={14} />
</IconBtn> Refresh
</> </button>
} </div>
/> </div>
</div>
</div> </div>
{/* Demo Mode Banner */} {/* Demo Mode Banner */}
{isDemoMode && ( {isDemoMode && (
<div className="w-full px-8 py-4"> <div className="w-full px-8 py-4">
<div className="s-inset flex items-center gap-2 text-xs text-[var(--warning)]"> <div className="px-4 py-3 rounded-[var(--radius-md)] border border-[var(--warning-soft)] bg-[var(--warning-soft)]/50">
<Sparkles size={13} /> <div className="flex items-center gap-2 text-sm text-[var(--warning)]">
Demo mode active — using sample data <Sparkles size={16} />
<span>Demo mode active — using sample data</span>
</div>
</div> </div>
</div> </div>
)} )}
<div className="w-full px-4 py-6 sm:px-8 space-y-4"> {/* Stats Overview */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3.5"> <div className="w-full px-8 py-6">
<SStat icon={<Box />} label="Total Builds" value={stats.total} foot="all jobs" /> <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<SStat icon={<Loader2 />} label="In Progress" value={stats.running} foot="building now" /> <div className="panel p-4">
<SStat icon={<Check />} label="Successful" value={stats.success} foot="completed ok" /> <div className="flex items-center justify-between mb-3">
<SStat icon={<X />} label="Failed" value={stats.failed} foot="need attention" /> <div className="w-8 h-8 rounded-[var(--radius-sm)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<Box size={16} className="text-[var(--accent-primary)]" />
</div>
</div>
<p className="text-2xl font-semibold text-[var(--text-primary)]">{stats.total}</p>
<p className="text-xs text-[var(--text-muted)] mt-1">Total Builds</p>
</div>
<div className="panel p-4">
<div className="flex items-center justify-between mb-3">
<div className="w-8 h-8 rounded-[var(--radius-sm)] bg-[var(--warning-soft)] flex items-center justify-center">
<Loader2 size={16} className="text-[var(--warning)]" />
</div>
</div>
<p className="text-2xl font-semibold text-[var(--warning)]">{stats.running}</p>
<p className="text-xs text-[var(--text-muted)] mt-1">In Progress</p>
</div>
<div className="panel p-4">
<div className="flex items-center justify-between mb-3">
<div className="w-8 h-8 rounded-[var(--radius-sm)] bg-[var(--success-soft)] flex items-center justify-center">
<Check size={16} className="text-[var(--success)]" />
</div>
</div>
<p className="text-2xl font-semibold text-[var(--success)]">{stats.success}</p>
<p className="text-xs text-[var(--text-muted)] mt-1">Successful</p>
</div>
<div className="panel p-4">
<div className="flex items-center justify-between mb-3">
<div className="w-8 h-8 rounded-[var(--radius-sm)] bg-[var(--error-soft)] flex items-center justify-center">
<X size={16} className="text-[var(--error)]" />
</div>
</div>
<p className="text-2xl font-semibold text-[var(--error)]">{stats.failed}</p>
<p className="text-xs text-[var(--text-muted)] mt-1">Failed</p>
</div>
</div> </div>
</div>
<SCard icon={<Filter />} title="Filters" {/* Filters */}
trail={<span className="v-mono text-[10.5px] text-[var(--text-muted)]">{builds.length} jobs shown</span>} <div className="w-full px-8">
> <div className="panel p-4">
<div className="flex flex-wrap items-end gap-3"> <div className="flex items-center gap-2">
<div className="flex-1 min-w-[280px]"> <Filter size={16} className="text-[var(--text-tertiary)]" />
<span className="text-sm font-medium text-[var(--text-secondary)]">Filters</span>
</div>
<div className="flex flex-wrap items-end gap-3 mt-3">
<div className="flex-1">
<FilterBar {...filter} /> <FilterBar {...filter} />
</div> </div>
<div> <div>
<label className="block text-[10.5px] font-medium uppercase tracking-wider text-[var(--text-muted)] mb-1.5"> <label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
Page Size Page Size
</label> </label>
<select <select
value={String(limit)} value={String(limit)}
onChange={(e) => setLimit(Number(e.target.value))} onChange={(e) => setLimit(Number(e.target.value))}
className="s-chip !cursor-pointer appearance-none pr-6" className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm focus:border-[var(--accent-primary)] transition-colors"
> >
<option value="20">20</option> <option value="20">20</option>
<option value="50">50</option> <option value="50">50</option>
@@ -258,41 +259,127 @@ export function BuildsPage() {
</select> </select>
</div> </div>
</div> </div>
</SCard> </div>
</div>
<SCard icon={<Box />} title="Build Queue" accent={`${builds.length} jobs`} pad={false} {/* Build Table */}
trail={ <div className="w-full px-8 py-6">
<IconBtn onClick={() => queryClient.invalidateQueries({ queryKey: ['builds-page'] })} title="Refresh"> <div className="panel overflow-hidden">
<RefreshCw size={13} />
</IconBtn>
}
>
{!isDemoMode && buildsQuery.isLoading ? ( {!isDemoMode && buildsQuery.isLoading ? (
<div className="p-12 text-center"> <div className="p-12 text-center">
<Loader2 size={24} className="animate-spin mx-auto text-[var(--text-tertiary)]" /> <Loader2 size={24} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
<p className="mt-3 text-sm text-[var(--text-muted)]">Loading builds...</p> <p className="mt-3 text-sm text-[var(--text-muted)]">Loading builds...</p>
</div> </div>
) : !isDemoMode && buildsQuery.isError ? ( ) : null}
<div className="p-10 text-center">
{!isDemoMode && buildsQuery.isError ? (
<div className="p-8 text-center">
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-[var(--error-soft)] flex items-center justify-center">
<X size={24} className="text-[var(--error)]" />
</div>
<p className="text-sm text-[var(--error)]">Failed to load builds</p> <p className="text-sm text-[var(--error)]">Failed to load builds</p>
<p className="text-xs text-[var(--text-muted)] mt-1">{buildsQuery.error instanceof Error ? buildsQuery.error.message : 'Unknown error'}</p> <p className="text-xs text-[var(--text-muted)] mt-1">{buildsQuery.error instanceof Error ? buildsQuery.error.message : 'Unknown error'}</p>
</div> </div>
) : builds.length === 0 ? ( ) : null}
{builds.length === 0 && !buildsQuery.isLoading ? (
<div className="p-12 text-center"> <div className="p-12 text-center">
<div className="s-ibox mx-auto mb-3 !w-10 !h-10"><Box /></div> <div className="w-12 h-12 mx-auto mb-4 rounded-full bg-[var(--surface-muted)] flex items-center justify-center">
<Box size={24} className="text-[var(--text-tertiary)]" />
</div>
<p className="text-sm text-[var(--text-muted)]">No builds match current filters</p> <p className="text-sm text-[var(--text-muted)]">No builds match current filters</p>
</div> </div>
) : ( ) : null}
<div className="px-4">
<STable {builds.length > 0 ? (
cols={buildCols} <div className="grid grid-cols-1 lg:grid-cols-2 gap-4 p-4">
rows={builds} {builds.map((build) => (
rowKey={(b) => b.id} <div
onRowClick={(b) => setSelectedBuild(b)} key={build.id}
/> className="panel p-4 group hover:border-[var(--accent-primary)]/30 transition-all duration-300 card-lift cursor-pointer"
onClick={() => setSelectedBuild(build)}
>
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-xl flex items-center justify-center ${
build.status === 'success'
? 'bg-[var(--success-soft)]'
: build.status === 'failed'
? 'bg-[var(--error-soft)]'
: build.status === 'running'
? 'bg-[var(--warning-soft)]'
: 'bg-[var(--surface-muted)]'
}`}>
{build.status === 'success' && <Check size={18} className="text-[var(--success)]" />}
{build.status === 'failed' && <X size={18} className="text-[var(--error)]" />}
{build.status === 'running' && <Loader2 size={18} className="text-[var(--warning)] animate-spin" />}
{build.status === 'pending' && <Clock size={18} className="text-[var(--text-tertiary)]" />}
{build.status === 'cancelled' && <X size={18} className="text-[var(--text-tertiary)]" />}
</div>
<div>
<p className="mono text-sm font-medium text-[var(--text-primary)]">{build.id}</p>
<div className="flex items-center gap-2 mt-1">
<StatusBadge status={build.status} />
<span className="text-xs text-[var(--text-tertiary)]">
{build.startedAt ? formatRelative(build.startedAt) : '—'}
</span>
</div>
</div>
</div>
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
<button className="p-2 rounded-lg hover:bg-[var(--surface-muted)] text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-all">
<FileText size={14} />
</button>
</div>
</div>
<div className="space-y-3">
{/* Image info */}
<div className="flex items-center justify-between text-xs">
<span className="text-[var(--text-tertiary)]">Image</span>
<span className="mono text-[var(--text-secondary)]">
{build.imageName || '—'}:{build.imageTag || 'latest'}
</span>
</div>
{/* Progress bar */}
{(build.status === 'running' || build.status === 'pending') && (
<div className="space-y-1.5">
<div className="flex items-center justify-between text-xs">
<span className="text-[var(--text-tertiary)]">Progress</span>
<span className="text-[var(--text-secondary)] font-medium">{build.progress}%</span>
</div>
<div className="h-2 rounded-full bg-[var(--surface-muted)] overflow-hidden">
<div
className="h-full rounded-full transition-all duration-300"
style={{ width: `${Math.max(0, Math.min(100, build.progress))}%`, background: 'var(--accent-primary)' }}
/>
</div>
</div>
)}
{/* Size and service */}
<div className="flex items-center justify-between text-xs pt-2 border-t border-[var(--border-subtle)]">
<div className="flex items-center gap-1.5 text-[var(--text-tertiary)]">
<HardDrive size={10} />
<span>{bytesToHumanReadable(build.size)}</span>
</div>
<span className="mono text-[var(--text-tertiary)]">{build.serviceId || '—'}</span>
</div>
</div>
{/* Error message if present */}
{build.error && (
<div className="mt-3 p-2 rounded-lg bg-[var(--error-soft)]/50 border border-[var(--error)]/20">
<p className="text-xs text-[var(--error)] line-clamp-2">{build.error}</p>
</div>
)}
</div>
))}
</div> </div>
)} ) : null}
</SCard> </div>
</div> </div>
{/* Error Toast */} {/* Error Toast */}
@@ -306,21 +393,27 @@ export function BuildsPage() {
{selectedBuild && ( {selectedBuild && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"> <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={() => setSelectedBuild(null)} /> <div className="absolute inset-0 bg-[var(--bg-void)]/80 backdrop-blur-sm" onClick={() => setSelectedBuild(null)} />
<div className="relative w-full max-w-3xl s-card"> <div className="relative w-full max-w-3xl panel p-6">
<div className="s-cardhead"> <div className="flex items-center justify-between mb-4">
<span className="s-ibox"><FileText /></span> <div className="flex items-center gap-3">
<div> <div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<div className="s-t">Build Logs</div> <FileText size={20} className="text-[var(--accent-primary)]" />
<div className="v-mono mt-0.5 text-[11px] text-[var(--text-tertiary)]">{selectedBuild.id}</div> </div>
</div> <div>
<div className="s-trail"> <h2 className="text-lg font-semibold text-[var(--text-primary)]">Build Logs</h2>
<QuietBtn onClick={() => setSelectedBuild(null)}>Close</QuietBtn> <p className="mono text-xs text-[var(--text-tertiary)]">{selectedBuild.id}</p>
</div>
</div> </div>
<button
onClick={() => setSelectedBuild(null)}
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm font-medium hover:border-[var(--border-default)] transition-colors"
>
Close
</button>
</div> </div>
<div className="px-4 pb-4">
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-void)] p-4 max-h-[400px] overflow-auto"> <div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-void)] p-4 max-h-[400px] overflow-auto">
<pre className="v-mono text-xs text-[var(--text-secondary)] whitespace-pre-wrap break-all"> <pre className="mono text-xs text-[var(--text-secondary)] whitespace-pre-wrap break-all">
{isDemoMode {isDemoMode
? selectedBuild.log || '[demo] No logs available.' ? selectedBuild.log || '[demo] No logs available.'
: logsQuery.isLoading : logsQuery.isLoading
@@ -330,7 +423,6 @@ export function BuildsPage() {
: logsQuery.data || '(empty logs)'} : logsQuery.data || '(empty logs)'}
</pre> </pre>
</div> </div>
</div>
</div> </div>
</div> </div>
)} )}
@@ -1,662 +0,0 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQueries, useQuery } from '@tanstack/react-query';
import {
CartesianGrid,
Line,
LineChart as RechartsLineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import {
AlertTriangle,
Cpu,
FolderKanban,
HardDrive,
MemoryStick,
RefreshCw,
Rocket,
Server,
Settings,
} from 'lucide-react';
import {
getAgentMetrics,
getHostMonitoring,
listActiveAlerts,
listAgents,
listProjects,
listRecentDeployments,
type AgentMetricPoint,
type NodeAgentEntity,
} from '@/lib/api-client';
import { useAuthSession } from '@/lib/use-auth-session';
import { useDemoMode } from '@/lib/demo-mode';
import { demoHostMonitoring, demoProjects } from '@/lib/demo-data';
import {
GhostBtn,
IconBtn,
MiniBars,
SAnomaly,
SCard,
SLegend,
SPageHead,
SPill,
STable,
SStat,
Ticks,
type SCol,
} from '@/shared/components/sentry';
import { statusTone } from '@/shared/components/sentry-utils';
import { QuietBtn } from '@/shared/components/sentry';
/* ------------------------------------------------------------------ */
/* Demo fixtures — deterministic, clearly sample data. */
/* ------------------------------------------------------------------ */
const demoAgents: NodeAgentEntity[] = [
{
id: 'demo-node-1',
name: 'core-01',
hostname: 'core-01.containr.local',
ipAddress: '10.0.1.11',
port: 9090,
status: 'online',
version: '0.9.2',
capabilities: {
containerRuntimes: ['docker'],
supportedArchitectures: ['amd64'],
maxContainers: 64,
storageDriver: 'overlay2',
networkPlugins: ['bridge'],
features: [],
},
resources: {
cpu: { cores: 8, allocation: 8, usage: 2.6 },
memory: { total: 16_662_000_000, allocated: 0, used: 9_832_000_000, available: 6_830_000_000 },
storage: { total: 240_000_000_000, allocated: 0, used: 141_000_000_000, available: 99_000_000_000 },
},
autoPrune: true,
schedulable: true,
tags: ['prod'],
metadata: {},
lastHeartbeat: new Date(Date.now() - 42_000).toISOString(),
},
{
id: 'demo-node-2',
name: 'edge-01',
hostname: 'edge-01.containr.local',
ipAddress: '10.0.2.4',
port: 9090,
status: 'degraded',
version: '0.9.2',
capabilities: {
containerRuntimes: ['docker'],
supportedArchitectures: ['arm64'],
maxContainers: 32,
storageDriver: 'overlay2',
networkPlugins: ['bridge'],
features: [],
},
resources: {
cpu: { cores: 4, allocation: 4, usage: 3.4 },
memory: { total: 8_290_000_000, allocated: 0, used: 7_110_000_000, available: 1_180_000_000 },
storage: { total: 120_000_000_000, allocated: 0, used: 98_000_000_000, available: 22_000_000_000 },
},
autoPrune: false,
schedulable: true,
tags: ['edge'],
metadata: {},
lastHeartbeat: new Date(Date.now() - 610_000).toISOString(),
},
];
/* Deterministic pseudo metric series for demo mode (sine + wobble). */
function demoMetricSeries(seed: number, points = 48): AgentMetricPoint[] {
const now = Date.now();
return Array.from({ length: points }, (_, i) => {
const t = now - (points - 1 - i) * 30 * 60_000;
const wave = Math.sin(i / 5.5 + seed) * 14 + Math.sin(i / 2.3 + seed * 2) * 6;
const cpu = Math.max(4, Math.min(96, 42 + wave + seed * 9));
const mem = Math.max(20, Math.min(95, 55 + wave / 2 + seed * 6));
return {
timestamp: new Date(t).toISOString(),
cpu: { usage: cpu, usage_percent: cpu, cores: 8 },
memory: { usage: mem, usage_percent: mem, limit: 100, available: 100 - mem },
system_load: { load_1m: cpu / 12, load_5m: cpu / 14, load_15m: cpu / 16 },
container_count: 12 + seed * 5,
};
});
}
const demoDeployRows = [
{ id: 'd1', serviceId: 's1', serviceName: 'api-gateway', projectName: 'Core Platform', status: 'deployed', imageName: undefined, startedAt: new Date(Date.now() - 240_000).toISOString(), completedAt: undefined, createdAt: '' },
{ id: 'd2', serviceId: 's2', serviceName: 'web-frontend', projectName: 'Core Platform', status: 'building', imageName: undefined, startedAt: new Date(Date.now() - 390_000).toISOString(), completedAt: undefined, createdAt: '' },
{ id: 'd3', serviceId: 's3', serviceName: 'batch-evaluator', projectName: 'Inference Lab', status: 'failed', imageName: undefined, startedAt: new Date(Date.now() - 3_900_000).toISOString(), completedAt: undefined, createdAt: '' },
{ id: 'd4', serviceId: 's4', serviceName: 'marketing-site', projectName: 'Growth Surface', status: 'deployed', imageName: undefined, startedAt: new Date(Date.now() - 7_400_000).toISOString(), completedAt: undefined, createdAt: '' },
];
/* ------------------------------------------------------------------ */
const SERIES_COLORS = ['#38bdf8', '#1cd8ec', '#a271d9', '#45e896'];
const MAX_CHART_AGENTS = 4;
type RangeKey = '1h' | '24h' | '7d';
function formatRelative(date?: string): string {
if (!date) return '—';
const diff = Date.now() - new Date(date).getTime();
if (diff < 0 || diff > 365 * 86_400_000) return '—';
if (diff < 60_000) return 'just now';
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
return `${Math.floor(diff / 86_400_000)}d ago`;
}
const fmtGB = (v: number) => `${(v / (1024 * 1024 * 1024)).toFixed(0)}G`;
const pct = (used: number, total: number) => (total > 0 ? Math.round((used / total) * 100) : 0);
/* Merge per-agent metric arrays into recharts rows keyed by index. */
function mergeSeries(seriesList: { key: string; points: AgentMetricPoint[] }[]) {
const len = Math.max(...seriesList.map((s) => s.points.length), 0);
return Array.from({ length: len }, (_, i) => {
const row: Record<string, number | string> = {};
seriesList.forEach((s) => {
const p = s.points[Math.min(i, s.points.length - 1)];
if (p) {
row.t = row.t ?? new Date(p.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
row[s.key] = Math.round(p.cpu.usage_percent * 10) / 10;
}
});
return row;
});
}
function ChartTooltip({ active, payload, label }: {
active?: boolean;
payload?: Array<{ name: string; value: number; color: string }>;
label?: string;
}) {
if (!active || !payload?.length) return null;
return (
<div className="s-tip">
<div className="s-tip-t">{label}</div>
{payload.map((p) => (
<div key={p.name} className="s-tip-r">
<span><i style={{ background: p.color }} />{p.name}</span>
<b>{p.value}<small>%</small></b>
</div>
))}
</div>
);
}
export function DashboardPage() {
const navigate = useNavigate();
const isDemoMode = useDemoMode();
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
const signedIn = isDemoMode || Boolean(sessionQuery.data);
const userName = sessionQuery.data?.user.name;
const firstName = isDemoMode ? 'Operator' : userName?.split(' ')[0];
const [range, setRange] = useState<RangeKey>('24h');
const projectsQuery = useQuery({
queryKey: ['projects'],
enabled: !isDemoMode,
queryFn: () => listProjects(),
});
const hostQuery = useQuery({
queryKey: ['host-monitoring'],
enabled: !isDemoMode,
queryFn: getHostMonitoring,
refetchInterval: 30_000,
});
// Agents are admin-scoped; failures collapse to an empty list, never an error state.
const agentsQuery = useQuery({
queryKey: ['agents'],
enabled: !isDemoMode && signedIn,
queryFn: listAgents,
retry: false,
staleTime: 30_000,
});
const deploysQuery = useQuery({
queryKey: ['recent-deploys', 50],
enabled: !isDemoMode,
queryFn: () => listRecentDeployments(50),
refetchInterval: 30_000,
});
const alertsQuery = useQuery({
queryKey: ['ha-alerts'],
enabled: !isDemoMode && signedIn,
queryFn: listActiveAlerts,
retry: false,
});
const projects = isDemoMode ? demoProjects : projectsQuery.data ?? [];
const host = isDemoMode ? demoHostMonitoring : hostQuery.data;
const agents = isDemoMode ? demoAgents : agentsQuery.data ?? [];
const deployments = isDemoMode ? demoDeployRows : deploysQuery.data ?? [];
const alerts = isDemoMode ? [{ id: 'a1', severity: 'warning' as const, message: 'Disk I/O spikes at 03:22' }] : alertsQuery.data ?? [];
const chartAgents = agents.slice(0, MAX_CHART_AGENTS);
const metricQueries = useQueries({
queries: chartAgents.map((a) => ({
queryKey: ['agent-metrics', a.id, range],
queryFn: () => getAgentMetrics(a.id, range),
enabled: !isDemoMode && agents.length > 0,
staleTime: 60_000,
retry: false,
})),
});
/* Series: real per-agent CPU%; demo synth; empty otherwise.
Plain computation — the compiler memoizes it. */
const seriesList = chartAgents.map((a, i) => ({
key: a.name || a.hostname || `node-${i + 1}`,
points: isDemoMode ? demoMetricSeries(i + 1) : metricQueries[i]?.data ?? [],
}));
const chartData = {
rows: mergeSeries(seriesList),
series: seriesList.map((s, i) => ({ key: s.key, color: SERIES_COLORS[i % SERIES_COLORS.length] })),
hasData: seriesList.some((s) => s.points.length > 1),
};
const hostLabel = host?.scope === 'docker-desktop-vm' ? 'VM' : 'Host';
const cpuPct = host && host.cpu.cores > 0
? Math.min(100, Math.round((host.load.load1m / host.cpu.cores) * 100))
: null;
const memPct = host ? Math.round(host.memory.usagePercent) : null;
const diskPct = host ? Math.round(host.storage.usagePercent) : null;
const totalServices = projects.reduce((sum, p) => sum + p.stats.service_count, 0);
const runningServices = projects.reduce((sum, p) => sum + p.stats.running_services, 0);
const onlineAgents = agents.filter((a) => a.status === 'online').length;
const succeeded = deployments.filter((d) => !['failed', 'error'].includes((d.status ?? '').toLowerCase())).length;
const anomalies = [
...(diskPct !== null && diskPct > 85 ? [`Disk usage at ${diskPct}% — prune images or expand volume`] : []),
...(memPct !== null && memPct > 85 ? [`Memory pressure at ${memPct}% — rebalance services`] : []),
...alerts.slice(0, 3).map((a) => a.message).filter((m): m is string => Boolean(m)),
...(deployments.some((d) => (d.status ?? '').toLowerCase() === 'failed')
? [`${deployments.filter((d) => (d.status ?? '').toLowerCase() === 'failed').length} failed deployment${deployments.filter((d) => (d.status ?? '').toLowerCase() === 'failed').length > 1 ? 's' : ''} in the feed`]
: []),
];
const statDefs = [
{
icon: <Server size={13.5} />,
label: 'Active Nodes',
value: agents.length > 0 ? onlineAgents : host?.dockerAvailable ? 1 : '—',
unit: agents.length > 0 ? `of ${agents.length}` : 'local',
ticks: null,
foot: agents.length > 0 ? `${agents.length - onlineAgents} offline or degraded` : 'single-host install',
delta: undefined,
},
{
icon: <Cpu size={13.5} />,
label: `${hostLabel} CPU`,
value: cpuPct ?? '—',
unit: cpuPct !== null ? '%' : undefined,
ticks: cpuPct,
foot: host ? `${host.cpu.cores} cores · load avg` : 'no telemetry',
delta: undefined,
},
{
icon: <MemoryStick size={13.5} />,
label: 'Memory Usage',
value: memPct ?? '—',
unit: memPct !== null ? '%' : undefined,
ticks: memPct,
foot: host ? `${fmtGB(host.memory.used)} / ${fmtGB(host.memory.total)} used` : 'no telemetry',
delta: undefined,
},
{
icon: <Rocket size={13.5} />,
label: 'Deployments',
value: deployments.length,
unit: 'recent',
ticks: null,
foot: `${succeeded} succeeded`,
delta: deployments.length > 0 ? { dir: 'up' as const, text: `+${deployments.length}` } : undefined,
},
];
type NodeRow = {
id: string;
name: string;
address: string;
status: string;
cpu: number | null;
mem: number | null;
disk: number | null;
containers: string;
heartbeat: string;
};
const nodeRows: NodeRow[] = agents.length > 0
? agents.map((a) => ({
id: a.id,
name: a.name || a.hostname,
address: `${a.ipAddress}${a.port ? `:${a.port}` : ''}`,
status: a.status,
cpu: a.resources.cpu.cores > 0 ? Math.round((a.resources.cpu.usage / a.resources.cpu.cores) * 100) : null,
mem: pct(a.resources.memory.used, a.resources.memory.total),
disk: pct(a.resources.storage.used, a.resources.storage.total),
containers: String(a.capabilities.maxContainers ? `≤${a.capabilities.maxContainers}` : '—'),
heartbeat: formatRelative(a.lastHeartbeat),
}))
: host
? [{
id: 'local',
name: host.hostname || 'local host',
address: `${host.os}/${host.architecture}`,
status: host.dockerAvailable ? 'online' : 'degraded',
cpu: cpuPct,
mem: memPct,
disk: diskPct,
containers: host.docker?.containers !== undefined ? String(host.docker.containers) : '—',
heartbeat: 'live',
}]
: [];
const metricCell = (v: number | null) =>
v === null ? <span className="text-[var(--text-muted)]">—</span> : (
<span className="inline-flex items-center gap-2">
<MiniBars tone={v > 85 ? 'var(--error)' : v > 70 ? 'var(--warning)' : 'var(--accent-primary)'} />
<span className="text-[12.5px] font-medium text-[var(--text-primary)]">{v}<small className="text-[var(--text-tertiary)] font-normal">%</small></span>
</span>
);
const nodeCols: SCol<NodeRow>[] = [
{ key: 'name', label: 'Node', width: '1.7fr', sortValue: (r) => r.name,
render: (r) => <span className="text-[var(--text-primary)] font-medium">{r.name}</span> },
{ key: 'address', label: 'Address', width: '1.15fr', sortValue: (r) => r.address,
render: (r) => <span className="v-mono text-[11.5px] text-[var(--text-secondary)]">{r.address}</span> },
{ key: 'status', label: 'Status', width: '0.85fr', sortValue: (r) => r.status,
render: (r) => <SPill tone={statusTone(r.status)}>{r.status}</SPill> },
{ key: 'cpu', label: 'CPU', width: '0.9fr', sortValue: (r) => r.cpu ?? -1, render: (r) => metricCell(r.cpu) },
{ key: 'mem', label: 'Memory', width: '0.9fr', sortValue: (r) => r.mem ?? -1, render: (r) => metricCell(r.mem) },
{ key: 'disk', label: 'Disk', width: '0.9fr', sortValue: (r) => r.disk ?? -1, render: (r) => metricCell(r.disk) },
{ key: 'containers', label: 'Capacity', width: '0.8fr', sortable: false,
render: (r) => <span className="v-mono text-[11.5px] text-[var(--text-secondary)]">{r.containers}</span> },
{ key: 'heartbeat', label: 'Heartbeat', width: '0.8fr', sortable: false,
render: (r) => <span className="v-mono text-[11.5px] text-[var(--text-tertiary)]">{r.heartbeat}</span> },
];
type DeployRow = { id: string; service: string; project: string; status: string; when: string };
const deployRows: DeployRow[] = deployments.slice(0, 7).map((d) => ({
id: d.id,
service: d.serviceName || d.imageName || d.serviceId || d.id,
project: d.projectName || '—',
status: d.status || 'queued',
when: formatRelative(d.startedAt ?? d.completedAt ?? d.createdAt),
}));
const deployCols: SCol<DeployRow>[] = [
{ key: 'service', label: 'Service', width: '1.6fr', sortValue: (r) => r.service,
render: (r) => (
<span className="text-[var(--text-primary)] font-medium">
{r.service}
<span className="v-mono ml-2 text-[10.5px] font-normal text-[var(--text-tertiary)]">{r.project}</span>
</span>
) },
{ key: 'status', label: 'Status', width: '0.9fr', sortValue: (r) => r.status,
render: (r) => <SPill tone={statusTone(r.status)}>{r.status}</SPill> },
{ key: 'when', label: 'When', width: '0.55fr', sortable: false,
render: (r) => <span className="v-mono text-[11.5px] text-[var(--text-tertiary)]">{r.when}</span> },
];
const refetchAll = () => {
projectsQuery.refetch();
hostQuery.refetch();
deploysQuery.refetch();
agentsQuery.refetch();
alertsQuery.refetch();
metricQueries.forEach((q) => q.refetch());
};
return (
<div className="min-h-screen">
<div className="w-full px-4 py-6 sm:px-7">
<SPageHead
title={firstName ? 'Welcome back,' : 'Fleet'}
titleAccent={firstName ?? 'overview'}
sub={
<>
{String(runningServices).padStart(2, '0')} of {String(totalServices).padStart(2, '0')} services online ·{' '}
</>
}
subAccent={agents.length > 0 ? `${onlineAgents}/${agents.length} nodes live` : 'single host'}
actions={
<>
{signedIn && (
<GhostBtn onClick={() => navigate('/projects')}>+ New Project</GhostBtn>
)}
<QuietBtn onClick={() => navigate('/operations')}>
<AlertTriangle size={13} />
View alerts
</QuietBtn>
<IconBtn title="Refresh" onClick={refetchAll}>
<RefreshCw size={14} />
</IconBtn>
<IconBtn title="Settings" onClick={() => navigate('/settings')}>
<Settings size={14} />
</IconBtn>
</>
}
/>
{/* Stat row */}
<div className="mb-4 grid grid-cols-2 gap-3.5 xl:grid-cols-4">
{statDefs.map((s) => (
<SStat
key={s.label}
icon={s.icon}
label={s.label}
value={s.value}
unit={s.unit}
foot={s.foot}
delta={s.delta}
extra={s.ticks !== null && s.ticks !== undefined ? <Ticks pct={s.ticks} count={44} warn={s.ticks > 85} /> : undefined}
/>
))}
</div>
{/* Chart + resource usage */}
<div className="mb-4 grid grid-cols-1 gap-3.5 xl:grid-cols-[2.32fr_1fr]">
<SCard
icon={<Cpu size={13.5} />}
title="Node CPU Load"
accent={`(${range})`}
trail={
<IconBtn title="Refresh" onClick={refetchAll}>
<RefreshCw size={13} />
</IconBtn>
}
pad={false}
>
<SLegend items={chartData.series.map((s) => ({ color: s.color, label: s.key }))} />
<div className="px-3 pb-3">
{chartData.hasData ? (
<ResponsiveContainer width="100%" height={292} initialDimension={{ width: 640, height: 292 }}>
<RechartsLineChart data={chartData.rows} margin={{ top: 14, right: 14, bottom: 4, left: -14 }}>
<CartesianGrid stroke="var(--border-subtle)" vertical={false} />
<XAxis
dataKey="t"
tick={{ fill: 'var(--text-tertiary)', fontSize: 10.5, fontFamily: 'IBM Plex Mono' }}
tickLine={false}
axisLine={{ stroke: 'var(--border-subtle)' }}
minTickGap={48}
/>
<YAxis
domain={[0, 100]}
tick={{ fill: 'var(--text-tertiary)', fontSize: 10, fontFamily: 'IBM Plex Mono' }}
tickLine={false}
axisLine={false}
tickFormatter={(v: number) => `${v}%`}
/>
<Tooltip content={<ChartTooltip />} cursor={{ stroke: 'var(--border-default)' }} />
{chartData.series.map((s, i) => (
<Line
key={s.key}
dataKey={s.key}
stroke={s.color}
strokeWidth={i === 0 ? 1.6 : 1.3}
dot={false}
isAnimationActive={false}
/>
))}
</RechartsLineChart>
</ResponsiveContainer>
) : (
<div className="flex h-[292px] flex-col items-center justify-center gap-2 text-center">
<Server size={22} className="text-[var(--text-tertiary)]" />
<p className="text-[12.5px] text-[var(--text-secondary)]">No node telemetry yet</p>
<p className="max-w-xs text-[11px] text-[var(--text-tertiary)]">
Enroll a Containr agent on a host to chart per-node CPU over time.
</p>
</div>
)}
</div>
{/* range picker rendered as a real control row under the chart */}
<div className="flex items-center gap-1.5 border-t border-[var(--border-subtle)] px-4 py-2">
{(['1h', '24h', '7d'] as RangeKey[]).map((r) => (
<button
key={r}
onClick={() => setRange(r)}
className={`rounded-[5px] px-2 py-1 text-[10.5px] font-medium transition-colors ${
range === r
? 'bg-[var(--nav-active)] text-[var(--text-primary)]'
: 'text-[var(--text-tertiary)] hover:text-[var(--text-primary)]'
}`}
>
{r}
</button>
))}
<span className="v-mono ml-auto text-[10.5px] text-[var(--text-muted)]">
{chartAgents.length > 0 ? `${chartAgents.length} node${chartAgents.length > 1 ? 's' : ''} sampled` : 'awaiting agents'}
</span>
</div>
</SCard>
<SCard
icon={<HardDrive size={13.5} />}
title="Resource Usage"
accent="(host)"
pad={false}
>
<div className="px-4 pt-1">
{[
{ label: 'CPU Load', v: cpuPct },
{ label: 'Memory', v: memPct },
{ label: 'Disk I/O', v: diskPct },
{ label: 'Services online', v: totalServices > 0 ? Math.round((runningServices / totalServices) * 100) : null },
].map((r) => (
<div key={r.label} className="py-2">
<div className="mb-2 flex items-baseline justify-between text-[12px]">
<span className="font-medium text-[var(--text-secondary)]">{r.label}</span>
<b className="font-semibold text-[var(--accent-primary)]">{r.v !== null ? `${r.v}%` : '—'}</b>
</div>
<Ticks pct={r.v ?? 0} count={56} warn={(r.v ?? 0) > 85} />
</div>
))}
</div>
<div className="p-4 pt-2.5">
<SAnomaly
icon={<AlertTriangle size={12.5} className="text-[var(--accent-primary)]" />}
title={
<>
<b className="font-semibold text-[var(--text-primary)]">{anomalies.length}</b>
{anomalies.length === 1 ? ' anomaly' : ' anomalies'} detected
</>
}
items={anomalies.length > 0 ? anomalies : ['All systems nominal']}
/>
</div>
</SCard>
</div>
{/* Node summary */}
<SCard
icon={<Server size={13.5} />}
title="Node Summary"
trail={
<IconBtn title="Refresh" onClick={() => agentsQuery.refetch()}>
<RefreshCw size={13} />
</IconBtn>
}
pad={false}
>
<STable
cols={nodeCols}
rows={nodeRows}
rowKey={(r) => r.id}
selectable
onRowClick={(r) => { if (r.id !== 'local') navigate(`/nodes/${r.id}`); }}
/>
{nodeRows.length === 0 && (
<p className="px-7 pb-5 text-[12px] text-[var(--text-tertiary)]">
No nodes enrolled. The local host appears here once telemetry arrives.
</p>
)}
</SCard>
{/* Deployments + projects */}
<div className="mt-4 grid grid-cols-1 gap-3.5 xl:grid-cols-[1.6fr_1fr]">
<SCard
icon={<Rocket size={13.5} />}
title="Recent Deployments"
accent={deployRows.length > 0 ? `(${deployments.length})` : undefined}
trail={
<IconBtn title="Refresh" onClick={() => deploysQuery.refetch()}>
<RefreshCw size={13} />
</IconBtn>
}
pad={false}
>
<STable cols={deployCols} rows={deployRows} rowKey={(r) => r.id} />
{deployRows.length === 0 && (
<p className="px-7 pb-5 text-[12px] text-[var(--text-tertiary)]">No deployments yet.</p>
)}
</SCard>
<SCard
icon={<FolderKanban size={13.5} />}
title="Projects"
accent={`(${projects.length})`}
pad={false}
>
<div className="px-2 pb-2">
{projects.slice(0, 6).map((p) => {
const running = p.stats.running_services;
const total = p.stats.service_count;
const tone = total === 0 ? 'off' : running === total ? 'ok' : running > 0 ? 'warn' : 'err';
return (
<button
key={p.id}
onClick={() => navigate(isDemoMode ? `/projects/${p.id}?demo=1` : `/projects/${p.id}`)}
className="flex w-full items-center gap-3 rounded-[8px] px-3 py-2.5 text-left transition-colors hover:bg-[var(--surface-muted)]"
>
<span className="s-ibox"><FolderKanban size={13.5} /></span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[12.5px] font-medium text-[var(--text-primary)]">{p.name}</span>
<span className="v-mono block text-[10.5px] text-[var(--text-tertiary)]">
{running}/{total} services · {p.stats.deployment_count} deploys
</span>
</span>
<SPill tone={tone}>{total === 0 ? 'idle' : running === total ? 'healthy' : running > 0 ? 'partial' : 'down'}</SPill>
</button>
);
})}
{projects.length === 0 && (
<p className="px-5 py-4 text-[12px] text-[var(--text-tertiary)]">No projects yet.</p>
)}
</div>
</SCard>
</div>
</div>
</div>
);
}
@@ -15,7 +15,6 @@ function renderMarkdown(markdown: string): string {
function excerpt(markdown: string, length = 140): string { function excerpt(markdown: string, length = 140): string {
const text = markdown const text = markdown
.replace(/^#[^\n]*\n/, ' ')
.replace(/```[\s\S]*?```/g, ' ') .replace(/```[\s\S]*?```/g, ' ')
.replace(/[#>*`\-[\]()]/g, ' ') .replace(/[#>*`\-[\]()]/g, ' ')
.replace(/\s+/g, ' ') .replace(/\s+/g, ' ')
@@ -120,7 +119,7 @@ export function DocsBrowser() {
<div className="min-w-0 flex-1 overflow-y-auto max-md:hidden"> <div className="min-w-0 flex-1 overflow-y-auto max-md:hidden">
{selected ? ( {selected ? (
<article className="docs-article px-4 py-6 sm:px-8"> <article className="docs-article px-8 py-6">
<p className="mb-1 text-[11px] font-medium uppercase tracking-wider text-[var(--text-tertiary)]"> <p className="mb-1 text-[11px] font-medium uppercase tracking-wider text-[var(--text-tertiary)]">
{selected.section} / {selected.path} {selected.section} / {selected.path}
</p> </p>
+102 -170
View File
@@ -1,4 +1,3 @@
import { useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { import {
@@ -9,18 +8,16 @@ import {
GitBranch, GitBranch,
LayoutTemplate, LayoutTemplate,
Rocket, Rocket,
Server,
ShieldCheck, ShieldCheck,
Terminal,
Zap, Zap,
} from 'lucide-react'; } from 'lucide-react';
import { getHostMonitoring, listProjects, listRecentDeployments, listTemplates } from '@/lib/api-client'; import { listProjects, listTemplates } from '@/lib/api-client';
import { DocsBrowser } from '@/features/docs/DocsBrowser'; import { DocsBrowser } from '@/features/docs/DocsBrowser';
import { useAuthSession } from '@/lib/use-auth-session'; import { useAuthSession } from '@/lib/use-auth-session';
import { useDemoMode } from '@/lib/demo-mode'; import { useDemoMode } from '@/lib/demo-mode';
import { useBranding } from '@/lib/use-branding'; import { useBranding } from '@/lib/use-branding';
import { BrandWordmark, MiniBars, SPill, Ticks } from '@/shared/components'; import { BrandWordmark } from '@/shared/components';
import { demoHostMonitoring, demoProjects, demoTemplates } from '@/lib/demo-data'; import { demoProjects, demoTemplates } from '@/lib/demo-data';
const features = [ const features = [
{ icon: Rocket, title: 'Git-push deploys', text: 'Connect a repository and ship. Builds, rollouts, and rollbacks are handled for you.' }, { icon: Rocket, title: 'Git-push deploys', text: 'Connect a repository and ship. Builds, rollouts, and rollbacks are handled for you.' },
@@ -31,14 +28,7 @@ const features = [
{ icon: Zap, title: 'Autoscaling', text: 'Metric-driven scaling policies adapt replicas to real load.' }, { icon: Zap, title: 'Autoscaling', text: 'Metric-driven scaling policies adapt replicas to real load.' },
]; ];
const demoDeploys = [
{ id: 'd1', serviceName: 'api-gateway', status: 'deployed', startedAt: new Date(Date.now() - 240_000).toISOString() },
{ id: 'd2', serviceName: 'web-frontend', status: 'building', startedAt: new Date(Date.now() - 390_000).toISOString() },
{ id: 'd3', serviceName: 'batch-evaluator', status: 'failed', startedAt: new Date(Date.now() - 3_900_000).toISOString() },
];
export function LandingPage() { export function LandingPage() {
const [now] = useState(() => Date.now());
const isDemoMode = useDemoMode(); const isDemoMode = useDemoMode();
const { logoUrl, productName } = useBranding(); const { logoUrl, productName } = useBranding();
const sessionQuery = useAuthSession({ enabled: !isDemoMode }); const sessionQuery = useAuthSession({ enabled: !isDemoMode });
@@ -47,35 +37,19 @@ export function LandingPage() {
const projectsQuery = useQuery({ queryKey: ['landing-projects'], queryFn: () => listProjects(), staleTime: 60_000, enabled: !isDemoMode }); const projectsQuery = useQuery({ queryKey: ['landing-projects'], queryFn: () => listProjects(), staleTime: 60_000, enabled: !isDemoMode });
const templatesQuery = useQuery({ queryKey: ['landing-templates'], queryFn: () => listTemplates(), staleTime: 60_000, enabled: !isDemoMode }); const templatesQuery = useQuery({ queryKey: ['landing-templates'], queryFn: () => listTemplates(), staleTime: 60_000, enabled: !isDemoMode });
const hostQuery = useQuery({ queryKey: ['landing-host'], queryFn: getHostMonitoring, staleTime: 30_000, enabled: !isDemoMode });
const deploysQuery = useQuery({ queryKey: ['landing-deploys'], queryFn: () => listRecentDeployments(4), staleTime: 30_000, enabled: !isDemoMode });
const projects = isDemoMode ? demoProjects : (projectsQuery.data ?? []); const projects = isDemoMode ? demoProjects : (projectsQuery.data ?? []);
const templates = (isDemoMode ? demoTemplates : (templatesQuery.data ?? [])).slice(0, 12); const templates = (isDemoMode ? demoTemplates : (templatesQuery.data ?? [])).slice(0, 12);
const host = isDemoMode ? demoHostMonitoring : hostQuery.data;
const deploys = isDemoMode ? demoDeploys : (deploysQuery.data ?? []).slice(0, 3);
const cpuPct = host ? Math.min(100, Math.round((host.load.load1m / Math.max(1, host.cpu.cores)) * 100)) : null;
const memPct = host ? Math.round(host.memory.usagePercent) : null;
const diskPct = host ? Math.round(host.storage.usagePercent) : null;
const rel = (d?: string) => {
if (!d) return '—';
const s = Math.floor((now - new Date(d).getTime()) / 1000);
if (s < 60) return 'just now';
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
return `${Math.floor(s / 86400)}d ago`;
};
return ( return (
<div className="min-h-screen bg-[var(--bg-void)] text-[var(--text-secondary)]"> <div className="min-h-screen bg-[var(--bg-void)] text-[var(--text-secondary)]">
{/* ── Top bar ── */} <div className="ambient-glow" />
<header className="sticky top-0 z-40 border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/80 backdrop-blur-xl"> <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"> <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"> <Link to="/" className="flex items-center gap-2.5">
<img src={logoUrl || '/containr.svg'} alt={productName} className="h-7 w-7 rounded-lg object-contain" /> <img src={logoUrl || '/containr.svg'} alt={productName} className="h-7 w-7 rounded-lg object-contain" />
<span className="font-headline text-[15px] font-semibold tracking-tight text-[var(--text-primary)]"> <span className="text-[15px] font-extrabold tracking-tight text-[var(--text-primary)]">
<BrandWordmark /> <BrandWordmark />
</span> </span>
</Link> </Link>
@@ -83,160 +57,112 @@ export function LandingPage() {
<a href="#features" className="rounded-[var(--radius-md)] px-3 py-1.5 text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] max-sm:hidden">Features</a> <a href="#features" className="rounded-[var(--radius-md)] px-3 py-1.5 text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] max-sm:hidden">Features</a>
<a href="#templates" className="rounded-[var(--radius-md)] px-3 py-1.5 text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] max-sm:hidden">Templates</a> <a href="#templates" className="rounded-[var(--radius-md)] px-3 py-1.5 text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] max-sm:hidden">Templates</a>
<a href="#docs" className="rounded-[var(--radius-md)] px-3 py-1.5 text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] max-sm:hidden">Docs</a> <a href="#docs" className="rounded-[var(--radius-md)] px-3 py-1.5 text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] max-sm:hidden">Docs</a>
<Link to={`/projects${demo}`} className="rounded-[var(--radius-md)] px-3 py-1.5 font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)]"> <Link
to={`/projects${demo}`}
className="rounded-[var(--radius-md)] px-3 py-1.5 font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)]"
>
Browse Browse
</Link> </Link>
{signedIn || isDemoMode ? ( {signedIn ? (
<Link to={`/${demo}`} className="ml-1 s-btn-accent"> <Link
to="/projects"
className="ml-1 inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-3.5 py-1.5 font-semibold text-[var(--accent-on)]"
>
Console <ArrowRight size={13} /> Console <ArrowRight size={13} />
</Link> </Link>
) : ( ) : (
<Link to="/auth/sign-in" className="ml-1 s-btn-accent"> <Link
Sign in <ArrowRight size={13} /> to={isDemoMode ? `/projects${demo}` : '/auth/sign-in'}
className="ml-1 inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-3.5 py-1.5 font-semibold text-[var(--accent-on)]"
>
{isDemoMode ? 'Live demo' : 'Sign in'} <ArrowRight size={13} />
</Link> </Link>
)} )}
</nav> </nav>
</div> </div>
</header> </header>
<main> <main className="relative">
{/* ── Hero + live instance window ── */} <section className="mx-auto max-w-6xl px-5 pb-16 pt-20 text-center">
<section className="border-b border-[var(--border-subtle)]"> <p className="mx-auto mb-5 inline-flex items-center gap-2 rounded-full border border-[var(--border-subtle)] bg-[var(--surface-card)] px-3.5 py-1 text-[11px] font-medium uppercase tracking-[0.16em] text-[var(--text-tertiary)]">
<div className="mx-auto grid max-w-6xl gap-10 px-5 pb-16 pt-16 lg:grid-cols-[1.1fr_1fr] lg:items-center"> <Container size={12} className="text-[var(--accent-primary)]" />
<div> self-hosted PaaS
<p className="s-chip mb-6 inline-flex"> </p>
<Container size={11} /> <h1 className="mx-auto max-w-3xl text-[42px] font-extrabold leading-[1.08] tracking-[-0.03em] text-[var(--text-primary)] max-sm:text-[32px]">
self-hosted PaaS Deploy containers like it's{' '}
</p> <span style={{ color: 'var(--accent-primary)' }}>your own cloud</span>
<h1 className="font-headline text-[44px] font-bold leading-[1.06] tracking-[-0.03em] text-[var(--text-primary)] max-sm:text-[32px]"> </h1>
Deploy containers like it's{' '} <p className="mx-auto mt-5 max-w-xl text-[15px] leading-relaxed text-[var(--text-secondary)]">
<span className="text-[var(--accent-primary)]">your own cloud</span> Containr turns any Docker host into a full deployment platform — git-push builds,
</h1> managed databases, preview environments, HA failover, and a searchable knowledge base.
<p className="mt-5 max-w-lg text-[14px] leading-relaxed text-[var(--text-secondary)]"> </p>
Containr turns any Docker host into a full deployment platform — git-push builds, <div className="mt-8 flex items-center justify-center gap-3">
managed databases, preview environments, HA failover, and a searchable knowledge base. <Link
</p> to={`/projects${demo}`}
<div className="mt-8 flex items-center gap-3"> className="inline-flex items-center gap-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-5 py-2.5 text-sm font-semibold text-[var(--accent-on)] transition-transform hover:scale-[1.02]"
<Link to={`/${demo}`} className="s-btn-accent !px-5 !h-10 text-sm"> >
{isDemoMode ? 'Explore the live demo' : 'Open the console'} <ArrowRight size={15} /> {isDemoMode ? 'Explore the live demo' : 'Browse projects'} <ArrowRight size={15} />
</Link> </Link>
<a href="#docs" className="s-btn-quiet !px-5 !h-10 text-sm"> <a
<BookOpen size={15} /> Read the docs href="#docs"
</a> className="inline-flex items-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-default)] px-5 py-2.5 text-sm font-medium text-[var(--text-secondary)] hover:border-[var(--border-strong)] hover:text-[var(--text-primary)]"
</div> >
<div className="v-mono mt-8 flex items-center gap-5 text-[11px] text-[var(--text-tertiary)]"> <BookOpen size={15} /> Read the docs
<span className="flex items-center gap-2"> </a>
<i className="inline-block h-1.5 w-1.5 rounded-full bg-[var(--success)]" />
{projects.length} public projects
</span>
<span>{projects.reduce((s, p) => s + (p.stats?.running_services ?? 0), 0)} services online</span>
<span className="max-sm:hidden">{projects.reduce((s, p) => s + (p.stats?.deployment_count ?? 0), 0)} deployments</span>
</div>
</div>
{/* Live window — real telemetry from this instance */}
<div className="s-card !p-0 overflow-hidden">
<div className="s-cardhead border-b border-[var(--border-subtle)]">
<span className="s-ibox"><Server size={13.5} /></span>
<span className="s-t">{host?.hostname ?? 'this instance'} <span className="s-ta">live</span></span>
<span className="s-trail">
<SPill tone={host?.dockerAvailable ? 'ok' : 'off'}>{host?.dockerAvailable ? 'online' : 'offline'}</SPill>
</span>
</div>
<div className="px-4 pb-2 pt-3">
{[
{ label: 'CPU', v: cpuPct },
{ label: 'Memory', v: memPct },
{ label: 'Disk', v: diskPct },
].map((r) => (
<div key={r.label} className="py-1.5">
<div className="mb-1.5 flex items-baseline justify-between text-[11.5px]">
<span className="text-[var(--text-secondary)]">{r.label}</span>
<b className="font-semibold text-[var(--accent-primary)]">{r.v !== null ? `${r.v}%` : '—'}</b>
</div>
<Ticks pct={r.v ?? 0} count={48} warn={(r.v ?? 0) > 85} />
</div>
))}
</div>
<div className="border-t border-[var(--border-subtle)] px-4 py-3">
<p className="v-mono mb-2 text-[10px] uppercase tracking-[0.12em] text-[var(--text-tertiary)]">latest deploys</p>
{deploys.map((d) => (
<div key={d.id} className="flex items-center gap-2.5 py-1.5 text-[12px]">
<MiniBars tone={(d.status ?? '').toLowerCase() === 'failed' ? 'var(--error)' : 'var(--accent-primary)'} />
<span className="min-w-0 flex-1 truncate font-medium text-[var(--text-primary)]">{d.serviceName || d.id}</span>
<span className="v-mono text-[10.5px] text-[var(--text-tertiary)]">{rel(d.startedAt)}</span>
</div>
))}
{deploys.length === 0 && (
<p className="py-1.5 text-[11.5px] text-[var(--text-tertiary)]">No deployments yet.</p>
)}
</div>
</div>
</div> </div>
{projects.length > 0 ? (
<div className="mx-auto mt-12 grid max-w-3xl grid-cols-3 gap-3 text-left max-sm:grid-cols-1">
<Stat label="Public projects" value={projects.length} />
<Stat
label="Running services"
value={projects.reduce((sum, p) => sum + (p.stats?.running_services ?? 0), 0)}
/>
<Stat
label="Deployments"
value={projects.reduce((sum, p) => sum + (p.stats?.deployment_count ?? 0), 0)}
/>
</div>
) : null}
</section> </section>
{/* ── Install strip ── */} <section id="features" className="border-t border-[var(--border-subtle)] bg-[var(--bg-base)]/60">
<section className="border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/60">
<div className="mx-auto flex max-w-6xl flex-wrap items-center gap-4 px-5 py-5">
<span className="s-ibox"><Terminal size={13.5} /></span>
<code className="v-mono flex-1 text-[12.5px] text-[var(--text-secondary)]">
<span className="text-[var(--accent-primary)]">$</span> docker compose up -d
</code>
<span className="v-mono text-[11px] text-[var(--text-tertiary)]">one host · one command · full platform</span>
</div>
</section>
{/* ── Features ── */}
<section id="features" className="border-b border-[var(--border-subtle)]">
<div className="mx-auto max-w-6xl px-5 py-16"> <div className="mx-auto max-w-6xl px-5 py-16">
<div className="flex items-end justify-between gap-6 max-sm:flex-col max-sm:items-start"> <h2 className="text-center text-[26px] font-bold tracking-tight text-[var(--text-primary)]">
<div> Everything a deployment platform needs
<p className="v-mono mb-2 flex items-center gap-2 text-[10.5px] uppercase tracking-[0.1em] text-[var(--text-tertiary)]"> </h2>
<i className="inline-block h-[5px] w-[5px] rounded-[1px] bg-[var(--accent-primary)]" /> <p className="mx-auto mt-2 max-w-lg text-center text-sm text-[var(--text-secondary)]">
platform One install, one dashboard, full control over your own infrastructure.
</p> </p>
<h2 className="font-headline text-[26px] font-bold tracking-tight text-[var(--text-primary)]"> <div className="mt-10 grid grid-cols-3 gap-4 max-md:grid-cols-2 max-sm:grid-cols-1">
Everything a deployment platform needs
</h2>
<p className="mt-1.5 max-w-lg text-[13px] text-[var(--text-secondary)]">
One install, one dashboard, full control over your own infrastructure.
</p>
</div>
<span className="v-mono text-[11px] text-[var(--text-muted)]">06 capabilities</span>
</div>
<div className="mt-9 grid grid-cols-3 gap-3.5 max-md:grid-cols-2 max-sm:grid-cols-1">
{features.map((feature) => ( {features.map((feature) => (
<div key={feature.title} className="s-card"> <div
<div className="s-cardhead !mb-0"> key={feature.title}
<span className="s-ibox"><feature.icon size={13.5} /></span> className="rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)] p-5 transition-colors hover:border-[var(--border-default)]"
<h3 className="s-t">{feature.title}</h3> >
</div> <feature.icon size={18} className="text-[var(--accent-primary)]" />
<p className="mt-2 text-[12.5px] leading-relaxed text-[var(--text-tertiary)]">{feature.text}</p> <h3 className="mt-3 text-[14.5px] font-semibold text-[var(--text-primary)]">{feature.title}</h3>
<p className="mt-1.5 text-[12.5px] leading-relaxed text-[var(--text-tertiary)]">{feature.text}</p>
</div> </div>
))} ))}
</div> </div>
</div> </div>
</section> </section>
{/* ── Templates ── */} <section id="templates" className="border-t border-[var(--border-subtle)]">
<section id="templates" className="border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/60">
<div className="mx-auto max-w-6xl px-5 py-16"> <div className="mx-auto max-w-6xl px-5 py-16">
<div className="flex items-end justify-between gap-6"> <div className="flex items-end justify-between">
<div> <div>
<p className="v-mono mb-2 flex items-center gap-2 text-[10.5px] uppercase tracking-[0.1em] text-[var(--text-tertiary)]"> <h2 className="text-[26px] font-bold tracking-tight text-[var(--text-primary)]">Template catalog</h2>
<i className="inline-block h-[5px] w-[5px] rounded-[1px] bg-[var(--accent-primary)]" /> <p className="mt-1 text-sm text-[var(--text-secondary)]">
catalog
</p>
<h2 className="font-headline text-[26px] font-bold tracking-tight text-[var(--text-primary)]">Template catalog</h2>
<p className="mt-1.5 text-[13px] text-[var(--text-secondary)]">
Production-ready presets — deploy in one click from the console. Production-ready presets — deploy in one click from the console.
</p> </p>
</div> </div>
<Link <Link
to={`/templates${demo}`} to={`/templates${demo}`}
className="s-chip !normal-case !tracking-normal" className="inline-flex items-center gap-1.5 text-[13px] font-medium text-[var(--accent-primary)] hover:underline"
> >
Browse all <ArrowRight size={11} /> Browse all <ArrowRight size={13} />
</Link> </Link>
</div> </div>
<div className="mt-8 grid grid-cols-4 gap-3 max-lg:grid-cols-3 max-sm:grid-cols-2"> <div className="mt-8 grid grid-cols-4 gap-3 max-lg:grid-cols-3 max-sm:grid-cols-2">
@@ -244,20 +170,22 @@ export function LandingPage() {
<Link <Link
key={template.id} key={template.id}
to={`/templates?template=${template.id}${isDemoMode ? '&demo=1' : ''}`} to={`/templates?template=${template.id}${isDemoMode ? '&demo=1' : ''}`}
className="s-card group" className="group rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)] p-4 transition-colors hover:border-[var(--border-strong)]"
> >
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
{template.logo ? ( {template.logo ? (
<img src={template.logo} alt="" className="h-7 w-7 rounded-[6px]" /> <img src={template.logo} alt="" className="h-7 w-7 rounded-md" />
) : ( ) : (
<span className="s-ibox !h-7 !w-7"><LayoutTemplate size={13.5} /></span> <span className="flex h-7 w-7 items-center justify-center rounded-md bg-[var(--surface-muted)] text-[var(--accent-primary)]">
<LayoutTemplate size={14} />
</span>
)} )}
<span className="truncate text-[13px] font-semibold text-[var(--text-primary)]">{template.name}</span> <span className="truncate text-[13px] font-semibold text-[var(--text-primary)]">{template.name}</span>
</div> </div>
<p className="mt-2 line-clamp-2 text-[11.5px] leading-relaxed text-[var(--text-tertiary)]"> <p className="mt-2 line-clamp-2 text-[11.5px] leading-relaxed text-[var(--text-tertiary)]">
{template.description} {template.description}
</p> </p>
<span className="s-chip mt-2" style={{ cursor: 'default' }}> <span className="mt-2 inline-block rounded-full bg-[var(--surface-muted)] px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-[var(--text-tertiary)]">
{template.category || 'app'} {template.category || 'app'}
</span> </span>
</Link> </Link>
@@ -271,15 +199,10 @@ export function LandingPage() {
</div> </div>
</section> </section>
{/* ── Docs ── */} <section id="docs" className="border-t border-[var(--border-subtle)] bg-[var(--bg-base)]/60">
<section id="docs" className="border-b border-[var(--border-subtle)]">
<div className="mx-auto max-w-6xl px-5 py-16"> <div className="mx-auto max-w-6xl px-5 py-16">
<p className="v-mono mb-2 flex items-center gap-2 text-[10.5px] uppercase tracking-[0.1em] text-[var(--text-tertiary)]"> <h2 className="text-[26px] font-bold tracking-tight text-[var(--text-primary)]">Documentation</h2>
<i className="inline-block h-[5px] w-[5px] rounded-[1px] bg-[var(--accent-primary)]" /> <p className="mt-1 text-sm text-[var(--text-secondary)]">
reference
</p>
<h2 className="font-headline text-[26px] font-bold tracking-tight text-[var(--text-primary)]">Documentation</h2>
<p className="mt-1.5 text-[13px] text-[var(--text-secondary)]">
Guides and references — synced from GitHub, cached locally, always available. Guides and references — synced from GitHub, cached locally, always available.
</p> </p>
<div className="mt-8"> <div className="mt-8">
@@ -290,10 +213,10 @@ export function LandingPage() {
</main> </main>
<footer className="border-t border-[var(--border-subtle)]"> <footer className="border-t border-[var(--border-subtle)]">
<div className="v-mono mx-auto flex max-w-6xl items-center justify-between px-5 py-6 text-[11.5px] text-[var(--text-tertiary)]"> <div className="mx-auto flex max-w-6xl items-center justify-between px-5 py-6 text-[12px] text-[var(--text-tertiary)]">
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<img src="/containr.svg" alt="" className="h-4 w-4 rounded" /> <img src="/containr.svg" alt="" className="h-4 w-4 rounded" />
containr — self-hosted container platform Containr — self-hosted container platform
</span> </span>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Link to={`/projects${demo}`} className="hover:text-[var(--text-secondary)]">Projects</Link> <Link to={`/projects${demo}`} className="hover:text-[var(--text-secondary)]">Projects</Link>
@@ -305,3 +228,12 @@ export function LandingPage() {
</div> </div>
); );
} }
function Stat({ label, value }: { label: string; value: number }) {
return (
<div className="rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)] px-4 py-3.5">
<p className="text-[22px] font-bold tabular-nums text-[var(--text-primary)]">{value}</p>
<p className="mt-0.5 text-[11px] uppercase tracking-wider text-[var(--text-tertiary)]">{label}</p>
</div>
);
}
@@ -19,10 +19,8 @@ import { useAuthSession } from '@/lib/use-auth-session';
import { useDemoMode } from '@/lib/demo-mode'; import { useDemoMode } from '@/lib/demo-mode';
import { formatBytes, formatRelative } from '@/lib/time'; import { formatBytes, formatRelative } from '@/lib/time';
import { DemoRestricted } from '@/shared/components'; import { DemoRestricted } from '@/shared/components';
import { GhostBtn, QuietBtn, SPill } from '@/shared/components/sentry';
import { import {
ArrowLeft, CheckCircle2, ChevronRight, Loader2, Pencil, XCircle, ArrowLeft, CheckCircle2, ChevronRight, Loader2, Pencil, XCircle,
Activity, Cpu, ListOrdered, Server,
} from 'lucide-react'; } from 'lucide-react';
const TIME_RANGES = [ const TIME_RANGES = [
@@ -131,7 +129,8 @@ export function NodeDetailPage() {
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<div className="w-full px-4 pt-6 sm:px-8"> <div className="border-b border-[var(--border-subtle)]">
<div className="w-full px-8 py-5">
<div className="flex items-center gap-2 v-mono text-[11px] text-[var(--text-tertiary)] mb-2"> <div className="flex items-center gap-2 v-mono text-[11px] text-[var(--text-tertiary)] mb-2">
<Link to="/usage" className="hover:text-[var(--text-primary)] flex items-center gap-1"> <Link to="/usage" className="hover:text-[var(--text-primary)] flex items-center gap-1">
<ArrowLeft size={12} /> usage <ArrowLeft size={12} /> usage
@@ -141,7 +140,7 @@ export function NodeDetailPage() {
</div> </div>
<div className="flex items-center justify-between gap-4 flex-wrap"> <div className="flex items-center justify-between gap-4 flex-wrap">
<div> <div>
<h1 className="v-title font-headline"> <h1 className="v-title">
{rename === null ? agent.name : rename} {rename === null ? agent.name : rename}
{isAdmin && rename === null && ( {isAdmin && rename === null && (
<button <button
@@ -164,7 +163,7 @@ export function NodeDetailPage() {
if (e.key === 'Escape') setRename(null); if (e.key === 'Escape') setRename(null);
}} }}
autoFocus autoFocus
className="rounded-md border border-[var(--border-subtle)] bg-[var(--bg-elevated)] px-2 py-1 text-sm text-[var(--text-primary)]" className="rounded-md border border-[var(--border-subtle)] bg-[var(--bg-tertiary)] px-2 py-1 text-sm text-[var(--text-primary)]"
/> />
<button onClick={() => updateMutation.mutate({ name: rename })} className="v-mono text-[11px] text-[var(--accent-primary)] hover:underline">save</button> <button onClick={() => updateMutation.mutate({ name: rename })} className="v-mono text-[11px] text-[var(--accent-primary)] hover:underline">save</button>
<button onClick={() => setRename(null)} className="v-mono text-[11px] text-[var(--text-muted)] hover:underline">cancel</button> <button onClick={() => setRename(null)} className="v-mono text-[11px] text-[var(--text-muted)] hover:underline">cancel</button>
@@ -210,7 +209,7 @@ export function NodeDetailPage() {
}} }}
autoFocus autoFocus
placeholder="eu-west, gpu" placeholder="eu-west, gpu"
className="rounded-md border border-[var(--border-subtle)] bg-[var(--bg-elevated)] px-2 py-0.5 text-[11px] text-[var(--text-primary)] w-52" className="rounded-md border border-[var(--border-subtle)] bg-[var(--bg-tertiary)] px-2 py-0.5 text-[11px] text-[var(--text-primary)] w-52"
/> />
<button <button
onClick={() => updateMutation.mutate({ tags: tagDraft.split(',').map((t) => t.trim()).filter(Boolean) })} onClick={() => updateMutation.mutate({ tags: tagDraft.split(',').map((t) => t.trim()).filter(Boolean) })}
@@ -248,7 +247,7 @@ export function NodeDetailPage() {
}} }}
autoFocus autoFocus
placeholder="apps.node1.example.com — empty clears" placeholder="apps.node1.example.com — empty clears"
className="rounded-md border border-[var(--border-subtle)] bg-[var(--bg-elevated)] px-2 py-0.5 text-[11px] text-[var(--text-primary)] w-64" className="rounded-md border border-[var(--border-subtle)] bg-[var(--bg-tertiary)] px-2 py-0.5 text-[11px] text-[var(--text-primary)] w-64"
/> />
<button <button
onClick={() => updateMutation.mutate({ default_domain: domainDraft.trim().toLowerCase() })} onClick={() => updateMutation.mutate({ default_domain: domainDraft.trim().toLowerCase() })}
@@ -259,9 +258,17 @@ export function NodeDetailPage() {
)} )}
</p> </p>
</div> </div>
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-3">
<SPill tone={online ? 'ok' : 'err'}>{agent.status}</SPill> <span className={`v-mono text-[11px] px-2 py-1 rounded-md border ${online
{!agent.schedulable && <SPill tone="warn">cordoned</SPill>} ? 'border-[var(--success)]/40 text-[var(--success)]'
: 'border-[var(--error)]/40 text-[var(--error)]'}`}>
{agent.status}
</span>
{!agent.schedulable && (
<span className="v-mono text-[11px] px-2 py-1 rounded-md border border-[var(--warning)]/40 text-[var(--warning)]">
cordoned
</span>
)}
{isAdmin && ( {isAdmin && (
<> <>
<label className="flex items-center gap-1.5 v-mono text-[11px] text-[var(--text-tertiary)] cursor-pointer" title="Daily automatic docker prune"> <label className="flex items-center gap-1.5 v-mono text-[11px] text-[var(--text-tertiary)] cursor-pointer" title="Daily automatic docker prune">
@@ -273,79 +280,81 @@ export function NodeDetailPage() {
/> />
auto-prune auto-prune
</label> </label>
<QuietBtn <button
onClick={() => cordonMutation.mutate(agent.schedulable)} onClick={() => cordonMutation.mutate(agent.schedulable)}
disabled={cordonMutation.isPending} disabled={cordonMutation.isPending}
title={agent.schedulable title={agent.schedulable
? 'Block new placements — running replicas stay' ? 'Block new placements — running replicas stay'
: 'Re-open for placement'} : 'Re-open for placement'}
className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--border-subtle)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:opacity-50"
> >
{agent.schedulable ? 'cordon' : 'uncordon'} {agent.schedulable ? 'cordon' : 'uncordon'}
</QuietBtn> </button>
<QuietBtn <button
onClick={() => setConfirmDrain(true)} onClick={() => setConfirmDrain(true)}
disabled={drainMutation.isPending || !online} disabled={drainMutation.isPending || !online}
title={online title={online
? 'Cordon + remove all service containers + unpin services (they redeploy locally)' ? 'Cordon + remove all service containers + unpin services (they redeploy locally)'
: 'Node must be online to drain'} : 'Node must be online to drain'}
className="!text-[var(--warning)] hover:!border-[var(--warning)]/60" className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--warning)]/40 text-[var(--warning)] hover:bg-[var(--warning)]/10 disabled:opacity-50"
> >
drain drain
</QuietBtn> </button>
<GhostBtn <button
onClick={() => upgradeMutation.mutate()} onClick={() => upgradeMutation.mutate()}
disabled={upgradeMutation.isPending || !online} disabled={upgradeMutation.isPending || !online}
title={online title={online
? `Upgrade agent to the server's bundled binary (server ${upgradeMutation.data?.version ?? 'build'}) — the agent restarts itself` ? `Upgrade agent to the server's bundled binary (server ${upgradeMutation.data?.version ?? 'build'}) — the agent restarts itself`
: 'Node must be online to upgrade'} : 'Node must be online to upgrade'}
className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--border-subtle)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:opacity-50"
> >
{upgradeMutation.isPending ? 'upgrading…' : 'upgrade agent'} {upgradeMutation.isPending ? 'upgrading…' : 'upgrade agent'}
</GhostBtn> </button>
<QuietBtn <button
onClick={() => setConfirmVolumes(true)} onClick={() => setConfirmVolumes(true)}
disabled={pruneMutation.isPending} disabled={pruneMutation.isPending}
className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--border-subtle)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:opacity-50"
> >
prune now prune now
</QuietBtn> </button>
<QuietBtn <button
onClick={() => setConfirmDelete(true)} onClick={() => setConfirmDelete(true)}
className="!text-[var(--error)] hover:!border-[var(--error)]/60" className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--error)]/40 text-[var(--error)] hover:bg-[var(--error)]/10"
> >
remove remove
</QuietBtn> </button>
</> </>
)} )}
</div> </div>
</div> </div>
</div> </div>
</div>
<div className="px-4 py-6 sm:px-8 space-y-6"> <div className="px-8 py-6 space-y-6">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Stat label="cpu" icon={<Cpu />} value={latest ? `${latest.cpu.usage_percent.toFixed(0)}%` : '—'} sub={latest ? `${latest.cpu.cores} cores` : ''} /> <Stat label="cpu" value={latest ? `${latest.cpu.usage_percent.toFixed(0)}%` : '—'} sub={latest ? `${latest.cpu.cores} cores` : ''} />
<Stat label="memory" icon={<Server />} value={latest ? `${latest.memory.usage_percent.toFixed(0)}%` : '—'} sub={latest ? `${formatBytes(latest.memory.available)} free of ${formatBytes(latest.memory.limit)}` : ''} /> <Stat label="memory" value={latest ? `${latest.memory.usage_percent.toFixed(0)}%` : '—'} sub={latest ? `${formatBytes(latest.memory.available)} free of ${formatBytes(latest.memory.limit)}` : ''} />
<Stat label="load 1m" icon={<Activity />} value={latest ? latest.system_load.load_1m.toFixed(2) : '—'} sub={latest ? `5m ${latest.system_load.load_5m.toFixed(2)} · 15m ${latest.system_load.load_15m.toFixed(2)}` : ''} /> <Stat label="load 1m" value={latest ? latest.system_load.load_1m.toFixed(2) : '—'} sub={latest ? `5m ${latest.system_load.load_5m.toFixed(2)} · 15m ${latest.system_load.load_15m.toFixed(2)}` : ''} />
<Stat label="containers" icon={<ListOrdered />} value={latest ? String(latest.container_count) : String(containers.length)} sub="reported by agent" /> <Stat label="containers" value={latest ? String(latest.container_count) : String(containers.length)} sub="reported by agent" />
</div> </div>
<section className="s-card !p-0"> <section className="panel p-5">
<div className="s-cardhead border-b border-[var(--border-subtle)]"> <div className="flex items-center justify-between mb-4">
<span className="s-ibox"><Activity /></span> <h2 className="v-mono text-[11px] uppercase tracking-wider text-[var(--text-tertiary)]">telemetry</h2>
<h2 className="s-t">Telemetry</h2> <div className="flex gap-1">
<span className="s-trail"> {TIME_RANGES.map((r) => (
<span className="flex gap-1"> <button
{TIME_RANGES.map((r) => ( key={r.value}
<button onClick={() => setTimeRange(r.value)}
key={r.value} className={`v-mono text-[11px] px-2.5 py-1 rounded-md border ${timeRange === r.value
onClick={() => setTimeRange(r.value)} ? 'border-[var(--accent-primary)]/50 text-[var(--accent-primary)]'
className={`s-chip v-mono ${timeRange === r.value ? 'on' : ''}`} : 'border-[var(--border-subtle)] text-[var(--text-muted)] hover:text-[var(--text-primary)]'}`}
> >
{r.label} {r.label}
</button> </button>
))} ))}
</span> </div>
</span>
</div> </div>
<div className="p-4">
{metricsQuery.isLoading ? ( {metricsQuery.isLoading ? (
<p className="text-xs text-[var(--text-muted)]">Loading metrics…</p> <p className="text-xs text-[var(--text-muted)]">Loading metrics…</p>
) : metrics.length < 2 ? ( ) : metrics.length < 2 ? (
@@ -356,17 +365,12 @@ export function NodeDetailPage() {
<Sparkline label="memory %" points={metrics} value={(m) => m.memory.usage_percent} /> <Sparkline label="memory %" points={metrics} value={(m) => m.memory.usage_percent} />
</div> </div>
)} )}
</div>
</section> </section>
<div className="grid gap-6 lg:grid-cols-2"> <div className="grid gap-6 lg:grid-cols-2">
<section className="s-card !p-0"> <section className="panel p-0">
<div className="s-cardhead border-b border-[var(--border-subtle)]"> <h2 className="v-mono text-[11px] uppercase tracking-wider text-[var(--text-tertiary)] px-5 pt-4 pb-3">containers</h2>
<span className="s-ibox"><Server /></span> <div className="divide-y divide-[var(--border-subtle)] border-t border-[var(--border-subtle)]">
<h2 className="s-t">Containers</h2>
<span className="s-trail"><span className="s-chip" style={{ cursor: 'default' }}>{containers.length}</span></span>
</div>
<div className="divide-y divide-[var(--border-subtle)]">
{containers.length === 0 && ( {containers.length === 0 && (
<p className="px-5 py-6 text-xs text-[var(--text-muted)]">No containers reported on this node.</p> <p className="px-5 py-6 text-xs text-[var(--text-muted)]">No containers reported on this node.</p>
)} )}
@@ -376,24 +380,15 @@ export function NodeDetailPage() {
<p className="truncate text-sm text-[var(--text-primary)]">{c.name ?? c.id}</p> <p className="truncate text-sm text-[var(--text-primary)]">{c.name ?? c.id}</p>
<p className="truncate v-mono text-[11px] text-[var(--text-tertiary)]">{c.image}</p> <p className="truncate v-mono text-[11px] text-[var(--text-tertiary)]">{c.image}</p>
</div> </div>
{/* Persisted query cache can replay the raw status object — flatten defensively. */} <span className="v-mono text-[11px] text-[var(--text-secondary)]">{c.status ?? '—'}</span>
<span className="v-mono text-[11px] text-[var(--text-secondary)]">
{typeof c.status === 'string'
? c.status
: (c.status as { state?: string } | undefined)?.state ?? '—'}
</span>
</div> </div>
))} ))}
</div> </div>
</section> </section>
<section className="s-card !p-0"> <section className="panel p-0">
<div className="s-cardhead border-b border-[var(--border-subtle)]"> <h2 className="v-mono text-[11px] uppercase tracking-wider text-[var(--text-tertiary)] px-5 pt-4 pb-3">command history</h2>
<span className="s-ibox"><ListOrdered /></span> <div className="divide-y divide-[var(--border-subtle)] border-t border-[var(--border-subtle)]">
<h2 className="s-t">Command History</h2>
<span className="s-trail"><span className="s-chip" style={{ cursor: 'default' }}>{commands.length}</span></span>
</div>
<div className="divide-y divide-[var(--border-subtle)]">
{commands.length === 0 && ( {commands.length === 0 && (
<p className="px-5 py-6 text-xs text-[var(--text-muted)]">No commands sent to this node yet.</p> <p className="px-5 py-6 text-xs text-[var(--text-muted)]">No commands sent to this node yet.</p>
)} )}
@@ -471,19 +466,12 @@ function meshAddresses(agent: { metadata: Record<string, unknown> }): [string, s
.filter((entry): entry is [string, string] => typeof entry[1] === 'string' && entry[1] !== ''); .filter((entry): entry is [string, string] => typeof entry[1] === 'string' && entry[1] !== '');
} }
function Stat({ label, icon, value, sub }: { label: string; icon: React.ReactNode; value: string; sub?: string }) { function Stat({ label, value, sub }: { label: string; value: string; sub?: string }) {
return ( return (
<div className="s-stat"> <div className="panel p-4">
<div className="px-4 pb-1 pt-4"> <p className="v-mono text-[10px] uppercase tracking-wider text-[var(--text-tertiary)]">{label}</p>
<div className="flex items-center gap-2.5"> <p className="mt-1 text-xl font-semibold text-[var(--text-primary)]">{value}</p>
<span className="s-ibox">{icon}</span> {sub && <p className="mt-0.5 v-mono text-[11px] text-[var(--text-muted)]">{sub}</p>}
<span className="text-[11.5px] font-medium text-[var(--text-secondary)]">{label}</span>
</div>
<p className="mt-3 font-headline text-[24px] font-bold leading-none tabular-nums text-[var(--text-primary)]">{value}</p>
</div>
<div className="s-stat-foot mt-3">
<span>{sub ?? '—'}</span>
</div>
</div> </div>
); );
} }
@@ -522,20 +510,24 @@ function ConfirmModal({ title, body, confirmLabel, danger, extra, onCancel, onCo
pending: boolean; pending: boolean;
}) { }) {
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm" onClick={onCancel}> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onCancel}>
<div className="s-card w-full max-w-sm" onClick={(e) => e.stopPropagation()}> <div className="panel w-full max-w-sm p-5" onClick={(e) => e.stopPropagation()}>
<h3 className="s-t">{title}</h3> <h3 className="text-sm font-semibold text-[var(--text-primary)]">{title}</h3>
<p className="mt-2 text-xs text-[var(--text-secondary)]">{body}</p> <p className="mt-2 text-xs text-[var(--text-secondary)]">{body}</p>
{extra && <div className="mt-3">{extra}</div>} {extra && <div className="mt-3">{extra}</div>}
<div className="mt-5 flex justify-end gap-2"> <div className="mt-5 flex justify-end gap-2">
<QuietBtn onClick={onCancel}>cancel</QuietBtn> <button onClick={onCancel} className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--border-subtle)] text-[var(--text-secondary)]">
<GhostBtn cancel
</button>
<button
onClick={onConfirm} onClick={onConfirm}
disabled={pending} disabled={pending}
className={danger ? '!text-[var(--error)] !border-[var(--error)]/40 !bg-[var(--error)]/10' : undefined} className={`v-mono text-[11px] px-3 py-1.5 rounded-md border disabled:opacity-50 ${danger
? 'border-[var(--error)]/50 text-[var(--error)] hover:bg-[var(--error)]/10'
: 'border-[var(--accent-primary)]/50 text-[var(--accent-primary)] hover:bg-[var(--accent-primary)]/10'}`}
> >
{pending ? 'working…' : confirmLabel} {pending ? 'working…' : confirmLabel}
</GhostBtn> </button>
</div> </div>
</div> </div>
</div> </div>
@@ -4,10 +4,28 @@ import { getOperations, type OperationsView } from '@/lib/api-client';
import { useDemoMode } from '@/lib/demo-mode'; import { useDemoMode } from '@/lib/demo-mode';
import { formatRelative } from '@/lib/time'; import { formatRelative } from '@/lib/time';
import { DemoRestricted } from '@/shared/components'; import { DemoRestricted } from '@/shared/components';
import { SPageHead, SPill, SStat, Ticks } from '@/shared/components/sentry';
import { statusTone } from '@/shared/components/sentry-utils';
import { Activity, AlertTriangle, Clock, Database, Loader2, Timer } from 'lucide-react'; 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 }: { function Section({ title, icon: Icon, count, children }: {
title: string; title: string;
icon: typeof Activity; icon: typeof Activity;
@@ -15,15 +33,13 @@ function Section({ title, icon: Icon, count, children }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<section className="s-card overflow-hidden"> <section className="panel p-5">
<div className="s-cardhead border-b border-[var(--border-subtle)]"> <div className="flex items-center gap-2 mb-4">
<span className="s-ibox"><Icon /></span> <Icon size={16} className="text-[var(--accent-primary)]" />
<h2 className="s-t">{title}</h2> <h2 className="text-sm font-semibold text-[var(--text-primary)]">{title}</h2>
<span className="s-trail"> <span className="text-xs text-[var(--text-muted)] mono">({count})</span>
<span className="s-chip" style={{ cursor: 'default' }}>{count}</span>
</span>
</div> </div>
<div className="p-3">{children}</div> {children}
</section> </section>
); );
} }
@@ -58,8 +74,13 @@ export function OperationsPage() {
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<div className="w-full px-4 pt-6 sm:px-8"> <div className="border-b border-[var(--border-subtle)]">
<SPageHead title="Operations" titleAccent="_" sub="Live view of deploys, queues, cron runs, and backups" /> <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> </div>
{opsQuery.isLoading ? ( {opsQuery.isLoading ? (
@@ -67,12 +88,24 @@ export function OperationsPage() {
<Loader2 size={20} className="animate-spin text-[var(--text-muted)]" /> <Loader2 size={20} className="animate-spin text-[var(--text-muted)]" />
</div> </div>
) : ( ) : (
<div className="w-full px-4 py-6 sm:px-8 space-y-6"> <div className="w-full px-8 py-6 space-y-6">
<div className="grid grid-cols-2 gap-3.5 md:grid-cols-4"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<SStat icon={<Activity />} label="In Flight" value={active.length} foot="deploying now" /> <div className="panel-soft p-4">
<SStat icon={<Clock />} label="Queued" value={queuedTotal} foot="waiting in queue" /> <p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">In flight</p>
<SStat icon={<AlertTriangle />} label="Failed 24h" value={failed.length} foot="need attention" /> <p className="mt-1 text-2xl font-semibold text-[var(--accent-primary)]">{active.length}</p>
<SStat icon={<Database />} label="Backups 24h" value={backups.length} foot="completed runs" /> </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>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6"> <div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
@@ -92,7 +125,7 @@ export function OperationsPage() {
<span className="text-sm text-[var(--text-primary)]">{d.service_name}</span> <span className="text-sm text-[var(--text-primary)]">{d.service_name}</span>
<span className="text-xs text-[var(--text-muted)]"> · {d.project_name}</span> <span className="text-xs text-[var(--text-muted)]"> · {d.project_name}</span>
</div> </div>
<SPill tone={statusTone(d.status)}>{d.status}</SPill> <span className={`text-xs mono ${statusClass(d.status)}`}>{d.status}</span>
<span className="text-xs text-[var(--text-muted)]">{formatRelative(d.created_at)}</span> <span className="text-xs text-[var(--text-muted)]">{formatRelative(d.created_at)}</span>
</button> </button>
))} ))}
@@ -119,7 +152,7 @@ export function OperationsPage() {
<p className="text-[11px] text-[var(--error)]/80 truncate">{d.error}</p> <p className="text-[11px] text-[var(--error)]/80 truncate">{d.error}</p>
)} )}
</div> </div>
<SPill tone={statusTone(d.status)}>{d.status}</SPill> <span className={`text-xs mono ${statusClass(d.status)}`}>{d.status}</span>
<span className="text-xs text-[var(--text-muted)]">{formatRelative(d.created_at)}</span> <span className="text-xs text-[var(--text-muted)]">{formatRelative(d.created_at)}</span>
</button> </button>
))} ))}
@@ -134,9 +167,9 @@ export function OperationsPage() {
<div className="space-y-1.5"> <div className="space-y-1.5">
{cron.map((r) => ( {cron.map((r) => (
<div key={r.id} className="flex items-center gap-3 px-3 py-1.5 text-xs"> <div key={r.id} className="flex items-center gap-3 px-3 py-1.5 text-xs">
<SPill tone={statusTone(r.status)}>{r.status}</SPill> <span className={`mono ${statusClass(r.status)}`}>{r.status}</span>
<span className="text-[var(--text-primary)]">{r.job_name}</span> <span className="text-[var(--text-primary)]">{r.job_name}</span>
<span className="v-mono text-[var(--text-muted)]">{r.schedule}</span> <span className="mono text-[var(--text-muted)]">{r.schedule}</span>
{r.error && <span className="text-[var(--error)]/80 truncate">{r.error}</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> <span className="ml-auto text-[var(--text-muted)]">{formatRelative(r.started_at)}</span>
</div> </div>
@@ -152,7 +185,7 @@ export function OperationsPage() {
<div className="space-y-1.5"> <div className="space-y-1.5">
{backups.map((b) => ( {backups.map((b) => (
<div key={b.id} className="flex items-center gap-3 px-3 py-1.5 text-xs"> <div key={b.id} className="flex items-center gap-3 px-3 py-1.5 text-xs">
<SPill tone={statusTone(b.status)}>{b.status}</SPill> <span className={`mono ${statusClass(b.status)}`}>{b.status}</span>
<span className="text-[var(--text-primary)]">{b.database_name}</span> <span className="text-[var(--text-primary)]">{b.database_name}</span>
<span className="text-[var(--text-muted)]">{b.size}</span> <span className="text-[var(--text-muted)]">{b.size}</span>
<span className="ml-auto text-[var(--text-muted)]">{formatRelative(b.created_at)}</span> <span className="ml-auto text-[var(--text-muted)]">{formatRelative(b.created_at)}</span>
@@ -165,15 +198,14 @@ export function OperationsPage() {
{queue.length > 0 && ( {queue.length > 0 && (
<Section title="Deploy queue depth" icon={Clock} count={queue.length}> <Section title="Deploy queue depth" icon={Clock} count={queue.length}>
<div className="space-y-2"> <div className="space-y-1.5">
{queue.map((q) => ( {queue.map((q) => (
<div key={q.service_id} className="flex items-center gap-3 px-3 py-1.5 text-xs"> <div key={q.service_id} className="flex items-center gap-3 px-3 py-1.5 text-xs mono">
<span className="v-mono text-[var(--text-secondary)]">{q.service_id.slice(0, 8)}</span> <span className="text-[var(--text-secondary)]">{q.service_id.slice(0, 8)}</span>
<SPill tone={q.running ? 'info' : 'off'}>{q.running ? 'running' : 'idle'}</SPill> <span className={q.running ? 'text-[var(--accent-primary)]' : 'text-[var(--text-muted)]'}>
<span className="flex-1 max-w-[220px]"> {q.running ? 'running' : 'idle'}
<Ticks pct={Math.min(100, q.queued * 25)} count={32} warn={!q.running} />
</span> </span>
<span className="v-mono text-[var(--warning)]">{q.queued} queued</span> <span className="text-[var(--warning)]">{q.queued} queued</span>
</div> </div>
))} ))}
</div> </div>
@@ -13,31 +13,10 @@ import {
Search, Search,
FolderOpen, FolderOpen,
X, X,
Cpu,
MemoryStick,
HardDrive,
Rocket,
RefreshCw,
Server,
} from 'lucide-react'; } from 'lucide-react';
import { useAuthSession } from '@/lib/use-auth-session'; import { useAuthSession } from '@/lib/use-auth-session';
import { useDemoMode } from '@/lib/demo-mode'; import { useDemoMode } from '@/lib/demo-mode';
import { demoProjects } from '@/lib/demo-data'; import { demoProjects } from '@/lib/demo-data';
import {
GhostBtn,
IconBtn,
QuietBtn,
SAnomaly,
SCard,
SPageHead,
SPill,
SSection,
SStat,
STable,
Ticks,
type SCol,
} from '@/shared/components/sentry';
import { statusTone } from '@/shared/components/sentry-utils';
const demoDeploys = [ const demoDeploys = [
{ id: 'd1', name: 'API Gateway', project: 'Core Platform', status: 'DEPLOYED', when: '4m' }, { id: 'd1', name: 'API Gateway', project: 'Core Platform', status: 'DEPLOYED', when: '4m' },
@@ -46,8 +25,6 @@ const demoDeploys = [
{ id: 'd4', name: 'Marketing Site', project: 'Growth Surface', status: 'DEPLOYED', when: '2h' }, { id: 'd4', name: 'Marketing Site', project: 'Growth Surface', status: 'DEPLOYED', when: '2h' },
]; ];
type DeployRow = { id: string; name: string; project?: string; status: string; when: string };
function getHealthStatus(stats: ProjectStats): 'healthy' | 'idle' | 'degraded' | 'critical' { function getHealthStatus(stats: ProjectStats): 'healthy' | 'idle' | 'degraded' | 'critical' {
if (stats.service_count === 0) return 'idle'; if (stats.service_count === 0) return 'idle';
if (stats.running_services === stats.service_count) return 'healthy'; if (stats.running_services === stats.service_count) return 'healthy';
@@ -55,6 +32,19 @@ function getHealthStatus(stats: ProjectStats): 'healthy' | 'idle' | 'degraded' |
return 'critical'; return 'critical';
} }
function healthBadge(health: ReturnType<typeof getHealthStatus>) {
switch (health) {
case 'healthy':
return { label: 'RUNNING', cls: 'v-st-ok' };
case 'idle':
return { label: 'IDLE', cls: '' };
case 'degraded':
return { label: 'DEGRADED', cls: 'v-st-warn' };
case 'critical':
return { label: 'DOWN', cls: 'v-st-fail' };
}
}
function formatRelative(date?: string): string { function formatRelative(date?: string): string {
if (!date) return '—'; if (!date) return '—';
const diff = Date.now() - new Date(date).getTime(); const diff = Date.now() - new Date(date).getTime();
@@ -64,7 +54,6 @@ function formatRelative(date?: string): string {
return `${Math.floor(diff / 86_400_000)}d ago`; return `${Math.floor(diff / 86_400_000)}d ago`;
} }
/* Mini service topology preview inside each project card. */
function MiniCanvas({ project }: { project: ProjectEntity }) { function MiniCanvas({ project }: { project: ProjectEntity }) {
const count = project.stats.service_count; const count = project.stats.service_count;
const running = project.stats.running_services; const running = project.stats.running_services;
@@ -95,61 +84,40 @@ function MiniCanvas({ project }: { project: ProjectEntity }) {
function ProjectCard({ project, href }: { project: ProjectEntity; href: string }) { function ProjectCard({ project, href }: { project: ProjectEntity; href: string }) {
const navigate = useNavigate(); const navigate = useNavigate();
const health = getHealthStatus(project.stats); const health = getHealthStatus(project.stats);
const envLabel = const badge = healthBadge(health);
health === 'healthy' ? 'production' : health === 'idle' ? 'idle' : health === 'critical' ? 'down' : 'degraded'; const envOk = health === 'healthy';
const envLabel = envOk ? 'production' : health === 'idle' ? 'idle' : health === 'critical' ? 'down' : 'degraded';
return ( return (
<article <article
className="s-card group cursor-pointer transition-all duration-200 hover:-translate-y-0.5 hover:border-[var(--border-strong)]" className="group panel p-4 cursor-pointer transition-all duration-200 hover:-translate-y-0.5 hover:border-[var(--border-strong)]"
onClick={() => navigate(href)} onClick={() => navigate(href)}
> >
<div className="s-cardhead"> <div className="mb-3 flex items-center gap-2">
<IboxStub /> <h3 className="truncate text-[14.5px] font-bold tracking-tight text-[var(--text-primary)]">
<h3 className="s-t truncate">{project.name}</h3> {project.name}
<span className="s-trail"> </h3>
<SPill <span className={`v-st ml-auto ${badge.cls}`}>{badge.label}</span>
tone={
health === 'healthy' ? 'ok' : health === 'idle' ? 'off' : health === 'critical' ? 'err' : 'warn'
}
>
{health === 'healthy' ? 'Running' : health === 'idle' ? 'Idle' : health === 'critical' ? 'Down' : 'Degraded'}
</SPill>
</span>
</div> </div>
<div className="px-4"> <MiniCanvas project={project} />
<MiniCanvas project={project} /> <div className="v-mono mt-3 flex items-center gap-2 text-[10.5px] text-[var(--text-tertiary)]">
</div> <i
<div className="s-stat-foot mt-3.5"> className="inline-block h-1.5 w-1.5 rounded-full"
<span className="flex items-center gap-2"> style={{ background: envOk ? 'var(--success)' : health === 'idle' ? 'var(--text-tertiary)' : health === 'critical' ? 'var(--error)' : 'var(--warning)' }}
<i />
className="inline-block h-1.5 w-1.5 rounded-full" <span className="text-[var(--text-secondary)]">
style={{ {envLabel}
background:
health === 'healthy'
? 'var(--success)'
: health === 'idle'
? 'var(--text-tertiary)'
: health === 'critical'
? 'var(--error)'
: 'var(--warning)',
}}
/>
{envLabel} · {project.stats.running_services}/{project.stats.service_count} online
</span> </span>
<span>{formatRelative(project.updatedAt)}</span> <span>·</span>
<span>
{project.stats.running_services}/{project.stats.service_count} online
</span>
<span className="ml-auto">{formatRelative(project.updatedAt)}</span>
</div> </div>
</article> </article>
); );
} }
function IboxStub() {
return (
<span className="s-ibox">
<Server size={13.5} />
</span>
);
}
export function ProjectsPage() { export function ProjectsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -213,7 +181,7 @@ export function ProjectsPage() {
const totalServices = projects.reduce((sum, p) => sum + p.stats.service_count, 0); const totalServices = projects.reduce((sum, p) => sum + p.stats.service_count, 0);
const runningServices = projects.reduce((sum, p) => sum + p.stats.running_services, 0); const runningServices = projects.reduce((sum, p) => sum + p.stats.running_services, 0);
const host = hostQuery.data; const host = hostQuery.data;
const hostLabel = host?.scope === 'docker-desktop-vm' ? 'VM' : 'Host'; const hostLabel = host?.scope === 'docker-desktop-vm' ? 'VM' : 'HOST';
const cpuPct = isDemoMode ? 14 : host && host.cpu.cores > 0 const cpuPct = isDemoMode ? 14 : host && host.cpu.cores > 0
? Math.min(100, Math.round((host.load.load1m / host.cpu.cores) * 100)) ? Math.min(100, Math.round((host.load.load1m / host.cpu.cores) * 100))
: null; : null;
@@ -222,9 +190,9 @@ export function ProjectsPage() {
const fmtGB = (v: number) => `${(v / (1024 * 1024 * 1024)).toFixed(0)}G`; const fmtGB = (v: number) => `${(v / (1024 * 1024 * 1024)).toFixed(0)}G`;
const deployments = isDemoMode ? [] : deploysQuery.data ?? []; const deployments = isDemoMode ? [] : deploysQuery.data ?? [];
const deployCount = deployments.length; const deployCount = deployments.length;
const feed: DeployRow[] = isDemoMode const feed = isDemoMode
? demoDeploys ? demoDeploys
: deployments.slice(0, 6).map((d) => ({ : deployments.slice(0, 5).map((d) => ({
id: d.id, id: d.id,
name: d.serviceName || d.imageName || d.serviceId || d.id, name: d.serviceName || d.imageName || d.serviceId || d.id,
project: d.projectName, project: d.projectName,
@@ -232,188 +200,120 @@ export function ProjectsPage() {
when: formatRelative(d.startedAt ?? d.completedAt ?? d.createdAt), when: formatRelative(d.startedAt ?? d.completedAt ?? d.createdAt),
})); }));
const statDefs = [ const statusClass = (status: string) =>
{ status === 'DEPLOYED' || status === 'SUCCESS' || status === 'SUCCEEDED'
icon: <Cpu size={13.5} />, ? 'v-st-ok'
label: `${hostLabel} CPU`, : status === 'BUILDING' || status === 'RUNNING' || status === 'QUEUED'
value: cpuPct ?? '—', ? 'v-st-run'
unit: cpuPct !== null ? '%' : undefined, : status === 'FAILED' || status === 'CANCELLED'
ticks: cpuPct, ? 'v-st-fail'
foot: isDemoMode || host ? `${host?.cpu.cores ?? 16} cores · load avg` : 'no telemetry', : 'v-st-off';
},
{
icon: <MemoryStick size={13.5} />,
label: `${hostLabel} Memory`,
value: memPct ?? '—',
unit: memPct !== null ? '%' : undefined,
ticks: memPct,
foot: isDemoMode
? '9.8G / 13G used'
: host
? `${fmtGB(host.memory.used)} / ${fmtGB(host.memory.total)} used`
: 'no telemetry',
},
{
icon: <HardDrive size={13.5} />,
label: `${hostLabel} Disk`,
value: diskPct ?? '—',
unit: diskPct !== null ? '%' : undefined,
ticks: diskPct,
foot: isDemoMode
? '338G of 465G'
: host
? `${fmtGB(host.storage.used)} of ${fmtGB(host.storage.total)}`
: 'no telemetry',
},
{
icon: <Rocket size={13.5} />,
label: 'Deployments',
value: isDemoMode ? 18 : deployCount,
unit: 'recent',
ticks: null,
foot: isDemoMode
? '17 succeeded'
: `${deployments.filter((d) => (d.status ?? '').toLowerCase() !== 'failed').length} succeeded`,
},
];
const deployCols: SCol<DeployRow>[] = [
{
key: 'service',
label: 'Service',
width: '2.4fr',
sortValue: (r) => r.name,
render: (r) => (
<span className="text-[var(--text-primary)] font-medium">
{r.name}
{r.project ? (
<span className="v-mono ml-2 text-[10.5px] font-normal text-[var(--text-tertiary)]">{r.project}</span>
) : null}
</span>
),
},
{
key: 'status',
label: 'Status',
width: '1fr',
sortValue: (r) => r.status,
render: (r) => <SPill tone={statusTone(r.status)}>{r.status}</SPill>,
},
{
key: 'when',
label: 'When',
width: '0.6fr',
sortable: false,
render: (r) => <span className="v-mono text-[11.5px] text-[var(--text-tertiary)]">{r.when}</span>,
},
];
const anomalies =
(diskPct ?? 0) > 85 || (memPct ?? 0) > 85
? [
...(diskPct ?? 0) > 85 ? [`Disk usage at ${diskPct}% — prune images or expand volume`] : [],
...(memPct ?? 0) > 85 ? [`Memory pressure at ${memPct}% — consider rebalancing services`] : [],
]
: [];
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<div className="w-full px-4 py-6 sm:px-7"> <div className="w-full px-8 py-6">
<SPageHead {/* Page head */}
title="Projects" <div className="mb-6 flex items-center justify-between gap-5">
titleAccent="_" <div>
sub={ <h1 className="v-title">Projects<span className="v-cursor">_</span></h1>
<> <p className="v-mono mt-1.5 text-[11px] text-[var(--text-tertiary)]">
{String(projects.length).padStart(2, '0')} projects · {String(totalServices).padStart(2, '0')} services ·{' '} <b className="text-[var(--text-secondary)]">{String(projects.length).padStart(2, '0')}</b> projects ·{' '}
</> <b className="text-[var(--text-secondary)]">{String(totalServices).padStart(2, '0')}</b> services ·{' '}
} <b className="text-[var(--text-secondary)]">{runningServices}</b> online
subAccent={`${runningServices} online`} </p>
actions={ </div>
<> <div className="flex items-center gap-2.5">
<div className="search-box" style={{ width: 'min(230px, 40vw)' }}> <div className="search-box" style={{ width: 'min(240px, 42vw)' }}>
<Search size={13} /> <Search size={13} />
<input <input
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
placeholder="Search projects" placeholder="search projects…"
/> className="v-mono"
</div> style={{ fontSize: 11.5 }}
<IconBtn title="Refresh" onClick={() => { projectsQuery.refetch(); deploysQuery.refetch(); hostQuery.refetch(); }}> />
<RefreshCw size={14} /> </div>
</IconBtn> {signedIn && (
{signedIn && ( <button onClick={openCreate} className="v-btn">
<GhostBtn onClick={openCreate}>+ New Project</GhostBtn> + new project
)} </button>
</> )}
} </div>
/>
{/* Host telemetry */}
<div className="mb-4 grid grid-cols-2 gap-3.5 lg:grid-cols-4">
{statDefs.map((s) => (
<SStat
key={s.label}
icon={s.icon}
label={s.label}
value={s.value}
unit={s.unit}
foot={s.foot}
delta={s.label === 'Deployments' && feed.length > 0 ? { dir: 'up', text: `+${feed.length}` } : undefined}
extra={s.ticks !== null && s.ticks !== undefined ? <Ticks pct={s.ticks} count={44} warn={s.ticks > 85} /> : undefined}
/>
))}
</div> </div>
{anomalies.length > 0 && ( {/* Host stat row */}
<div className="mb-4"> <div className="mb-7 grid grid-cols-2 gap-3 lg:grid-cols-4">
<SAnomaly <div className="v-stat">
icon={ <div className="v-k"><span>{hostLabel}.CPU</span><span className="v-live" /></div>
<svg width="12.5" height="12.5" viewBox="0 0 16 16" fill="none" stroke="var(--warning)" strokeWidth="1.5"> <div className="v-v">{cpuPct ?? '—'}<span>%</span></div>
<path d="M8 2.4 2.2 13h11.6z" /> {isDemoMode ? (
<path d="M8 6.6v3M8 11.4v.3" /> <div className="v-spark">
</svg> {[28, 44, 36, 58, 42, 66, 50, 34, 56, 40, 30, 20].map((h, i) => (
} <i key={i} style={{ height: `${h}%` }} />
title={<><b className="text-[var(--text-primary)] font-semibold">{anomalies.length}</b> resource anomaly{anomalies.length > 1 ? 's' : ''} detected</>} ))}
items={anomalies} </div>
/> ) : null}
<div className="v-d">
{isDemoMode || host ? (
<><b style={{ color: 'var(--success)' }}>healthy</b> · {host?.cpu.cores ?? 16} cores</>
) : 'no telemetry'}
</div>
</div> </div>
)} <div className="v-stat">
<div className="v-k"><span>{hostLabel}.MEM</span><span className="v-live" /></div>
<div className="v-v">{memPct ?? '—'}<span>%</span></div>
<div className="v-meter"><i className={memPct !== null && memPct > 85 ? 'r' : 'y'} style={{ width: `${memPct ?? 0}%` }} /></div>
<div className="v-d">{isDemoMode ? '9.8G / 13G' : host ? `${fmtGB(host.memory.used)} / ${fmtGB(host.memory.total)}` : 'no telemetry'}</div>
</div>
<div className="v-stat">
<div className="v-k"><span>{hostLabel}.DISK</span><span className="v-live" /></div>
<div className="v-v">{diskPct ?? '—'}<span>%</span></div>
<div className="v-meter"><i className="r" style={{ width: `${diskPct ?? 0}%` }} /></div>
<div className="v-d">{isDemoMode ? '338G of 465G · high' : host ? `${fmtGB(host.storage.used)} of ${fmtGB(host.storage.total)}` : 'no telemetry'}</div>
</div>
<div className="v-stat">
<div className="v-k"><span>DEPLOYS</span></div>
<div className="v-v">{isDemoMode ? 18 : deployCount}</div>
<div className="v-meter"><i style={{ width: '94%' }} /></div>
<div className="v-d"><b style={{ color: 'var(--success)' }}>{isDemoMode ? '17 ok' : `${deployments.filter((d) => (d.status ?? '').toLowerCase() !== 'failed').length} recent`}</b></div>
</div>
</div>
{/* Projects grid */} {/* Projects grid */}
<SSection right={`${filteredProjects.length} records`}>Projects</SSection> <p className="v-sect">PROJECTS <span className="v-sect-r">{filteredProjects.length} records</span></p>
{!isDemoMode && projectsQuery.isLoading && ( {!isDemoMode && projectsQuery.isLoading && (
<div className="grid grid-cols-1 gap-3.5 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4"> <div className="grid grid-cols-1 gap-3.5 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{Array.from({ length: 6 }).map((_, i) => ( {Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="s-card h-[210px] animate-pulse" /> <div key={i} className="panel h-[210px] animate-pulse" />
))} ))}
</div> </div>
)} )}
{!isDemoMode && projectsQuery.isError && ( {!isDemoMode && projectsQuery.isError && (
<div className="s-card p-8 text-center"> <div className="panel p-8 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-[var(--error-soft)]"> <div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-[var(--error-soft)]">
<X size={22} className="text-[var(--error)]" /> <X size={24} className="text-[var(--error)]" />
</div> </div>
<p className="text-[15px] font-semibold text-[var(--text-primary)]">Failed to load projects</p> <p className="text-lg font-medium text-[var(--text-primary)]">Failed to load projects</p>
<p className="mt-1.5 text-[12px] text-[var(--text-tertiary)]">Check API connectivity and try again</p> <p className="mt-2 text-sm text-[var(--text-secondary)]">Check API connectivity and try again</p>
</div> </div>
)} )}
{filteredProjects.length === 0 && !projectsQuery.isLoading && !projectsQuery.isError ? ( {filteredProjects.length === 0 && !projectsQuery.isLoading && !projectsQuery.isError ? (
<div className="s-card p-12 text-center"> <div className="panel p-12 text-center">
<div className="mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-xl border border-[var(--border-default)] bg-[var(--bg-overlay)]"> <div className="mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-[var(--accent-primary-soft)]">
<FolderOpen size={24} className="text-[var(--accent-primary)]" /> <FolderOpen size={28} className="text-[var(--accent-primary)]" />
</div> </div>
<p className="font-headline text-[17px] font-bold text-[var(--text-primary)]">No projects yet</p> <p className="text-xl font-semibold text-[var(--text-primary)]">No projects yet</p>
<p className="mx-auto mt-2 max-w-md text-[12.5px] text-[var(--text-secondary)]"> <p className="mx-auto mt-2 max-w-md text-[var(--text-secondary)]">
{signedIn {signedIn
? 'Create your first project to start deploying services with visual topology management.' ? 'Create your first project to start deploying services with visual topology management.'
: 'No public projects yet. Sign in to create one — an admin approves it before it goes public.'} : 'No public projects yet. Sign in to create one — an admin approves it before it goes public.'}
</p> </p>
<GhostBtn onClick={openCreate} className="mt-6"> <button onClick={openCreate} className="v-btn mt-6">
{signedIn ? '+ New Project' : 'Sign in to create'} {signedIn ? '+ create project' : 'sign in to create'}
</GhostBtn> </button>
</div> </div>
) : null} ) : null}
@@ -426,23 +326,24 @@ export function ProjectsPage() {
)} )}
{/* Deploy feed */} {/* Deploy feed */}
<div className="mt-6"> <p className="v-sect mt-7">RECENT DEPLOYMENTS <span className="v-sect-r">latest</span></p>
<SCard <div className="v-tbl">
icon={<Rocket size={13.5} />} <table>
title="Recent Deployments" <thead>
accent={feed.length > 0 ? `(${feed.length})` : undefined} <tr><th>SERVICE</th><th>STATUS</th><th style={{ textAlign: 'right' }}>WHEN</th></tr>
trail={ </thead>
<IconBtn title="Refresh" onClick={() => deploysQuery.refetch()}> <tbody>
<RefreshCw size={13} /> {feed.length === 0 ? (
</IconBtn> <tr><td colSpan={3} className="text-center" style={{ color: 'var(--text-tertiary)' }}>no deployments yet</td></tr>
} ) : feed.map((d) => (
pad={false} <tr key={d.id}>
> <td className="v-lead">{d.name}{d.project ? <span className="v-mono ml-2 text-[10.5px] font-normal text-[var(--text-tertiary)]">{d.project}</span> : null}</td>
<STable cols={deployCols} rows={feed} rowKey={(r) => r.id} /> <td><span className={`v-st ${statusClass(d.status)}`}>{d.status}</span></td>
{feed.length === 0 && ( <td style={{ textAlign: 'right' }}>{d.when}</td>
<p className="px-7 pb-5 text-[12px] text-[var(--text-tertiary)]">No deployments yet.</p> </tr>
)} ))}
</SCard> </tbody>
</table>
</div> </div>
</div> </div>
@@ -450,55 +351,58 @@ export function ProjectsPage() {
{isCreateOpen && ( {isCreateOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"> <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={() => setCreateOpen(false)} /> <div className="absolute inset-0 bg-[var(--bg-void)]/80 backdrop-blur-sm" onClick={() => setCreateOpen(false)} />
<div className="s-card relative w-full max-w-lg p-6"> <div className="relative w-full max-w-lg panel p-6">
<h2 className="font-headline text-[19px] font-bold tracking-tight text-[var(--text-primary)]">New project</h2> <h2 className="text-xl font-semibold text-[var(--text-primary)]">Create new project</h2>
<p className="mt-1 text-[12.5px] text-[var(--text-secondary)]"> <p className="mt-1 text-sm text-[var(--text-secondary)]">
Projects organize your services and provide a visual canvas for topology management. Projects organize your services and provide a visual canvas for topology management.
</p> </p>
<div className="s-inset mt-3 text-[11.5px] text-[var(--warning)]"> <p className="mt-2 rounded-[var(--radius-md)] bg-[var(--warning-soft)] px-3 py-2 text-xs text-[var(--warning)]">
New projects stay private until a platform admin approves them for public listing. New projects stay private until a platform admin approves them for public listing.
</div> </p>
<div className="mt-5 space-y-4"> <div className="mt-6 space-y-4">
<div> <div>
<label className="mb-2 block text-[11px] font-medium uppercase tracking-wider text-[var(--text-tertiary)]"> <label className="mb-2 block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)]">
Project Name Project Name
</label> </label>
<input <input
value={form.name} value={form.name}
onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))} onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))}
placeholder="my-awesome-project" placeholder="my-awesome-project"
className="h-10 w-full rounded-lg border border-[var(--border-default)] bg-[var(--bg-overlay)] px-3.5 text-[13px] text-[var(--text-primary)] transition-all placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:outline-none" className="h-11 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-4 text-[var(--text-primary)] transition-all placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)]"
/> />
</div> </div>
<div> <div>
<label className="mb-2 block text-[11px] font-medium uppercase tracking-wider text-[var(--text-tertiary)]"> <label className="mb-2 block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)]">
Description <span className="normal-case text-[var(--text-muted)]">(optional)</span> Description <span className="normal-case text-[var(--text-muted)]">(optional)</span>
</label> </label>
<textarea <textarea
value={form.description} value={form.description}
onChange={(e) => setForm((p) => ({ ...p, description: e.target.value }))} onChange={(e) => setForm((p) => ({ ...p, description: e.target.value }))}
placeholder="Describe your project…" placeholder="Describe your project..."
rows={3} rows={3}
className="w-full resize-none rounded-lg border border-[var(--border-default)] bg-[var(--bg-overlay)] px-3.5 py-3 text-[13px] text-[var(--text-primary)] transition-all placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:outline-none" className="w-full resize-none rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-4 py-3 text-[var(--text-primary)] transition-all placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)]"
/> />
</div> </div>
</div> </div>
{createProjectMutation.isError && ( {createProjectMutation.isError && (
<div className="s-inset mt-4 text-[12px] text-[var(--error)]"> <div className="mt-4 rounded-[var(--radius-md)] bg-[var(--error-soft)] px-4 py-3 text-sm text-[var(--error)]">
{(createProjectMutation.error as Error).message} {(createProjectMutation.error as Error).message}
</div> </div>
)} )}
<div className="mt-6 flex justify-end gap-2.5"> <div className="mt-6 flex justify-end gap-3">
<QuietBtn onClick={() => setCreateOpen(false)}>Cancel</QuietBtn> <button onClick={() => setCreateOpen(false)} className="v-btn-ghost">
<GhostBtn Cancel
</button>
<button
onClick={() => createProjectMutation.mutate()} onClick={() => createProjectMutation.mutate()}
disabled={!form.name.trim() || createProjectMutation.isPending} disabled={!form.name.trim() || createProjectMutation.isPending}
className="v-btn"
> >
{createProjectMutation.isPending ? 'Creating…' : 'Create Project'} {createProjectMutation.isPending ? 'Creating...' : 'Create Project'}
</GhostBtn> </button>
</div> </div>
</div> </div>
</div> </div>
+196 -174
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { import {
@@ -104,7 +104,6 @@ import { formatRelative } from '@/lib/time';
import { getAuthBaseUrl, signOutAuthSession } from '@/lib/auth-client'; import { getAuthBaseUrl, signOutAuthSession } from '@/lib/auth-client';
import { useBuildUpdates } from '@/lib/use-build-updates'; import { useBuildUpdates } from '@/lib/use-build-updates';
import { EnhancedMetricCard, LineAreaChart, DonutChart, SegmentedBar, BarChart, DemoRestricted } from '@/shared/components'; import { EnhancedMetricCard, LineAreaChart, DonutChart, SegmentedBar, BarChart, DemoRestricted } from '@/shared/components';
import { SPageHead } from '@/shared/components/sentry';
import { import {
Clock, Clock,
Activity, Activity,
@@ -149,10 +148,13 @@ import {
Send, Send,
} from 'lucide-react'; } from 'lucide-react';
function SecondaryPageHeader({ title, description, actions }: { title: string; description: string; actions?: ReactNode }) { function SecondaryPageHeader({ title, description }: { title: string; description: string }) {
return ( return (
<div className="w-full px-4 pt-6 sm:px-8"> <div className="border-b border-[var(--border-subtle)]">
<SPageHead title={title} titleAccent="_" sub={description} actions={actions} /> <div className="w-full px-8 py-5">
<h1 className="v-title">{title}<span className="v-cursor">_</span></h1>
<p className="v-mono mt-1.5 text-[11px] text-[var(--text-tertiary)]">{description}</p>
</div>
</div> </div>
); );
} }
@@ -178,17 +180,15 @@ function StatCard({
}; };
return ( return (
<div className="s-stat"> <div className="panel p-5">
<div className="px-4 pb-1 pt-4"> <div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2.5"> <div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--surface-muted)] flex items-center justify-center">
<span className="s-ibox"><Icon /></span> <Icon size={18} className="text-[var(--text-tertiary)]" />
<span className="text-[11.5px] font-medium text-[var(--text-secondary)]">{title}</span>
</div> </div>
<p className={`font-headline mt-4 text-[30px] font-bold leading-none tracking-tight ${colorClasses[color]}`}>{value}</p>
</div>
<div className="s-stat-foot mt-4">
<span>{description}</span>
</div> </div>
<p className={`text-2xl font-semibold ${colorClasses[color]}`}>{value}</p>
<p className="text-xs font-medium text-[var(--text-muted)] mt-1">{title}</p>
<p className="text-xs text-[var(--text-tertiary)] mt-2">{description}</p>
</div> </div>
); );
} }
@@ -436,7 +436,7 @@ export function UsagePage() {
title="Usage" title="Usage"
description="Platform usage metrics and operational summaries" description="Platform usage metrics and operational summaries"
/> />
<div className="w-full px-4 py-6 sm:px-8"> <div className="w-full px-8 py-6">
<div className="flex items-center gap-2 mb-6"> <div className="flex items-center gap-2 mb-6">
<div className={`w-2 h-2 rounded-full ${liveStatus === 'live' ? 'bg-[var(--success)] animate-pulse' : 'bg-[var(--text-muted)]'}`} /> <div className={`w-2 h-2 rounded-full ${liveStatus === 'live' ? 'bg-[var(--success)] animate-pulse' : 'bg-[var(--text-muted)]'}`} />
<span className={`text-sm ${liveStatus === 'live' ? 'text-[var(--success)]' : 'text-[var(--text-muted)]'}`}> <span className={`text-sm ${liveStatus === 'live' ? 'text-[var(--success)]' : 'text-[var(--text-muted)]'}`}>
@@ -450,14 +450,14 @@ export function UsagePage() {
<p className="mt-3 text-sm text-[var(--text-muted)]">Loading usage data...</p> <p className="mt-3 text-sm text-[var(--text-muted)]">Loading usage data...</p>
</div> </div>
) : !isDemoMode && buildsQuery.isError ? ( ) : !isDemoMode && buildsQuery.isError ? (
<div className="s-card p-8 text-center"> <div className="panel p-8 text-center">
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-[var(--error-soft)] flex items-center justify-center"> <div className="w-12 h-12 mx-auto mb-4 rounded-full bg-[var(--error-soft)] flex items-center justify-center">
<AlertCircle size={24} className="text-[var(--error)]" /> <AlertCircle size={24} className="text-[var(--error)]" />
</div> </div>
<p className="text-sm text-[var(--error)]">Failed to load usage data</p> <p className="text-sm text-[var(--error)]">Failed to load usage data</p>
</div> </div>
) : !signedIn ? ( ) : !signedIn ? (
<div className="s-card p-8 text-center"> <div className="panel p-8 text-center">
<p className="text-sm text-[var(--text-secondary)]">Sign in to view build activity and capacity.</p> <p className="text-sm text-[var(--text-secondary)]">Sign in to view build activity and capacity.</p>
</div> </div>
) : ( ) : (
@@ -490,12 +490,14 @@ export function UsagePage() {
/> />
</div> </div>
<section className="s-card p-5"> <section className="panel p-6">
<div className="mb-5 flex items-center justify-between gap-3"> <div className="mb-5 flex items-center justify-between gap-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="s-ibox !w-9 !h-9"><Server /></span> <div className="flex h-10 w-10 items-center justify-center rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)]">
<Server size={18} className="text-[var(--accent-primary)]" />
</div>
<div> <div>
<h2 className="s-t">Host Monitoring</h2> <h2 className="text-lg font-semibold text-[var(--text-primary)]">Host Monitoring</h2>
<p className="text-xs text-[var(--text-tertiary)]"> <p className="text-xs text-[var(--text-tertiary)]">
{host {host
? `${host.hostname} · ${host.os}/${host.architecture} · uptime ${formatUptime(host.uptimeSeconds)}${host.dockerAvailable ? '' : ' · Docker unavailable'}` ? `${host.hostname} · ${host.os}/${host.architecture} · uptime ${formatUptime(host.uptimeSeconds)}${host.dockerAvailable ? '' : ' · Docker unavailable'}`
@@ -576,11 +578,13 @@ export function UsagePage() {
)} )}
</section> </section>
<section className="s-card p-5"> <section className="panel p-6">
<div className="mb-5 flex items-center gap-3"> <div className="mb-5 flex items-center gap-3">
<span className="s-ibox !w-9 !h-9"><Radio /></span> <div className="flex h-10 w-10 items-center justify-center rounded-[var(--radius-md)] bg-[var(--surface-muted)]">
<Radio size={18} className="text-[var(--text-tertiary)]" />
</div>
<div> <div>
<h2 className="s-t">Node Agents</h2> <h2 className="text-lg font-semibold text-[var(--text-primary)]">Node Agents</h2>
<p className="text-xs text-[var(--text-tertiary)]">Remote VPS, VM, and LXC capacity for placement and autoscaling.</p> <p className="text-xs text-[var(--text-tertiary)]">Remote VPS, VM, and LXC capacity for placement and autoscaling.</p>
</div> </div>
</div> </div>
@@ -674,8 +678,8 @@ export function UsagePage() {
<button <button
onClick={() => createTokenMutation.mutate()} onClick={() => createTokenMutation.mutate()}
disabled={createTokenMutation.isPending} disabled={createTokenMutation.isPending}
className="s-btn-accent h-8 disabled:opacity-50" className="h-8 px-3 rounded-[var(--radius-md)] text-xs font-medium text-[var(--accent-on)] transition-all disabled:opacity-50"
style={{ background: 'var(--accent-primary)' }}
> >
{createTokenMutation.isPending ? 'Issuing…' : 'Issue token'} {createTokenMutation.isPending ? 'Issuing…' : 'Issue token'}
</button> </button>
@@ -684,7 +688,7 @@ export function UsagePage() {
{issuedToken && ( {issuedToken && (
<div className="mb-3 rounded-[var(--radius-md)] border border-[var(--success)]/30 bg-[var(--success-soft)] p-3"> <div className="mb-3 rounded-[var(--radius-md)] border border-[var(--success)]/30 bg-[var(--success-soft)] p-3">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<span className="v-mono text-xs text-[var(--text-primary)] break-all">{issuedToken.token}</span> <span className="mono text-xs text-[var(--text-primary)] break-all">{issuedToken.token}</span>
<button <button
onClick={() => void navigator.clipboard.writeText(issuedToken.token ?? '')} onClick={() => void navigator.clipboard.writeText(issuedToken.token ?? '')}
className="flex items-center gap-1 text-xs text-[var(--accent-primary)] hover:underline shrink-0" className="flex items-center gap-1 text-xs text-[var(--accent-primary)] hover:underline shrink-0"
@@ -702,11 +706,11 @@ export function UsagePage() {
</p> </p>
)} )}
<pre className="v-mono text-xs text-[var(--text-secondary)] whitespace-pre-wrap">{installCommand}</pre> <pre className="mono text-xs text-[var(--text-secondary)] whitespace-pre-wrap">{installCommand}</pre>
<pre className="v-mono text-xs text-[var(--text-secondary)] whitespace-pre-wrap mt-2">{sshCommand}</pre> <pre className="mono text-xs text-[var(--text-secondary)] whitespace-pre-wrap mt-2">{sshCommand}</pre>
<div className="mt-4 space-y-2 text-xs text-[var(--text-tertiary)]"> <div className="mt-4 space-y-2 text-xs text-[var(--text-tertiary)]">
<p>Agent endpoint: <span className="v-mono text-[var(--text-primary)]">{agentEndpoint}</span></p> <p>Agent endpoint: <span className="mono text-[var(--text-primary)]">{agentEndpoint}</span></p>
<p>Use an issued token above, or set <span className="v-mono text-[var(--text-primary)]">CONTAINR_AGENT_AUTH_TOKENS</span> on the backend.</p> <p>Use an issued token above, or set <span className="mono text-[var(--text-primary)]">CONTAINR_AGENT_AUTH_TOKENS</span> on the backend.</p>
<p>Agent sends host resources, polls pending Docker commands, and reports command results.</p> <p>Agent sends host resources, polls pending Docker commands, and reports command results.</p>
</div> </div>
@@ -840,7 +844,7 @@ export function PeoplePage() {
title="People" title="People"
description="Team management and access control" description="Team management and access control"
/> />
<div className="w-full px-4 py-6 sm:px-8"> <div className="w-full px-8 py-6">
{profileQuery.isLoading ? ( {profileQuery.isLoading ? (
<div className="py-16 text-center"> <div className="py-16 text-center">
<Loader2 size={24} className="animate-spin mx-auto text-[var(--text-tertiary)]" /> <Loader2 size={24} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
@@ -872,17 +876,19 @@ export function PeoplePage() {
/> />
</div> </div>
<section className="s-card p-5"> <section className="panel p-6">
<div className="mb-5 flex items-center gap-3"> <div className="mb-5 flex items-center gap-3">
<span className="s-ibox !w-9 !h-9"><UserPlus /></span> <div className="flex h-10 w-10 items-center justify-center rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)]">
<UserPlus size={18} className="text-[var(--accent-primary)]" />
</div>
<div> <div>
<h2 className="s-t">Manual User Creation</h2> <h2 className="text-lg font-semibold text-[var(--text-primary)]">Manual User Creation</h2>
<p className="text-xs text-[var(--text-tertiary)]">Public registration is closed after bootstrap.</p> <p className="text-xs text-[var(--text-tertiary)]">Public registration is closed after bootstrap.</p>
</div> </div>
</div> </div>
<form <form
className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_auto]" className="grid gap-3 lg:grid-cols-[1fr_1fr_1fr_auto]"
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault(); event.preventDefault();
createUserMutation.mutate(); createUserMutation.mutate();
@@ -915,8 +921,8 @@ export function PeoplePage() {
<button <button
type="submit" type="submit"
disabled={createUserMutation.isPending} disabled={createUserMutation.isPending}
className="s-btn-accent h-10 justify-center disabled:opacity-60" className="inline-flex h-10 items-center justify-center gap-2 rounded-[var(--radius-md)] px-4 text-sm font-semibold text-[var(--accent-on)] disabled:opacity-60"
style={{ background: 'var(--accent-primary)' }}
> >
{createUserMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : <UserPlus size={14} />} {createUserMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : <UserPlus size={14} />}
Create Create
@@ -1051,12 +1057,14 @@ function GitProvidersSection() {
const selectedType = gitProviderTypes.find((t) => t.value === form.name)!; const selectedType = gitProviderTypes.find((t) => t.value === form.name)!;
return ( return (
<section className="s-card p-5"> <section className="panel p-6">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="s-ibox !w-9 !h-9"><GitBranch /></span> <div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<GitBranch size={18} className="text-[var(--accent-primary)]" />
</div>
<div> <div>
<h2 className="s-t">Git Providers</h2> <h2 className="text-lg font-semibold text-[var(--text-primary)]">Git Providers</h2>
<p className="text-xs text-[var(--text-tertiary)]">Connect accounts to deploy services from repositories</p> <p className="text-xs text-[var(--text-tertiary)]">Connect accounts to deploy services from repositories</p>
</div> </div>
</div> </div>
@@ -1065,8 +1073,8 @@ function GitProvidersSection() {
<button <button
onClick={() => provisionMutation.mutate()} onClick={() => provisionMutation.mutate()}
disabled={provisionMutation.isPending} disabled={provisionMutation.isPending}
className="s-btn-accent h-9 disabled:opacity-50" className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-[var(--accent-on)] text-sm font-medium shadow-lg transition-all disabled:opacity-50"
style={{ background: 'var(--accent-primary)' }}
> >
<GitBranch size={14} /> <GitBranch size={14} />
{provisionMutation.isPending ? 'Opening GitHub…' : 'Set up GitHub App'} {provisionMutation.isPending ? 'Opening GitHub…' : 'Set up GitHub App'}
@@ -1082,8 +1090,8 @@ function GitProvidersSection() {
)} )}
<button <button
onClick={() => setFormOpen((open) => !open)} onClick={() => setFormOpen((open) => !open)}
className="s-btn-accent h-9" className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-[var(--accent-on)] text-sm font-medium shadow-lg transition-all"
style={{ background: 'var(--accent-primary)' }}
> >
<Link2 size={14} /> <Link2 size={14} />
{formOpen ? 'Close' : 'Connect'} {formOpen ? 'Close' : 'Connect'}
@@ -1132,10 +1140,12 @@ function GitProvidersSection() {
{providers.map((provider) => ( {providers.map((provider) => (
<div <div
key={provider.id} key={provider.id}
className="s-inset flex items-center justify-between" className="flex items-center justify-between p-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]"
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="s-ibox"><GitBranch /></span> <div className="w-8 h-8 rounded-[var(--radius-sm)] bg-[var(--surface-card)] flex items-center justify-center">
<GitBranch size={14} className="text-[var(--text-tertiary)]" />
</div>
<div> <div>
<p className="text-sm font-medium text-[var(--text-primary)]">{provider.display_name}</p> <p className="text-sm font-medium text-[var(--text-primary)]">{provider.display_name}</p>
<p className="text-xs text-[var(--text-muted)] capitalize">{provider.name.replace('_', ' ')}</p> <p className="text-xs text-[var(--text-muted)] capitalize">{provider.name.replace('_', ' ')}</p>
@@ -1191,7 +1201,7 @@ function GitProvidersSection() {
type="password" type="password"
value={form.accessToken} value={form.accessToken}
onChange={(e) => setForm((p) => ({ ...p, accessToken: e.target.value }))} onChange={(e) => setForm((p) => ({ ...p, accessToken: e.target.value }))}
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm v-mono focus:border-[var(--accent-primary)] transition-colors" className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm mono focus:border-[var(--accent-primary)] transition-colors"
placeholder="••••••••••••••••" placeholder="••••••••••••••••"
/> />
<p className="text-xs text-[var(--text-muted)] mt-1.5">{selectedType.tokenHint}</p> <p className="text-xs text-[var(--text-muted)] mt-1.5">{selectedType.tokenHint}</p>
@@ -1204,7 +1214,7 @@ function GitProvidersSection() {
<input <input
value={form.apiUrl} value={form.apiUrl}
onChange={(e) => setForm((p) => ({ ...p, apiUrl: e.target.value }))} onChange={(e) => setForm((p) => ({ ...p, apiUrl: e.target.value }))}
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm v-mono focus:border-[var(--accent-primary)] transition-colors" className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm mono focus:border-[var(--accent-primary)] transition-colors"
placeholder="https://gitea.example.com" placeholder="https://gitea.example.com"
/> />
</div> </div>
@@ -1218,8 +1228,8 @@ function GitProvidersSection() {
<button <button
onClick={() => createMutation.mutate()} onClick={() => createMutation.mutate()}
disabled={!form.accessToken.trim() || createMutation.isPending} disabled={!form.accessToken.trim() || createMutation.isPending}
className="s-btn-accent h-9 disabled:opacity-50 disabled:cursor-not-allowed" className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-[var(--accent-on)] text-sm font-medium shadow-lg disabled:opacity-50 disabled:cursor-not-allowed transition-all"
style={{ background: 'var(--accent-primary)' }}
> >
{createMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />} {createMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />}
Connect Provider Connect Provider
@@ -1264,7 +1274,7 @@ export function GitHubAppCallbackPage() {
{!code ? ( {!code ? (
<> <>
<AlertCircle size={28} className="mx-auto text-[var(--error)]" /> <AlertCircle size={28} className="mx-auto text-[var(--error)]" />
<h1 className="mt-4 font-headline text-lg font-semibold text-[var(--text-primary)]">Missing code</h1> <h1 className="mt-4 text-lg font-semibold text-[var(--text-primary)]">Missing code</h1>
<p className="mt-2 text-sm text-[var(--text-tertiary)]"> <p className="mt-2 text-sm text-[var(--text-tertiary)]">
GitHub did not return an app code. Start the setup again from Git providers. GitHub did not return an app code. Start the setup again from Git providers.
</p> </p>
@@ -1272,14 +1282,14 @@ export function GitHubAppCallbackPage() {
) : convertMutation.isError ? ( ) : convertMutation.isError ? (
<> <>
<AlertCircle size={28} className="mx-auto text-[var(--error)]" /> <AlertCircle size={28} className="mx-auto text-[var(--error)]" />
<h1 className="mt-4 font-headline text-lg font-semibold text-[var(--text-primary)]">Setup failed</h1> <h1 className="mt-4 text-lg font-semibold text-[var(--text-primary)]">Setup failed</h1>
<p className="mt-2 text-sm text-[var(--text-tertiary)]"> <p className="mt-2 text-sm text-[var(--text-tertiary)]">
{convertMutation.error instanceof Error ? convertMutation.error.message : 'Could not provision the GitHub App'} {convertMutation.error instanceof Error ? convertMutation.error.message : 'Could not provision the GitHub App'}
</p> </p>
<button <button
type="button" type="button"
onClick={() => navigate('/settings', { replace: true })} onClick={() => navigate('/settings', { replace: true })}
className="s-btn-accent mt-6" className="mt-6 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-sm font-semibold text-[var(--accent-on)]"
> >
Back to settings Back to settings
</button> </button>
@@ -1287,7 +1297,7 @@ export function GitHubAppCallbackPage() {
) : ( ) : (
<> <>
<Loader2 size={28} className="mx-auto animate-spin text-[var(--accent-primary)]" /> <Loader2 size={28} className="mx-auto animate-spin text-[var(--accent-primary)]" />
<h1 className="mt-4 font-headline text-lg font-semibold text-[var(--text-primary)]">Setting up GitHub App</h1> <h1 className="mt-4 text-lg font-semibold text-[var(--text-primary)]">Setting up GitHub App</h1>
<p className="mt-2 text-sm text-[var(--text-tertiary)]">Exchanging the app credentials…</p> <p className="mt-2 text-sm text-[var(--text-tertiary)]">Exchanging the app credentials…</p>
</> </>
)} )}
@@ -1343,11 +1353,13 @@ function PlatformSettingsSection() {
}; };
return ( return (
<section className="s-card p-5"> <section className="panel p-6">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<span className="s-ibox !w-9 !h-9"><Cloud /></span> <div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<Cloud size={18} className="text-[var(--accent-primary)]" />
</div>
<div> <div>
<h2 className="s-t">Platform</h2> <h2 className="text-lg font-semibold text-[var(--text-primary)]">Platform</h2>
<p className="text-xs text-[var(--text-tertiary)]">Owner only - values set here override environment variables</p> <p className="text-xs text-[var(--text-tertiary)]">Owner only - values set here override environment variables</p>
</div> </div>
</div> </div>
@@ -1400,8 +1412,8 @@ function PlatformSettingsSection() {
<button <button
onClick={saveToken} onClick={saveToken}
disabled={saveMutation.isPending || !tokenDraft.trim()} disabled={saveMutation.isPending || !tokenDraft.trim()}
className="s-btn-accent h-9 disabled:opacity-50 disabled:cursor-not-allowed transition-colors" 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 Save
</button> </button>
@@ -1463,8 +1475,8 @@ function PlatformSettingsSection() {
saveMutation.mutate({ branding: b }); saveMutation.mutate({ branding: b });
}} }}
disabled={saveMutation.isPending || Object.keys(brandDraft).length === 0} disabled={saveMutation.isPending || Object.keys(brandDraft).length === 0}
className="s-btn-accent h-9 disabled:opacity-50 disabled:cursor-not-allowed transition-colors" 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 Save branding
</button> </button>
@@ -1536,11 +1548,13 @@ function UserTokensSection({ isAdmin }: { isAdmin: boolean }) {
]; ];
return ( return (
<section className="s-card p-5"> <section className="panel p-6">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<span className="s-ibox !w-9 !h-9"><Key /></span> <div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<Key size={18} className="text-[var(--accent-primary)]" />
</div>
<div> <div>
<h2 className="s-t">Personal Access Tokens</h2> <h2 className="text-lg font-semibold text-[var(--text-primary)]">Personal Access Tokens</h2>
<p className="text-xs text-[var(--text-tertiary)]">Authenticate the CLI, MCP server, and scripts - same rights as you, bounded by scope</p> <p className="text-xs text-[var(--text-tertiary)]">Authenticate the CLI, MCP server, and scripts - same rights as you, bounded by scope</p>
</div> </div>
</div> </div>
@@ -1574,8 +1588,8 @@ function UserTokensSection({ isAdmin }: { isAdmin: boolean }) {
<button <button
onClick={() => createMutation.mutate()} onClick={() => createMutation.mutate()}
disabled={createMutation.isPending || !tokenName.trim()} disabled={createMutation.isPending || !tokenName.trim()}
className="s-btn-accent h-9 disabled:opacity-50 disabled:cursor-not-allowed" 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-all"
style={{ background: 'var(--accent-primary)' }}
> >
{createMutation.isPending ? 'Creating…' : 'Create token'} {createMutation.isPending ? 'Creating…' : 'Create token'}
</button> </button>
@@ -1584,7 +1598,7 @@ function UserTokensSection({ isAdmin }: { isAdmin: boolean }) {
{issuedToken ? ( {issuedToken ? (
<div className="rounded-[var(--radius-md)] border border-[var(--success)]/30 bg-[var(--success-soft)] p-3"> <div className="rounded-[var(--radius-md)] border border-[var(--success)]/30 bg-[var(--success-soft)] p-3">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<span className="v-mono text-xs text-[var(--text-primary)] break-all">{issuedToken.token}</span> <span className="mono text-xs text-[var(--text-primary)] break-all">{issuedToken.token}</span>
<button <button
onClick={() => void navigator.clipboard.writeText(issuedToken.token ?? '')} onClick={() => void navigator.clipboard.writeText(issuedToken.token ?? '')}
className="flex items-center gap-1 text-xs text-[var(--accent-primary)] hover:underline shrink-0" className="flex items-center gap-1 text-xs text-[var(--accent-primary)] hover:underline shrink-0"
@@ -1594,7 +1608,7 @@ function UserTokensSection({ isAdmin }: { isAdmin: boolean }) {
</button> </button>
</div> </div>
<p className="mt-1 text-xs text-[var(--text-tertiary)]"> <p className="mt-1 text-xs text-[var(--text-tertiary)]">
Shown once - store it now. Use with <span className="v-mono">containr auth login --token</span> or as a Bearer token. Shown once - store it now. Use with <span className="mono">containr auth login --token</span> or as a Bearer token.
</p> </p>
</div> </div>
) : null} ) : null}
@@ -1616,7 +1630,7 @@ function UserTokensSection({ isAdmin }: { isAdmin: boolean }) {
<div key={token.id} className="flex items-center justify-between gap-2 text-xs"> <div key={token.id} className="flex items-center justify-between gap-2 text-xs">
<div className="min-w-0"> <div className="min-w-0">
<span className="text-[var(--text-primary)]">{token.name}</span> <span className="text-[var(--text-primary)]">{token.name}</span>
<span className="ml-2 v-mono text-[var(--text-muted)]">{token.key_prefix}…</span> <span className="ml-2 mono text-[var(--text-muted)]">{token.key_prefix}…</span>
<span className="ml-2 rounded px-1.5 py-0.5 bg-[var(--surface-muted)] text-[var(--text-secondary)]">{token.scope}</span> <span className="ml-2 rounded px-1.5 py-0.5 bg-[var(--surface-muted)] text-[var(--text-secondary)]">{token.scope}</span>
<span className="ml-2 text-[var(--text-muted)]"> <span className="ml-2 text-[var(--text-muted)]">
{token.last_used_at ? `used ${formatRelative(token.last_used_at)}` : 'unused'} {token.last_used_at ? `used ${formatRelative(token.last_used_at)}` : 'unused'}
@@ -1678,11 +1692,13 @@ function RegistriesSection() {
const registries = registriesQuery.data ?? []; const registries = registriesQuery.data ?? [];
return ( return (
<section className="s-card p-5"> <section className="panel p-6">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<span className="s-ibox !w-9 !h-9"><Archive /></span> <div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<Archive size={18} className="text-[var(--accent-primary)]" />
</div>
<div> <div>
<h2 className="s-t">Registry credentials</h2> <h2 className="text-lg font-semibold text-[var(--text-primary)]">Registry credentials</h2>
<p className="text-xs text-[var(--text-tertiary)]">Pull private images on deploy - matched by image host, stored encrypted</p> <p className="text-xs text-[var(--text-tertiary)]">Pull private images on deploy - matched by image host, stored encrypted</p>
</div> </div>
</div> </div>
@@ -1717,8 +1733,8 @@ function RegistriesSection() {
<button <button
onClick={() => createMutation.mutate()} onClick={() => createMutation.mutate()}
disabled={createMutation.isPending || !name.trim() || !host.trim()} disabled={createMutation.isPending || !name.trim() || !host.trim()}
className="s-btn-accent h-9 disabled:opacity-50 disabled:cursor-not-allowed" 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-all"
style={{ background: 'var(--accent-primary)' }}
> >
{createMutation.isPending ? 'Adding…' : 'Add registry'} {createMutation.isPending ? 'Adding…' : 'Add registry'}
</button> </button>
@@ -1744,7 +1760,7 @@ function RegistriesSection() {
<div key={r.id} className="flex items-center justify-between gap-2 text-xs"> <div key={r.id} className="flex items-center justify-between gap-2 text-xs">
<div className="min-w-0"> <div className="min-w-0">
<span className="text-[var(--text-primary)]">{r.name}</span> <span className="text-[var(--text-primary)]">{r.name}</span>
<span className="ml-2 v-mono text-[var(--text-muted)]">{r.host}</span> <span className="ml-2 mono text-[var(--text-muted)]">{r.host}</span>
{r.username ? <span className="ml-2 text-[var(--text-secondary)]">{r.username}</span> : null} {r.username ? <span className="ml-2 text-[var(--text-secondary)]">{r.username}</span> : null}
<span className="ml-2 rounded px-1.5 py-0.5 bg-[var(--surface-muted)] text-[var(--text-secondary)]"> <span className="ml-2 rounded px-1.5 py-0.5 bg-[var(--surface-muted)] text-[var(--text-secondary)]">
{r.has_password ? 'password set' : 'anonymous'} {r.has_password ? 'password set' : 'anonymous'}
@@ -1812,11 +1828,13 @@ function BackupTargetsSection() {
const fieldCls = 'h-9 flex-1 min-w-[130px] rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all'; const fieldCls = 'h-9 flex-1 min-w-[130px] rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all';
return ( return (
<section className="s-card p-5"> <section className="panel p-6">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<span className="s-ibox !w-9 !h-9"><HardDrive /></span> <div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<HardDrive size={18} className="text-[var(--accent-primary)]" />
</div>
<div> <div>
<h2 className="s-t">Backup targets</h2> <h2 className="text-lg font-semibold text-[var(--text-primary)]">Backup targets</h2>
<p className="text-xs text-[var(--text-tertiary)]">S3-compatible destinations for database archives — keys stored encrypted</p> <p className="text-xs text-[var(--text-tertiary)]">S3-compatible destinations for database archives — keys stored encrypted</p>
</div> </div>
</div> </div>
@@ -1835,8 +1853,8 @@ function BackupTargetsSection() {
<button <button
onClick={() => createMutation.mutate()} onClick={() => createMutation.mutate()}
disabled={createMutation.isPending || !form.name.trim() || !form.endpoint.trim() || !form.bucket.trim()} disabled={createMutation.isPending || !form.name.trim() || !form.endpoint.trim() || !form.bucket.trim()}
className="s-btn-accent h-9 disabled:opacity-50 disabled:cursor-not-allowed" 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-all"
style={{ background: 'var(--accent-primary)' }}
> >
{createMutation.isPending ? 'Checking…' : 'Add target'} {createMutation.isPending ? 'Checking…' : 'Add target'}
</button> </button>
@@ -1862,7 +1880,7 @@ function BackupTargetsSection() {
<div key={t.id} className="flex items-center justify-between gap-2 text-xs"> <div key={t.id} className="flex items-center justify-between gap-2 text-xs">
<div className="min-w-0"> <div className="min-w-0">
<span className="text-[var(--text-primary)]">{t.name}</span> <span className="text-[var(--text-primary)]">{t.name}</span>
<span className="ml-2 v-mono text-[var(--text-muted)]">{t.endpoint}/{t.bucket}{t.prefix ? `/${t.prefix}` : ''}</span> <span className="ml-2 mono text-[var(--text-muted)]">{t.endpoint}/{t.bucket}{t.prefix ? `/${t.prefix}` : ''}</span>
<span className="ml-2 rounded px-1.5 py-0.5 bg-[var(--surface-muted)] text-[var(--text-secondary)]"> <span className="ml-2 rounded px-1.5 py-0.5 bg-[var(--surface-muted)] text-[var(--text-secondary)]">
{t.has_credentials ? 'keys set' : 'anonymous'}{t.use_tls ? '' : ' · no TLS'} {t.has_credentials ? 'keys set' : 'anonymous'}{t.use_tls ? '' : ' · no TLS'}
</span> </span>
@@ -1943,11 +1961,13 @@ function NotificationChannelsSection() {
const channels = channelsQuery.data ?? []; const channels = channelsQuery.data ?? [];
return ( return (
<section className="s-card p-5"> <section className="panel p-6">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<span className="s-ibox !w-9 !h-9"><Bell /></span> <div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<Bell size={18} className="text-[var(--accent-primary)]" />
</div>
<div> <div>
<h2 className="s-t">Push notifications</h2> <h2 className="text-lg font-semibold text-[var(--text-primary)]">Push notifications</h2>
<p className="text-xs text-[var(--text-tertiary)]">Relay notifications to a self-hosted ntfy or Gotify server</p> <p className="text-xs text-[var(--text-tertiary)]">Relay notifications to a self-hosted ntfy or Gotify server</p>
</div> </div>
</div> </div>
@@ -1978,8 +1998,8 @@ function NotificationChannelsSection() {
<button <button
onClick={() => createMutation.mutate()} onClick={() => createMutation.mutate()}
disabled={createMutation.isPending || !endpoint.trim()} disabled={createMutation.isPending || !endpoint.trim()}
className="s-btn-accent h-9 disabled:opacity-50 disabled:cursor-not-allowed" 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-all"
style={{ background: 'var(--accent-primary)' }}
> >
{createMutation.isPending ? 'Adding…' : 'Add channel'} {createMutation.isPending ? 'Adding…' : 'Add channel'}
</button> </button>
@@ -2007,7 +2027,7 @@ function NotificationChannelsSection() {
<span className="rounded px-1.5 py-0.5 bg-[var(--surface-muted)] text-[var(--text-secondary)] uppercase"> <span className="rounded px-1.5 py-0.5 bg-[var(--surface-muted)] text-[var(--text-secondary)] uppercase">
{ch.kind} {ch.kind}
</span> </span>
<span className="ml-2 v-mono text-[var(--text-primary)] break-all">{ch.endpoint}</span> <span className="ml-2 mono text-[var(--text-primary)] break-all">{ch.endpoint}</span>
{testResult[ch.id] === 'ok' ? ( {testResult[ch.id] === 'ok' ? (
<span className="ml-2 text-[var(--success)]">delivered</span> <span className="ml-2 text-[var(--success)]">delivered</span>
) : testResult[ch.id] ? ( ) : testResult[ch.id] ? (
@@ -2125,14 +2145,16 @@ export function SettingsPage() {
title="Settings" title="Settings"
description="Manage account profile and local configuration" description="Manage account profile and local configuration"
/> />
<div className="w-full px-4 py-6 sm:px-8"> <div className="w-full px-8 py-6">
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6"> <div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
{/* Profile Section */} {/* Profile Section */}
<section className="s-card p-5"> <section className="panel p-6">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<span className="s-ibox !w-9 !h-9"><User /></span> <div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<User size={18} className="text-[var(--accent-primary)]" />
</div>
<div> <div>
<h2 className="s-t">Profile</h2> <h2 className="text-lg font-semibold text-[var(--text-primary)]">Profile</h2>
<p className="text-xs text-[var(--text-tertiary)]">Backed by GET/PUT /user/profile</p> <p className="text-xs text-[var(--text-tertiary)]">Backed by GET/PUT /user/profile</p>
</div> </div>
</div> </div>
@@ -2174,8 +2196,8 @@ export function SettingsPage() {
<button <button
onClick={() => updateProfileMutation.mutate()} onClick={() => updateProfileMutation.mutate()}
disabled={!hasProfileChanges || updateProfileMutation.isPending} disabled={!hasProfileChanges || updateProfileMutation.isPending}
className="s-btn-accent h-9 disabled:opacity-50 disabled:cursor-not-allowed" className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-[var(--accent-on)] text-sm font-medium shadow-lg disabled:opacity-50 disabled:cursor-not-allowed transition-all"
style={{ background: 'var(--accent-primary)' }}
> >
{updateProfileMutation.isPending ? ( {updateProfileMutation.isPending ? (
<> <>
@@ -2226,11 +2248,13 @@ export function SettingsPage() {
<GitProvidersSection /> <GitProvidersSection />
{/* Audit Logs Section */} {/* Audit Logs Section */}
<section className="s-card p-5"> <section className="panel p-6">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<span className="s-ibox !w-9 !h-9"><ScrollText /></span> <div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<ScrollText size={18} className="text-[var(--accent-primary)]" />
</div>
<div> <div>
<h2 className="s-t">Audit Logs</h2> <h2 className="text-lg font-semibold text-[var(--text-primary)]">Audit Logs</h2>
<p className="text-xs text-[var(--text-tertiary)]">Every authenticated action recorded by the platform</p> <p className="text-xs text-[var(--text-tertiary)]">Every authenticated action recorded by the platform</p>
</div> </div>
</div> </div>
@@ -2247,11 +2271,13 @@ export function SettingsPage() {
</section> </section>
{/* Runtime & Local State Section */} {/* Runtime & Local State Section */}
<section className="s-card p-5"> <section className="panel p-6">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<span className="s-ibox !w-9 !h-9"><Settings /></span> <div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--surface-muted)] flex items-center justify-center">
<Settings size={18} className="text-[var(--text-tertiary)]" />
</div>
<div> <div>
<h2 className="s-t">Runtime & Local State</h2> <h2 className="text-lg font-semibold text-[var(--text-primary)]">Runtime & Local State</h2>
<p className="text-xs text-[var(--text-tertiary)]">API endpoints, cookie-session mode, and storage diagnostics</p> <p className="text-xs text-[var(--text-tertiary)]">API endpoints, cookie-session mode, and storage diagnostics</p>
</div> </div>
</div> </div>
@@ -2262,7 +2288,7 @@ export function SettingsPage() {
<Key size={14} className="text-[var(--text-tertiary)]" /> <Key size={14} className="text-[var(--text-tertiary)]" />
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">API Base</span> <span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">API Base</span>
</div> </div>
<p className="v-mono text-xs text-[var(--text-primary)] break-all">{getApiBaseUrl()}</p> <p className="mono text-xs text-[var(--text-primary)] break-all">{getApiBaseUrl()}</p>
</div> </div>
<div className="p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]"> <div className="p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]">
@@ -2270,7 +2296,7 @@ export function SettingsPage() {
<Key size={14} className="text-[var(--text-tertiary)]" /> <Key size={14} className="text-[var(--text-tertiary)]" />
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Auth Base</span> <span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Auth Base</span>
</div> </div>
<p className="v-mono text-xs text-[var(--text-primary)] break-all">{getAuthBaseUrl()}</p> <p className="mono text-xs text-[var(--text-primary)] break-all">{getAuthBaseUrl()}</p>
</div> </div>
<div className="p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]"> <div className="p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]">
@@ -2324,7 +2350,7 @@ export function SettingsPage() {
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">Cached Keys</p> <p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">Cached Keys</p>
<ul className="space-y-1"> <ul className="space-y-1">
{storageSummary.canvasKeys.slice(0, 5).map((key) => ( {storageSummary.canvasKeys.slice(0, 5).map((key) => (
<li key={key} className="v-mono text-xs text-[var(--text-tertiary)] truncate"> <li key={key} className="mono text-xs text-[var(--text-tertiary)] truncate">
{key} {key}
</li> </li>
))} ))}
@@ -2350,7 +2376,7 @@ export function DocsPage() {
title="Docs" title="Docs"
description="Guides and references — synced from GitHub, cached offline" description="Guides and references — synced from GitHub, cached offline"
/> />
<div className="w-full px-4 py-6 sm:px-8"> <div className="w-full px-8 py-6">
<DocsBrowser /> <DocsBrowser />
</div> </div>
</div> </div>
@@ -2487,7 +2513,7 @@ export function DatabasesPage() {
title="Databases" title="Databases"
description="Managed database services — connection info, runtime actions and backups" description="Managed database services — connection info, runtime actions and backups"
/> />
<div className="w-full px-4 py-6 sm:px-8"> <div className="w-full px-8 py-6">
<div className="mb-5 flex items-center justify-between"> <div className="mb-5 flex items-center justify-between">
<p className="text-xs text-[var(--text-tertiary)]"> <p className="text-xs text-[var(--text-tertiary)]">
{databases.length} {databases.length === 1 ? 'database' : 'databases'} {databases.length} {databases.length === 1 ? 'database' : 'databases'}
@@ -2496,7 +2522,7 @@ export function DatabasesPage() {
<button <button
type="button" type="button"
onClick={() => setCreateOpen(true)} onClick={() => setCreateOpen(true)}
className="s-btn-accent h-9 text-[12.5px]" className="inline-flex h-9 items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 text-[12.5px] font-semibold text-[var(--accent-on)]"
> >
<Database size={13} /> New database <Database size={13} /> New database
</button> </button>
@@ -2514,7 +2540,7 @@ export function DatabasesPage() {
<button <button
type="button" type="button"
onClick={() => setCreateOpen(true)} onClick={() => setCreateOpen(true)}
className="s-btn-accent mt-4 text-[12.5px]" className="mt-4 inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-[12.5px] font-semibold text-[var(--accent-on)]"
> >
<Database size={13} /> Create your first database <Database size={13} /> Create your first database
</button> </button>
@@ -2548,7 +2574,7 @@ export function DatabasesPage() {
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm font-medium text-[var(--text-primary)]">{db.name}</span> <span className="text-sm font-medium text-[var(--text-primary)]">{db.name}</span>
<span className="text-xs px-1.5 py-0.5 rounded bg-[var(--surface-muted)] text-[var(--text-secondary)] v-mono">{db.type}{db.version ? ` ${db.version}` : ''}</span> <span className="text-xs px-1.5 py-0.5 rounded bg-[var(--surface-muted)] text-[var(--text-secondary)] mono">{db.type}{db.version ? ` ${db.version}` : ''}</span>
{db.provider === 'external' && ( {db.provider === 'external' && (
<span className="text-xs px-1.5 py-0.5 rounded bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]">external</span> <span className="text-xs px-1.5 py-0.5 rounded bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]">external</span>
)} )}
@@ -2568,7 +2594,7 @@ export function DatabasesPage() {
<div> <div>
<p className="mb-1.5 text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Connection URL</p> <p className="mb-1.5 text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Connection URL</p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<code className="v-mono flex-1 truncate rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2 text-xs text-[var(--text-primary)]"> <code className="mono flex-1 truncate rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2 text-xs text-[var(--text-primary)]">
{db.connection_url || '—'} {db.connection_url || '—'}
</code> </code>
{db.connection_url && ( {db.connection_url && (
@@ -2591,7 +2617,7 @@ export function DatabasesPage() {
<button <button
onClick={() => testByIdMutation.mutate(db.id ?? '')} onClick={() => testByIdMutation.mutate(db.id ?? '')}
disabled={testByIdMutation.isPending} disabled={testByIdMutation.isPending}
className="s-btn-quiet" className="flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] disabled:opacity-40 transition-colors"
> >
<RefreshCw size={11} /> Test connection <RefreshCw size={11} /> Test connection
</button> </button>
@@ -2608,21 +2634,21 @@ export function DatabasesPage() {
<button <button
onClick={() => actionMutation.mutate({ id: db.id ?? '', action: 'start' })} onClick={() => actionMutation.mutate({ id: db.id ?? '', action: 'start' })}
disabled={running || actionMutation.isPending} disabled={running || actionMutation.isPending}
className="s-btn-quiet" className="flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] disabled:opacity-40 transition-colors"
> >
<Play size={11} /> Start <Play size={11} /> Start
</button> </button>
<button <button
onClick={() => actionMutation.mutate({ id: db.id ?? '', action: 'stop' })} onClick={() => actionMutation.mutate({ id: db.id ?? '', action: 'stop' })}
disabled={!running || actionMutation.isPending} disabled={!running || actionMutation.isPending}
className="s-btn-quiet" className="flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] disabled:opacity-40 transition-colors"
> >
<Square size={11} /> Stop <Square size={11} /> Stop
</button> </button>
<button <button
onClick={() => actionMutation.mutate({ id: db.id ?? '', action: 'restart' })} onClick={() => actionMutation.mutate({ id: db.id ?? '', action: 'restart' })}
disabled={!running || actionMutation.isPending} disabled={!running || actionMutation.isPending}
className="s-btn-quiet" className="flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] disabled:opacity-40 transition-colors"
> >
<RefreshCw size={11} /> Restart <RefreshCw size={11} /> Restart
</button> </button>
@@ -2639,7 +2665,7 @@ export function DatabasesPage() {
onClick={() => setBindDb(bindDb?.id === db.id ? null : db)} onClick={() => setBindDb(bindDb?.id === db.id ? null : db)}
disabled={!db.connection_url} disabled={!db.connection_url}
title={db.connection_url ? 'Inject connection URL into a service' : 'No connection URL available'} title={db.connection_url ? 'Inject connection URL into a service' : 'No connection URL available'}
className="s-btn-quiet" className="flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] disabled:opacity-40 transition-colors"
> >
<Link2 size={11} /> Bind <Link2 size={11} /> Bind
</button> </button>
@@ -2671,7 +2697,7 @@ export function DatabasesPage() {
] as const).map(([label, value]) => ( ] as const).map(([label, value]) => (
<div key={label} className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2"> <div key={label} className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2">
<p className="text-xs text-[var(--text-muted)]">{label}</p> <p className="text-xs text-[var(--text-muted)]">{label}</p>
<p className="v-mono text-sm text-[var(--text-primary)]">{value}</p> <p className="mono text-sm text-[var(--text-primary)]">{value}</p>
</div> </div>
))} ))}
</div> </div>
@@ -2717,8 +2743,8 @@ export function DatabasesPage() {
<button <button
onClick={() => backupMutation.mutate(db.id ?? '')} onClick={() => backupMutation.mutate(db.id ?? '')}
disabled={!running || backupMutation.isPending} disabled={!running || backupMutation.isPending}
className="s-btn-accent disabled:opacity-40" className="flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] text-xs font-medium text-[var(--accent-on)] disabled:opacity-40 transition-all"
style={{ background: 'var(--accent-primary)' }}
> >
<Archive size={11} /> <Archive size={11} />
{backupMutation.isPending ? 'Starting…' : 'New backup'} {backupMutation.isPending ? 'Starting…' : 'New backup'}
@@ -2738,7 +2764,7 @@ export function DatabasesPage() {
{backups.map((b) => ( {backups.map((b) => (
<div key={b.id} className="flex items-center gap-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2 text-xs"> <div key={b.id} className="flex items-center gap-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2 text-xs">
<span className="text-[var(--text-secondary)]">{formatRelative(b.created_at)}</span> <span className="text-[var(--text-secondary)]">{formatRelative(b.created_at)}</span>
<span className="v-mono text-[var(--text-muted)]">{b.size}</span> <span className="mono text-[var(--text-muted)]">{b.size}</span>
<span className={`${b.status === 'completed' ? 'text-[var(--success)]' : b.status === 'in_progress' ? 'text-[var(--warning)]' : 'text-[var(--error)]'}`}>{b.status}</span> <span className={`${b.status === 'completed' ? 'text-[var(--success)]' : b.status === 'in_progress' ? 'text-[var(--warning)]' : 'text-[var(--error)]'}`}>{b.status}</span>
{b.remote && <span className="rounded bg-[var(--surface-card)] px-1.5 py-0.5 text-[var(--accent-primary)]" title="Also shipped to the configured backup target">offsite</span>} {b.remote && <span className="rounded bg-[var(--surface-card)] px-1.5 py-0.5 text-[var(--accent-primary)]" title="Also shipped to the configured backup target">offsite</span>}
{b.status === 'completed' && ( {b.status === 'completed' && (
@@ -2968,7 +2994,7 @@ export function DatabasesPage() {
(createMode === 'managed' ? createMutation.isPending : registerExternalMutation.isPending) || (createMode === 'managed' ? createMutation.isPending : registerExternalMutation.isPending) ||
(createMode === 'managed' ? createForm.name.trim().length === 0 : externalForm.name.trim().length === 0 || externalForm.host.trim().length === 0) (createMode === 'managed' ? createForm.name.trim().length === 0 : externalForm.name.trim().length === 0 || externalForm.host.trim().length === 0)
} }
className="s-btn-accent disabled:opacity-50" className="inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-xs font-semibold text-[var(--accent-on)] disabled:opacity-50"
> >
{(createMode === 'managed' ? createMutation.isPending : registerExternalMutation.isPending) ? <Loader2 size={13} className="animate-spin" /> : null} {(createMode === 'managed' ? createMutation.isPending : registerExternalMutation.isPending) ? <Loader2 size={13} className="animate-spin" /> : null}
{createMode === 'managed' ? 'Create database' : 'Register database'} {createMode === 'managed' ? 'Create database' : 'Register database'}
@@ -3015,10 +3041,10 @@ function BindDatabasePanel({
const services = servicesQuery.data ?? []; const services = servicesQuery.data ?? [];
return ( return (
<div className="s-card space-y-3"> <div className="rounded-[var(--radius-md)] border border-[var(--border-default)] bg-[var(--surface-card)] p-4 space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="s-t">Bind to service</p> <p className="text-xs font-medium text-[var(--text-primary)]">Bind to service</p>
<button onClick={onClose} className="v-mono text-[11px] text-[var(--text-muted)] hover:text-[var(--text-primary)]">close</button> <button onClick={onClose} className="text-xs text-[var(--text-muted)] hover:text-[var(--text-primary)]">Close</button>
</div> </div>
<p className="text-xs text-[var(--text-muted)]"> <p className="text-xs text-[var(--text-muted)]">
Injects this database's connection URL as an environment variable on the selected service. Injects this database's connection URL as an environment variable on the selected service.
@@ -3056,13 +3082,13 @@ function BindDatabasePanel({
<input <input
value={varKey} value={varKey}
onChange={(e) => setVarKey(e.target.value)} onChange={(e) => setVarKey(e.target.value)}
className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-2 py-1.5 text-xs v-mono text-[var(--text-primary)] w-40" className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-2 py-1.5 text-xs mono text-[var(--text-primary)] w-40"
/> />
</label> </label>
<button <button
onClick={() => bindMutation.mutate()} onClick={() => bindMutation.mutate()}
disabled={!serviceId || !varKey.trim() || bindMutation.isPending} disabled={!serviceId || !varKey.trim() || bindMutation.isPending}
className="s-btn-accent disabled:opacity-40" className="flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-xs font-medium disabled:opacity-40"
> >
{bindMutation.isPending ? <Loader2 size={11} className="animate-spin" /> : <Link2 size={11} />} Bind {bindMutation.isPending ? <Loader2 size={11} className="animate-spin" /> : <Link2 size={11} />} Bind
</button> </button>
@@ -3103,7 +3129,7 @@ function BackupScheduleRow({
value={value} value={value}
onChange={(e) => setValue(e.target.value)} onChange={(e) => setValue(e.target.value)}
placeholder="cron schedule, e.g. 0 3 * * *" placeholder="cron schedule, e.g. 0 3 * * *"
className="w-44 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-2 py-1.5 text-xs v-mono text-[var(--text-primary)]" className="w-44 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-2 py-1.5 text-xs mono text-[var(--text-primary)]"
/> />
<button <button
onClick={() => mutation.mutate(value.trim())} onClick={() => mutation.mutate(value.trim())}
@@ -3225,7 +3251,7 @@ export function HighAvailabilityPage() {
title="High availability" title="High availability"
description="Failover manager, policies, and active alerts" description="Failover manager, policies, and active alerts"
/> />
<div className="p-4 space-y-8 sm:p-8"> <div className="p-8 space-y-8">
<div className="flex items-center gap-2 text-xs text-[var(--text-tertiary)]"> <div className="flex items-center gap-2 text-xs text-[var(--text-tertiary)]">
<ShieldCheck size={13} className="text-[var(--accent-primary)]" /> <ShieldCheck size={13} className="text-[var(--accent-primary)]" />
<span>HA state feeds the platform security posture.</span> <span>HA state feeds the platform security posture.</span>
@@ -3271,17 +3297,17 @@ export function HighAvailabilityPage() {
</div> </div>
{isAdmin ? ( {isAdmin ? (
<div className="s-card p-5"> <div className="panel p-6">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<Zap size={18} className="text-[var(--accent-primary)]" /> <Zap size={18} className="text-[var(--accent-primary)]" />
<h2 className="s-t">Manager controls</h2> <h2 className="text-base font-semibold text-[var(--text-primary)]">Manager controls</h2>
</div> </div>
<div className="flex flex-wrap items-end gap-3"> <div className="flex flex-wrap items-end gap-3">
<button <button
type="button" type="button"
disabled={toggleMutation.isPending || statusQuery.isLoading} disabled={toggleMutation.isPending || statusQuery.isLoading}
onClick={() => toggleMutation.mutate(!status?.enabled)} onClick={() => toggleMutation.mutate(!status?.enabled)}
className="s-btn-accent disabled:opacity-50" className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50"
> >
{status?.enabled ? 'Disable HA manager' : 'Enable HA manager'} {status?.enabled ? 'Disable HA manager' : 'Enable HA manager'}
</button> </button>
@@ -3312,10 +3338,10 @@ export function HighAvailabilityPage() {
</div> </div>
) : null} ) : null}
<div className="s-card p-5"> <div className="panel p-6">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<AlertCircle size={18} className="text-[var(--accent-primary)]" /> <AlertCircle size={18} className="text-[var(--accent-primary)]" />
<h2 className="s-t">Active alerts</h2> <h2 className="text-base font-semibold text-[var(--text-primary)]">Active alerts</h2>
</div> </div>
{!isDemoMode && alertsQuery.isLoading ? ( {!isDemoMode && alertsQuery.isLoading ? (
<p className="text-sm text-[var(--text-secondary)]">Loading…</p> <p className="text-sm text-[var(--text-secondary)]">Loading…</p>
@@ -3339,7 +3365,7 @@ export function HighAvailabilityPage() {
type="button" type="button"
disabled={resolveMutation.isPending} disabled={resolveMutation.isPending}
onClick={() => alert.id && resolveMutation.mutate(alert.id)} onClick={() => alert.id && resolveMutation.mutate(alert.id)}
className="s-btn-quiet" className="ml-4 shrink-0 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs text-[var(--text-primary)] disabled:opacity-50"
> >
Resolve Resolve
</button> </button>
@@ -3350,17 +3376,17 @@ export function HighAvailabilityPage() {
)} )}
</div> </div>
<div className="s-card p-5"> <div className="panel p-6">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<ShieldCheck size={18} className="text-[var(--accent-primary)]" /> <ShieldCheck size={18} className="text-[var(--accent-primary)]" />
<h2 className="s-t">Failover policies</h2> <h2 className="text-base font-semibold text-[var(--text-primary)]">Failover policies</h2>
</div> </div>
{policyForm === null && isAdmin && ( {policyForm === null && isAdmin && (
<button <button
type="button" type="button"
onClick={() => setPolicyForm({ serviceId: '', strategy: 'active_passive', minHealthyNodes: '1', maxFailures: '3', enabled: true, editing: false })} onClick={() => setPolicyForm({ serviceId: '', strategy: 'active_passive', minHealthyNodes: '1', maxFailures: '3', enabled: true, editing: false })}
className="s-btn-accent " className="px-3 py-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-xs font-medium"
> >
New policy New policy
</button> </button>
@@ -3454,14 +3480,14 @@ export function HighAvailabilityPage() {
type="button" type="button"
disabled={savePolicyMutation.isPending || !policyForm.serviceId} disabled={savePolicyMutation.isPending || !policyForm.serviceId}
onClick={() => savePolicyMutation.mutate()} onClick={() => savePolicyMutation.mutate()}
className="s-btn-accent disabled:opacity-50" className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50"
> >
{savePolicyMutation.isPending ? 'Saving…' : 'Save policy'} {savePolicyMutation.isPending ? 'Saving…' : 'Save policy'}
</button> </button>
<button <button
type="button" type="button"
onClick={() => setPolicyForm(null)} onClick={() => setPolicyForm(null)}
className="s-btn-quiet" className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
> >
Cancel Cancel
</button> </button>
@@ -3500,7 +3526,7 @@ export function HighAvailabilityPage() {
enabled: policy.enabled ?? true, enabled: policy.enabled ?? true,
editing: true, editing: true,
})} })}
className="s-btn-quiet" className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs text-[var(--text-primary)]"
> >
Edit Edit
</button> </button>
@@ -3527,10 +3553,10 @@ export function HighAvailabilityPage() {
)} )}
</div> </div>
<div className="s-card p-5"> <div className="panel p-6">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<HeartPulse size={18} className="text-[var(--accent-primary)]" /> <HeartPulse size={18} className="text-[var(--accent-primary)]" />
<h2 className="s-t">Health check results</h2> <h2 className="text-base font-semibold text-[var(--text-primary)]">Health check results</h2>
</div> </div>
{!isDemoMode && healthQuery.isLoading ? ( {!isDemoMode && healthQuery.isLoading ? (
<p className="text-sm text-[var(--text-secondary)]">Loading…</p> <p className="text-sm text-[var(--text-secondary)]">Loading…</p>
@@ -3589,43 +3615,39 @@ export function SecurityPage() {
const projectsQuery = useQuery({ queryKey: ['projects'], queryFn: () => listProjects(), enabled: !isDemoMode }); const projectsQuery = useQuery({ queryKey: ['projects'], queryFn: () => listProjects(), enabled: !isDemoMode });
const projects = isDemoMode ? demoProjects : projectsQuery.data ?? []; const projects = isDemoMode ? demoProjects : projectsQuery.data ?? [];
// Auto-select the first project so the page never renders empty — derived, not
// stored, so clearing the select still shows the empty state explicitly.
const effectiveProjectId = projectId || projects[0]?.id || '';
const servicesQuery = useQuery({ const servicesQuery = useQuery({
queryKey: ['services', effectiveProjectId], queryKey: ['services', projectId],
queryFn: () => listServicesByProject(effectiveProjectId), queryFn: () => listServicesByProject(projectId),
enabled: !isDemoMode && Boolean(effectiveProjectId), enabled: !isDemoMode && Boolean(projectId),
}); });
const services = isDemoMode ? getDemoServicesByProject(effectiveProjectId) : servicesQuery.data ?? []; const services = isDemoMode ? getDemoServicesByProject(projectId) : servicesQuery.data ?? [];
const metricsQuery = useQuery({ const metricsQuery = useQuery({
queryKey: ['security-metrics', effectiveProjectId], queryKey: ['security-metrics', projectId],
queryFn: () => getSecurityMetrics(effectiveProjectId), queryFn: () => getSecurityMetrics(projectId),
enabled: !isDemoMode && Boolean(effectiveProjectId), enabled: !isDemoMode && Boolean(projectId),
refetchInterval: 15_000, refetchInterval: 15_000,
}); });
const historyQuery = useQuery({ const historyQuery = useQuery({
queryKey: ['security-history', effectiveProjectId], queryKey: ['security-history', projectId],
queryFn: () => getSecurityHistory(effectiveProjectId), queryFn: () => getSecurityHistory(projectId),
enabled: !isDemoMode && Boolean(effectiveProjectId), enabled: !isDemoMode && Boolean(projectId),
refetchInterval: 15_000, refetchInterval: 15_000,
}); });
const vulnsQuery = useQuery({ const vulnsQuery = useQuery({
queryKey: ['security-vulns', effectiveProjectId], queryKey: ['security-vulns', projectId],
queryFn: () => listVulnerabilities(effectiveProjectId), queryFn: () => listVulnerabilities(projectId),
enabled: !isDemoMode && Boolean(effectiveProjectId), enabled: !isDemoMode && Boolean(projectId),
}); });
const invalidate = () => { const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ['security-metrics', effectiveProjectId] }); queryClient.invalidateQueries({ queryKey: ['security-metrics', projectId] });
queryClient.invalidateQueries({ queryKey: ['security-history', effectiveProjectId] }); queryClient.invalidateQueries({ queryKey: ['security-history', projectId] });
queryClient.invalidateQueries({ queryKey: ['security-vulns', effectiveProjectId] }); queryClient.invalidateQueries({ queryKey: ['security-vulns', projectId] });
}; };
const scanMutation = useMutation({ const scanMutation = useMutation({
mutationFn: () => startSecurityScan({ mutationFn: () => startSecurityScan({
project_id: effectiveProjectId, project_id: projectId,
service_id: scanServiceId || undefined, service_id: scanServiceId || undefined,
scan_type: scanType as 'dependency' | 'configuration' | 'comprehensive', scan_type: scanType as 'dependency' | 'configuration' | 'comprehensive',
}), }),
@@ -3639,10 +3661,10 @@ export function SecurityPage() {
const metrics = isDemoMode ? demoSecurityMetrics : metricsQuery.data; const metrics = isDemoMode ? demoSecurityMetrics : metricsQuery.data;
const scanHistory = isDemoMode const scanHistory = isDemoMode
? demoSecurityScans.filter((s) => s.project_id === effectiveProjectId) ? demoSecurityScans.filter((s) => s.project_id === projectId)
: historyQuery.data ?? []; : historyQuery.data ?? [];
const vulns = (isDemoMode const vulns = (isDemoMode
? demoVulnerabilities.filter((v) => v.project_id === effectiveProjectId) ? demoVulnerabilities.filter((v) => v.project_id === projectId)
: vulnsQuery.data ?? [] : vulnsQuery.data ?? []
).filter((v) => v.status !== 'resolved'); ).filter((v) => v.status !== 'resolved');
const pageError = const pageError =
@@ -3655,7 +3677,7 @@ export function SecurityPage() {
title="Security" title="Security"
description="Scan findings, security posture, and compliance status" description="Scan findings, security posture, and compliance status"
/> />
<div className="p-4 space-y-8 sm:p-8"> <div className="p-8 space-y-8">
<div className="flex items-center gap-2 text-xs text-[var(--text-tertiary)]"> <div className="flex items-center gap-2 text-xs text-[var(--text-tertiary)]">
<HeartPulse size={13} className="text-[var(--accent-primary)]" /> <HeartPulse size={13} className="text-[var(--accent-primary)]" />
<span>Platform availability and failover live under High availability.</span> <span>Platform availability and failover live under High availability.</span>
@@ -3673,7 +3695,7 @@ export function SecurityPage() {
<div> <div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Project</label> <label className="block text-xs text-[var(--text-tertiary)] mb-1">Project</label>
<select <select
value={effectiveProjectId} value={projectId}
onChange={(e) => { setProjectId(e.target.value); setScanServiceId(''); }} onChange={(e) => { setProjectId(e.target.value); setScanServiceId(''); }}
className="px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)] w-56" className="px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)] w-56"
> >
@@ -3683,7 +3705,7 @@ export function SecurityPage() {
))} ))}
</select> </select>
</div> </div>
{effectiveProjectId && ( {projectId && (
<> <>
<div> <div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Scan type</label> <label className="block text-xs text-[var(--text-tertiary)] mb-1">Scan type</label>
@@ -3715,7 +3737,7 @@ export function SecurityPage() {
type="button" type="button"
disabled={scanMutation.isPending} disabled={scanMutation.isPending}
onClick={() => scanMutation.mutate()} onClick={() => scanMutation.mutate()}
className="s-btn-accent disabled:opacity-50" className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50"
> >
{scanMutation.isPending ? 'Starting…' : 'Run scan'} {scanMutation.isPending ? 'Starting…' : 'Run scan'}
</button> </button>
@@ -3726,7 +3748,7 @@ export function SecurityPage() {
)} )}
</div> </div>
{effectiveProjectId && metrics && ( {projectId && metrics && (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard <StatCard
title="Security score" title="Security score"
@@ -3757,11 +3779,11 @@ export function SecurityPage() {
</div> </div>
)} )}
{effectiveProjectId && ( {projectId && (
<div className="s-card p-5"> <div className="panel p-6">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<Shield size={18} className="text-[var(--accent-primary)]" /> <Shield size={18} className="text-[var(--accent-primary)]" />
<h2 className="s-t">Findings</h2> <h2 className="text-base font-semibold text-[var(--text-primary)]">Findings</h2>
</div> </div>
{!isDemoMode && vulnsQuery.isLoading ? ( {!isDemoMode && vulnsQuery.isLoading ? (
<p className="text-sm text-[var(--text-secondary)]">Loading…</p> <p className="text-sm text-[var(--text-secondary)]">Loading…</p>
@@ -3800,7 +3822,7 @@ export function SecurityPage() {
type="button" type="button"
disabled={vulnMutation.isPending} disabled={vulnMutation.isPending}
onClick={() => vulnMutation.mutate({ id: v.id!, status: 'ignored' })} onClick={() => vulnMutation.mutate({ id: v.id!, status: 'ignored' })}
className="s-btn-quiet" className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs text-[var(--text-secondary)] disabled:opacity-50"
> >
Ignore Ignore
</button> </button>
@@ -3813,11 +3835,11 @@ export function SecurityPage() {
</div> </div>
)} )}
{effectiveProjectId && ( {projectId && (
<div className="s-card p-5"> <div className="panel p-6">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<Clock size={18} className="text-[var(--accent-primary)]" /> <Clock size={18} className="text-[var(--accent-primary)]" />
<h2 className="s-t">Scan history</h2> <h2 className="text-base font-semibold text-[var(--text-primary)]">Scan history</h2>
</div> </div>
{!isDemoMode && historyQuery.isLoading ? ( {!isDemoMode && historyQuery.isLoading ? (
<p className="text-sm text-[var(--text-secondary)]">Loading…</p> <p className="text-sm text-[var(--text-secondary)]">Loading…</p>
@@ -34,7 +34,8 @@ export function ComponentShowcase() {
}; };
return ( return (
<div className="min-h-screen p-8" style={{ background: 'var(--bg-void)' }}> <div className="min-h-screen p-8" style={{ background: '#16171c' }}>
<div className="ambient-glow" />
<div className="w-full relative z-10"> <div className="w-full relative z-10">
{/* Header */} {/* Header */}
@@ -90,25 +91,25 @@ export function ComponentShowcase() {
Line Charts Line Charts
</h2> </h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="s-card p-6"> <div className="panel p-6">
<h3 className="s-t mb-4"> <h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4">
Basic Line Chart (CPU Style) Basic Line Chart (CPU Style)
</h3> </h3>
<LineChart <LineChart
data={sampleLineData} data={sampleLineData}
color="#38bdf8" color="#b4e34a"
height={120} height={120}
showDots={true} showDots={true}
smooth={true} smooth={true}
/> />
</div> </div>
<div className="s-card p-6"> <div className="panel p-6">
<h3 className="s-t mb-4"> <h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4">
Line Chart (No Dots) Line Chart (No Dots)
</h3> </h3>
<LineChart <LineChart
data={sampleLineData} data={sampleLineData}
color="var(--accent-primary)" color="#7ab8ff"
height={120} height={120}
showDots={false} showDots={false}
smooth={true} smooth={true}
@@ -122,8 +123,8 @@ export function ComponentShowcase() {
Area Charts Area Charts
</h2> </h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="s-card p-6"> <div className="panel p-6">
<h3 className="s-t mb-4"> <h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4">
Area Chart (Active Users Style) Area Chart (Active Users Style)
</h3> </h3>
<LineAreaChart <LineAreaChart
@@ -133,8 +134,8 @@ export function ComponentShowcase() {
height={150} height={150}
/> />
</div> </div>
<div className="s-card p-6"> <div className="panel p-6">
<h3 className="s-t mb-4"> <h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4">
Area Chart (Success Color) Area Chart (Success Color)
</h3> </h3>
<LineAreaChart <LineAreaChart
@@ -152,20 +153,20 @@ export function ComponentShowcase() {
Multi-Line & Bar Charts Multi-Line & Bar Charts
</h2> </h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="s-card p-6"> <div className="panel p-6">
<h3 className="s-t mb-4"> <h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4">
Multi-Line Chart (Performance Style) Multi-Line Chart (Performance Style)
</h3> </h3>
<MultiLineChart <MultiLineChart
datasets={[ datasets={[
{ data: sampleLineData.map(v => v + 10), color: 'var(--accent-primary)', fillOpacity: 0.15 }, { data: sampleLineData.map(v => v + 10), color: '#7ab8ff', fillOpacity: 0.15 },
{ data: sampleLineData, color: 'var(--warning)', fillOpacity: 0.15 } { data: sampleLineData, color: '#f2c94c', fillOpacity: 0.15 }
]} ]}
height={120} height={120}
/> />
</div> </div>
<div className="s-card p-6"> <div className="panel p-6">
<h3 className="s-t mb-4"> <h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4">
Bar Chart (Timeline Style) Bar Chart (Timeline Style)
</h3> </h3>
<BarChart <BarChart
@@ -183,23 +184,23 @@ export function ComponentShowcase() {
Donut Charts Donut Charts
</h2> </h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="s-card p-6 flex flex-col items-center"> <div className="panel p-6 flex flex-col items-center">
<h3 className="s-t mb-4"> <h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4">
RAM Usage (65%) RAM Usage (65%)
</h3> </h3>
<DonutChart percentage={65} color="var(--warning)" size={160} /> <DonutChart percentage={65} color="#f2c94c" size={160} />
</div> </div>
<div className="s-card p-6 flex flex-col items-center"> <div className="panel p-6 flex flex-col items-center">
<h3 className="s-t mb-4"> <h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4">
Disk Usage (82%) Disk Usage (82%)
</h3> </h3>
<DonutChart percentage={82} color="#38bdf8" size={160} /> <DonutChart percentage={82} color="#b4e34a" size={160} />
</div> </div>
<div className="s-card p-6 flex flex-col items-center"> <div className="panel p-6 flex flex-col items-center">
<h3 className="s-t mb-4"> <h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4">
Network (45%) Network (45%)
</h3> </h3>
<DonutChart percentage={45} color="var(--accent-primary)" size={160} /> <DonutChart percentage={45} color="#7ab8ff" size={160} />
</div> </div>
</div> </div>
</section> </section>
@@ -221,7 +222,7 @@ export function ComponentShowcase() {
status="good" status="good"
statusText="Good" statusText="Good"
icon={<Cpu size={16} />} icon={<Cpu size={16} />}
chart={<LineChart data={sampleLineData} color="#38bdf8" height={76} />} chart={<LineChart data={sampleLineData} color="#b4e34a" height={76} />}
onClick={() => handleCardClick('CPU Usage')} onClick={() => handleCardClick('CPU Usage')}
animationDelay={0.04} animationDelay={0.04}
/> />
@@ -232,7 +233,7 @@ export function ComponentShowcase() {
status="average" status="average"
statusText="Average" statusText="Average"
icon={<MemoryStick size={16} />} icon={<MemoryStick size={16} />}
chart={<LineChart data={sampleLineData.map(v => v + 20)} color="var(--warning)" height={76} />} chart={<LineChart data={sampleLineData.map(v => v + 20)} color="#f2c94c" height={76} />}
onClick={() => handleCardClick('Memory')} onClick={() => handleCardClick('Memory')}
animationDelay={0.09} animationDelay={0.09}
/> />
@@ -275,7 +276,7 @@ export function ComponentShowcase() {
value="12.5K" value="12.5K"
subtitle="Queries per minute" subtitle="Queries per minute"
icon={<Database size={16} />} icon={<Database size={16} />}
chart={<LineAreaChart data={sampleAreaData.map(v => v + 10)} color="var(--accent-primary)" height={130} />} chart={<LineAreaChart data={sampleAreaData.map(v => v + 10)} color="#7ab8ff" height={130} />}
details={ details={
<div className="flex items-center gap-3 text-xs"> <div className="flex items-center gap-3 text-xs">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
@@ -312,8 +313,8 @@ export function ComponentShowcase() {
upSpeed={10.4} upSpeed={10.4}
downSpeed={5.2} downSpeed={5.2}
datasets={[ datasets={[
{ data: sampleLineData.map(v => v + 20), color: 'var(--accent-primary)', fillOpacity: 0.15 }, { data: sampleLineData.map(v => v + 20), color: '#7ab8ff', fillOpacity: 0.15 },
{ data: sampleLineData.map(v => v + 10), color: 'var(--warning)', fillOpacity: 0.15 } { data: sampleLineData.map(v => v + 10), color: '#f2c94c', fillOpacity: 0.15 }
]} ]}
onClick={() => handleCardClick('Performance')} onClick={() => handleCardClick('Performance')}
animationDelay={0.09} animationDelay={0.09}
@@ -330,7 +331,7 @@ export function ComponentShowcase() {
<h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4"> <h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4">
Badges & Indicators Badges & Indicators
</h2> </h2>
<div className="s-card p-6 space-y-4"> <div className="panel p-6 space-y-4">
<div className="flex items-center gap-4 flex-wrap"> <div className="flex items-center gap-4 flex-wrap">
<span className="badge-active"> <span className="badge-active">
<span className="live-dot" /> <span className="live-dot" />
@@ -357,7 +358,7 @@ export function ComponentShowcase() {
<h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4"> <h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4">
Buttons Buttons
</h2> </h2>
<div className="s-card p-6 space-y-4"> <div className="panel p-6 space-y-4">
<div className="flex items-center gap-4 flex-wrap"> <div className="flex items-center gap-4 flex-wrap">
<button className="btn-stop"> <button className="btn-stop">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
@@ -386,7 +387,7 @@ export function ComponentShowcase() {
<h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4"> <h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4">
Pills & Tabs Pills & Tabs
</h2> </h2>
<div className="s-card p-6 space-y-6"> <div className="panel p-6 space-y-6">
<div> <div>
<p className="text-sm text-[var(--text-secondary)] mb-3">Pill Group</p> <p className="text-sm text-[var(--text-secondary)] mb-3">Pill Group</p>
<div className="pill-group"> <div className="pill-group">
@@ -419,9 +420,9 @@ export function ComponentShowcase() {
<h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4"> <h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4">
Search & Input Search & Input
</h2> </h2>
<div className="s-card p-6 space-y-4"> <div className="panel p-6 space-y-4">
<div className="search-box"> <div className="search-box">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2" strokeLinecap="round"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#6b6e7d" strokeWidth="2" strokeLinecap="round">
<circle cx="11" cy="11" r="8"/> <circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/> <line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg> </svg>
@@ -434,7 +435,7 @@ export function ComponentShowcase() {
<h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4"> <h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4">
Toast Notifications Toast Notifications
</h2> </h2>
<div className="s-card p-6"> <div className="panel p-6">
<div className="flex gap-3 flex-wrap"> <div className="flex gap-3 flex-wrap">
<button <button
onClick={() => showToast('Operation completed successfully!', 'success')} onClick={() => showToast('Operation completed successfully!', 'success')}
File diff suppressed because it is too large. Load diff
@@ -78,8 +78,8 @@ export function ServiceMetricsDashboard() {
userData, userData,
perf: perfPercent, perf: perfPercent,
perfData: [ perfData: [
{ data: perfData1, color: 'var(--accent-primary)', fillOpacity: 0.15 }, { data: perfData1, color: '#7ab8ff', fillOpacity: 0.15 },
{ data: perfData2, color: 'var(--warning)', fillOpacity: 0.15 } { data: perfData2, color: '#f2c94c', fillOpacity: 0.15 }
], ],
upSpeed: seededRandom(`${serviceId}:upspeed`, 8, 13).toFixed(1), upSpeed: seededRandom(`${serviceId}:upspeed`, 8, 13).toFixed(1),
downSpeed: seededRandom(`${serviceId}:downspeed`, 4, 7).toFixed(1), downSpeed: seededRandom(`${serviceId}:downspeed`, 4, 7).toFixed(1),
@@ -107,8 +107,9 @@ export function ServiceMetricsDashboard() {
}; };
return ( return (
<div className="min-h-screen" style={{ background: 'var(--bg-void)' }}> <div className="min-h-screen" style={{ background: '#16171c' }}>
{/* Ambient glow */} {/* Ambient glow */}
<div className="ambient-glow" />
<div className="flex min-h-screen relative"> <div className="flex min-h-screen relative">
{/* Sidebar - self.html exact match */} {/* Sidebar - self.html exact match */}
@@ -116,8 +117,8 @@ export function ServiceMetricsDashboard() {
className="hidden md:flex shrink-0 flex-col items-center border-r" className="hidden md:flex shrink-0 flex-col items-center border-r"
style={{ style={{
width: '64px', width: '64px',
background: 'var(--bg-surface)', background: '#111217',
borderRight: '1px solid var(--border-subtle)', borderRight: '1px solid rgba(255,255,255,0.07)',
padding: '16px 0', padding: '16px 0',
gap: '5px' gap: '5px'
}} }}
@@ -168,10 +169,10 @@ export function ServiceMetricsDashboard() {
style={{ style={{
width: '34px', width: '34px',
height: '34px', height: '34px',
background: 'var(--accent-primary-soft)', background: '#22233a',
fontSize: '11px', fontSize: '11px',
fontWeight: 700, fontWeight: 700,
color: 'var(--text-tertiary)', color: '#9295a4',
marginTop: '4px', marginTop: '4px',
letterSpacing: '-0.3px' letterSpacing: '-0.3px'
}} }}
@@ -187,8 +188,8 @@ export function ServiceMetricsDashboard() {
className="shrink-0 flex items-center" className="shrink-0 flex items-center"
style={{ style={{
height: '52px', height: '52px',
background: 'var(--bg-surface)', background: '#111217',
borderBottom: '1px solid var(--border-subtle)', borderBottom: '1px solid rgba(255,255,255,0.07)',
padding: '0 22px', padding: '0 22px',
gap: '14px' gap: '14px'
}} }}
@@ -200,22 +201,22 @@ export function ServiceMetricsDashboard() {
<div className="ml-auto flex items-center" style={{ gap: '8px' }}> <div className="ml-auto flex items-center" style={{ gap: '8px' }}>
<button <button
className="rounded-[9px] border bg-transparent text-[var(--text-tertiary)] font-medium cursor-pointer" className="rounded-[9px] border bg-transparent text-[#9295a4] font-medium cursor-pointer"
style={{ style={{
height: '32px', height: '32px',
padding: '0 14px', padding: '0 14px',
border: '1px solid var(--border-default)', border: '1px solid rgba(255,255,255,0.1)',
fontSize: '13px' fontSize: '13px'
}} }}
> >
Support Support
</button> </button>
<button <button
className="rounded-[9px] border-none flex items-center text-[var(--text-primary)] font-medium cursor-pointer" className="rounded-[9px] border-none flex items-center text-[#e8e9f0] font-medium cursor-pointer"
style={{ style={{
height: '32px', height: '32px',
padding: '0 14px', padding: '0 14px',
background: 'var(--bg-overlay)', background: 'rgba(255,255,255,0.08)',
fontSize: '13px', fontSize: '13px',
gap: '6px' gap: '6px'
}} }}
@@ -228,10 +229,10 @@ export function ServiceMetricsDashboard() {
style={{ style={{
width: '32px', width: '32px',
height: '32px', height: '32px',
border: '1px solid var(--border-default)' border: '1px solid rgba(255,255,255,0.1)'
}} }}
> >
<Bell size={15} /> <Bell size={15} color="#9295a4" />
</button> </button>
</div> </div>
</header> </header>
@@ -244,22 +245,22 @@ export function ServiceMetricsDashboard() {
style={{ style={{
gap: '6px', gap: '6px',
padding: '14px 0 10px', padding: '14px 0 10px',
color: 'var(--text-tertiary)', color: '#6b6e7d',
fontSize: '13px' fontSize: '13px'
}} }}
> >
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#6b6e7d" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 18 9 12 15 6"/> <polyline points="15 18 9 12 15 6"/>
</svg> </svg>
<button <button
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
style={{ color: 'var(--text-tertiary)' }} style={{ color: '#6b6e7d' }}
className="hover:text-[var(--text-tertiary)] transition-colors" className="hover:text-[#9295a4] transition-colors"
> >
Servers Servers
</button> </button>
<span style={{ opacity: 0.4 }}>/</span> <span style={{ opacity: 0.4 }}>/</span>
<span style={{ color: 'var(--text-tertiary)' }}>[NuFest] - App Project</span> <span style={{ color: '#9295a4' }}>[NuFest] - App Project</span>
</div> </div>
{/* Project Header */} {/* Project Header */}
@@ -279,7 +280,7 @@ export function ServiceMetricsDashboard() {
</div> </div>
<div> <div>
<div className="flex items-center" style={{ gap: '10px' }}> <div className="flex items-center" style={{ gap: '10px' }}>
<span style={{ fontSize: '20px', fontWeight: 800, letterSpacing: '-0.5px', color: 'var(--text-primary)' }}> <span style={{ fontSize: '20px', fontWeight: 800, letterSpacing: '-0.5px', color: '#e8e9f0' }}>
[NuFest] - App Project [NuFest] - App Project
</span> </span>
<span className={`badge-${isSystemRunning ? 'active' : 'stopped'}`}> <span className={`badge-${isSystemRunning ? 'active' : 'stopped'}`}>
@@ -292,8 +293,8 @@ export function ServiceMetricsDashboard() {
href="https://nufest-dth.app" href="https://nufest-dth.app"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center hover:text-[var(--text-tertiary)] transition-colors" className="flex items-center hover:text-[#9295a4] transition-colors"
style={{ color: 'var(--text-tertiary)', fontSize: '12.5px', textDecoration: 'none', gap: '4px' }} style={{ color: '#6b6e7d', fontSize: '12.5px', textDecoration: 'none', gap: '4px' }}
> >
https://nufest-dth.app https://nufest-dth.app
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"> <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
@@ -303,8 +304,8 @@ export function ServiceMetricsDashboard() {
</svg> </svg>
</a> </a>
<button <button
className="flex items-center hover:text-[var(--text-tertiary)] transition-colors" className="flex items-center hover:text-[#9295a4] transition-colors"
style={{ color: 'var(--text-tertiary)', fontSize: '12.5px', gap: '4px' }} style={{ color: '#6b6e7d', fontSize: '12.5px', gap: '4px' }}
> >
Project Information Project Information
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"> <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
@@ -343,7 +344,7 @@ export function ServiceMetricsDashboard() {
<div <div
className="flex" className="flex"
style={{ style={{
borderBottom: '1px solid var(--border-subtle)', borderBottom: '1px solid rgba(255,255,255,0.07)',
marginBottom: '18px' marginBottom: '18px'
}} }}
> >
@@ -364,16 +365,16 @@ export function ServiceMetricsDashboard() {
{/* Metrics Header */} {/* Metrics Header */}
<div className="flex items-center justify-between" style={{ marginBottom: '16px' }}> <div className="flex items-center justify-between" style={{ marginBottom: '16px' }}>
<span style={{ fontSize: '15px', fontWeight: 700, color: 'var(--text-primary)' }}>Metrics</span> <span style={{ fontSize: '15px', fontWeight: 700, color: '#e8e9f0' }}>Metrics</span>
<div className="flex items-center" style={{ gap: '8px' }}> <div className="flex items-center" style={{ gap: '8px' }}>
<button <button
className="rounded-[9px] border flex items-center font-medium cursor-pointer" className="rounded-[9px] border flex items-center font-medium cursor-pointer"
style={{ style={{
height: '32px', height: '32px',
padding: '0 12px', padding: '0 12px',
border: '1px solid var(--border-default)', border: '1px solid rgba(255,255,255,0.09)',
background: 'var(--bg-elevated)', background: 'rgba(255,255,255,0.04)',
color: 'var(--text-tertiary)', color: '#9295a4',
fontSize: '12.5px', fontSize: '12.5px',
gap: '6px' gap: '6px'
}} }}
@@ -407,7 +408,7 @@ export function ServiceMetricsDashboard() {
status="good" status="good"
statusText="Good" statusText="Good"
icon={<Cpu size={16} />} icon={<Cpu size={16} />}
chart={<LineChart data={metrics.cpuData} color="#38bdf8" height={76} />} chart={<LineChart data={metrics.cpuData} color="#b4e34a" height={76} />}
animationDelay={0.04} animationDelay={0.04}
/> />
@@ -417,23 +418,23 @@ export function ServiceMetricsDashboard() {
<div className="card-icon"> <div className="card-icon">
<MemoryStick size={16} /> <MemoryStick size={16} />
</div> </div>
<span style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)' }}>RAM Usage</span> <span style={{ fontSize: 14, fontWeight: 600, color: '#e8e9f0' }}>RAM Usage</span>
</div> </div>
<div style={{ fontSize: 38, fontWeight: 900, letterSpacing: '-1.5px', lineHeight: 1, color: 'var(--text-primary)' }}> <div style={{ fontSize: 38, fontWeight: 900, letterSpacing: '-1.5px', lineHeight: 1, color: '#e8e9f0' }}>
{metrics.ram}% {metrics.ram}%
</div> </div>
<div style={{ fontSize: 12, color: 'var(--text-tertiary)', marginTop: 4 }}> <div style={{ fontSize: 12, color: '#6b6e7d', marginTop: 4 }}>
<span style={{ color: 'var(--warning)', fontWeight: 700 }}>Average</span> Daily usage <span style={{ color: '#f2c94c', fontWeight: 700 }}>Average</span> Daily usage
</div> </div>
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', margin: '10px 0 4px', position: 'relative' }}> <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', margin: '10px 0 4px', position: 'relative' }}>
<DonutChart percentage={metrics.ram} color="var(--warning)" /> <DonutChart percentage={metrics.ram} color="#f2c94c" />
<div style={{ position: 'absolute', bottom: 14, textAlign: 'center' }}> <div style={{ position: 'absolute', bottom: 14, textAlign: 'center' }}>
<div style={{ fontSize: 10.5, color: 'var(--text-tertiary)', marginBottom: 1 }}>Used</div> <div style={{ fontSize: 10.5, color: '#6b6e7d', marginBottom: 1 }}>Used</div>
<div style={{ fontSize: 12.5, fontWeight: 700, color: 'var(--text-primary)' }}>{metrics.ramUsedGB} GB / 8GB</div> <div style={{ fontSize: 12.5, fontWeight: 700, color: '#e8e9f0' }}>{metrics.ramUsedGB} GB / 8GB</div>
</div> </div>
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingTop: 4 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingTop: 4 }}>
<span style={{ fontSize: 13, color: 'var(--text-tertiary)', fontWeight: 500, cursor: 'pointer' }}>Details</span> <span style={{ fontSize: 13, color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}>Details</span>
<div className="arrow-btn"> <div className="arrow-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round"> <svg viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6" /> <polyline points="9 18 15 12 9 6" />
@@ -9,7 +9,6 @@ import {
getSetupStatus, getSetupStatus,
updatePlatformSettings, updatePlatformSettings,
} from '@/lib/api-client'; } from '@/lib/api-client';
import { GhostBtn, QuietBtn } from '@/shared/components/sentry';
// Marker the GitHub App callback reads so the wizard flow resumes here // Marker the GitHub App callback reads so the wizard flow resumes here
// instead of the settings page. // instead of the settings page.
@@ -40,7 +39,7 @@ export function SetupWizardPage() {
mutationFn: completeSetup, mutationFn: completeSetup,
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['setup-status'] }); queryClient.invalidateQueries({ queryKey: ['setup-status'] });
navigate('/', { replace: true }); navigate('/projects', { replace: true });
}, },
}); });
@@ -54,7 +53,7 @@ export function SetupWizardPage() {
// Wizard is optional once the instance is already configured. // Wizard is optional once the instance is already configured.
if (status && !status.needs_setup && !override) { if (status && !status.needs_setup && !override) {
return <Navigate to="/" replace />; return <Navigate to="/projects" replace />;
} }
const steps: { key: Step; label: string }[] = [ const steps: { key: Step; label: string }[] = [
@@ -93,12 +92,9 @@ export function SetupWizardPage() {
</div> </div>
{step === 'account' ? ( {step === 'account' ? (
<section className="s-card"> <section className="panel p-6">
<div className="s-cardhead mb-1"> <h1 className="text-xl font-bold text-[var(--text-primary)]">Welcome to Containr</h1>
<span className="s-ibox"><Rocket /></span> <p className="mt-2 text-sm text-[var(--text-secondary)]">
<h1 className="s-t">Welcome to Containr</h1>
</div>
<p className="mt-1 text-sm text-[var(--text-secondary)]">
First boot detected. Create the admin account — registration closes to the public First boot detected. Create the admin account — registration closes to the public
right after, so this account owns the instance. right after, so this account owns the instance.
</p> </p>
@@ -109,7 +105,8 @@ export function SetupWizardPage() {
</p> </p>
<Link <Link
to="/auth/sign-in?redirect=/setup" to="/auth/sign-in?redirect=/setup"
className="mt-4 s-btn-accent inline-flex items-center gap-2" className="mt-4 inline-flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)]"
style={{ background: 'var(--accent-primary)' }}
> >
Sign in <ChevronRight size={14} /> Sign in <ChevronRight size={14} />
</Link> </Link>
@@ -117,7 +114,8 @@ export function SetupWizardPage() {
) : !status?.has_users ? ( ) : !status?.has_users ? (
<Link <Link
to="/auth/sign-up?redirect=/setup" to="/auth/sign-up?redirect=/setup"
className="mt-4 s-btn-accent inline-flex items-center gap-2" className="mt-4 inline-flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)]"
style={{ background: 'var(--accent-primary)' }}
> >
Create admin account <ChevronRight size={14} /> Create admin account <ChevronRight size={14} />
</Link> </Link>
@@ -129,9 +127,9 @@ export function SetupWizardPage() {
{step === 'tunnel' ? <TunnelStep onNext={() => setOverride('done')} /> : null} {step === 'tunnel' ? <TunnelStep onNext={() => setOverride('done')} /> : null}
{step === 'done' ? ( {step === 'done' ? (
<section className="s-card text-center"> <section className="panel p-6 text-center">
<div className="s-ibox mx-auto !h-10 !w-10"><ShieldCheck /></div> <Rocket size={28} className="mx-auto text-[var(--accent-primary)]" />
<h1 className="mt-4 font-headline text-lg font-bold text-[var(--text-primary)]">All set</h1> <h1 className="mt-4 text-xl font-bold text-[var(--text-primary)]">All set</h1>
<p className="mt-2 text-sm text-[var(--text-secondary)]"> <p className="mt-2 text-sm text-[var(--text-secondary)]">
Deploy your first service from a git repo, an image, or a template — the dashboard is Deploy your first service from a git repo, an image, or a template — the dashboard is
ready. ready.
@@ -143,10 +141,12 @@ export function SetupWizardPage() {
: 'Failed to save setup state'} : 'Failed to save setup state'}
</p> </p>
) : null} ) : null}
<GhostBtn <button
type="button"
onClick={() => completeMutation.mutate()} onClick={() => completeMutation.mutate()}
disabled={completeMutation.isPending} disabled={completeMutation.isPending}
className="mt-5 !h-10 !px-6" className="mt-5 inline-flex items-center gap-2 h-10 px-6 rounded-[var(--radius-md)] text-sm font-semibold text-[var(--accent-on)] disabled:opacity-50"
style={{ background: 'var(--accent-primary)' }}
> >
{completeMutation.isPending ? ( {completeMutation.isPending ? (
<Loader2 size={14} className="animate-spin" /> <Loader2 size={14} className="animate-spin" />
@@ -154,7 +154,7 @@ export function SetupWizardPage() {
<ShieldCheck size={15} /> <ShieldCheck size={15} />
)} )}
Enter Containr Enter Containr
</GhostBtn> </button>
</section> </section>
) : null} ) : null}
</div> </div>
@@ -185,12 +185,12 @@ function GitHubStep({ onNext }: { onNext: () => void }) {
}); });
return ( return (
<section className="s-card"> <section className="panel p-6">
<div className="s-cardhead mb-1"> <div className="flex items-center gap-3 mb-3">
<span className="s-ibox"><Github /></span> <Github size={20} className="text-[var(--text-primary)]" />
<h1 className="s-t">Connect a Git Provider</h1> <h1 className="text-xl font-bold text-[var(--text-primary)]">Connect a git provider</h1>
</div> </div>
<p className="mt-1 text-sm text-[var(--text-secondary)]"> <p className="text-sm text-[var(--text-secondary)]">
Provision the instance GitHub App — repo push events then trigger deployments and preview Provision the instance GitHub App — repo push events then trigger deployments and preview
environments automatically. Self-hosted GitLab/Gitea can be added later in Settings. environments automatically. Self-hosted GitLab/Gitea can be added later in Settings.
</p> </p>
@@ -201,15 +201,24 @@ function GitHubStep({ onNext }: { onNext: () => void }) {
: 'Could not start GitHub App provisioning'} : 'Could not start GitHub App provisioning'}
</p> </p>
) : null} ) : null}
<div className="mt-5 flex items-center gap-2"> <div className="mt-5 flex items-center gap-3">
<GhostBtn <button
type="button"
onClick={() => provisionMutation.mutate()} onClick={() => provisionMutation.mutate()}
disabled={provisionMutation.isPending} disabled={provisionMutation.isPending}
className="inline-flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50"
style={{ background: 'var(--accent-primary)' }}
> >
{provisionMutation.isPending ? <Loader2 size={13} className="animate-spin" /> : null} {provisionMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : null}
Set up GitHub App Set up GitHub App
</GhostBtn> </button>
<QuietBtn onClick={onNext}>Skip for now</QuietBtn> <button
type="button"
onClick={onNext}
className="h-9 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
>
Skip for now
</button>
</div> </div>
</section> </section>
); );
@@ -228,12 +237,9 @@ function TunnelStep({ onNext }: { onNext: () => void }) {
}); });
return ( return (
<section className="s-card"> <section className="panel p-6">
<div className="s-cardhead mb-1"> <h1 className="text-xl font-bold text-[var(--text-primary)]">Public access (optional)</h1>
<span className="s-ibox"><ShieldCheck /></span> <p className="mt-2 text-sm text-[var(--text-secondary)]">
<h1 className="s-t">Public Access (optional)</h1>
</div>
<p className="mt-1 text-sm text-[var(--text-secondary)]">
Paste a Cloudflare Tunnel token to expose this instance on the internet. Without one, Paste a Cloudflare Tunnel token to expose this instance on the internet. Without one,
Containr stays reachable on this network — you can add it later in Settings. Containr stays reachable on this network — you can add it later in Settings.
</p> </p>
@@ -242,22 +248,31 @@ function TunnelStep({ onNext }: { onNext: () => void }) {
value={token} value={token}
onChange={(e) => setToken(e.target.value)} onChange={(e) => setToken(e.target.value)}
placeholder="eyJhIjo… tunnel token" placeholder="eyJhIjo… tunnel token"
className="mt-4 w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] v-mono text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-colors" className="mt-4 w-full 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)] transition-colors"
/> />
{saveMutation.isError ? ( {saveMutation.isError ? (
<p className="mt-3 text-xs text-[var(--error)]"> <p className="mt-3 text-xs text-[var(--error)]">
{saveMutation.error instanceof Error ? saveMutation.error.message : 'Failed to save token'} {saveMutation.error instanceof Error ? saveMutation.error.message : 'Failed to save token'}
</p> </p>
) : null} ) : null}
<div className="mt-5 flex items-center gap-2"> <div className="mt-5 flex items-center gap-3">
<GhostBtn <button
type="button"
onClick={() => (token.trim() ? saveMutation.mutate() : onNext())} onClick={() => (token.trim() ? saveMutation.mutate() : onNext())}
disabled={saveMutation.isPending} disabled={saveMutation.isPending}
className="inline-flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50"
style={{ background: 'var(--accent-primary)' }}
> >
{saveMutation.isPending ? <Loader2 size={13} className="animate-spin" /> : null} {saveMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : null}
{token.trim() ? 'Save tunnel token' : 'Continue'} {token.trim() ? 'Save tunnel token' : 'Continue'}
</GhostBtn> </button>
<QuietBtn onClick={onNext}>Skip</QuietBtn> <button
type="button"
onClick={onNext}
className="h-9 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
>
Skip
</button>
</div> </div>
</section> </section>
); );
@@ -17,7 +17,6 @@ import {
} from '@xyflow/react'; } from '@xyflow/react';
import '@xyflow/react/dist/style.css'; import '@xyflow/react/dist/style.css';
import { Database, GitBranch, Package, Timer, Trash2, X } from 'lucide-react'; import { Database, GitBranch, Package, Timer, Trash2, X } from 'lucide-react';
import { GhostBtn, QuietBtn } from '@/shared/components/sentry';
import { import {
canvasFromConfig, canvasFromConfig,
configFromCanvas, configFromCanvas,
@@ -58,7 +57,7 @@ function ServiceBuilderNodeView({ data, selected }: NodeProps<ServiceBuilderNode
</div> </div>
<div className="mt-1 flex items-center justify-between gap-2"> <div className="mt-1 flex items-center justify-between gap-2">
<span className="text-[10.5px] uppercase tracking-wide text-[var(--text-tertiary)]">{meta.label}</span> <span className="text-[10.5px] uppercase tracking-wide text-[var(--text-tertiary)]">{meta.label}</span>
<span className="v-mono max-w-[120px] truncate text-[10.5px] text-[var(--text-secondary)]"> <span className="mono max-w-[120px] truncate text-[10.5px] text-[var(--text-secondary)]">
{data.svc.runtime || data.svc.repo || '—'} {data.svc.runtime || data.svc.repo || '—'}
</span> </span>
</div> </div>
@@ -121,8 +120,8 @@ function BuilderInner({
id: `${dep}->${svc.key}`, id: `${dep}->${svc.key}`,
source: dep, source: dep,
target: svc.key, target: svc.key,
markerEnd: { type: MarkerType.ArrowClosed, width: 14, height: 14, color: 'rgba(56, 189, 248,0.55)' }, markerEnd: { type: MarkerType.ArrowClosed, width: 14, height: 14, color: 'rgba(180,227,74,0.55)' },
style: { stroke: 'rgba(56, 189, 248,0.42)', strokeWidth: 1.6 }, style: { stroke: 'rgba(180,227,74,0.42)', strokeWidth: 1.6 },
className: 'edge-premium', className: 'edge-premium',
})), })),
), ),
@@ -240,7 +239,7 @@ function BuilderInner({
key={t} key={t}
type="button" type="button"
onClick={() => addService(t)} onClick={() => addService(t)}
className="s-chip !py-1.5" className="canvas-pill inline-flex items-center gap-1.5 px-2.5 py-1.5 text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
> >
<Icon size={12} className={meta.cls} /> <Icon size={12} className={meta.cls} />
{meta.label} {meta.label}
@@ -264,7 +263,7 @@ function BuilderInner({
deleteKeyCode={['Backspace', 'Delete']} deleteKeyCode={['Backspace', 'Delete']}
proOptions={{ hideAttribution: true }} proOptions={{ hideAttribution: true }}
> >
<Background variant={BackgroundVariant.Dots} gap={22} size={1.2} color="var(--tint-07)" /> <Background variant={BackgroundVariant.Dots} gap={22} size={1.2} color="rgba(255,255,255,0.07)" />
</ReactFlow> </ReactFlow>
<div className="canvas-pill absolute bottom-3 left-3 z-10 px-3 py-1.5 text-[10.5px] text-[var(--text-tertiary)]"> <div className="canvas-pill absolute bottom-3 left-3 z-10 px-3 py-1.5 text-[10.5px] text-[var(--text-tertiary)]">
drag = move · connect handles = depends_on · double-click = add web · Del = remove drag = move · connect handles = depends_on · double-click = add web · Del = remove
@@ -296,16 +295,21 @@ function BuilderInner({
</div> </div>
) : null} ) : null}
<div className="flex gap-2"> <div className="flex gap-2">
<QuietBtn onClick={onCancel} className="flex-1 justify-center"> <button
type="button"
onClick={onCancel}
className="flex-1 rounded-[var(--radius-md)] px-3 py-2 text-xs font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-muted)]"
>
Back to JSON Back to JSON
</QuietBtn> </button>
<GhostBtn <button
type="button"
disabled={errors.length > 0 || services.size === 0} disabled={errors.length > 0 || services.size === 0}
onClick={() => onApply(configFromCanvas(builderNodes, config))} onClick={() => onApply(configFromCanvas(builderNodes, config))}
className="flex-1 justify-center" className="flex-1 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-3 py-2 text-xs font-semibold text-[var(--accent-on)] disabled:opacity-50"
> >
Apply to JSON Apply to JSON
</GhostBtn> </button>
</div> </div>
</div> </div>
</div> </div>
@@ -405,7 +409,7 @@ function Inspector({
<label className={`${LABEL_CLS} mt-3`}>Environment (KEY=value, one per line)</label> <label className={`${LABEL_CLS} mt-3`}>Environment (KEY=value, one per line)</label>
<textarea <textarea
className={`${FIELD_CLS} v-mono min-h-[70px] resize-y`} className={`${FIELD_CLS} mono min-h-[70px] resize-y`}
defaultValue={envText} defaultValue={envText}
spellCheck={false} spellCheck={false}
onBlur={(e) => { onBlur={(e) => {
@@ -421,7 +425,7 @@ function Inspector({
<label className={`${LABEL_CLS} mt-3`}>Volumes (source:target[:ro], one per line)</label> <label className={`${LABEL_CLS} mt-3`}>Volumes (source:target[:ro], one per line)</label>
<textarea <textarea
className={`${FIELD_CLS} v-mono min-h-[50px] resize-y`} className={`${FIELD_CLS} mono min-h-[50px] resize-y`}
defaultValue={volText} defaultValue={volText}
spellCheck={false} spellCheck={false}
onBlur={(e) => { onBlur={(e) => {
@@ -18,7 +18,6 @@ import {
import { useAuthSession } from '@/lib/use-auth-session'; import { useAuthSession } from '@/lib/use-auth-session';
import { useDemoMode } from '@/lib/demo-mode'; import { useDemoMode } from '@/lib/demo-mode';
import { demoProjects, demoTemplates, demoTemplateDetails } from '@/lib/demo-data'; import { demoProjects, demoTemplates, demoTemplateDetails } from '@/lib/demo-data';
import { GhostBtn, QuietBtn, SPageHead } from '@/shared/components/sentry';
import { import {
Search, Search,
Filter, Filter,
@@ -490,51 +489,67 @@ export function TemplatesPage() {
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<div className="w-full px-4 pt-6 sm:px-8"> {/* Header */}
<SPageHead <div className="border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/50 backdrop-blur-sm">
title="Template Catalog" <div className="w-full px-8 py-4">
titleAccent="_" <div className="flex items-center justify-between gap-4">
sub={`${filteredTemplates.length} templates · deploy services from pre-configured stacks`} <div>
actions={signedIn && !isDemoMode ? ( <h1 className="v-title">Template Catalog<span className="v-cursor">_</span></h1>
<> <p className="text-sm text-[var(--text-secondary)]">Deploy services from pre-configured templates</p>
<QuietBtn </div>
onClick={() => { <div className="flex items-center gap-3 text-sm text-[var(--text-tertiary)]">
setComposeYaml(''); <Layers size={16} />
setComposeWarnings([]); <span>{filteredTemplates.length} templates</span>
setComposeError(null); {signedIn && !isDemoMode ? (
setComposeOpen(true); <>
}} <button
> type="button"
<FileCode size={13} /> Import Compose onClick={() => {
</QuietBtn> setComposeYaml('');
<GhostBtn onClick={openCreateEditor}> setComposeWarnings([]);
<Plus size={13} /> New template setComposeError(null);
</GhostBtn> setComposeOpen(true);
</> }}
) : undefined} className="inline-flex h-9 items-center gap-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-3.5 text-[12.5px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
/> >
<FileCode size={14} /> Import Compose
</button>
<button
type="button"
onClick={openCreateEditor}
className="inline-flex h-9 items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-3.5 text-[12.5px] font-semibold text-[var(--accent-on)]"
>
<Plus size={14} /> New template
</button>
</>
) : null}
</div>
</div>
</div>
</div> </div>
{/* Demo Mode Banner */} {/* Demo Mode Banner */}
{isDemoMode && ( {isDemoMode && (
<div className="w-full px-8 py-4"> <div className="w-full px-8 py-4">
<div className="s-inset flex items-center gap-2 text-xs text-[var(--warning)]"> <div className="px-4 py-3 rounded-[var(--radius-md)] border border-[var(--warning-soft)] bg-[var(--warning-soft)]/50">
<Sparkles size={13} /> <div className="flex items-center gap-2 text-sm text-[var(--warning)]">
Demo mode active — using sample data <Sparkles size={16} />
<span>Demo mode active — using sample data</span>
</div>
</div> </div>
</div> </div>
)} )}
{/* Main Content */} {/* Main Content */}
<div className="w-full px-4 py-6 sm:px-8"> <div className="w-full px-8 py-6">
<div className="grid grid-cols-1 xl:grid-cols-[380px_1fr] gap-6"> <div className="grid grid-cols-1 xl:grid-cols-[380px_1fr] gap-6">
{/* Template List */} {/* Template List */}
<section className="s-card overflow-hidden"> <section className="panel overflow-hidden">
<div className="s-cardhead border-b border-[var(--border-subtle)]">
<span className="s-ibox"><Filter /></span>
<span className="s-t">Filters</span>
</div>
<div className="p-4 border-b border-[var(--border-subtle)]"> <div className="p-4 border-b border-[var(--border-subtle)]">
<div className="flex items-center gap-2 mb-4">
<Filter size={16} className="text-[var(--text-tertiary)]" />
<span className="text-sm font-medium text-[var(--text-secondary)]">Filters</span>
</div>
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2"> <label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
@@ -599,21 +614,21 @@ export function TemplatesPage() {
{filteredTemplates.map((template) => { {filteredTemplates.map((template) => {
const selected = template.id === selectedTemplateId; const selected = template.id === selectedTemplateId;
const Icon = categoryIcon(template.category); const Icon = categoryIcon(template.category);
const categoryColor = template.category === 'database' ? 'var(--info)' : template.category === 'frontend' ? 'var(--warning)' : template.category === 'web' ? 'var(--accent-primary)' : 'var(--text-tertiary)'; const categoryColor = template.category === 'database' ? '#7ab8ff' : template.category === 'frontend' ? '#f2c94c' : template.category === 'web' ? 'var(--accent-primary)' : '#9295a4';
return ( return (
<button <button
key={template.id} key={template.id}
onClick={() => selectTemplate(template.id)} onClick={() => selectTemplate(template.id)}
className={`w-full p-4 rounded-[var(--radius-lg)] border text-left transition-all duration-300 group ${ className={`w-full p-4 rounded-[var(--radius-lg)] border text-left transition-all duration-300 group ${
selected selected
? 'border-[var(--accent-primary)]/60 bg-[var(--accent-primary-soft)]' ? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] shadow-lg shadow-[var(--accent-primary-glow)]'
: 'border-[var(--border-subtle)] hover:border-[var(--border-default)] hover:bg-[var(--surface-muted)]/50 card-lift' : 'border-[var(--border-subtle)] hover:border-[var(--border-default)] hover:bg-[var(--surface-muted)]/50 card-lift'
}`} }`}
> >
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div <div
className={`w-11 h-11 rounded-xl flex items-center justify-center transition-all duration-300 ${ className={`w-11 h-11 rounded-xl flex items-center justify-center transition-all duration-300 ${
selected ? 'ring-1 ring-[var(--accent-primary)]/40' : '' selected ? 'ring-2 ring-white/20' : ''
}`} }`}
style={{ style={{
background: selected ? categoryColor : `${categoryColor}20`, background: selected ? categoryColor : `${categoryColor}20`,
@@ -676,10 +691,12 @@ export function TemplatesPage() {
</section> </section>
{/* Template Detail */} {/* Template Detail */}
<section className="s-card p-5"> <section className="panel p-6">
{!selectedTemplateId ? ( {!selectedTemplateId ? (
<div className="py-16 text-center"> <div className="py-16 text-center">
<div className="s-ibox mx-auto mb-4 !w-14 !h-14"><Layers size={22} /></div> <div className="w-16 h-16 mx-auto mb-4 rounded-full bg-[var(--surface-muted)] flex items-center justify-center">
<Layers size={28} className="text-[var(--text-tertiary)]" />
</div>
<p className="text-sm text-[var(--text-muted)]">Select a template to view details and deploy</p> <p className="text-sm text-[var(--text-muted)]">Select a template to view details and deploy</p>
</div> </div>
) : null} ) : null}
@@ -705,15 +722,15 @@ export function TemplatesPage() {
{/* Header */} {/* Header */}
<div className="flex items-start justify-between gap-4 mb-6"> <div className="flex items-start justify-between gap-4 mb-6">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="s-ibox !w-14 !h-14 !rounded-xl text-[var(--accent-primary)]"> <div className="w-14 h-14 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
{(() => { {(() => {
const Icon = categoryIcon(selectedDetail.template.category); const Icon = categoryIcon(selectedDetail.template.category);
return <Icon size={24} />; return <Icon size={24} className="text-[var(--accent-primary)]" />;
})()} })()}
</div> </div>
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h2 className="font-headline text-lg font-semibold text-[var(--text-primary)]">{selectedDetail.template.name}</h2> <h2 className="text-xl font-semibold text-[var(--text-primary)]">{selectedDetail.template.name}</h2>
{selectedDetail.template.isOfficial && ( {selectedDetail.template.isOfficial && (
<span className="flex items-center gap-1 px-2 py-0.5 rounded-full bg-[var(--warning-soft)] text-[var(--warning)] text-xs font-medium"> <span className="flex items-center gap-1 px-2 py-0.5 rounded-full bg-[var(--warning-soft)] text-[var(--warning)] text-xs font-medium">
<Star size={10} className="fill-[var(--warning)]" /> <Star size={10} className="fill-[var(--warning)]" />
@@ -721,7 +738,7 @@ export function TemplatesPage() {
</span> </span>
)} )}
{!selectedDetail.template.isOfficial && selectedDetail.template.isPublic && ( {!selectedDetail.template.isOfficial && selectedDetail.template.isPublic && (
<span className="s-chip !text-[var(--info)] !border-[color-mix(in_srgb,var(--info)_30%,transparent)] !bg-[var(--info-soft)] v-mono !text-[10.5px]"> <span className="px-2 py-0.5 rounded-full bg-[var(--info-soft)] text-[var(--info)] text-xs font-medium">
Public Public
</span> </span>
)} )}
@@ -730,10 +747,10 @@ export function TemplatesPage() {
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="s-chip v-mono !text-[10.5px]"> <span className="px-3 py-1 rounded-full border border-[var(--border-subtle)] text-xs text-[var(--text-tertiary)]">
{categoryLabel(selectedDetail.template.category)} {categoryLabel(selectedDetail.template.category)}
</span> </span>
<span className="s-chip v-mono !text-[10.5px]"> <span className="px-3 py-1 rounded-full border border-[var(--border-subtle)] text-xs text-[var(--text-tertiary)]">
{selectedDetail.config.runtime || 'n/a'} {selectedDetail.config.runtime || 'n/a'}
</span> </span>
{canManageTemplate(selectedDetail.template) ? ( {canManageTemplate(selectedDetail.template) ? (
@@ -760,25 +777,25 @@ export function TemplatesPage() {
{/* Config Grid */} {/* Config Grid */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div className="s-inset"> <div className="panel-soft p-4">
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Type</p> <p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Type</p>
<p className="v-mono text-sm text-[var(--text-primary)] mt-2">{selectedDetail.config.type || '—'}</p> <p className="mono text-sm text-[var(--text-primary)] mt-2">{selectedDetail.config.type || '—'}</p>
</div> </div>
<div className="s-inset"> <div className="panel-soft p-4">
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Port</p> <p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Port</p>
<p className="v-mono text-sm text-[var(--text-primary)] mt-2">{selectedDetail.config.port || '—'}</p> <p className="mono text-sm text-[var(--text-primary)] mt-2">{selectedDetail.config.port || '—'}</p>
</div> </div>
<div className="s-inset"> <div className="panel-soft p-4">
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Build</p> <p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Build</p>
<p className="v-mono text-xs text-[var(--text-primary)] mt-2 truncate">{selectedDetail.config.buildCommand || '—'}</p> <p className="mono text-xs text-[var(--text-primary)] mt-2 truncate">{selectedDetail.config.buildCommand || '—'}</p>
</div> </div>
<div className="s-inset"> <div className="panel-soft p-4">
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Start</p> <p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Start</p>
<p className="v-mono text-xs text-[var(--text-primary)] mt-2 truncate">{selectedDetail.config.startCommand || '—'}</p> <p className="mono text-xs text-[var(--text-primary)] mt-2 truncate">{selectedDetail.config.startCommand || '—'}</p>
</div> </div>
</div> </div>
<div className="s-inset mb-6"> <div className="panel-soft p-4 mb-6">
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">How Templates Work</p> <p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">How Templates Work</p>
<p className="text-sm text-[var(--text-secondary)]"> <p className="text-sm text-[var(--text-secondary)]">
Template defaults are merged with your variable inputs. Creating from template saves a stopped service in the selected project, then you deploy it from the service detail page. Template defaults are merged with your variable inputs. Creating from template saves a stopped service in the selected project, then you deploy it from the service detail page.
@@ -791,7 +808,7 @@ export function TemplatesPage() {
</div> </div>
{isGraphTemplate ? ( {isGraphTemplate ? (
<div className="s-inset mb-6"> <div className="panel-soft p-4 mb-6">
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-3">
<Network size={14} className="text-[var(--accent-primary)]" /> <Network size={14} className="text-[var(--accent-primary)]" />
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]"> <p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
@@ -807,14 +824,14 @@ export function TemplatesPage() {
) : ( ) : (
<Box size={13} className="text-[var(--text-tertiary)] shrink-0" /> <Box size={13} className="text-[var(--text-tertiary)] shrink-0" />
)} )}
<span className="v-mono text-[var(--text-primary)] truncate">{member.name || member.key}</span> <span className="mono text-[var(--text-primary)] truncate">{member.name || member.key}</span>
{member.dependsOn && member.dependsOn.length > 0 ? ( {member.dependsOn && member.dependsOn.length > 0 ? (
<span className="text-[10px] text-[var(--text-tertiary)]"> <span className="text-[10px] text-[var(--text-tertiary)]">
after {member.dependsOn.join(', ')} after {member.dependsOn.join(', ')}
</span> </span>
) : null} ) : null}
</div> </div>
<span className="v-mono text-[11px] text-[var(--text-tertiary)] truncate"> <span className="mono text-[11px] text-[var(--text-tertiary)] truncate">
{member.type === 'database' ? `managed ${member.runtime}` : member.runtime || member.repo || '—'} {member.type === 'database' ? `managed ${member.runtime}` : member.runtime || member.repo || '—'}
</span> </span>
</div> </div>
@@ -828,28 +845,26 @@ export function TemplatesPage() {
{/* Deploy Section */} {/* Deploy Section */}
<div className="border-t border-[var(--border-subtle)] pt-6"> <div className="border-t border-[var(--border-subtle)] pt-6">
<div className="s-cardhead !mb-4"> <div className="flex items-center gap-3 mb-4">
<span className="s-ibox"><Play /></span> <div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--success-soft)] flex items-center justify-center">
<Play size={18} className="text-[var(--success)]" />
</div>
<div> <div>
<span className="s-t">Deploy from Template</span> <h3 className="text-lg font-semibold text-[var(--text-primary)]">Deploy from Template</h3>
<p className="v-mono text-[10.5px] text-[var(--text-tertiary)] mt-0.5"> <p className="text-sm text-[var(--text-secondary)]">
{isGraphTemplate ? 'Deploy all members into a project' : 'Configure and create a new service'} {isGraphTemplate ? 'Deploy all members into a project' : 'Configure and create a new service'}
</p> </p>
</div> </div>
</div> </div>
{sessionQuery.isPending && !isDemoMode ? ( {!signedIn ? (
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-4 py-5 text-center">
<Loader2 size={16} className="mx-auto animate-spin text-[var(--text-muted)]" />
</div>
) : !signedIn ? (
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-4 py-5 text-center"> <div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-4 py-5 text-center">
<p className="text-sm text-[var(--text-secondary)]"> <p className="text-sm text-[var(--text-secondary)]">
Sign in to deploy this template into one of your projects. Sign in to deploy this template into one of your projects.
</p> </p>
<Link <Link
to="/auth/sign-in" to="/auth/sign-in"
className="s-btn-accent mt-3 text-[12.5px]" className="mt-3 inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-[12.5px] font-semibold text-[var(--accent-on)]"
> >
Sign in <ArrowRight size={13} /> Sign in <ArrowRight size={13} />
</Link> </Link>
@@ -955,7 +970,7 @@ export function TemplatesPage() {
<span> <span>
{lastDeployment.created {lastDeployment.created
? `Stack deployed — ${lastDeployment.created.length} resources` ? `Stack deployed — ${lastDeployment.created.length} resources`
: <>Service <span className="v-mono font-medium">{lastDeployment.serviceName}</span> created</>} : <>Service <span className="mono font-medium">{lastDeployment.serviceName}</span> created</>}
</span> </span>
<Link <Link
to={lastDeployment.created to={lastDeployment.created
@@ -971,7 +986,7 @@ export function TemplatesPage() {
{lastDeployment.created.map((m) => ( {lastDeployment.created.map((m) => (
<div key={m.id} className="flex items-center gap-2 text-xs"> <div key={m.id} className="flex items-center gap-2 text-xs">
{m.kind === 'database' ? <Database size={11} /> : <Box size={11} />} {m.kind === 'database' ? <Database size={11} /> : <Box size={11} />}
<span className="v-mono">{m.name}</span> <span className="mono">{m.name}</span>
<span className="opacity-70">({m.kind})</span> <span className="opacity-70">({m.kind})</span>
</div> </div>
))} ))}
@@ -984,8 +999,8 @@ export function TemplatesPage() {
<button <button
onClick={() => deployMutation.mutate()} onClick={() => deployMutation.mutate()}
disabled={isDeployDisabled} disabled={isDeployDisabled}
className="s-btn-accent h-11 disabled:opacity-50 disabled:cursor-not-allowed" className="flex items-center gap-2 h-11 px-6 rounded-[var(--radius-md)] text-[var(--accent-on)] text-sm font-medium shadow-lg disabled:opacity-50 disabled:cursor-not-allowed transition-all"
style={{ background: 'var(--accent-primary)' }}
> >
{deployMutation.isPending ? ( {deployMutation.isPending ? (
<> <>
@@ -1118,7 +1133,7 @@ export function TemplatesPage() {
value={editorJson} value={editorJson}
onChange={(e) => setEditorJson(e.target.value)} onChange={(e) => setEditorJson(e.target.value)}
spellCheck={false} spellCheck={false}
className="v-mono min-h-[380px] flex-1 resize-none bg-[var(--bg-void)] p-4 text-[12.5px] leading-relaxed text-[var(--text-secondary)] outline-none" className="mono min-h-[380px] flex-1 resize-none bg-[var(--bg-void)] p-4 text-[12.5px] leading-relaxed text-[var(--text-secondary)] outline-none"
/> />
)} )}
{composeWarnings.length > 0 ? ( {composeWarnings.length > 0 ? (
@@ -1156,7 +1171,7 @@ export function TemplatesPage() {
type="button" type="button"
onClick={submitEditor} onClick={submitEditor}
disabled={saveTemplateMutation.isPending} disabled={saveTemplateMutation.isPending}
className="s-btn-accent disabled:opacity-50" className="inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-xs font-semibold text-[var(--accent-on)] disabled:opacity-50"
> >
{saveTemplateMutation.isPending ? <Loader2 size={13} className="animate-spin" /> : null} {saveTemplateMutation.isPending ? <Loader2 size={13} className="animate-spin" /> : null}
{editingTemplate ? 'Save changes' : 'Create template'} {editingTemplate ? 'Save changes' : 'Create template'}
@@ -1208,7 +1223,7 @@ export function TemplatesPage() {
onChange={(e) => setComposeYaml(e.target.value)} onChange={(e) => setComposeYaml(e.target.value)}
spellCheck={false} spellCheck={false}
placeholder={'services:\n db:\n image: postgres:16\n web:\n image: myapp:latest\n depends_on: [db]'} placeholder={'services:\n db:\n image: postgres:16\n web:\n image: myapp:latest\n depends_on: [db]'}
className="v-mono min-h-[320px] flex-1 resize-none bg-[var(--bg-void)] p-4 text-[12.5px] leading-relaxed text-[var(--text-secondary)] outline-none" className="mono min-h-[320px] flex-1 resize-none bg-[var(--bg-void)] p-4 text-[12.5px] leading-relaxed text-[var(--text-secondary)] outline-none"
/> />
) : ( ) : (
<div className="flex min-h-[320px] flex-1 flex-col gap-3 bg-[var(--bg-void)] p-4"> <div className="flex min-h-[320px] flex-1 flex-col gap-3 bg-[var(--bg-void)] p-4">
@@ -1219,7 +1234,7 @@ export function TemplatesPage() {
onChange={(e) => setGitRepo(e.target.value)} onChange={(e) => setGitRepo(e.target.value)}
spellCheck={false} spellCheck={false}
placeholder="owner/repo or https://github.com/owner/repo.git" placeholder="owner/repo or https://github.com/owner/repo.git"
className="v-mono mt-1 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-base)] px-3 py-2 text-[12.5px] text-[var(--text-secondary)] outline-none focus:border-[var(--accent-primary)]" className="mono mt-1 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-base)] px-3 py-2 text-[12.5px] text-[var(--text-secondary)] outline-none focus:border-[var(--accent-primary)]"
/> />
</label> </label>
<label className="text-xs text-[var(--text-tertiary)]"> <label className="text-xs text-[var(--text-tertiary)]">
@@ -1229,7 +1244,7 @@ export function TemplatesPage() {
onChange={(e) => setGitPath(e.target.value)} onChange={(e) => setGitPath(e.target.value)}
spellCheck={false} spellCheck={false}
placeholder="deploy/compose.yml" placeholder="deploy/compose.yml"
className="v-mono mt-1 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-base)] px-3 py-2 text-[12.5px] text-[var(--text-secondary)] outline-none focus:border-[var(--accent-primary)]" className="mono mt-1 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-base)] px-3 py-2 text-[12.5px] text-[var(--text-secondary)] outline-none focus:border-[var(--accent-primary)]"
/> />
</label> </label>
<label className="text-xs text-[var(--text-tertiary)]"> <label className="text-xs text-[var(--text-tertiary)]">
@@ -1239,7 +1254,7 @@ export function TemplatesPage() {
onChange={(e) => setGitRef(e.target.value)} onChange={(e) => setGitRef(e.target.value)}
spellCheck={false} spellCheck={false}
placeholder="main" placeholder="main"
className="v-mono mt-1 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-base)] px-3 py-2 text-[12.5px] text-[var(--text-secondary)] outline-none focus:border-[var(--accent-primary)]" className="mono mt-1 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-base)] px-3 py-2 text-[12.5px] text-[var(--text-secondary)] outline-none focus:border-[var(--accent-primary)]"
/> />
</label> </label>
<p className="text-[11px] text-[var(--text-tertiary)]"> <p className="text-[11px] text-[var(--text-tertiary)]">
@@ -1264,7 +1279,7 @@ export function TemplatesPage() {
type="button" type="button"
onClick={() => composeImportMutation.mutate()} onClick={() => composeImportMutation.mutate()}
disabled={(composeMode === 'paste' ? !composeYaml.trim() : !gitRepo.trim()) || composeImportMutation.isPending} disabled={(composeMode === 'paste' ? !composeYaml.trim() : !gitRepo.trim()) || composeImportMutation.isPending}
className="s-btn-accent disabled:opacity-50" className="inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-xs font-semibold text-[var(--accent-on)] disabled:opacity-50"
> >
{composeImportMutation.isPending ? <Loader2 size={13} className="animate-spin" /> : <FileCode size={13} />} {composeImportMutation.isPending ? <Loader2 size={13} className="animate-spin" /> : <FileCode size={13} />}
Convert to template Convert to template
@@ -8,12 +8,11 @@ import {
testWebhook, testWebhook,
updateWebhook, updateWebhook,
type OutboundWebhook, type OutboundWebhook,
type WebhookDelivery,
} from '@/lib/api-client'; } from '@/lib/api-client';
import { useDemoMode } from '@/lib/demo-mode'; import { useDemoMode } from '@/lib/demo-mode';
import { formatRelative } from '@/lib/time'; import { formatRelative } from '@/lib/time';
import { DemoRestricted, useToast } from '@/shared/components'; import { DemoRestricted, useToast } from '@/shared/components';
import { GhostBtn, QuietBtn, SPageHead, SPill } from '@/shared/components/sentry';
import { statusTone } from '@/shared/components/sentry-utils';
import { import {
Webhook, Webhook,
Plus, Plus,
@@ -31,6 +30,17 @@ const inputClass =
const eventSuggestions = ['*', 'service.*', 'project.*', 'database.*', 'deployment.*', 'template.*']; 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({ function WebhookRow({
webhook, webhook,
onDelete, onDelete,
@@ -53,50 +63,59 @@ function WebhookRow({
}); });
return ( return (
<div className="s-card !p-0"> <div className="panel p-4">
<div className="flex items-center gap-3 px-4 py-3"> <div className="flex items-center gap-3">
<span className="s-ibox shrink-0"><Webhook /></span> <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="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm font-semibold text-[var(--text-primary)]">{webhook.name}</span> <span className="text-sm font-semibold text-[var(--text-primary)]">{webhook.name}</span>
{!webhook.enabled && <SPill tone="off">disabled</SPill>} {!webhook.enabled && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--surface-muted)] text-[var(--text-muted)]">
disabled
</span>
)}
</div> </div>
<p className="v-mono text-[10.5px] text-[var(--text-muted)] truncate">{webhook.url}</p> <p className="text-xs text-[var(--text-muted)] mono truncate">{webhook.url}</p>
</div> </div>
<div className="hidden md:flex gap-1.5"> <div className="hidden md:flex gap-1">
{webhook.events.map((ev) => ( {webhook.events.map((ev) => (
<span key={ev} className="s-chip">{ev}</span> <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>
<div className="flex items-center gap-1 shrink-0"> <div className="flex items-center gap-1 shrink-0">
<button
onClick={() => setExpanded((v) => !v)}
className="s-icon-btn"
title={expanded ? 'Collapse' : 'Deliveries'}
>
{expanded ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
</button>
<button <button
onClick={onTest} onClick={onTest}
disabled={testing || !webhook.enabled} disabled={testing || !webhook.enabled}
className="s-icon-btn disabled:opacity-40" 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" title="Send test ping"
> >
{testing ? <Loader2 size={13} className="animate-spin" /> : <Send size={13} />} {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>
<QuietBtn onClick={onToggle}>{webhook.enabled ? 'Disable' : 'Enable'}</QuietBtn>
<button <button
onClick={onDelete} onClick={onDelete}
className="s-icon-btn !text-[var(--text-tertiary)] hover:!text-[var(--error)]" className="p-2 rounded-[var(--radius-md)] text-[var(--text-tertiary)] hover:text-[var(--error)] transition-colors"
title="Delete webhook" title="Delete webhook"
> >
<Trash2 size={13} /> <Trash2 size={15} />
</button> </button>
</div> </div>
</div> </div>
{expanded && ( {expanded && (
<div className="border-t border-[var(--border-subtle)] bg-[var(--surface-muted)]/40 px-4 py-3"> <div className="mt-3 border-t border-[var(--border-subtle)] pt-3">
<p className="v-mono text-[10px] uppercase tracking-[0.12em] text-[var(--text-muted)] mb-2"> <p className="text-[11px] uppercase tracking-wider text-[var(--text-muted)] mb-2">
Recent deliveries Recent deliveries
</p> </p>
{deliveriesQuery.isLoading ? ( {deliveriesQuery.isLoading ? (
@@ -104,10 +123,10 @@ function WebhookRow({
) : (deliveriesQuery.data ?? []).length === 0 ? ( ) : (deliveriesQuery.data ?? []).length === 0 ? (
<p className="text-xs text-[var(--text-muted)]">No deliveries yet.</p> <p className="text-xs text-[var(--text-muted)]">No deliveries yet.</p>
) : ( ) : (
<div className="space-y-1.5"> <div className="space-y-1">
{(deliveriesQuery.data ?? []).slice(0, 20).map((d) => ( {(deliveriesQuery.data ?? []).slice(0, 20).map((d) => (
<div key={d.id} className="flex items-center gap-3 text-[11px] v-mono"> <div key={d.id} className="flex items-center gap-3 text-xs mono">
<SPill tone={statusTone(d.status)}>{d.status}</SPill> <span className={statusClass(d.status)}>{d.status}</span>
<span className="text-[var(--text-secondary)]">{d.event}</span> <span className="text-[var(--text-secondary)]">{d.event}</span>
{d.response_status != null && ( {d.response_status != null && (
<span className="text-[var(--text-muted)]">HTTP {d.response_status}</span> <span className="text-[var(--text-muted)]">HTTP {d.response_status}</span>
@@ -188,41 +207,46 @@ export function WebhooksPage() {
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<div className="w-full px-4 pt-6 sm:px-8"> <div className="border-b border-[var(--border-subtle)]">
<SPageHead <div className="w-full px-8 py-5 flex items-center justify-between">
title="Webhooks" <div>
titleAccent="_" <h1 className="v-title">Webhooks<span className="v-cursor">_</span></h1>
sub="Signed HTTP delivery for every platform event — deploys, restarts, deletes" <p className="v-mono mt-1.5 text-[11px] text-[var(--text-tertiary)]">
actions={ Signed HTTP delivery for every platform event — deploys, restarts, deletes
<GhostBtn onClick={() => setCreating(true)}> </p>
<Plus size={13} /> New webhook </div>
</GhostBtn> <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>
<div className="w-full px-4 sm:px-8 space-y-4 max-w-4xl"> <div className="w-full px-8 py-6 space-y-4 max-w-4xl">
{secret && ( {secret && (
<div className="s-inset p-4 flex items-center gap-3"> <div className="panel-soft p-4 flex items-center gap-3">
<span className="s-ibox shrink-0"><Webhook /></span>
<div className="flex-1 min-w-0"> <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-sm font-medium text-[var(--text-primary)]">Signing secret — shown once</p>
<p className="v-mono text-[11px] text-[var(--text-muted)]"> <p className="text-xs text-[var(--text-muted)]">
Verify deliveries with <span className="v-mono">X-Containr-Signature</span> Verify deliveries with <span className="mono">X-Containr-Signature</span>
</p> </p>
<code className="block mt-2 v-mono text-xs text-[var(--accent-primary)] break-all">{secret}</code> <code className="block mt-2 text-xs mono text-[var(--accent-primary)] break-all">{secret}</code>
</div> </div>
<button <button
onClick={() => { onClick={() => {
navigator.clipboard?.writeText(secret); navigator.clipboard?.writeText(secret);
toast.showToast('Secret copied', 'success'); toast.showToast('Secret copied', 'success');
}} }}
className="s-icon-btn" className="p-2 text-[var(--text-tertiary)] hover:text-[var(--text-primary)]"
> >
<Copy size={13} /> <Copy size={15} />
</button> </button>
<button onClick={() => setSecret(null)} className="s-icon-btn"> <button onClick={() => setSecret(null)} className="p-2 text-[var(--text-tertiary)]">
<X size={13} /> <X size={15} />
</button> </button>
</div> </div>
)} )}
@@ -230,12 +254,12 @@ export function WebhooksPage() {
{webhooksQuery.isLoading ? ( {webhooksQuery.isLoading ? (
<Loader2 size={18} className="animate-spin text-[var(--text-muted)]" /> <Loader2 size={18} className="animate-spin text-[var(--text-muted)]" />
) : (webhooksQuery.data ?? []).length === 0 ? ( ) : (webhooksQuery.data ?? []).length === 0 ? (
<div className="s-card py-10 text-center"> <div className="panel p-8 text-center">
<div className="s-ibox mx-auto mb-3"><Webhook /></div> <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-sm text-[var(--text-secondary)]">No webhooks yet.</p>
<p className="v-mono text-[11px] text-[var(--text-muted)] mt-1"> <p className="text-xs text-[var(--text-muted)] mt-1">
Subscribe an HTTP endpoint to platform events like <span className="v-mono">service.*</span> or{' '} Subscribe an HTTP endpoint to platform events like <span className="mono">service.*</span> or{' '}
<span className="v-mono">deployment.fail</span>. <span className="mono">deployment.fail</span>.
</p> </p>
</div> </div>
) : ( ) : (
@@ -259,11 +283,8 @@ export function WebhooksPage() {
{creating && ( {creating && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"> <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="absolute inset-0 bg-[var(--bg-void)]/80 backdrop-blur-sm" onClick={() => setCreating(false)} />
<div className="relative w-full max-w-md s-card"> <div className="relative w-full max-w-md panel p-6">
<div className="s-cardhead mb-4"> <h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">New webhook</h3>
<span className="s-ibox"><Webhook /></span>
<h3 className="s-t">New Webhook</h3>
</div>
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2"> <label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
@@ -283,7 +304,7 @@ export function WebhooksPage() {
<input <input
value={form.url} value={form.url}
onChange={(e) => setForm((f) => ({ ...f, url: e.target.value }))} onChange={(e) => setForm((f) => ({ ...f, url: e.target.value }))}
className={`${inputClass} w-full v-mono`} className={`${inputClass} w-full mono`}
placeholder="https://example.com/hooks/containr" placeholder="https://example.com/hooks/containr"
/> />
</div> </div>
@@ -296,21 +317,30 @@ export function WebhooksPage() {
<button <button
key={ev} key={ev}
onClick={() => toggleEvent(ev)} onClick={() => toggleEvent(ev)}
className={`s-chip v-mono ${form.events.includes(ev) ? 'on' : ''}`} 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} {ev}
</button> </button>
))} ))}
</div> </div>
<p className="mt-2 v-mono text-[10.5px] text-[var(--text-muted)]"> <p className="mt-2 text-[11px] text-[var(--text-muted)]">
Events are <span className="v-mono">resource.action</span> — wildcards like{' '} Events are <span className="mono">resource.action</span> — wildcards like{' '}
<span className="v-mono">service.*</span> match the whole resource. <span className="mono">service.*</span> match the whole resource.
</p> </p>
</div> </div>
</div> </div>
<div className="mt-6 flex justify-end gap-2"> <div className="mt-6 flex justify-end gap-2">
<QuietBtn onClick={() => setCreating(false)}>Cancel</QuietBtn> <button
<GhostBtn onClick={() => setCreating(false)}
className="h-10 px-4 rounded-[var(--radius-md)] text-sm text-[var(--text-secondary)]"
>
Cancel
</button>
<button
onClick={() => createMutation.mutate()} onClick={() => createMutation.mutate()}
disabled={ disabled={
createMutation.isPending || createMutation.isPending ||
@@ -318,10 +348,12 @@ export function WebhooksPage() {
!form.url.trim() || !form.url.trim() ||
form.events.length === 0 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={13} className="animate-spin" />} {createMutation.isPending && <Loader2 size={14} className="animate-spin" />}
Create Create
</GhostBtn> </button>
</div> </div>
</div> </div>
</div> </div>
@@ -167,12 +167,12 @@ function toFlowEdges(
}, },
markerEnd: { markerEnd: {
type: MarkerType.ArrowClosed, type: MarkerType.ArrowClosed,
color: 'color-mix(in srgb, var(--accent-primary) 55%, transparent)', color: 'rgba(180, 227, 74, 0.55)',
width: 13, width: 13,
height: 13, height: 13,
}, },
style: { style: {
stroke: 'color-mix(in srgb, var(--accent-primary) 42%, transparent)', stroke: 'rgba(180, 227, 74, 0.42)',
strokeWidth: 1.6, strokeWidth: 1.6,
}, },
className: 'edge-premium', className: 'edge-premium',
@@ -198,7 +198,7 @@ function ZoomControls({ onTidy }: { onTidy: () => void }) {
<button <button
type="button" type="button"
onClick={() => setViewport({ x: 0, y: 0, zoom: 1 }, { duration: 180 })} onClick={() => setViewport({ x: 0, y: 0, zoom: 1 }, { duration: 180 })}
className="canvas-pill-btn v-mono w-12 text-[11px]" className="canvas-pill-btn mono w-12 text-[11px]"
title="Reset zoom (0)" title="Reset zoom (0)"
> >
{Math.round(zoom * 100)}% {Math.round(zoom * 100)}%
@@ -873,7 +873,7 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
const minimapNodeColor = useCallback( const minimapNodeColor = useCallback(
(node: CanvasNode) => { (node: CanvasNode) => {
if (node.type === 'groupNode') { if (node.type === 'groupNode') {
return 'var(--tint-08)'; return 'rgba(255,255,255,0.08)';
} }
return serviceAccent((node.data as ServiceNodeData).service); return serviceAccent((node.data as ServiceNodeData).service);
}, },
@@ -922,14 +922,14 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
selectionKeyCode="Shift" selectionKeyCode="Shift"
proOptions={{ hideAttribution: true }} proOptions={{ hideAttribution: true }}
> >
<Background variant={BackgroundVariant.Dots} gap={26} size={1.2} color="var(--tint-05)" /> <Background variant={BackgroundVariant.Dots} gap={26} size={1.2} color="rgba(255,255,255,0.055)" />
<MiniMap <MiniMap
position="bottom-right" position="bottom-right"
pannable pannable
zoomable zoomable
nodeColor={minimapNodeColor} nodeColor={minimapNodeColor}
bgColor="var(--surface-glass)" bgColor="rgba(14, 15, 18, 0.92)"
maskColor="var(--surface-glass)" maskColor="rgba(12, 13, 15, 0.72)"
className="canvas-minimap hidden md:block" className="canvas-minimap hidden md:block"
/> />
</ReactFlow> </ReactFlow>
@@ -937,19 +937,21 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
{/* Floating: env filter + group (top-left) */} {/* Floating: env filter + group (top-left) */}
<div className="absolute left-4 top-4 flex items-center gap-2"> <div className="absolute left-4 top-4 flex items-center gap-2">
{environments.length > 1 && ( {environments.length > 1 && (
<div className="canvas-pill !rounded-[9px] p-1"> <div className="canvas-pill flex items-center gap-0.5 p-1">
<span className="s-seg"> {['all', ...environments].map((env) => (
{['all', ...environments].map((env) => ( <button
<button key={env}
key={env} type="button"
type="button" onClick={() => setEnvFilter(env)}
onClick={() => setEnvFilter(env)} className={`px-2.5 py-1 rounded-full text-[11px] font-medium capitalize transition-all ${
className={`!w-auto !h-auto px-2.5 py-1 capitalize v-mono text-[10.5px] ${envFilter === env ? 'on' : ''}`} envFilter === env
> ? 'bg-[var(--surface-card)] text-[var(--text-primary)] shadow-sm'
{env} : 'text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]'
</button> }`}
))} >
</span> {env}
</button>
))}
</div> </div>
)} )}
{!readOnly && ( {!readOnly && (
@@ -1016,12 +1018,12 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
{/* Empty state */} {/* Empty state */}
{services.length === 0 && ( {services.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none"> <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="text-center pointer-events-auto s-card !py-8 !px-10"> <div className="text-center pointer-events-auto">
<div className="s-ibox mx-auto mb-4 !h-11 !w-11"> <div className="w-14 h-14 mx-auto mb-4 rounded-2xl bg-[var(--surface-card)] border border-[var(--border-subtle)] flex items-center justify-center">
<Rocket /> <Rocket size={24} className="text-[var(--accent-primary)]" />
</div> </div>
<h3 className="font-headline text-base font-semibold text-[var(--text-primary)]">Deploy your first service</h3> <h3 className="text-base font-semibold text-[var(--text-primary)]">Deploy your first service</h3>
<p className="v-mono text-[11.5px] text-[var(--text-tertiary)] mt-1.5 mb-5 max-w-xs"> <p className="text-sm text-[var(--text-tertiary)] mt-1 mb-4 max-w-xs">
Add a service from a git repo or Docker image - it runs on a private network with the rest of this Add a service from a git repo or Docker image - it runs on a private network with the rest of this
project. project.
</p> </p>
@@ -1029,9 +1031,10 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
<button <button
type="button" type="button"
onClick={onAddService} onClick={onAddService}
className="s-btn-accent inline-flex items-center gap-2" className="inline-flex items-center gap-2 h-9 px-4 rounded-lg text-[var(--accent-on)] text-sm font-medium shadow-lg"
style={{ background: 'var(--accent-primary)' }}
> >
<Plus size={13} /> <Plus size={15} />
Add Service Add Service
</button> </button>
)} )}
@@ -1,7 +1,6 @@
import type { ServiceEntity } from '@/lib/api-client'; import type { ServiceEntity } from '@/lib/api-client';
import { serviceStatusClass } from '@/lib/api-client';
import type { ServiceVariable } from '../auto-connections'; import type { ServiceVariable } from '../auto-connections';
import { GhostBtn, QuietBtn, SPill } from '@/shared/components/sentry';
import { statusTone } from '@/shared/components/sentry-utils';
import { ServiceIcon } from './ServiceIcon'; import { ServiceIcon } from './ServiceIcon';
import { serviceAccent } from './service-visuals'; import { serviceAccent } from './service-visuals';
import { formatRelative } from '@/lib/time'; import { formatRelative } from '@/lib/time';
@@ -64,11 +63,28 @@ function statusLabel(status: string): string {
} }
} }
function statusColor(status: string): string {
switch (status) {
case 'running':
case 'deployed':
return 'var(--success)';
case 'failed':
return 'var(--error)';
case 'building':
case 'deploying':
case 'pending':
case 'rolling_back':
return 'var(--warning)';
default:
return 'var(--text-tertiary)';
}
}
function Section({ title, children, action }: { title: string; children: React.ReactNode; action?: React.ReactNode }) { function Section({ title, children, action }: { title: string; children: React.ReactNode; action?: React.ReactNode }) {
return ( return (
<div> <div>
<div className="flex items-center justify-between mb-1.5"> <div className="flex items-center justify-between mb-1.5">
<p className="v-mono text-[10px] uppercase tracking-[0.14em] text-[var(--text-muted)]">{title}</p> <p className="text-[10px] uppercase tracking-wider text-[var(--text-muted)]">{title}</p>
{action} {action}
</div> </div>
{children} {children}
@@ -147,49 +163,48 @@ export function ServiceInspector({
return ( return (
<aside className="canvas-inspector absolute right-4 top-4 bottom-4 w-[312px] panel-glass flex flex-col overflow-hidden"> <aside className="canvas-inspector absolute right-4 top-4 bottom-4 w-[312px] panel-glass flex flex-col overflow-hidden">
<div className="s-cardhead border-b border-[var(--border-subtle)]"> <div className="flex items-center gap-3 px-4 pt-4 pb-3 border-b border-[var(--border-subtle)]">
<span <div
className="s-ibox flex-shrink-0" className="w-9 h-9 rounded-[var(--radius-md)] flex items-center justify-center flex-shrink-0"
style={{ background: `${accent}14`, color: accent, borderColor: `${accent}33` }} style={{ background: `${accent}1c`, color: accent }}
> >
<ServiceIcon service={service} size={15} /> <ServiceIcon service={service} size={16} />
</span> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<h3 className="text-[13px] font-semibold text-[var(--text-primary)] truncate leading-tight">{service.name}</h3> <h3 className="text-sm font-semibold text-[var(--text-primary)] truncate">{service.name}</h3>
<div className="flex items-center gap-1.5 mt-1"> <div className="flex items-center gap-1.5 text-[11px] text-[var(--text-tertiary)]">
<SPill tone={statusTone(service.status)}>{statusLabel(service.status)}</SPill> <span className={`status-dot ${serviceStatusClass(service.status)}`} />
<span className="v-mono text-[10px] text-[var(--text-tertiary)]"> <span style={{ color: statusColor(service.status) }}>{statusLabel(service.status)}</span>
{service.type} <span>· {service.type}</span>
{service.environment && service.environment !== 'production' ? ` · ${service.environment}` : ''} {service.environment && service.environment !== 'production' && <span>· {service.environment}</span>}
</span>
</div> </div>
</div> </div>
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
className="s-icon-btn" className="w-7 h-7 rounded-md flex items-center justify-center text-[var(--text-tertiary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-muted)]"
title="Close (Esc)" title="Close (Esc)"
> >
<X size={12} /> <X size={14} />
</button> </button>
</div> </div>
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-4"> <div className="flex-1 overflow-y-auto px-4 py-3 space-y-4">
{(publicUrl || service.port) && ( {(publicUrl || service.port) && (
<Section title="Address"> <Section title="Address">
<div className="s-inset flex items-center gap-1.5 px-2.5 py-2 group"> <div className="flex items-center gap-1.5 group">
<Globe size={11} className="text-[var(--text-tertiary)] flex-shrink-0" /> <Globe size={11} className="text-[var(--text-tertiary)] flex-shrink-0" />
{publicUrl ? ( {publicUrl ? (
<a <a
href={publicUrl} href={publicUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="v-mono text-[11px] text-[var(--accent-primary)] hover:underline break-all" className="mono text-[11px] text-[var(--accent-primary)] hover:underline break-all"
> >
{publicUrl.replace(/^https?:\/\//, '')} {publicUrl.replace(/^https?:\/\//, '')}
</a> </a>
) : ( ) : (
<span className="v-mono text-[11px] text-[var(--text-secondary)]"> <span className="mono text-[11px] text-[var(--text-secondary)]">
{service.name}:{service.port} {service.name}:{service.port}
</span> </span>
)} )}
@@ -203,7 +218,7 @@ export function ServiceInspector({
</button> </button>
</div> </div>
{publicUrl && service.port ? ( {publicUrl && service.port ? (
<p className="v-mono text-[10.5px] text-[var(--text-tertiary)] mt-1 pl-1"> <p className="mono text-[10.5px] text-[var(--text-tertiary)] mt-0.5 pl-4">
{service.name}:{service.port} {service.name}:{service.port}
</p> </p>
) : null} ) : null}
@@ -251,13 +266,13 @@ export function ServiceInspector({
{variables.slice(0, 6).map((variable) => ( {variables.slice(0, 6).map((variable) => (
<div <div
key={variable.key} key={variable.key}
className="s-inset !rounded-[6px] flex items-center gap-2 px-2 py-1.5" className="flex items-center gap-2 px-2 py-1.5 rounded-[var(--radius-sm)] bg-[var(--surface-muted)]/50"
> >
<KeyRound size={9} className="text-[var(--text-tertiary)] flex-shrink-0" /> <KeyRound size={9} className="text-[var(--text-tertiary)] flex-shrink-0" />
<span className="v-mono text-[10px] text-[var(--text-primary)] truncate font-medium"> <span className="mono text-[10px] text-[var(--text-primary)] truncate font-medium">
{variable.key} {variable.key}
</span> </span>
<span className="v-mono text-[10px] text-[var(--text-tertiary)] truncate ml-auto max-w-[130px]" title={variable.isSecret ? 'Secret' : variable.value}> <span className="mono text-[10px] text-[var(--text-tertiary)] truncate ml-auto max-w-[130px]" title={variable.isSecret ? 'Secret' : variable.value}>
{variable.isSecret ? '••••••••' : variable.value} {variable.isSecret ? '••••••••' : variable.value}
</span> </span>
</div> </div>
@@ -284,7 +299,7 @@ export function ServiceInspector({
{visibleConfig.map(([label, value]) => ( {visibleConfig.map(([label, value]) => (
<div key={label} className="flex items-baseline gap-2"> <div key={label} className="flex items-baseline gap-2">
<dt className="text-[10px] text-[var(--text-muted)] w-[70px] flex-shrink-0">{label}</dt> <dt className="text-[10px] text-[var(--text-muted)] w-[70px] flex-shrink-0">{label}</dt>
<dd className="v-mono text-[10.5px] text-[var(--text-secondary)] truncate" title={value}> <dd className="mono text-[10.5px] text-[var(--text-secondary)] truncate" title={value}>
{value} {value}
</dd> </dd>
</div> </div>
@@ -292,7 +307,7 @@ export function ServiceInspector({
{(service.replicas ?? 0) > 0 && ( {(service.replicas ?? 0) > 0 && (
<div className="flex items-baseline gap-2"> <div className="flex items-baseline gap-2">
<dt className="text-[10px] text-[var(--text-muted)] w-[70px] flex-shrink-0">Replicas</dt> <dt className="text-[10px] text-[var(--text-muted)] w-[70px] flex-shrink-0">Replicas</dt>
<dd className="v-mono text-[10.5px] text-[var(--text-secondary)]">{service.replicas}</dd> <dd className="mono text-[10.5px] text-[var(--text-secondary)]">{service.replicas}</dd>
</div> </div>
)} )}
</dl> </dl>
@@ -303,7 +318,7 @@ export function ServiceInspector({
{connections.length === 0 ? ( {connections.length === 0 ? (
<p className="text-[11px] text-[var(--text-muted)]"> <p className="text-[11px] text-[var(--text-muted)]">
No inferred connections. Reference another service via{' '} No inferred connections. Reference another service via{' '}
<span className="v-mono">{'{{variable}}'}</span> placeholders in env vars. <span className="mono">{'{{variable}}'}</span> placeholders in env vars.
</p> </p>
) : ( ) : (
<div className="space-y-1.5"> <div className="space-y-1.5">
@@ -312,13 +327,13 @@ export function ServiceInspector({
key={conn.id} key={conn.id}
type="button" type="button"
onClick={() => onSelectPeer(conn.peerId)} onClick={() => onSelectPeer(conn.peerId)}
className="s-inset !rounded-[6px] w-full text-left px-2.5 py-2 hover:border-[var(--border-default)] transition-colors" className="w-full text-left px-2.5 py-2 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]/40 hover:bg-[var(--surface-muted)] transition-colors"
> >
<div className="flex items-center gap-1.5 text-[11px] text-[var(--text-primary)]"> <div className="flex items-center gap-1.5 text-[11px] text-[var(--text-primary)]">
<Link2 size={10} className="text-[var(--accent-primary)] flex-shrink-0" /> <Link2 size={10} className="text-[var(--accent-primary)] flex-shrink-0" />
<span className="truncate font-medium">{conn.outbound ? `→ ${conn.peer}` : `← ${conn.peer}`}</span> <span className="truncate font-medium">{conn.outbound ? `→ ${conn.peer}` : `← ${conn.peer}`}</span>
</div> </div>
<p className="v-mono text-[9.5px] text-[var(--text-tertiary)] truncate mt-0.5 pl-4"> <p className="mono text-[9.5px] text-[var(--text-tertiary)] truncate mt-0.5 pl-4">
{conn.reasons.join(' · ')} {conn.reasons.join(' · ')}
</p> </p>
</button> </button>
@@ -335,58 +350,65 @@ export function ServiceInspector({
</div> </div>
<div className="border-t border-[var(--border-subtle)] px-4 py-3 space-y-2"> <div className="border-t border-[var(--border-subtle)] px-4 py-3 space-y-2">
<GhostBtn <button
type="button"
onClick={() => onOpenService(service.id)} onClick={() => onOpenService(service.id)}
className="w-full justify-center !h-9" className="w-full flex items-center justify-center gap-2 h-9 rounded-[var(--radius-md)] text-[var(--accent-on)] text-[13px] font-medium"
style={{ background: 'var(--accent-primary)' }}
> >
<ArrowUpRight size={14} /> <ArrowUpRight size={14} />
Open service Open service
</GhostBtn> </button>
<div className="grid grid-cols-3 gap-1.5"> <div className="grid grid-cols-3 gap-1.5">
<QuietBtn <button
type="button"
disabled={actions.pending || readOnly} disabled={actions.pending || readOnly}
onClick={actions.deploy} onClick={actions.deploy}
className="justify-center" className="flex items-center justify-center gap-1 h-8 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors disabled:opacity-40"
> >
{actions.deployPending ? <Loader2 size={11} className="animate-spin" /> : <Rocket size={11} />} {actions.deployPending ? <Loader2 size={11} className="animate-spin" /> : <Rocket size={11} />}
Deploy Deploy
</QuietBtn> </button>
{service.status !== 'running' ? ( {service.status !== 'running' ? (
<QuietBtn <button
type="button"
disabled={actions.pending || readOnly} disabled={actions.pending || readOnly}
onClick={actions.start} onClick={actions.start}
className="justify-center" className="flex items-center justify-center gap-1 h-8 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors disabled:opacity-40"
> >
<Play size={11} /> <Play size={11} />
Start Start
</QuietBtn> </button>
) : ( ) : (
<QuietBtn <button
type="button"
disabled={actions.pending || readOnly} disabled={actions.pending || readOnly}
onClick={actions.restart} onClick={actions.restart}
className="justify-center" className="flex items-center justify-center gap-1 h-8 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors disabled:opacity-40"
> >
{actions.restartPending ? <Loader2 size={11} className="animate-spin" /> : <Play size={11} />} {actions.restartPending ? <Loader2 size={11} className="animate-spin" /> : <Play size={11} />}
Restart Restart
</QuietBtn> </button>
)} )}
<QuietBtn <button
type="button"
disabled={actions.pending || readOnly || service.status !== 'running'} disabled={actions.pending || readOnly || service.status !== 'running'}
onClick={actions.stop} onClick={actions.stop}
className="justify-center" className="flex items-center justify-center gap-1 h-8 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors disabled:opacity-40"
> >
<Square size={11} /> <Square size={11} />
Stop Stop
</QuietBtn> </button>
</div> </div>
<QuietBtn <button
type="button"
disabled={actions.pending || readOnly} disabled={actions.pending || readOnly}
onClick={actions.remove} onClick={actions.remove}
className="w-full justify-center !text-[var(--error)] hover:!border-[var(--error)]/50" className="w-full flex items-center justify-center gap-1.5 h-8 rounded-[var(--radius-sm)] text-[11px] font-medium text-[var(--error)] hover:bg-[var(--error-soft)] transition-colors disabled:opacity-40"
> >
<Trash2 size={11} /> <Trash2 size={11} />
Delete service Delete service
</QuietBtn> </button>
</div> </div>
</aside> </aside>
); );
@@ -1,11 +1,10 @@
import { Handle, Position, type Node, type NodeProps } from '@xyflow/react'; import { Handle, Position, type Node, type NodeProps } from '@xyflow/react';
import type { ServiceEntity } from '@/lib/api-client'; import type { ServiceEntity } from '@/lib/api-client';
import { serviceStatusClass } from '@/lib/api-client';
import { Box, ExternalLink, Copy, Layers, Loader2 } from 'lucide-react'; import { Box, ExternalLink, Copy, Layers, Loader2 } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { serviceIcon, serviceAccent } from './service-visuals'; import { serviceIcon, serviceAccent } from './service-visuals';
import { ServiceIcon } from './ServiceIcon'; import { ServiceIcon } from './ServiceIcon';
import { SPill } from '@/shared/components/sentry';
import { statusTone } from '@/shared/components/sentry-utils';
export type ServiceNodeData = { export type ServiceNodeData = {
service: ServiceEntity; service: ServiceEntity;
@@ -66,10 +65,10 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
return ( return (
<div <div
className={`w-full rounded-[10px] border transition-[border-color,box-shadow] duration-150 group relative overflow-hidden ${ className={`w-full rounded-[var(--radius-lg)] border transition-[border-color,box-shadow,transform] duration-150 group relative overflow-hidden ${
selected selected
? 'border-[var(--accent-primary)] bg-[var(--surface-card)] shadow-[0_0_0_1px_var(--accent-primary),0_12px_36px_rgba(0,0,0,0.5)]' ? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] shadow-[0_0_0_1px_var(--accent-primary),0_12px_36px_rgba(0,0,0,0.5)]'
: 'border-[var(--border-subtle)] bg-[var(--surface-card)] hover:border-[var(--border-default)]' : 'border-[var(--border-subtle)] bg-[var(--surface-card)] hover:border-[var(--border-default)] hover:shadow-[0_12px_32px_rgba(0,0,0,0.45)] hover:-translate-y-px'
}`} }`}
> >
<div <div
@@ -87,39 +86,39 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
style={{ background: stateColor }} style={{ background: stateColor }}
/> />
<div className="relative"> <div className="relative px-3.5 pt-3.5 pb-3">
<div className="flex items-center gap-2.5 px-3.5 pt-3 pb-2.5"> <div className="flex items-center gap-3 mb-2.5">
<div <div
className="w-8 h-8 rounded-[7px] border border-[var(--border-subtle)] flex items-center justify-center flex-shrink-0 transition-all duration-200" className="w-9 h-9 rounded-[var(--radius-md)] flex items-center justify-center flex-shrink-0 transition-all duration-200"
style={{ style={{
background: selected && !brand ? typeColor : `${typeColor}14`, background: selected && !brand ? typeColor : `${typeColor}1c`,
color: selected && !brand ? 'var(--bg-void)' : typeColor, color: selected && !brand ? 'var(--bg-void)' : typeColor,
}} }}
> >
{isTransient ? ( {isTransient ? (
<Loader2 size={14} className="animate-spin" /> <Loader2 size={16} className="animate-spin" />
) : ( ) : (
<ServiceIcon service={service} size={15} /> <ServiceIcon service={service} size={17} />
)} )}
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<h4 className="font-semibold text-[12.5px] leading-tight text-[var(--text-primary)] truncate tracking-tight"> <h4 className="font-semibold text-[13px] leading-tight text-[var(--text-primary)] truncate tracking-tight">
{service.name} {service.name}
</h4> </h4>
<p className="v-mono text-[10px] text-[var(--text-tertiary)] truncate mt-0.5"> <p className="text-[10.5px] text-[var(--text-tertiary)] truncate mt-0.5">
{service.type} {service.type}
{service.environment && service.environment !== 'production' ? ` · ${service.environment}` : ''} {service.environment && service.environment !== 'production' ? ` · ${service.environment}` : ''}
</p> </p>
</div> </div>
{replicas > 1 && ( {replicas > 1 && (
<span className="s-chip"> <span className="flex items-center gap-1 px-1.5 py-0.5 rounded bg-[var(--surface-muted)] text-[10px] font-semibold text-[var(--text-secondary)]">
<Layers size={9} /> <Layers size={9} />
{replicas} {replicas}
</span> </span>
)} )}
</div> </div>
<div className="mx-3 mb-2.5 s-inset !rounded-[6px] flex items-center gap-2 px-2.5 py-1.5 group/addr"> <div className="flex items-center gap-2 mb-2.5 px-2.5 py-1.5 rounded-[var(--radius-sm)] bg-[var(--surface-muted)]/60 border border-[var(--border-subtle)]/60 group/addr">
<ExternalLink size={10} className="text-[var(--text-tertiary)] flex-shrink-0" /> <ExternalLink size={10} className="text-[var(--text-tertiary)] flex-shrink-0" />
{publicUrl ? ( {publicUrl ? (
<a <a
@@ -127,12 +126,12 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className="v-mono text-[10.5px] text-[var(--accent-primary)] truncate hover:underline" className="text-[10.5px] text-[var(--accent-primary)] truncate mono hover:underline"
> >
{publicUrl.replace(/^https?:\/\//, '')} {publicUrl.replace(/^https?:\/\//, '')}
</a> </a>
) : ( ) : (
<span className="v-mono text-[10.5px] text-[var(--text-secondary)] truncate" title="Internal network address"> <span className="text-[10.5px] text-[var(--text-secondary)] truncate mono" title="Internal network address">
{internalAddr} {internalAddr}
</span> </span>
)} )}
@@ -149,10 +148,15 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
</button> </button>
</div> </div>
<div className="flex items-center justify-between px-3.5 py-2 border-t border-[var(--border-subtle)]"> <div className="flex items-center justify-between">
<SPill tone={statusTone(service.status)}>{statusLabel(service.status)}</SPill> <div className="flex items-center gap-1.5">
<span className={`status-dot ${serviceStatusClass(service.status)}`} />
<span className="text-[10px] font-semibold tracking-[0.08em] uppercase" style={{ color: stateColor }}>
{statusLabel(service.status)}
</span>
</div>
{service.image && ( {service.image && (
<span className="v-mono text-[9.5px] text-[var(--text-muted)] truncate max-w-[110px]" title={service.image}> <span className="text-[9.5px] text-[var(--text-muted)] truncate mono max-w-[110px]" title={service.image}>
{service.image} {service.image}
</span> </span>
)} )}
@@ -187,25 +191,25 @@ export function GroupNode({ id, data }: NodeProps<GroupNodeType>) {
return ( return (
<div <div
className="h-full w-full rounded-[12px] border border-dashed transition-all duration-200 relative" className="h-full w-full rounded-[var(--radius-xl)] border-2 border-dashed transition-all duration-200 relative"
style={{ style={{
borderColor: isHovered ? 'var(--border-strong)' : 'var(--border-default)', borderColor: isHovered ? 'var(--border-strong)' : 'var(--border-default)',
background: isHovered ? 'var(--accent-primary-soft)' : 'var(--tint-03)', background: isHovered ? 'var(--accent-primary-soft)' : 'rgba(255,255,255,0.02)',
}} }}
onMouseEnter={() => setIsHovered(true)} onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)} onMouseLeave={() => setIsHovered(false)}
> >
<div className="relative p-4"> <div className="relative p-4">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<span <div
className="s-ibox !h-7 !w-7 transition-colors duration-200" className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors duration-200"
style={{ style={{
background: isHovered ? 'var(--accent-primary-soft)' : undefined, background: isHovered ? 'var(--accent-primary-soft)' : 'var(--surface-card)',
color: isHovered ? 'var(--accent-primary)' : undefined, color: isHovered ? 'var(--accent-primary)' : 'var(--text-tertiary)',
}} }}
> >
<Box /> <Box size={14} />
</span> </div>
{editing ? ( {editing ? (
<input <input
autoFocus autoFocus
@@ -223,7 +227,7 @@ export function GroupNode({ id, data }: NodeProps<GroupNodeType>) {
/> />
) : ( ) : (
<h3 <h3
className="font-headline text-sm font-semibold text-[var(--text-primary)] tracking-tight" className="text-sm font-semibold text-[var(--text-primary)] tracking-tight"
onDoubleClick={(e) => { onDoubleClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setDraft(data.title); setDraft(data.title);
@@ -237,7 +241,7 @@ export function GroupNode({ id, data }: NodeProps<GroupNodeType>) {
</div> </div>
<p <p
className="v-mono text-[10px] uppercase tracking-[0.12em] text-[var(--text-tertiary)] mt-2 transition-opacity duration-200" className="text-[10px] text-[var(--text-tertiary)] mt-2 transition-opacity duration-200"
style={{ opacity: isHovered ? 1 : 0 }} style={{ opacity: isHovered ? 1 : 0 }}
> >
Drag services here to group them Drag services here to group them
@@ -18,15 +18,15 @@ export function serviceTypeIcon(type: string, size = 18) {
export function serviceTypeColor(type: string): string { export function serviceTypeColor(type: string): string {
switch (type) { switch (type) {
case 'web': case 'web':
return 'var(--info)'; return '#7ab8ff';
case 'database': case 'database':
return '#38bdf8'; return '#b4e34a';
case 'worker': case 'worker':
return 'var(--warning)'; return '#f2c94c';
case 'cron': case 'cron':
return 'var(--accent-secondary)'; return '#f2994a';
default: default:
return 'var(--text-tertiary)'; return '#9295a4';
} }
} }
@@ -1,198 +1,306 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useState } from 'react';
import { useQueries } from '@tanstack/react-query'; import {
import { Cpu, HardDrive, ArrowDownUp, Boxes } from 'lucide-react'; LineMetricChart,
import { LineMetricChart, DonutChart } from '@/shared/components'; DualLineChart,
import { getServiceMetrics, type ServiceMetrics } from '@/lib/api-client'; DonutChart,
import { formatBytes } from '@/lib/time'; } from '@/shared/components';
interface MetricData {
cpu: number[];
ram: number;
ramUsed: string;
cache: number;
cacheBreakdown: { cache: number; nonCache: number; total: number };
users: number[];
performance: number[];
performanceAlt: number[];
upSpeed: number;
downSpeed: number;
}
interface MetricsDashboardProps { interface MetricsDashboardProps {
services: { id: string; name: string; status: string }[]; projectId?: string;
isDemoMode?: boolean; isRunning?: boolean;
onStop?: () => void;
onRestart?: () => void;
} }
function healthLabel(pct: number): { text: string; color: string } { function getRandom(min: number, max: number) {
if (pct < 50) return { text: 'Good', color: 'var(--success)' }; return Math.floor(Math.random() * (max - min + 1)) + min;
if (pct < 80) return { text: 'Average', color: 'var(--warning)' };
return { text: 'High', color: 'var(--error)' };
} }
function Tile({ icon: Icon, label, children }: { function generateInitialData(): MetricData {
icon: React.ElementType; return {
label: string; cpu: Array.from({ length: 24 }, () => getRandom(10, 45)),
children: React.ReactNode; ram: getRandom(50, 85),
}) { ramUsed: '5.4 GB',
return ( cache: 352,
<div className="s-card !p-0 overflow-hidden"> cacheBreakdown: { cache: 212, nonCache: 85.5, total: 1750 },
<div className="s-cardhead"> users: Array.from({ length: 15 }, () => getRandom(40, 110)),
<span className="s-ibox"><Icon /></span> performance: Array.from({ length: 12 }, () => getRandom(70, 95)),
<span className="s-t">{label}</span> performanceAlt: Array.from({ length: 12 }, () => getRandom(60, 85)),
</div> upSpeed: 10.4,
<div className="px-4 pb-4 pt-3">{children}</div> downSpeed: 5.2,
</div> };
);
} }
export function MetricsDashboard({ services, isDemoMode = false }: MetricsDashboardProps) { export function MetricsDashboard({
const liveServices = services.filter((s) => s.status === 'running'); isRunning = true,
}: MetricsDashboardProps) {
const [data, setData] = useState<MetricData>(generateInitialData);
const [timeRange, setTimeRange] = useState<'day' | 'month' | 'year'>('day');
const metricsQueries = useQueries({
queries: liveServices.map((service) => ({
queryKey: ['service-metrics', service.id],
queryFn: () => getServiceMetrics(service.id),
enabled: !isDemoMode,
refetchInterval: 5000,
retry: false,
})),
});
const samples = metricsQueries
.map((q) => q.data)
.filter((m): m is ServiceMetrics => Boolean(m) && m!.status === 'ok');
const totalCpu = samples.reduce((sum, m) => sum + m.cpu_percent, 0);
const avgCpu = samples.length > 0 ? totalCpu / samples.length : 0;
const memUsed = samples.reduce((sum, m) => sum + m.memory_usage_bytes, 0);
const memLimit = samples.reduce((sum, m) => sum + m.memory_limit_bytes, 0);
const memPct = memLimit > 0 ? (memUsed / memLimit) * 100 : 0;
const netRx = samples.reduce((sum, m) => sum + m.network_rx_bytes, 0);
const netTx = samples.reduce((sum, m) => sum + m.network_tx_bytes, 0);
const instanceCount = samples.reduce((sum, m) => sum + m.instances.length, 0);
// Rolling history for the sparklines — accumulated from real samples.
const [cpuHistory, setCpuHistory] = useState<number[]>([]);
const [netHistory, setNetHistory] = useState<{ rx: number[]; tx: number[] }>({ rx: [], tx: [] });
const lastNetRef = useRef<{ rx: number; tx: number; at: number } | null>(null);
const samplesKey = samples.map((s) => `${s.service_id}:${s.collected_at}`).join('|');
const avgCpuRef = avgCpu;
const netRxRef = netRx;
const netTxRef = netTx;
useEffect(() => { useEffect(() => {
if (!samplesKey) return; if (!isRunning) return;
const now = Date.now();
const prev = lastNetRef.current;
const elapsed = prev ? Math.max(1, (now - prev.at) / 1000) : 0;
const rxRate = prev && elapsed > 0 ? Math.max(0, (netRxRef - prev.rx) / elapsed) : 0;
const txRate = prev && elapsed > 0 ? Math.max(0, (netTxRef - prev.tx) / elapsed) : 0;
lastNetRef.current = { rx: netRxRef, tx: netTxRef, at: now };
setCpuHistory((h) => [...h, Math.min(100, avgCpuRef)].slice(-24));
setNetHistory((h) => ({ rx: [...h.rx, rxRate].slice(-24), tx: [...h.tx, txRate].slice(-24) }));
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the samples' collected_at signature
}, [samplesKey]);
const cpuHealth = healthLabel(avgCpu); const interval = setInterval(() => {
const memHealth = healthLabel(memPct); setData((prev) => {
const reporting = samples.length; const newCpu = [...prev.cpu.slice(1), getRandom(10, 45)];
const noData = !isDemoMode && liveServices.length > 0 && reporting === 0; const newUsers = [...prev.users.slice(1), getRandom(60, 110)];
const newPerf = [...prev.performance.slice(1), getRandom(82, 99)];
const newPerfAlt = [...prev.performanceAlt.slice(1), getRandom(60, 85)];
return {
...prev,
cpu: newCpu,
ram: getRandom(50, 85),
ramUsed: `${(8 * (prev.ram / 100)).toFixed(1)} GB`,
users: newUsers,
performance: newPerf,
performanceAlt: newPerfAlt,
upSpeed: parseFloat((Math.random() * 5 + 8).toFixed(1)),
downSpeed: parseFloat((Math.random() * 3 + 4).toFixed(1)),
};
});
}, 2000);
return () => clearInterval(interval);
}, [isRunning]);
const currentCpu = data.cpu[data.cpu.length - 1];
const currentUsers = data.users[data.users.length - 1];
const currentPerf = data.performance[data.performance.length - 1];
return ( return (
<div> <div>
<div className="flex items-center justify-between mb-4"> {/* Metrics Header - self.html exact match */}
<span className="v-mono text-[10.5px] uppercase tracking-[0.1em] text-[var(--text-tertiary)]">Metrics</span> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '16px' }}>
<span className="v-mono text-[10.5px] text-[var(--text-muted)]"> <span style={{ fontSize: '15px', fontWeight: 700, color: '#e8e9f0' }}>Metrics</span>
{isDemoMode <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
? 'sample data' <button style={{
: reporting > 0 height: '32px',
? `${reporting} service${reporting === 1 ? '' : 's'} reporting · every 5s` padding: '0 12px',
: 'no running services'} borderRadius: '9px',
</span> border: '1px solid rgba(255,255,255,0.09)',
background: 'rgba(255,255,255,0.04)',
color: '#9295a4',
fontSize: '12.5px',
fontWeight: 500,
fontFamily: 'inherit',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '6px',
}}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" />
</svg>
Filter
</button>
<div className="pill-group">
{(['day', 'month', 'year'] as const).map((range) => (
<div
key={range}
className={`pill ${timeRange === range ? 'active' : ''}`}
onClick={() => setTimeRange(range)}
>
{range.charAt(0).toUpperCase() + range.slice(1)}
</div>
))}
</div>
</div>
</div> </div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4"> {/* Row 1: CPU, RAM, Cache - self.html exact: 13px gap */}
{/* CPU */} <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1.95fr', gap: '13px', marginBottom: '13px' }}>
<Tile icon={Cpu} label="CPU Usage"> {/* CPU Card */}
<div className="font-headline text-[30px] font-bold leading-none tracking-[-0.02em] text-[var(--text-primary)]"> <div className="card">
{avgCpu.toFixed(1)}<span className="text-[13px] font-medium text-[var(--text-tertiary)]">%</span> <div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '12px' }}>
<div className="card-icon"><svg viewBox="0 0 24 24"><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><line x1="9" y1="1" x2="9" y2="4"/><line x1="15" y1="1" x2="15" y2="4"/><line x1="9" y1="20" x2="9" y2="23"/><line x1="15" y1="20" x2="15" y2="23"/><line x1="20" y1="9" x2="23" y2="9"/><line x1="20" y1="14" x2="23" y2="14"/><line x1="1" y1="9" x2="4" y2="9"/><line x1="1" y1="14" x2="4" y2="14"/></svg></div>
<span style={{ fontSize: '14px', fontWeight: 600, color: '#e8e9f0' }}>CPU Usage</span>
</div> </div>
<div className="mt-1.5 v-mono text-[10.5px] text-[var(--text-tertiary)]"> <div style={{ fontSize: '38px', fontWeight: 900, letterSpacing: '-1.5px', lineHeight: 1, color: '#e8e9f0' }}>{currentCpu}%</div>
<span style={{ color: cpuHealth.color }}>{cpuHealth.text}</span> <div style={{ fontSize: '12px', color: '#6b6e7d', marginTop: '4px' }}>
{' '}· avg across running services <span style={{ color: currentCpu < 50 ? '#5ee6a0' : currentCpu < 75 ? '#f2c94c' : '#b4e34a', fontWeight: 700 }}>
{currentCpu < 50 ? 'Good' : currentCpu < 75 ? 'Average' : 'High'}
</span>{' '}
Daily usage
</div> </div>
<div style={{ height: 56, marginTop: 12 }}> <div style={{ height: 76, margin: '12px 0 6px' }}>
{cpuHistory.length > 1 ? ( <LineMetricChart data={data.cpu} color="#b4e34a" height={76} />
<LineMetricChart data={cpuHistory} color="var(--accent-primary)" fillOpacity={0.15} showArea height={56} />
) : (
<div className="h-full rounded bg-[var(--surface-muted)] opacity-40" />
)}
</div> </div>
</Tile> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingTop: '4px' }}>
<span style={{ fontSize: '13px', color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}>Details</span>
<div className="arrow-btn">
<svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg>
</div>
</div>
</div>
{/* Memory */} {/* RAM Card */}
<Tile icon={HardDrive} label="Memory"> <div className="card">
<div className="font-headline text-[30px] font-bold leading-none tracking-[-0.02em] text-[var(--text-primary)]"> <div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '12px' }}>
{memPct.toFixed(0)}<span className="text-[13px] font-medium text-[var(--text-tertiary)]">%</span> <div className="card-icon"><svg viewBox="0 0 24 24"><rect x="2" y="8" width="20" height="8" rx="2"/><path d="M6 8V6M10 8V6M14 8V6M18 8V6M6 16v2M18 16v2"/></svg></div>
<span style={{ fontSize: '14px', fontWeight: 600, color: '#e8e9f0' }}>RAM Usage</span>
</div> </div>
<div className="mt-1.5 v-mono text-[10.5px] text-[var(--text-tertiary)]"> <div style={{ fontSize: '38px', fontWeight: 900, letterSpacing: '-1.5px', lineHeight: 1, color: '#e8e9f0' }}>{data.ram}%</div>
<span style={{ color: memHealth.color }}>{memHealth.text}</span> <div style={{ fontSize: '12px', color: '#6b6e7d', marginTop: '4px' }}>
{' '}· {formatBytes(memUsed)} used <span style={{ color: data.ram < 60 ? '#5ee6a0' : '#f2c94c', fontWeight: 700 }}>
{data.ram < 60 ? 'Good' : 'Average'}
</span>{' '}
Daily usage
</div> </div>
<div className="mt-2 flex justify-center"> <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', margin: '10px 0 4px', position: 'relative' }}>
<DonutChart percentage={memPct} size={100} thickness={11} /> <DonutChart percent={data.ram} color="#f2c94c" size={160} thickness={16} />
<div style={{ position: 'absolute', bottom: 14, textAlign: 'center' }}>
<div style={{ fontSize: '10.5px', color: '#6b6e7d', marginBottom: 1 }}>Used</div>
<div style={{ fontSize: '12.5px', fontWeight: 700, color: '#e8e9f0' }}>{data.ramUsed} / 8GB</div>
</div>
</div> </div>
<div className="mt-1 text-center v-mono text-[10.5px] text-[var(--text-tertiary)]"> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingTop: '4px' }}>
{formatBytes(memUsed)} / {memLimit > 0 ? formatBytes(memLimit) : '∞'} <span style={{ fontSize: '13px', color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}>Details</span>
<div className="arrow-btn">
<svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg>
</div>
</div> </div>
</Tile> </div>
{/* Network */} {/* Cache Card */}
<Tile icon={ArrowDownUp} label="Network"> <div className="card">
<div className="font-headline text-[30px] font-bold leading-none tracking-[-0.02em] text-[var(--text-primary)]"> <div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '12px' }}>
{formatBytes(netRx)} <div className="card-icon"><svg viewBox="0 0 24 24"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg></div>
<span style={{ fontSize: '14px', fontWeight: 600, color: '#e8e9f0' }}>Cache</span>
</div> </div>
<div className="mt-1.5 v-mono text-[10.5px] text-[var(--text-tertiary)]"> <div style={{ fontSize: '38px', fontWeight: 900, letterSpacing: '-1.5px', lineHeight: 1, color: '#e8e9f0' }}>{data.cache} MB</div>
<span className="text-[var(--accent-primary)]">RX</span> <div style={{ fontSize: '12px', color: '#6b6e7d', marginTop: '4px' }}>
{' '}total · TX {formatBytes(netTx)} <span style={{ color: '#f2c94c', fontWeight: 700 }}>220MB Average</span>{' '}
cached images and files
</div> </div>
<div style={{ height: 56, marginTop: 12 }}>
{netHistory.rx.length > 1 ? (
<LineMetricChart data={netHistory.rx} color="var(--accent-primary)" fillOpacity={0.12} showArea height={56} />
) : (
<div className="h-full rounded bg-[var(--surface-muted)] opacity-40" />
)}
</div>
<div className="mt-2 v-mono text-[10.5px] text-[var(--text-muted)]">
{netHistory.rx.length > 0
? `↓ ${formatBytes(netHistory.rx[netHistory.rx.length - 1])}/s · ↑ ${formatBytes(netHistory.tx[netHistory.tx.length - 1])}/s`
: 'rate accumulating…'}
</div>
</Tile>
{/* Instances */} {/* Segmented Bar */}
<Tile icon={Boxes} label="Instances"> <div style={{ display: 'flex', alignItems: 'center', gap: '5px', margin: '16px 0 15px', height: '32px' }}>
<div className="font-headline text-[30px] font-bold leading-none tracking-[-0.02em] text-[var(--text-primary)]"> <div className="cache-seg" style={{ width: '43%', background: '#ff6b5b', borderRadius: '10px 4px 4px 10px' }} />
{isDemoMode ? liveServices.length : instanceCount} <div className="cache-seg" style={{ width: '13%', background: '#8c6ef0', borderRadius: '5px' }} />
<div className="cache-seg" style={{ flex: 1, background: 'rgba(255,255,255,0.07)', borderRadius: '4px 10px 10px 4px' }} />
</div> </div>
<div className="mt-1.5 v-mono text-[10.5px] text-[var(--text-tertiary)]">
<span className="text-[var(--success)]">Running</span> {/* Stats row */}
{' '}across {liveServices.length} service{liveServices.length === 1 ? '' : 's'} <div style={{ display: 'grid', gridTemplateColumns: '1fr auto 1fr auto 1fr', gap: 0, alignItems: 'stretch' }}>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '5px', fontSize: '11px', color: '#6b6e7d', marginBottom: '5px' }}>
<div className="stat-dot" style={{ background: '#ff6b5b' }} />
Cache
</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: '4px' }}>
<span style={{ fontSize: '15px', fontWeight: 800, color: '#e8e9f0' }}>212 MB</span>
<span style={{ fontSize: '11px', color: '#6b6e7d' }}>12%</span>
</div>
</div>
<div style={{ width: '1px', background: 'rgba(255,255,255,0.08)', margin: '0 16px' }} />
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '5px', fontSize: '11px', color: '#6b6e7d', marginBottom: '5px' }}>
<div className="stat-dot" style={{ background: '#8c6ef0' }} />
Non-Cache
</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: '4px' }}>
<span style={{ fontSize: '15px', fontWeight: 800, color: '#e8e9f0' }}>85.5 MB</span>
<span style={{ fontSize: '11px', color: '#6b6e7d' }}>4%</span>
</div>
</div>
<div style={{ width: '1px', background: 'rgba(255,255,255,0.08)', margin: '0 16px' }} />
<div>
<div style={{ fontSize: '11px', color: '#6b6e7d', marginBottom: '5px' }}>Total</div>
<div style={{ fontSize: '15px', fontWeight: 800, color: '#e8e9f0' }}>1.75 GB</div>
</div>
</div> </div>
<ul className="mt-3 space-y-1.5">
{liveServices.slice(0, 4).map((service, i) => { <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingTop: '16px' }}>
const m = samples.find((s) => s.service_id === service.id); <span style={{ fontSize: '13px', color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}>Details</span>
return ( <div className="arrow-btn">
<li key={service.id} className="flex items-center justify-between text-[11.5px]"> <svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg>
<span className="truncate text-[var(--text-secondary)]"> </div>
<span className="mr-1.5 inline-block h-1.5 w-1.5 rounded-full bg-[var(--success)] align-middle" /> </div>
{service.name} </div>
</span>
<span className="v-mono text-[10.5px] text-[var(--text-muted)]">
{m ? `${m.cpu_percent.toFixed(1)}%` : i < 3 ? '—' : ''}
</span>
</li>
);
})}
{liveServices.length > 4 && (
<li className="v-mono text-[10.5px] text-[var(--text-muted)]">+{liveServices.length - 4} more</li>
)}
</ul>
</Tile>
</div> </div>
{noData && ( {/* Row 2: Active Users, Performance - self.html exact: 13px gap */}
<p className="mt-3 v-mono text-[11px] text-[var(--text-muted)]"> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '13px' }}>
Metrics collector is not reporting yet — check that the node agent is online. {/* Active Users - horizontal layout */}
</p> <div className="card" style={{ flexDirection: 'row', padding: 0, overflow: 'hidden' }}>
)} <div style={{ flex: 1, padding: '20px 18px 18px 20px', display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '12px' }}>
<div className="card-icon"><svg viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/></svg></div>
<span style={{ fontSize: '14px', fontWeight: 600, color: '#e8e9f0' }}>Active User</span>
</div>
<div style={{ fontSize: '36px', fontWeight: 900, letterSpacing: '-1.5px', lineHeight: 1, color: '#e8e9f0' }}>{currentUsers} K</div>
<div style={{ fontSize: '12px', color: '#6b6e7d', marginTop: '4px' }}>User active right now</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '5px', marginTop: '12px', flexWrap: 'wrap' }}>
{['🇨🇳', '🇮🇩', '🇲🇲', '🇲🇾', '🇯🇵', '🇮🇳', '🇰🇷', '🇵🇭'].map((flag, i) => (
<span key={i} className="flag">{flag}</span>
))}
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: 'auto', paddingTop: '14px' }}>
<span style={{ fontSize: '13px', color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}>Details</span>
<div className="arrow-btn">
<svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg>
</div>
</div>
</div>
<div style={{ width: '50%', padding: '16px 14px 46px 0', display: 'flex', alignItems: 'flex-end' }}>
<LineMetricChart data={data.users} color="var(--accent-primary)" fillOpacity={0.15} showArea height={130} />
</div>
</div>
{/* Performance Card */}
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '12px' }}>
<div className="card-icon"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></div>
<span style={{ fontSize: '14px', fontWeight: 600, color: '#e8e9f0' }}>Performance</span>
</div>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '16px', flex: 1 }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '36px', fontWeight: 900, letterSpacing: '-1.5px', lineHeight: 1, color: '#e8e9f0' }}>{currentPerf}%</div>
<div style={{ fontSize: '12px', color: '#6b6e7d', marginTop: '4px' }}>
<span style={{ color: currentPerf > 85 ? '#5ee6a0' : '#f2c94c', fontWeight: 700 }}>
{currentPerf > 85 ? 'Good' : 'Average'}
</span>{' '}
Last scan
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: '8px' }}>
<div style={{ width: 134, height: 58 }}>
<DualLineChart data1={data.performance} data2={data.performanceAlt} height={58} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px', alignItems: 'flex-end' }}>
<div className="speed-row" style={{ color: '#7ab8ff' }}>
<svg viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 19 19 12"/></svg>
<span>{data.upSpeed}</span> Mbps
</div>
<div className="speed-row" style={{ color: 'var(--accent-primary)' }}>
<svg viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 5 5 12"/></svg>
<span>{data.downSpeed}</span> Mbps
</div>
</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingTop: '14px' }}>
<span style={{ fontSize: '13px', color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}>Check Speed</span>
<div className="arrow-btn">
<svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg>
</div>
</div>
</div>
</div>
</div> </div>
); );
} }
@@ -2,7 +2,6 @@ import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { listProjectVariables, updateProjectVariables } from '@/lib/api-client'; import { listProjectVariables, updateProjectVariables } from '@/lib/api-client';
import { Loader2, Plus, Save, Trash2, KeyRound } from 'lucide-react'; import { Loader2, Plus, Save, Trash2, KeyRound } from 'lucide-react';
import { QuietBtn } from '@/shared/components/sentry';
type Row = { key: string; value: string; isSecret: boolean }; type Row = { key: string; value: string; isSecret: boolean };
@@ -43,23 +42,26 @@ export function SharedVariablesSection({ projectId }: { projectId: string }) {
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]"> <div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<div> <div>
<h3 className="s-t">Shared Variables</h3> <h3 className="text-sm font-medium text-[var(--text-secondary)]">Shared Variables</h3>
<p className="mt-1 text-xs text-[var(--text-muted)]"> <p className="mt-1 text-xs text-[var(--text-muted)]">
Reference from any service as{' '} Reference from any service as{' '}
<code className="v-mono text-[var(--accent-primary)]">{'${{shared.KEY}}'}</code>. Applied on next <code className="mono text-[var(--accent-primary)]">{'${{shared.KEY}}'}</code>. Applied on next
deploy/redeploy — not live-updated. deploy/redeploy — not live-updated.
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<QuietBtn onClick={() => setDrafts([...rows, { key: '', value: '', isSecret: false }])}> <button
onClick={() => setDrafts([...rows, { key: '', value: '', isSecret: false }])}
className="flex items-center gap-2 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] transition-colors"
>
<Plus size={12} /> <Plus size={12} />
Add Add
</QuietBtn> </button>
<button <button
onClick={() => saveMutation.mutate(rows)} onClick={() => saveMutation.mutate(rows)}
disabled={!dirty || saveMutation.isPending} disabled={!dirty || saveMutation.isPending}
className="s-btn-accent disabled:opacity-50" className="flex items-center gap-2 px-3 py-1.5 rounded-[var(--radius-md)] text-xs font-medium text-[var(--accent-on)] disabled:opacity-50 transition-all"
style={{ background: 'var(--accent-primary)' }}
> >
{saveMutation.isPending ? <Loader2 size={12} className="animate-spin" /> : <Save size={12} />} {saveMutation.isPending ? <Loader2 size={12} className="animate-spin" /> : <Save size={12} />}
Save Save
@@ -76,12 +78,12 @@ export function SharedVariablesSection({ projectId }: { projectId: string }) {
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{rows.map((row, i) => ( {rows.map((row, i) => (
<div key={i} className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto_auto] gap-2 items-center"> <div key={i} className="grid grid-cols-[1fr_1fr_auto_auto] gap-2 items-center">
<input <input
value={row.key} value={row.key}
onChange={(e) => updateRow(i, { key: e.target.value })} onChange={(e) => updateRow(i, { key: e.target.value })}
placeholder="KEY" placeholder="KEY"
className="h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] v-mono text-xs text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all" className="h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] mono text-xs text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all"
/> />
<input <input
value={row.value} value={row.value}
@@ -89,13 +91,13 @@ export function SharedVariablesSection({ projectId }: { projectId: string }) {
type={row.isSecret ? 'password' : 'text'} type={row.isSecret ? 'password' : 'text'}
placeholder="value" placeholder="value"
autoComplete="off" autoComplete="off"
className="h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] v-mono text-xs text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all" className="h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] mono text-xs text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all"
/> />
<button <button
type="button" type="button"
title={row.isSecret ? 'Secret — masked on save' : 'Mark as secret'} title={row.isSecret ? 'Secret — masked on save' : 'Mark as secret'}
onClick={() => updateRow(i, { isSecret: !row.isSecret })} onClick={() => updateRow(i, { isSecret: !row.isSecret })}
className={`w-9 h-9 rounded-lg border flex items-center justify-center transition-colors ${ className={`w-9 h-9 rounded-[var(--radius-md)] border flex items-center justify-center transition-colors ${
row.isSecret row.isSecret
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]' ? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]'
: 'border-[var(--border-subtle)] text-[var(--text-muted)] hover:border-[var(--border-default)]' : 'border-[var(--border-subtle)] text-[var(--text-muted)] hover:border-[var(--border-default)]'
@@ -106,7 +108,7 @@ export function SharedVariablesSection({ projectId }: { projectId: string }) {
<button <button
type="button" type="button"
onClick={() => setDrafts(rows.filter((_, idx) => idx !== i))} onClick={() => setDrafts(rows.filter((_, idx) => idx !== i))}
className="w-9 h-9 rounded-lg border border-[var(--border-subtle)] flex items-center justify-center text-[var(--text-muted)] hover:border-[var(--error)] hover:text-[var(--error)] transition-colors" className="w-9 h-9 rounded-[var(--radius-md)] border border-[var(--border-subtle)] flex items-center justify-center text-[var(--text-muted)] hover:border-[var(--error)] hover:text-[var(--error)] transition-colors"
> >
<Trash2 size={12} /> <Trash2 size={12} />
</button> </button>
@@ -50,9 +50,7 @@ import {
GitBranch, GitBranch,
Container, Container,
FileUp, FileUp,
RefreshCw,
} from 'lucide-react'; } from 'lucide-react';
import { GhostBtn, QuietBtn } from '@/shared/components/sentry';
type WorkspaceView = 'canvas' | 'observability' | 'logs' | 'activity' | 'settings'; type WorkspaceView = 'canvas' | 'observability' | 'logs' | 'activity' | 'settings';
@@ -148,10 +146,12 @@ function ServiceCreateDialog(props: {
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"> <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={props.onClose} /> <div className="absolute inset-0 bg-[var(--bg-void)]/80 backdrop-blur-sm" onClick={props.onClose} />
<div className="relative w-full max-w-lg s-card !p-6"> <div className="relative w-full max-w-lg panel p-6">
<div className="flex items-center gap-2.5 mb-1"> <div className="flex items-center gap-3 mb-1">
<span className="s-ibox !w-9 !h-9"><Plus /></span> <div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<h3 className="font-headline text-lg font-semibold text-[var(--text-primary)]">Add Service</h3> <Plus size={20} className="text-[var(--accent-primary)]" />
</div>
<h3 className="text-xl font-semibold text-[var(--text-primary)]">Add Service</h3>
</div> </div>
<p className="text-sm text-[var(--text-secondary)]"> <p className="text-sm text-[var(--text-secondary)]">
Deploy a new service to this project. Configure runtime settings after creation. Deploy a new service to this project. Configure runtime settings after creation.
@@ -202,7 +202,7 @@ function ServiceCreateDialog(props: {
</div> </div>
{isDatabase ? ( {isDatabase ? (
<div className="s-inset space-y-3"> <div className="space-y-3 p-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]/50">
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2"> <label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
@@ -287,12 +287,12 @@ function ServiceCreateDialog(props: {
<input <input
value={form.image ?? ''} value={form.image ?? ''}
onChange={(e) => setForm((p) => ({ ...p, image: e.target.value }))} onChange={(e) => setForm((p) => ({ ...p, image: e.target.value }))}
className="w-full h-11 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all v-mono text-sm" className="w-full h-11 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all mono text-sm"
placeholder="ghcr.io/org/app:latest" placeholder="ghcr.io/org/app:latest"
/> />
</div> </div>
) : ( ) : (
<div className="s-inset space-y-3"> <div className="space-y-3 p-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]/50">
<div> <div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2"> <label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
Provider Provider
@@ -326,7 +326,7 @@ function ServiceCreateDialog(props: {
<input <input
value={repoSearch} value={repoSearch}
onChange={(e) => setRepoSearch(e.target.value)} onChange={(e) => setRepoSearch(e.target.value)}
className="w-full h-9 px-3 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm v-mono mb-2" className="w-full h-9 px-3 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm mono mb-2"
placeholder="Search repositories..." placeholder="Search repositories..."
/> />
{repositoriesQuery.isLoading ? ( {repositoriesQuery.isLoading ? (
@@ -357,7 +357,7 @@ function ServiceCreateDialog(props: {
: 'text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)]' : 'text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)]'
}`} }`}
> >
<span className="v-mono text-xs truncate">{repo.full_name}</span> <span className="mono text-xs truncate">{repo.full_name}</span>
<span className="text-[10px] text-[var(--text-muted)] ml-2 shrink-0">{repo.default_branch}</span> <span className="text-[10px] text-[var(--text-muted)] ml-2 shrink-0">{repo.default_branch}</span>
</button> </button>
))} ))}
@@ -380,7 +380,7 @@ function ServiceCreateDialog(props: {
<select <select
value={form.git_branch ?? ''} value={form.git_branch ?? ''}
onChange={(e) => setForm((p) => ({ ...p, git_branch: e.target.value }))} onChange={(e) => setForm((p) => ({ ...p, git_branch: e.target.value }))}
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm v-mono" className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm mono"
> >
<option value="">Select branch</option> <option value="">Select branch</option>
{branches.map((branch) => ( {branches.map((branch) => (
@@ -391,7 +391,7 @@ function ServiceCreateDialog(props: {
<input <input
value={form.git_branch ?? ''} value={form.git_branch ?? ''}
onChange={(e) => setForm((p) => ({ ...p, git_branch: e.target.value }))} onChange={(e) => setForm((p) => ({ ...p, git_branch: e.target.value }))}
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm v-mono" className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm mono"
placeholder={selectedRepo?.default_branch ?? 'main'} placeholder={selectedRepo?.default_branch ?? 'main'}
/> />
)} )}
@@ -403,7 +403,7 @@ function ServiceCreateDialog(props: {
<input <input
value={form.build_path ?? ''} value={form.build_path ?? ''}
onChange={(e) => setForm((p) => ({ ...p, build_path: e.target.value }))} onChange={(e) => setForm((p) => ({ ...p, build_path: e.target.value }))}
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm v-mono" className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm mono"
placeholder="/ (repo root)" placeholder="/ (repo root)"
/> />
</div> </div>
@@ -419,7 +419,7 @@ function ServiceCreateDialog(props: {
<input <input
value={form.command ?? ''} value={form.command ?? ''}
onChange={(e) => setForm((p) => ({ ...p, command: e.target.value }))} onChange={(e) => setForm((p) => ({ ...p, command: e.target.value }))}
className="w-full h-11 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all v-mono text-sm" className="w-full h-11 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all mono text-sm"
placeholder="npm run start" placeholder="npm run start"
/> />
</div> </div>
@@ -434,7 +434,7 @@ function ServiceCreateDialog(props: {
<div className="mt-6 flex justify-end gap-3"> <div className="mt-6 flex justify-end gap-3">
<button <button
onClick={props.onClose} onClick={props.onClose}
className="s-btn-quiet" className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-[var(--text-secondary)] text-sm font-medium hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors"
> >
Cancel Cancel
</button> </button>
@@ -454,8 +454,8 @@ function ServiceCreateDialog(props: {
database: isDatabase ? { engine: dbEngine, plan: dbPlan } : undefined, database: isDatabase ? { engine: dbEngine, plan: dbPlan } : undefined,
}) })
} }
className="s-btn-accent disabled:opacity-50 disabled:cursor-not-allowed" className="px-5 py-2 rounded-[var(--radius-md)] text-[var(--accent-on)] text-sm font-medium shadow-lg disabled:opacity-50 disabled:cursor-not-allowed transition-all"
style={{ background: 'var(--accent-primary)' }}
> >
{props.loading ? 'Creating...' : isDatabase ? 'Create Database' : 'Create Service'} {props.loading ? 'Creating...' : isDatabase ? 'Create Database' : 'Create Service'}
</button> </button>
@@ -689,17 +689,17 @@ export function ProjectWorkspacePage() {
if ((!isDemoMode && projectQuery.isError) || !project) { if ((!isDemoMode && projectQuery.isError) || !project) {
return ( return (
<div className="min-h-screen flex items-center justify-center p-8"> <div className="min-h-screen flex items-center justify-center p-8">
<div className="s-card p-8 text-center max-w-md"> <div className="panel p-8 text-center max-w-md">
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-[var(--error-soft)] flex items-center justify-center"> <div className="w-12 h-12 mx-auto mb-4 rounded-full bg-[var(--error-soft)] flex items-center justify-center">
<X size={24} className="text-[var(--error)]" /> <X size={24} className="text-[var(--error)]" />
</div> </div>
<p className="font-headline text-lg font-medium text-[var(--text-primary)]">Project not found</p> <p className="text-lg font-medium text-[var(--text-primary)]">Project not found</p>
<p className="mt-2 text-sm text-[var(--text-secondary)]"> <p className="mt-2 text-sm text-[var(--text-secondary)]">
This project may have been deleted or you don't have access. This project may have been deleted or you don't have access.
</p> </p>
<button <button
onClick={() => navigate(isDemoMode ? '/projects?demo=1' : '/projects')} onClick={() => navigate(isDemoMode ? '/projects?demo=1' : '/projects')}
className="s-btn-quiet" className="mt-6 px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm font-medium hover:border-[var(--border-default)] transition-colors"
> >
Back to Projects Back to Projects
</button> </button>
@@ -724,13 +724,13 @@ export function ProjectWorkspacePage() {
<span className="hidden sm:inline">Projects</span> <span className="hidden sm:inline">Projects</span>
</button> </button>
<div className="w-px h-4 bg-[var(--border-subtle)] shrink-0" /> <div className="w-px h-4 bg-[var(--border-subtle)] shrink-0" />
<h1 className="v-title font-headline text-sm shrink-0 min-w-0 truncate">{project.name}<span className="v-cursor">_</span></h1> <h1 className="v-title text-sm shrink-0 min-w-0 truncate">{project.name}<span className="v-cursor">_</span></h1>
<span className="hidden lg:flex items-center gap-1.5 text-[11px] text-[var(--text-tertiary)] shrink-0"> <span className="hidden lg:flex items-center gap-1.5 text-[11px] text-[var(--text-tertiary)] shrink-0">
{services.length} service{services.length !== 1 ? 's' : ''} · {runningServices} running {services.length} service{services.length !== 1 ? 's' : ''} · {runningServices} running
</span> </span>
{/* View tabs */} {/* View tabs */}
<nav className="flex items-center gap-0.5 mx-2 overflow-x-auto min-w-0 self-stretch [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"> <nav className="flex items-center gap-0.5 rounded-full bg-[var(--surface-muted)] border border-[var(--border-subtle)] p-0.5 mx-2 overflow-x-auto min-w-0">
{viewItems.filter((item) => signedIn || !item.authOnly).map((item) => { {viewItems.filter((item) => signedIn || !item.authOnly).map((item) => {
const active = activeView === item.key; const active = activeView === item.key;
const Icon = item.icon; const Icon = item.icon;
@@ -742,7 +742,11 @@ export function ProjectWorkspacePage() {
next.set('view', item.key); next.set('view', item.key);
return next; return next;
})} })}
className={`tab ${active ? 'active' : ''} !py-0 h-full !text-[11.5px]`} className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-all whitespace-nowrap ${
active
? 'bg-[var(--surface-card)] text-[var(--text-primary)] shadow-sm'
: 'text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]'
}`}
> >
<Icon size={13} /> <Icon size={13} />
<span className="hidden md:inline">{item.label}</span> <span className="hidden md:inline">{item.label}</span>
@@ -761,21 +765,29 @@ export function ProjectWorkspacePage() {
</span> </span>
<button <button
onClick={() => setCommandPaletteOpen(true)} onClick={() => setCommandPaletteOpen(true)}
className="s-chip hidden lg:flex" className="hidden md:flex items-center gap-2 h-8 px-2.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-muted)] text-xs hover:border-[var(--border-default)] hover:text-[var(--text-secondary)] transition-colors"
> >
<Search size={12} /> <Search size={12} />
<kbd className="px-1 py-px rounded border border-[var(--border-subtle)] bg-[var(--bg-surface)] text-[10px] font-mono">⌘K</kbd> <kbd className="px-1 py-0.5 rounded bg-[var(--surface-card)] text-[10px] font-mono">⌘K</kbd>
</button> </button>
{!isDemoMode && signedIn && ( {!isDemoMode && signedIn && (
<> <>
<QuietBtn onClick={() => setImportOpen(true)} title="Deploy a docker-compose file into this project"> <button
<FileUp size={13} /> onClick={() => setImportOpen(true)}
className="flex items-center gap-1.5 h-8 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] hover:text-[var(--text-primary)] transition-colors"
title="Deploy a docker-compose file into this project"
>
<FileUp size={14} />
<span className="hidden sm:inline">Compose</span> <span className="hidden sm:inline">Compose</span>
</QuietBtn> </button>
<GhostBtn onClick={openAddService}> <button
<Plus size={13} /> onClick={openAddService}
className="flex items-center gap-1.5 h-8 px-3 rounded-[var(--radius-md)] text-[var(--accent-on)] text-xs font-semibold shadow-lg hover:shadow-xl transition-all"
style={{ background: 'var(--accent-primary)' }}
>
<Plus size={14} />
<span className="hidden sm:inline">Add Service</span> <span className="hidden sm:inline">Add Service</span>
</GhostBtn> </button>
</> </>
)} )}
</div> </div>
@@ -806,48 +818,67 @@ export function ProjectWorkspacePage() {
<div className="flex-1 min-h-0 overflow-y-auto"> <div className="flex-1 min-h-0 overflow-y-auto">
{isDemoMode && ( {isDemoMode && (
<div className="w-full px-8 pt-6"> <div className="w-full px-8 pt-6">
<div className="s-inset flex items-center gap-2 text-xs text-[var(--warning)]"> <div className="px-4 py-3 rounded-[var(--radius-md)] border border-[var(--warning-soft)] bg-[var(--warning-soft)]/50">
<Sparkles size={13} /> <div className="flex items-center gap-2 text-sm text-[var(--warning)]">
Demo mode active — using sample data for preview <Sparkles size={16} />
<span>Demo mode active - using sample data for preview</span>
</div>
</div> </div>
</div> </div>
)} )}
<div className="w-full px-4 py-6 sm:px-8"> <div className="w-full px-8 py-6">
<section className="min-w-0"> <section className="min-w-0">
{activeView === 'observability' && ( {activeView === 'observability' && (
<div className="space-y-6"> <div className="space-y-6">
{/* Health Summary */} {/* Health Summary */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3.5"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{([ <div className="panel-soft p-4">
{ label: 'Healthy', color: 'var(--success)', value: runningServices, pulse: true }, <div className="flex items-center gap-2">
{ label: 'Failed', color: 'var(--error)', value: services.filter((s) => s.status === 'failed').length, pulse: false }, <div className="w-2 h-2 rounded-full bg-[var(--success)] live-pulse" />
{ label: 'Building', color: 'var(--warning)', value: services.filter((s) => s.status === 'building').length, pulse: true }, <p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Healthy</p>
]).map((s) => (
<div key={s.label} className="s-card">
<div className="s-cardhead !py-2.5">
<span className={`w-1.5 h-1.5 rounded-full ${s.pulse ? 'animate-pulse' : ''}`} style={{ background: s.color }} />
<span className="text-[11px] text-[var(--text-tertiary)]">{s.label}</span>
</div>
<div className="px-4 pb-3.5">
<span className="font-headline text-[28px] font-bold leading-none" style={{ color: s.color }}>{s.value}</span>
</div>
</div> </div>
))} <p className="mt-2 text-3xl font-semibold text-[var(--success)]">{runningServices}</p>
</div>
<div className="panel-soft p-4">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-[var(--error)]" />
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Failed</p>
</div>
<p className="mt-2 text-3xl font-semibold text-[var(--error)]">
{services.filter((s) => s.status === 'failed').length}
</p>
</div>
<div className="panel-soft p-4">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-[var(--warning)] animate-pulse" />
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Building</p>
</div>
<p className="mt-2 text-3xl font-semibold text-[var(--warning)]">
{services.filter((s) => s.status === 'building').length}
</p>
</div>
</div> </div>
{/* Metrics Dashboard */} {/* Metrics Dashboard */}
<MetricsDashboard services={visibleServices} isDemoMode={isDemoMode} /> <MetricsDashboard
isRunning={runningServices > 0}
onStop={() => toast.showToast('Services stopped', 'warning')}
onRestart={() => toast.showToast('Services restarted', 'success')}
/>
{/* Service List */} {/* Service List */}
<div className="s-card"> <div className="panel p-6">
<div className="s-cardhead"> <div className="flex items-center justify-between mb-3">
<span className="s-ibox"><Layers /></span> <h3 className="text-sm font-medium text-[var(--text-secondary)]">Services</h3>
<span className="s-t">Services</span>
{!isDemoMode && environments.length > 0 && ( {!isDemoMode && environments.length > 0 && (
<div className="s-trail flex-wrap"> <div className="flex items-center gap-1.5 flex-wrap">
<button <button
onClick={() => setEnvFilter(null)} onClick={() => setEnvFilter(null)}
className={`s-chip !py-1 !px-2.5 ${envFilter === null ? '!text-[var(--accent-primary)] !border-[var(--accent-primary)]/40' : ''}`} className={`px-2.5 py-1 rounded-full text-xs font-medium transition-colors ${
envFilter === null
? 'bg-[var(--accent-primary)] text-white'
: 'bg-[var(--surface-muted)] text-[var(--text-secondary)] hover:bg-[var(--surface-card)]'
}`}
> >
All All
</button> </button>
@@ -855,7 +886,11 @@ export function ProjectWorkspacePage() {
<button <button
key={env.id} key={env.id}
onClick={() => setEnvFilter(envFilter === env.name ? null : env.name)} onClick={() => setEnvFilter(envFilter === env.name ? null : env.name)}
className={`s-chip !py-1 !px-2.5 ${envFilter === env.name ? '!text-[var(--accent-primary)] !border-[var(--accent-primary)]/40' : ''}`} className={`px-2.5 py-1 rounded-full text-xs font-medium transition-colors ${
envFilter === env.name
? 'bg-[var(--accent-primary)] text-white'
: 'bg-[var(--surface-muted)] text-[var(--text-secondary)] hover:bg-[var(--surface-card)]'
}`}
> >
{env.name} ({env.service_count}) {env.name} ({env.service_count})
</button> </button>
@@ -864,7 +899,6 @@ export function ProjectWorkspacePage() {
</div> </div>
)} )}
</div> </div>
<div className="px-4 pb-4">
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] overflow-hidden divide-y divide-[var(--border-subtle)]"> <div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] overflow-hidden divide-y divide-[var(--border-subtle)]">
{visibleServices.length === 0 ? ( {visibleServices.length === 0 ? (
<div className="p-8 text-center"> <div className="p-8 text-center">
@@ -884,7 +918,9 @@ export function ProjectWorkspacePage() {
className="w-full flex items-center justify-between px-4 py-3 hover:bg-[var(--surface-muted)]/50 transition-colors text-left" className="w-full flex items-center justify-between px-4 py-3 hover:bg-[var(--surface-muted)]/50 transition-colors text-left"
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="s-ibox"><Box /></span> <div className="w-8 h-8 rounded-[var(--radius-sm)] bg-[var(--surface-card)] border border-[var(--border-subtle)] flex items-center justify-center">
<Box size={14} className="text-[var(--text-tertiary)]" />
</div>
<div> <div>
<p className="text-sm font-medium text-[var(--text-primary)]">{service.name}</p> <p className="text-sm font-medium text-[var(--text-primary)]">{service.name}</p>
<p className="text-xs text-[var(--text-tertiary)]">{service.type}</p> <p className="text-xs text-[var(--text-tertiary)]">{service.type}</p>
@@ -895,31 +931,33 @@ export function ProjectWorkspacePage() {
)) ))
)} )}
</div> </div>
</div>
</div> </div>
</div> </div>
)} )}
{activeView === 'logs' && ( {activeView === 'logs' && (
<div className="s-card"> <div className="panel p-6">
<div className="s-cardhead"> <div className="flex items-center justify-between mb-4">
<span className="s-ibox"><FileText /></span> <div className="flex items-center gap-3">
<div> <div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<div className="s-t">Logs</div> <FileText size={20} className="text-[var(--accent-primary)]" />
<div className="mt-0.5 text-[11px] text-[var(--text-tertiary)]">Aggregated service output</div> </div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Logs</h2>
<p className="text-sm text-[var(--text-secondary)]">Aggregated service output</p>
</div>
</div> </div>
{!isDemoMode && ( {!isDemoMode && (
<div className="s-trail"> <button
<QuietBtn onClick={() => workspaceLogsQuery.refetch()}> onClick={() => workspaceLogsQuery.refetch()}
<RefreshCw size={13} /> className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors"
Refresh >
</QuietBtn> Refresh
</div> </button>
)} )}
</div> </div>
<div className="px-4 pb-4">
<div className="v-mono rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-void)] p-4 text-xs text-[var(--text-secondary)] overflow-x-auto max-h-[600px] overflow-y-auto"> <div className="mono rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-void)] p-4 text-xs text-[var(--text-secondary)] overflow-x-auto max-h-[600px] overflow-y-auto">
{services.length === 0 ? ( {services.length === 0 ? (
<p className="text-[var(--text-muted)]">No services available.</p> <p className="text-[var(--text-muted)]">No services available.</p>
) : isDemoMode ? ( ) : isDemoMode ? (
@@ -960,20 +998,20 @@ export function ProjectWorkspacePage() {
</div> </div>
)} )}
</div> </div>
</div>
</div> </div>
)} )}
{activeView === 'activity' && ( {activeView === 'activity' && (
<div className="s-card"> <div className="panel p-6">
<div className="s-cardhead"> <div className="flex items-center gap-3 mb-4">
<span className="s-ibox"><Clock /></span> <div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<Clock size={20} className="text-[var(--accent-primary)]" />
</div>
<div> <div>
<div className="s-t">Activity</div> <h2 className="text-lg font-semibold text-[var(--text-primary)]">Activity</h2>
<div className="mt-0.5 text-[11px] text-[var(--text-tertiary)]">Project timeline — deploys, config, membership</div> <p className="text-sm text-[var(--text-secondary)]">Project timeline — deploys, config, membership</p>
</div> </div>
</div> </div>
<div className="px-4 pb-4">
<div className="divide-y divide-[var(--border-subtle)] border border-[var(--border-subtle)] rounded-[var(--radius-md)]"> <div className="divide-y divide-[var(--border-subtle)] border border-[var(--border-subtle)] rounded-[var(--radius-md)]">
{activityQuery.isLoading ? ( {activityQuery.isLoading ? (
<p className="px-4 py-6 text-xs text-[var(--text-muted)]">Loading…</p> <p className="px-4 py-6 text-xs text-[var(--text-muted)]">Loading…</p>
@@ -996,40 +1034,40 @@ export function ProjectWorkspacePage() {
)) ))
)} )}
</div> </div>
</div>
</div> </div>
)} )}
{activeView === 'settings' && ( {activeView === 'settings' && (
<div className="s-card"> <div className="panel p-6">
<div className="s-cardhead"> <div className="flex items-center gap-3 mb-6">
<span className="s-ibox"><Settings /></span> <div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<Settings size={20} className="text-[var(--accent-primary)]" />
</div>
<div> <div>
<div className="s-t">Settings</div> <h2 className="text-lg font-semibold text-[var(--text-primary)]">Settings</h2>
<div className="mt-0.5 text-[11px] text-[var(--text-tertiary)]">Project configuration</div> <p className="text-sm text-[var(--text-secondary)]">Project configuration</p>
</div> </div>
</div> </div>
<div className="px-4 pb-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="s-inset"> <div className="panel-soft p-4">
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Project ID</p> <p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Project ID</p>
<p className="v-mono mt-2 text-sm text-[var(--text-primary)] break-all">{project.id}</p> <p className="mono mt-2 text-sm text-[var(--text-primary)] break-all">{project.id}</p>
</div> </div>
<div className="s-inset"> <div className="panel-soft p-4">
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Created</p> <p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Created</p>
<p className="mt-2 text-sm text-[var(--text-primary)]">{formatDate(project.createdAt)}</p> <p className="mt-2 text-sm text-[var(--text-primary)]">{formatDate(project.createdAt)}</p>
</div> </div>
<div className="s-inset md:col-span-2"> <div className="panel-soft p-4 md:col-span-2">
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Canvas Storage Key</p> <p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Canvas Storage Key</p>
<p className="v-mono mt-2 text-xs text-[var(--text-secondary)] break-all">{canvasStorageKey(project.id)}</p> <p className="mono mt-2 text-xs text-[var(--text-secondary)] break-all">{canvasStorageKey(project.id)}</p>
</div> </div>
</div> </div>
{!isDemoMode && signedIn && <SharedVariablesSection projectId={project.id} />} {!isDemoMode && signedIn && <SharedVariablesSection projectId={project.id} />}
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]"> <div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
<h3 className="s-t mb-3">Local Data</h3> <h3 className="text-sm font-medium text-[var(--text-secondary)] mb-3">Local Data</h3>
<button <button
onClick={() => { onClick={() => {
clearCanvasMetadata(project.id); clearCanvasMetadata(project.id);
@@ -1041,7 +1079,6 @@ export function ProjectWorkspacePage() {
Reset Canvas Layout Reset Canvas Layout
</button> </button>
</div> </div>
</div>
</div> </div>
)} )}
</section> </section>
@@ -1068,10 +1105,12 @@ export function ProjectWorkspacePage() {
{importOpen && ( {importOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"> <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={() => setImportOpen(false)} /> <div className="absolute inset-0 bg-[var(--bg-void)]/80 backdrop-blur-sm" onClick={() => setImportOpen(false)} />
<div className="relative w-full max-w-2xl s-card !p-6"> <div className="relative w-full max-w-2xl panel p-6">
<div className="flex items-center gap-2.5 mb-1"> <div className="flex items-center gap-3 mb-1">
<span className="s-ibox !w-9 !h-9"><FileUp /></span> <div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<h3 className="font-headline text-lg font-semibold text-[var(--text-primary)]">Import docker-compose</h3> <FileUp size={20} className="text-[var(--accent-primary)]" />
</div>
<h3 className="text-xl font-semibold text-[var(--text-primary)]">Import docker-compose</h3>
</div> </div>
<p className="text-sm text-[var(--text-secondary)]"> <p className="text-sm text-[var(--text-secondary)]">
Paste a docker-compose.yml. Services deploy in dependency order; postgres, mysql, redis, mongo, Paste a docker-compose.yml. Services deploy in dependency order; postgres, mysql, redis, mongo,
@@ -1081,7 +1120,7 @@ export function ProjectWorkspacePage() {
value={composeYaml} value={composeYaml}
onChange={(e) => setComposeYaml(e.target.value)} onChange={(e) => setComposeYaml(e.target.value)}
spellCheck={false} spellCheck={false}
className="mt-5 w-full h-72 px-4 py-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all v-mono text-sm resize-none" className="mt-5 w-full h-72 px-4 py-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all mono text-sm resize-none"
placeholder={'services:\n web:\n image: nginx:alpine\n ports:\n - "80"\n db:\n image: postgres:16-alpine\n environment:\n POSTGRES_PASSWORD: secret'} placeholder={'services:\n web:\n image: nginx:alpine\n ports:\n - "80"\n db:\n image: postgres:16-alpine\n environment:\n POSTGRES_PASSWORD: secret'}
/> />
<div className="mt-5 flex justify-end gap-2"> <div className="mt-5 flex justify-end gap-2">
@@ -1094,8 +1133,8 @@ export function ProjectWorkspacePage() {
<button <button
onClick={() => composeImportMutation.mutate(composeYaml)} onClick={() => composeImportMutation.mutate(composeYaml)}
disabled={composeImportMutation.isPending || composeYaml.trim().length === 0} disabled={composeImportMutation.isPending || composeYaml.trim().length === 0}
className="s-btn-accent h-10 disabled:opacity-50" className="h-10 px-5 rounded-[var(--radius-md)] text-sm font-semibold text-[var(--accent-on)] disabled:opacity-50 transition-all flex items-center gap-2"
style={{ background: 'var(--accent-primary)' }}
> >
{composeImportMutation.isPending && <Loader2 size={14} className="animate-spin" />} {composeImportMutation.isPending && <Loader2 size={14} className="animate-spin" />}
Deploy stack Deploy stack
@@ -1124,9 +1163,6 @@ export function ProjectWorkspacePage() {
return next; return next;
}); });
}} }}
navTargets={viewItems
.filter((item) => signedIn || !item.authOnly)
.map((item) => ({ label: `Go to ${item.label}`, target: item.key }))}
/> />
{/* Error Banner */} {/* Error Banner */}
+194 -715
View File
File diff suppressed because it is too large. Load diff
+3 -12
View File
@@ -492,12 +492,11 @@ export function getApiBaseUrl(): string {
// pingServer hits the public /health route — any HTTP response proves // pingServer hits the public /health route — any HTTP response proves
// reachability, and no auth means no 401 noise in the connectivity probe. // reachability, and no auth means no 401 noise in the connectivity probe.
export async function pingServer(): Promise<boolean> { export async function pingServer(): Promise<void> {
const res = await fetch(`${API_ROOT}/health`); const res = await fetch(`${API_ROOT}/health`);
if (!res.ok) { if (!res.ok) {
throw new Error(`Server returned ${res.status}`); throw new Error(`Server returned ${res.status}`);
} }
return true;
} }
export type SetupStatus = { export type SetupStatus = {
@@ -1239,17 +1238,9 @@ export type AgentContainer = {
created_at?: string; created_at?: string;
}; };
type RawAgentContainer = Omit<AgentContainer, 'status'> & {
status?: string | { state?: string; health?: string };
};
export async function listAgentContainers(id: string): Promise<AgentContainer[]> { export async function listAgentContainers(id: string): Promise<AgentContainer[]> {
const payload = await requestJson<{ containers?: RawAgentContainer[] }>(`/agents/${id}/containers`); const payload = await requestJson<{ containers?: AgentContainer[] }>(`/agents/${id}/containers`);
// The wire sends status as a Docker state object — flatten to its state string. return payload.containers ?? [];
return (payload.containers ?? []).map((c) => ({
...c,
status: typeof c.status === 'string' ? c.status : c.status?.state || '—',
}));
} }
export type AgentCommand = { export type AgentCommand = {
-5
View File
@@ -13,11 +13,6 @@ export function formatRelative(iso?: string): string {
const hour = 60 * minute; const hour = 60 * minute;
const day = 24 * hour; const day = 24 * hour;
// Future-dated or year-plus-old timestamps are effectively meaningless —
// surface them as absent rather than "739898d ago".
if (diffMs < 0 || diffMs > 365 * day) {
return 'n/a';
}
if (diffMs < minute) { if (diffMs < minute) {
return 'just now'; return 'just now';
} }
-1
View File
@@ -23,7 +23,6 @@ const queryClient = new QueryClient({
// High-churn or bulky keys never hit localStorage — a stale log tail is // High-churn or bulky keys never hit localStorage — a stale log tail is
// worse than none. // worse than none.
const NEVER_PERSIST = new Set([ const NEVER_PERSIST = new Set([
'auth-session',
'shell-notifications', 'shell-notifications',
'notifications', 'notifications',
'service-logs', 'service-logs',
+2 -2
View File
@@ -77,12 +77,12 @@ export function CardHeader({ title, description, icon, action, className = '', .
<div className={`flex items-start justify-between gap-4 ${className}`} {...props}> <div className={`flex items-start justify-between gap-4 ${className}`} {...props}>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
{icon && ( {icon && (
<div className="w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 ring-1 ring-[var(--tint-05)]" style={{ background: 'var(--tint-07)' }}> <div className="w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 ring-1 ring-white/5" style={{ background: 'rgba(255,255,255,0.07)' }}>
{icon} {icon}
</div> </div>
)} )}
<div> <div>
<h3 className="font-headline text-base font-semibold text-[var(--text-primary)] tracking-tight">{title}</h3> <h3 className="text-lg font-semibold text-[var(--text-primary)] tracking-tight">{title}</h3>
{description && ( {description && (
<p className="text-sm text-[var(--text-secondary)] mt-0.5">{description}</p> <p className="text-sm text-[var(--text-secondary)] mt-0.5">{description}</p>
)} )}
@@ -68,7 +68,7 @@ function gradientId(prefix: string, color: string, index = 0) {
export function LineChart({ export function LineChart({
data, data,
color = '#38bdf8', color = '#b4e34a',
fillColor = 'transparent', fillColor = 'transparent',
height = 76, height = 76,
showDots = true, showDots = true,
@@ -181,7 +181,7 @@ export function MultiLineChart({
export function LineMetricChart({ export function LineMetricChart({
data, data,
color = '#38bdf8', color = '#b4e34a',
fillOpacity = 0.15, fillOpacity = 0.15,
height = 76, height = 76,
showArea = false, showArea = false,
@@ -204,8 +204,8 @@ export function DualLineChart({
data1, data1,
data2, data2,
height = 58, height = 58,
color1 = 'var(--accent-primary)', color1 = '#7ab8ff',
color2 = 'var(--warning)', color2 = '#f2c94c',
fillOpacity1 = 0.15, fillOpacity1 = 0.15,
fillOpacity2 = 0.15, fillOpacity2 = 0.15,
}: DualLineChartProps) { }: DualLineChartProps) {
@@ -1,25 +1,24 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Command } from 'cmdk'; import { Command } from 'cmdk';
import { import {
ArrowRight,
Box, Box,
CornerDownRight,
Database, Database,
Github,
Image,
Clock, Clock,
Search, Search,
Settings,
FileText,
Activity,
LayoutGrid,
} from 'lucide-react'; } from 'lucide-react';
export interface PaletteTarget {
label: string;
target: string;
description?: string;
}
interface CommandItem { interface CommandItem {
id: string; id: string;
label: string; label: string;
description?: string; description?: string;
icon: typeof Box; icon: typeof Box;
shortcut?: string;
action: () => void; action: () => void;
category?: string; category?: string;
} }
@@ -28,39 +27,14 @@ interface CommandPaletteProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
onAddService?: (type: string) => void; onAddService?: (type: string) => void;
onNavigate?: (target: string) => void; onNavigate?: (path: string) => void;
/** Navigate-group items; falls back to the full app route map. */
navTargets?: PaletteTarget[];
/** Extra searchable "Open …" rows, e.g. projects. */
openTargets?: PaletteTarget[];
} }
const APP_NAV: PaletteTarget[] = [
{ label: 'Dashboard', target: '/', description: 'Index — stats, nodes, deploys' },
{ label: 'Projects', target: '/projects', description: 'All projects' },
{ label: 'Templates', target: '/templates', description: 'Template catalog' },
{ label: 'Builds', target: '/builds', description: 'Build pipeline' },
{ label: 'Databases', target: '/databases', description: 'Managed databases' },
{ label: 'Operations', target: '/operations', description: 'Backups, cron, maintenance' },
{ label: 'Activity', target: '/activity', description: 'Platform event feed' },
{ label: 'High Availability', target: '/ha', description: 'Failover & alerts' },
{ label: 'Security', target: '/security', description: 'Access, sessions, secrets' },
{ label: 'Usage', target: '/usage', description: 'Resource consumption' },
{ label: 'People', target: '/people', description: 'Team & access control' },
{ label: 'Audit Logs', target: '/settings/audit-logs', description: 'Recorded actions' },
{ label: 'Webhooks', target: '/settings/webhooks', description: 'Signed event delivery' },
{ label: 'Settings', target: '/settings', description: 'Platform preferences' },
{ label: 'Docs', target: '/docs', description: 'Guides & reference' },
{ label: 'Landing', target: '/landing', description: 'Public page' },
];
export function CommandPalette({ export function CommandPalette({
open, open,
onClose, onClose,
onAddService, onAddService,
onNavigate, onNavigate,
navTargets,
openTargets,
}: CommandPaletteProps) { }: CommandPaletteProps) {
if (!open) return null; if (!open) return null;
@@ -69,8 +43,6 @@ export function CommandPalette({
onClose={onClose} onClose={onClose}
onAddService={onAddService} onAddService={onAddService}
onNavigate={onNavigate} onNavigate={onNavigate}
navTargets={navTargets}
openTargets={openTargets}
/> />
); );
} }
@@ -79,8 +51,6 @@ function CommandPaletteContent({
onClose, onClose,
onAddService, onAddService,
onNavigate, onNavigate,
navTargets,
openTargets,
}: Omit<CommandPaletteProps, 'open'>) { }: Omit<CommandPaletteProps, 'open'>) {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
@@ -96,69 +66,119 @@ function CommandPaletteContent({
return () => document.removeEventListener('keydown', down); return () => document.removeEventListener('keydown', down);
}, [onClose]); }, [onClose]);
const commands: CommandItem[] = []; const commands: CommandItem[] = [
{
// Create items only exist where onAddService is wired — elsewhere the id: 'add-web',
// entries would silently no-op. label: 'Add Web Service',
if (onAddService) { description: 'Deploy a web application',
commands.push( icon: Box,
{ shortcut: 'W',
id: 'add-web', category: 'Create',
label: 'Add Web Service', action: () => {
description: 'Deploy a web application', onAddService?.('web');
icon: Box, onClose();
category: 'Create',
action: () => { onAddService('web'); onClose(); },
}, },
{ },
id: 'add-worker', {
label: 'Add Worker Service', id: 'add-worker',
description: 'Deploy a background worker', label: 'Add Worker Service',
icon: Clock, description: 'Deploy a background worker',
category: 'Create', icon: Clock,
action: () => { onAddService('worker'); onClose(); }, shortcut: 'K',
category: 'Create',
action: () => {
onAddService?.('worker');
onClose();
}, },
{ },
id: 'add-database', {
label: 'Add Database', id: 'add-database',
description: 'Provision a PostgreSQL database', label: 'Add Database',
icon: Database, description: 'Provision a PostgreSQL database',
category: 'Create', icon: Database,
action: () => { onAddService('database'); onClose(); }, shortcut: 'D',
category: 'Create',
action: () => {
onAddService?.('database');
onClose();
}, },
{ },
id: 'add-cron', {
label: 'Add Cron Job', id: 'add-cron',
description: 'Schedule a recurring task', label: 'Add Cron Job',
icon: Clock, description: 'Schedule a recurring task',
category: 'Create', icon: Clock,
action: () => { onAddService('cron'); onClose(); }, category: 'Create',
action: () => {
onAddService?.('cron');
onClose();
}, },
); },
} {
id: 'connect-github',
if (onNavigate) { label: 'Connect GitHub Repo',
(navTargets ?? APP_NAV).forEach((t, i) => description: 'Link a repository for auto-deploy',
commands.push({ icon: Github,
id: `nav-${i}`, category: 'Connect',
label: t.label, action: () => {
description: t.description, onClose();
icon: CornerDownRight, },
category: 'Navigate', },
action: () => { onNavigate(t.target); onClose(); }, {
}), id: 'deploy-image',
); label: 'Deploy Docker Image',
(openTargets ?? []).forEach((t, i) => description: 'Deploy from a container registry',
commands.push({ icon: Image,
id: `open-${i}`, category: 'Connect',
label: t.label, action: () => {
description: t.description, onClose();
icon: ArrowRight, },
category: 'Open', },
action: () => { onNavigate(t.target); onClose(); }, {
}), id: 'goto-canvas',
); label: 'Go to Canvas',
} icon: LayoutGrid,
shortcut: 'C',
category: 'Navigate',
action: () => {
onNavigate?.('canvas');
onClose();
},
},
{
id: 'goto-logs',
label: 'Go to Logs',
icon: FileText,
shortcut: 'L',
category: 'Navigate',
action: () => {
onNavigate?.('logs');
onClose();
},
},
{
id: 'goto-metrics',
label: 'Go to Metrics',
icon: Activity,
shortcut: 'M',
category: 'Navigate',
action: () => {
onNavigate?.('observability');
onClose();
},
},
{
id: 'goto-settings',
label: 'Go to Settings',
icon: Settings,
shortcut: 'S',
category: 'Navigate',
action: () => {
onNavigate?.('settings');
onClose();
},
},
];
const filteredCommands = commands.filter( const filteredCommands = commands.filter(
(cmd) => (cmd) =>
@@ -177,10 +197,6 @@ function CommandPaletteContent({
{} as Record<string, CommandItem[]> {} as Record<string, CommandItem[]>
); );
const placeholder = onNavigate
? 'Search pages, projects, actions…'
: 'What would you like to create?';
return ( return (
<div className="fixed inset-0 z-50"> <div className="fixed inset-0 z-50">
<div <div
@@ -189,7 +205,7 @@ function CommandPaletteContent({
/> />
<div className="absolute left-1/2 top-[20%] -translate-x-1/2 w-full max-w-xl"> <div className="absolute left-1/2 top-[20%] -translate-x-1/2 w-full max-w-xl">
<Command <Command
className="s-card !p-0 overflow-hidden shadow-2xl shadow-black/50" className="panel overflow-hidden"
loop loop
> >
<div className="flex items-center gap-3 px-4 py-3 border-b border-[var(--border-subtle)]"> <div className="flex items-center gap-3 px-4 py-3 border-b border-[var(--border-subtle)]">
@@ -197,7 +213,7 @@ function CommandPaletteContent({
<Command.Input <Command.Input
value={search} value={search}
onValueChange={setSearch} onValueChange={setSearch}
placeholder={placeholder} placeholder="What would you like to create?"
className="flex-1 bg-transparent text-[var(--text-primary)] placeholder:text-[var(--text-muted)] text-sm outline-none" className="flex-1 bg-transparent text-[var(--text-primary)] placeholder:text-[var(--text-muted)] text-sm outline-none"
/> />
<kbd className="px-2 py-0.5 rounded bg-[var(--surface-muted)] text-[10px] font-mono text-[var(--text-muted)]"> <kbd className="px-2 py-0.5 rounded bg-[var(--surface-muted)] text-[10px] font-mono text-[var(--text-muted)]">
@@ -212,7 +228,7 @@ function CommandPaletteContent({
{Object.entries(groupedCommands).map(([category, items]) => ( {Object.entries(groupedCommands).map(([category, items]) => (
<Command.Group key={category} heading={category} className="mb-2"> <Command.Group key={category} heading={category} className="mb-2">
<div className="px-2 py-1.5 v-mono text-[10px] uppercase tracking-[0.14em] text-[var(--text-muted)]"> <div className="px-2 py-1.5 text-[10px] font-semibold uppercase tracking-widest text-[var(--text-muted)]">
{category} {category}
</div> </div>
{items.map((item) => { {items.map((item) => {
@@ -222,15 +238,20 @@ function CommandPaletteContent({
key={item.id} key={item.id}
value={item.label} value={item.label}
onSelect={item.action} onSelect={item.action}
className="flex items-center gap-3 px-3 py-2 rounded-[var(--radius-md)] cursor-pointer text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] aria-selected:bg-[var(--accent-primary-soft)] aria-selected:text-[var(--accent-primary)] transition-colors" className="flex items-center gap-3 px-3 py-2.5 rounded-[var(--radius-md)] cursor-pointer text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] aria-selected:bg-[var(--accent-primary-soft)] aria-selected:text-[var(--accent-primary)] transition-colors"
> >
<span className="s-ibox !h-7 !w-7"><Icon size={13.5} /></span> <Icon size={18} className="text-[var(--text-muted)]" />
<div className="flex-1"> <div className="flex-1">
<div className="text-sm font-medium">{item.label}</div> <div className="text-sm font-medium">{item.label}</div>
{item.description && ( {item.description && (
<div className="text-xs text-[var(--text-muted)]">{item.description}</div> <div className="text-xs text-[var(--text-muted)]">{item.description}</div>
)} )}
</div> </div>
{item.shortcut && (
<kbd className="px-2 py-0.5 rounded bg-[var(--surface-muted)] text-[10px] font-mono text-[var(--text-muted)]">
{item.shortcut}
</kbd>
)}
</Command.Item> </Command.Item>
); );
})} })}
@@ -8,15 +8,15 @@ import { Sparkles } from 'lucide-react';
export function DemoRestricted({ feature }: { feature: string }) { export function DemoRestricted({ feature }: { feature: string }) {
return ( return (
<div className="flex min-h-[60vh] items-center justify-center p-8"> <div className="flex min-h-[60vh] items-center justify-center p-8">
<div className="s-card max-w-md text-center"> <div className="panel max-w-md p-8 text-center">
<div className="s-ibox mx-auto mb-4 !h-10 !w-10"> <div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-[var(--accent-primary-soft)]">
<Sparkles size={18} /> <Sparkles size={22} className="text-[var(--accent-primary)]" />
</div> </div>
<p className="font-headline text-[17px] font-semibold text-[var(--text-primary)]">Not part of the demo</p> <p className="text-lg font-semibold text-[var(--text-primary)]">Not part of the demo</p>
<p className="mt-2 text-sm text-[var(--text-secondary)]"> <p className="mt-2 text-sm text-[var(--text-secondary)]">
{feature} needs a live Containr session. The demo covers sample projects, services, and platform views only. {feature} needs a live Containr session. The demo covers sample projects, services, and platform views only.
</p> </p>
<Link to="/projects?demo=1" className="s-btn-accent mt-5 inline-flex"> <Link to="/projects?demo=1" className="v-btn mt-5 inline-flex">
Back to demo projects Back to demo projects
</Link> </Link>
</div> </div>
@@ -17,8 +17,8 @@ export function DonutChart({
percentage, percentage,
size = 160, size = 160,
thickness = 16, thickness = 16,
color = '#38bdf8', color = '#b4e34a',
trackColor = 'var(--tint-07)', trackColor = 'rgba(255, 255, 255, 0.07)',
segments = 28, segments = 28,
gap = 0.048, gap = 0.048,
animated = true, animated = true,
@@ -1,5 +1,4 @@
import { type ReactNode } from 'react'; import { type ReactNode } from 'react';
import { ChevronRight, MoreHorizontal } from 'lucide-react';
interface EnhancedMetricCardProps { interface EnhancedMetricCardProps {
title: string; title: string;
@@ -16,42 +15,6 @@ interface EnhancedMetricCardProps {
horizontal?: boolean; horizontal?: boolean;
} }
const statusColors: Record<string, string> = {
good: 'var(--success)',
average: 'var(--warning)',
warning: 'var(--warning)',
};
function CardHead({ icon, title }: { icon: ReactNode; title: string }) {
return (
<div className="s-cardhead">
<span className="s-ibox">{icon}</span>
<span className="text-[11.5px] font-medium text-[var(--text-secondary)] whitespace-nowrap">{title}</span>
<span className="ml-auto text-[var(--text-tertiary)]">
<MoreHorizontal size={14} />
</span>
</div>
);
}
function CardFoot({ label, onClick }: { label: string; onClick?: () => void }) {
return (
<div className="s-stat-foot mt-auto">
<span
onClick={onClick}
className="cursor-pointer hover:text-[var(--text-secondary)] transition-colors"
>
{label}
</span>
{onClick && (
<button className="s-icon-btn !w-[26px] !h-[26px]" onClick={onClick} title={label}>
<ChevronRight size={12} />
</button>
)}
</div>
);
}
export function EnhancedMetricCard({ export function EnhancedMetricCard({
title, title,
value, value,
@@ -66,23 +29,75 @@ export function EnhancedMetricCard({
animationDelay = 0, animationDelay = 0,
horizontal = false, horizontal = false,
}: EnhancedMetricCardProps) { }: EnhancedMetricCardProps) {
const statusColors: Record<string, string> = {
good: '#5ee6a0',
average: '#f2c94c',
warning: '#f2c94c',
};
// Horizontal layout for cards like Active User
if (horizontal) { if (horizontal) {
return ( return (
<div className={`s-card flex flex-row overflow-hidden ${className}`} style={{ animationDelay: `${animationDelay}s` }}> <div
<div className="flex flex-1 flex-col"> className={`card ${className}`}
<CardHead icon={icon} title={title} /> style={{
<div className="px-4"> display: 'flex',
<div className="font-headline text-[32px] font-bold leading-none tracking-[-0.025em] text-[var(--text-primary)]"> flexDirection: 'row',
{value} padding: 0,
overflow: 'hidden',
animationDelay: `${animationDelay}s`,
}}
>
<div style={{
flex: 1,
padding: '20px 18px 18px 20px',
display: 'flex',
flexDirection: 'column',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
<div className="card-icon">{icon}</div>
<span style={{ fontSize: 14, fontWeight: 600, color: '#e8e9f0' }}>{title}</span>
</div>
<div style={{
fontSize: 36,
fontWeight: 900,
letterSpacing: '-1.5px',
lineHeight: 1,
color: '#e8e9f0',
}}>
{value}
</div>
{subtitle && (
<div style={{ fontSize: 12, color: '#6b6e7d', marginTop: 4 }}>{subtitle}</div>
)}
{details && <div style={{ marginTop: 12 }}>{details}</div>}
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: 'auto',
paddingTop: 14,
}}>
<span
style={{ fontSize: 13, color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}
onClick={onClick}
>
Details
</span>
<div className="arrow-btn" onClick={onClick}>
<svg viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6" />
</svg>
</div> </div>
{subtitle && <div className="mt-1 text-[11.5px] text-[var(--text-tertiary)]">{subtitle}</div>}
{details && <div className="mt-3">{details}</div>}
</div> </div>
<div className="mt-4" />
<CardFoot label="Details" onClick={onClick} />
</div> </div>
{chart && ( {chart && (
<div className="flex w-1/2 items-end py-3 pr-3.5"> <div style={{
width: '50%',
padding: '16px 14px 46px 0',
display: 'flex',
alignItems: 'flex-end',
}}>
{chart} {chart}
</div> </div>
)} )}
@@ -90,25 +105,64 @@ export function EnhancedMetricCard({
); );
} }
// Standard vertical card layout - matching self.html exactly
return ( return (
<div className={`s-card flex flex-col ${className}`} style={{ animationDelay: `${animationDelay}s` }}> <div
<CardHead icon={icon} title={title} /> className={`card ${className}`}
<div className="px-4 pb-1"> style={{
<div className="font-headline text-[32px] font-bold leading-none tracking-[-0.025em] text-[var(--text-primary)]"> display: 'flex',
{value} flexDirection: 'column',
padding: 20,
animationDelay: `${animationDelay}s`,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
<div className="card-icon">{icon}</div>
<span style={{ fontSize: 14, fontWeight: 600, color: '#e8e9f0' }}>{title}</span>
</div>
<div style={{
fontSize: 38,
fontWeight: 900,
letterSpacing: '-1.5px',
lineHeight: 1,
color: '#e8e9f0',
}}>
{value}
</div>
{(subtitle || statusText) && (
<div style={{ fontSize: 12, color: '#6b6e7d', marginTop: 4 }}>
{statusText && status && (
<span style={{ color: statusColors[status], fontWeight: 700 }}>{statusText}</span>
)}{' '}
{subtitle}
</div> </div>
{(subtitle || statusText) && ( )}
<div className="mt-1.5 text-[11.5px] text-[var(--text-tertiary)]">
{statusText && status && ( {chart && <div style={{ marginTop: 12, marginBottom: 6 }}>{chart}</div>}
<span className="font-semibold" style={{ color: statusColors[status] }}>{statusText}</span>
)}{' '} {details && <div style={{ marginTop: 16 }}>{details}</div>}
{subtitle}
</div> <div style={{
)} display: 'flex',
{chart && <div className="mt-3 mb-1">{chart}</div>} alignItems: 'center',
{details && <div className="mt-3">{details}</div>} justifyContent: 'space-between',
paddingTop: 4,
marginTop: 'auto'
}}>
<span
onClick={onClick}
style={{ fontSize: 13, color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}
>
Details
</span>
<div className="arrow-btn" onClick={onClick}>
<svg viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6" />
</svg>
</div>
</div> </div>
<CardFoot label="Details" onClick={onClick} />
</div> </div>
); );
} }
@@ -132,56 +186,118 @@ export function CacheMetricCard({
const nonCachePercent = Math.round((nonCacheMB / totalMB) * 100); const nonCachePercent = Math.round((nonCacheMB / totalMB) * 100);
return ( return (
<div className="s-card flex flex-col" style={{ animationDelay: `${animationDelay}s` }}> <div
<CardHead className="card"
icon={ style={{
<svg viewBox="0 0 24 24" width="13.5" height="13.5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"> display: 'flex',
<polyline points="3 6 5 6 21 6" /> flexDirection: 'column',
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /> padding: 20,
animationDelay: `${animationDelay}s`,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
<div className="card-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="#9295a4" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg> </svg>
}
title="Cache"
/>
<div className="px-4">
<div className="font-headline text-[32px] font-bold leading-none tracking-[-0.025em] text-[var(--text-primary)]">
{totalMB} <span className="text-[12px] font-medium text-[var(--text-tertiary)]">MB</span>
</div>
<div className="mt-1.5 text-[11.5px] text-[var(--text-tertiary)]">
<span className="font-semibold text-[var(--warning)]">{Math.round(totalMB * 0.625)}MB average</span> cached images and files
</div> </div>
<span style={{ fontSize: 14, fontWeight: 600, color: '#e8e9f0' }}>Cache</span>
</div>
{/* Segmented bar */} <div style={{
<div className="my-4 flex h-8 items-center gap-1"> fontSize: 38,
<div className="h-full rounded-l-md" style={{ width: `${cachePercent}%`, background: 'var(--error)' }} /> fontWeight: 900,
<div className="h-full" style={{ width: `${nonCachePercent}%`, background: 'var(--accent-tertiary)' }} /> letterSpacing: '-1.5px',
<div className="h-full flex-1 rounded-r-md bg-[var(--tint-07)]" /> lineHeight: 1,
</div> color: '#e8e9f0',
}}>
{totalMB} MB
</div>
{/* Stats row */} <div style={{ fontSize: 12, color: '#6b6e7d', marginTop: 4 }}>
<div className="grid grid-cols-[1fr_auto_1fr_auto_1fr] items-stretch"> <span style={{ color: '#f2c94c', fontWeight: 700 }}>{Math.round(totalMB * 0.625)}MB Average</span> cached images and files
{[ </div>
{ dot: 'var(--error)', label: 'Cache', value: `${cacheMB} MB`, pct: `${cachePercent}%` },
{ dot: 'var(--accent-tertiary)', label: 'Non-Cache', value: `${nonCacheMB} MB`, pct: `${nonCachePercent}%` }, {/* Segmented Bar */}
{ dot: null, label: 'Total', value: `${totalMB * 5} GB`, pct: '' }, <div style={{ display: 'flex', alignItems: 'center', gap: 5, margin: '16px 0 15px', height: 32 }}>
].map((s, i) => ( <div
<div key={s.label} className="contents"> className="cache-seg"
{i > 0 && <div className="mx-4 w-px bg-[var(--border-subtle)]" />} style={{
<div> width: `${cachePercent}%`,
<div className="mb-1 flex items-center gap-1.5 text-[10.5px] text-[var(--text-tertiary)]"> background: '#ff6b5b',
{s.dot && <i className="h-1.5 w-1.5 rounded-full" style={{ background: s.dot }} />} borderRadius: '10px 4px 4px 10px'
{s.label} }}
</div> />
<div className="flex items-baseline gap-1"> <div
<span className="text-[14px] font-bold text-[var(--text-primary)]">{s.value}</span> className="cache-seg"
{s.pct && <span className="text-[10.5px] text-[var(--text-tertiary)]">{s.pct}</span>} style={{
</div> width: `${nonCachePercent}%`,
</div> background: '#8c6ef0',
</div> borderRadius: '5px'
))} }}
/>
<div
className="cache-seg"
style={{
flex: 1,
background: 'rgba(255,255,255,0.07)',
borderRadius: '4px 10px 10px 4px'
}}
/>
</div>
{/* Stats Row */}
<div style={{
display: 'grid',
gridTemplateColumns: '1fr auto 1fr auto 1fr',
gap: 0,
alignItems: 'stretch'
}}>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 11, color: '#6b6e7d', marginBottom: 5 }}>
<div className="stat-dot" style={{ background: '#ff6b5b' }} /> Cache
</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 4 }}>
<span style={{ fontSize: 15, fontWeight: 800 }}>{cacheMB} MB</span>
<span style={{ fontSize: 11, color: '#6b6e7d' }}>{cachePercent}%</span>
</div>
</div>
<div style={{ width: 1, background: 'rgba(255,255,255,0.08)', margin: '0 16px' }} />
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 11, color: '#6b6e7d', marginBottom: 5 }}>
<div className="stat-dot" style={{ background: '#8c6ef0' }} /> Non-Cache
</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 4 }}>
<span style={{ fontSize: 15, fontWeight: 800 }}>{nonCacheMB} MB</span>
<span style={{ fontSize: 11, color: '#6b6e7d' }}>{nonCachePercent}%</span>
</div>
</div>
<div style={{ width: 1, background: 'rgba(255,255,255,0.08)', margin: '0 16px' }} />
<div>
<div style={{ fontSize: 11, color: '#6b6e7d', marginBottom: 5 }}>Total</div>
<div style={{ fontSize: 15, fontWeight: 800 }}>{totalMB * 5} GB</div>
</div>
</div>
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
paddingTop: 16
}}>
<span
onClick={onClick}
style={{ fontSize: 13, color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}
>
Details
</span>
<div className="arrow-btn" onClick={onClick}>
<svg viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6" />
</svg>
</div> </div>
</div> </div>
<div className="mt-4" />
<CardFoot label="Details" onClick={onClick} />
</div> </div>
); );
} }
@@ -203,47 +319,86 @@ export function PerformanceMetricCard({
animationDelay = 0, animationDelay = 0,
}: PerformanceMetricCardProps) { }: PerformanceMetricCardProps) {
const status = percentage >= 85 ? 'Good' : percentage >= 70 ? 'Average' : 'Warning'; const status = percentage >= 85 ? 'Good' : percentage >= 70 ? 'Average' : 'Warning';
const statusColor = percentage >= 85 ? 'var(--success)' : percentage >= 70 ? 'var(--warning)' : 'var(--error)'; const statusColor = percentage >= 85 ? '#5ee6a0' : percentage >= 70 ? '#f2c94c' : '#ff6f5c';
return ( return (
<div className="s-card flex flex-col" style={{ animationDelay: `${animationDelay}s` }}> <div
<CardHead className="card"
icon={ style={{
<svg viewBox="0 0 24 24" width="13.5" height="13.5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"> display: 'flex',
<circle cx="12" cy="12" r="10" /> flexDirection: 'column',
<line x1="2" y1="12" x2="22" y2="12" /> padding: 20,
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /> animationDelay: `${animationDelay}s`,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
<div className="card-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="#9295a4" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="2" y1="12" x2="22" y2="12"/>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
</svg> </svg>
} </div>
title="Performance" <span style={{ fontSize: 14, fontWeight: 600, color: '#e8e9f0' }}>Performance</span>
/> </div>
<div className="flex flex-1 items-start gap-4 px-4">
<div className="flex-1"> <div style={{ display: 'flex', alignItems: 'flex-start', gap: 16, flex: 1 }}>
<div className="font-headline text-[32px] font-bold leading-none tracking-[-0.025em] text-[var(--text-primary)]"> <div style={{ flex: 1 }}>
<div style={{
fontSize: 36,
fontWeight: 900,
letterSpacing: '-1.5px',
lineHeight: 1,
color: '#e8e9f0',
}}>
{percentage}% {percentage}%
</div> </div>
<div className="mt-1.5 text-[11.5px] text-[var(--text-tertiary)]"> <div style={{ fontSize: 12, color: '#6b6e7d', marginTop: 4 }}>
<span className="font-semibold" style={{ color: statusColor }}>{status}</span> <span style={{ color: statusColor, fontWeight: 700 }}>{status}</span> Last scan on {new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
{' '}Last scan {new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
</div> </div>
</div> </div>
<div className="flex flex-col items-end gap-2">
<div className="h-[58px] w-[134px] rounded-lg bg-[var(--tint-03)]" /> <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 8 }}>
<div className="flex flex-col items-end gap-1"> {/* Chart placeholder - will be replaced with actual chart */}
{[{ v: upSpeed, up: false }, { v: downSpeed, up: true }].map((s, i) => ( <div style={{ width: 134, height: 58, background: 'rgba(255,255,255,0.03)', borderRadius: 8 }} />
<div key={i} className="flex items-center gap-1 text-[11px] font-medium text-[var(--accent-primary)]">
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"> <div style={{ display: 'flex', flexDirection: 'column', gap: 4, alignItems: 'flex-end' }}>
<line x1="12" y1={s.up ? 5 : 19} x2="12" y2={s.up ? 19 : 5} /> <div className="speed-row" style={{ color: '#7ab8ff' }}>
{s.up ? <polyline points="19 12 12 5 5 12" /> : <polyline points="5 12 12 19 19 12" />} <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
</svg> <line x1="12" y1="19" x2="12" y2="5"/>
<span>{s.v}</span> Mbps <polyline points="5 12 12 19 19 12"/>
</div> </svg>
))} <span>{upSpeed}</span> Mbps
</div>
<div className="speed-row" style={{ color: 'var(--accent-primary)' }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
<line x1="12" y1="5" x2="12" y2="19"/>
<polyline points="19 12 12 5 5 12"/>
</svg>
<span>{downSpeed}</span> Mbps
</div>
</div> </div>
</div> </div>
</div> </div>
<div className="mt-4" />
<CardFoot label="Check Speed" onClick={onClick} /> <div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
paddingTop: 14
}}>
<span
onClick={onClick}
style={{ fontSize: 13, color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}
>
Check Speed
</span>
<div className="arrow-btn" onClick={onClick}>
<svg viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6" />
</svg>
</div>
</div>
</div> </div>
); );
} }
@@ -61,9 +61,9 @@ export class ErrorBoundary extends Component<Props, State> {
return ( return (
<div className="min-h-screen bg-[var(--bg-void)] flex items-center justify-center p-4"> <div className="min-h-screen bg-[var(--bg-void)] flex items-center justify-center p-4">
<div className="s-card max-w-md w-full space-y-4"> <div className="max-w-md w-full bg-[var(--bg-primary)] border border-[var(--border-subtle)] rounded-lg p-6 space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<h2 className="font-headline text-xl font-semibold text-[var(--text-primary)]"> <h2 className="text-xl font-semibold text-[var(--text-primary)]">
Something went wrong Something went wrong
</h2> </h2>
<p className="text-sm text-[var(--text-secondary)]"> <p className="text-sm text-[var(--text-secondary)]">
@@ -72,18 +72,24 @@ export class ErrorBoundary extends Component<Props, State> {
</div> </div>
{import.meta.env.DEV && this.state.error && ( {import.meta.env.DEV && this.state.error && (
<div className="s-inset"> <div className="bg-[var(--surface-muted)] border border-[var(--border-subtle)] rounded p-3">
<p className="v-mono text-[11px] text-[var(--error)] break-all"> <p className="text-xs font-mono text-[var(--error)] break-all">
{this.state.error.toString()} {this.state.error.toString()}
</p> </p>
</div> </div>
)} )}
<div className="flex gap-2"> <div className="flex gap-2">
<button onClick={this.handleReset} className="s-btn-accent flex-1 justify-center"> <button
onClick={this.handleReset}
className="flex-1 px-4 py-2 bg-[var(--accent-primary-soft)] text-[var(--text-primary)] rounded hover:opacity-90 transition-opacity"
>
Try Again Try Again
</button> </button>
<button onClick={() => window.location.reload()} className="s-btn-quiet flex-1 justify-center"> <button
onClick={() => window.location.reload()}
className="flex-1 px-4 py-2 bg-[var(--surface-muted)] text-[var(--text-primary)] border border-[var(--border-subtle)] rounded hover:bg-[var(--surface-soft)] transition-colors"
>
Reload Page Reload Page
</button> </button>
</div> </div>
+119 -50
View File
@@ -1,5 +1,4 @@
import { type ReactNode, useEffect, useRef, useState } from 'react'; import { type ReactNode, useEffect, useRef, useState } from 'react';
import { ChevronRight } from 'lucide-react';
interface MetricCardProps { interface MetricCardProps {
title: string; title: string;
@@ -16,12 +15,6 @@ interface MetricCardProps {
horizontal?: boolean; horizontal?: boolean;
} }
const statusColors: Record<string, string> = {
good: 'var(--success)',
average: 'var(--warning)',
warning: 'var(--warning)',
};
export function MetricCard({ export function MetricCard({
title, title,
value, value,
@@ -56,64 +49,140 @@ export function MetricCard({
return () => observer.disconnect(); return () => observer.disconnect();
}, [animationDelay]); }, [animationDelay]);
const reveal = { const statusColors: Record<string, string> = {
opacity: isVisible ? 1 : 0, good: '#5ee6a0',
transform: isVisible ? 'translateY(0)' : 'translateY(12px)', average: '#f2c94c',
transition: 'all 0.5s ease', warning: '#f2c94c',
} as const; };
const footer = (
<div className="s-stat-foot">
<span
onClick={onClick}
style={{ cursor: onClick ? 'pointer' : 'default' }}
>
{statusText && status ? (
<b style={{ color: statusColors[status], fontWeight: 600 }}>{statusText}</b>
) : null}{' '}
{subtitle ?? 'Details'}
</span>
{onClick ? (
<button className="s-icon-btn !h-6 !w-6" onClick={onClick} title="Details">
<ChevronRight size={11} />
</button>
) : null}
</div>
);
// Horizontal layout for cards like Active User
if (horizontal) { if (horizontal) {
return ( return (
<div ref={cardRef} className={`s-stat ${className}`} style={{ ...reveal, display: 'flex' }}> <div
<div className="flex-1 px-4 pt-4 pb-2 flex flex-col"> ref={cardRef}
<div className="flex items-center gap-2.5"> className={`panel ${className}`}
<span className="s-ibox">{icon}</span> style={{
<span className="text-[11.5px] font-medium text-[var(--text-secondary)]">{title}</span> display: 'flex',
flexDirection: 'row',
padding: 0,
overflow: 'hidden',
opacity: isVisible ? 1 : 0,
transform: isVisible ? 'translateY(0)' : 'translateY(12px)',
transition: 'all 0.5s ease',
}}
>
<div style={{
flex: 1,
padding: '20px 18px 18px 20px',
display: 'flex',
flexDirection: 'column',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
<div className="card-icon">{icon}</div>
<span style={{ fontSize: 14, fontWeight: 600, color: '#e8e9f0' }}>{title}</span>
</div> </div>
<div className="mt-4 font-headline text-[30px] font-bold leading-none tracking-[-0.025em] text-[var(--text-primary)]"> <div style={{
fontSize: 36,
fontWeight: 900,
letterSpacing: '-1.5px',
lineHeight: 1,
color: '#e8e9f0',
}}>
{value} {value}
</div> </div>
{details ? <div className="mt-3">{details}</div> : null} {subtitle && (
<div className="mt-auto">{footer}</div> <div style={{ fontSize: 12, color: '#6b6e7d', marginTop: 4 }}>{subtitle}</div>
)}
{details && <div style={{ marginTop: 12 }}>{details}</div>}
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: 'auto',
paddingTop: 14,
}}>
<span
style={{ fontSize: 13, color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}
onClick={onClick}
>
Details
</span>
<div className="arrow-btn" onClick={onClick}>
<svg viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6" />
</svg>
</div>
</div>
</div> </div>
{chart ? ( {chart && (
<div className="w-1/2 flex items-end pr-3 pb-10 pt-3">{chart}</div> <div style={{
) : null} width: '50%',
padding: '16px 14px 46px 0',
display: 'flex',
alignItems: 'flex-end',
}}>
{chart}
</div>
)}
</div> </div>
); );
} }
// Standard vertical card layout - matching self.html exactly
return ( return (
<div ref={cardRef} className={`s-stat ${className}`} style={reveal}> <div
<div className="flex items-center gap-2.5 px-4 pt-4"> ref={cardRef}
<span className="s-ibox">{icon}</span> className={`panel ${className}`}
<span className="text-[11.5px] font-medium text-[var(--text-secondary)]">{title}</span> style={{
display: 'flex',
flexDirection: 'column',
padding: 20,
opacity: isVisible ? 1 : 0,
transform: isVisible ? 'translateY(0)' : 'translateY(12px)',
transition: 'all 0.5s ease',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
<div className="card-icon">{icon}</div>
<span style={{ fontSize: 14, fontWeight: 600, color: '#e8e9f0' }}>{title}</span>
</div> </div>
<div className="px-4 mt-4 font-headline text-[32px] font-bold leading-none tracking-[-0.025em] text-[var(--text-primary)]">
<div style={{
fontSize: 38,
fontWeight: 900,
letterSpacing: '-1.5px',
lineHeight: 1,
color: '#e8e9f0',
}}>
{value} {value}
</div> </div>
{chart ? <div className="px-4 mt-3">{chart}</div> : null}
{details ? <div className="px-4 mt-3">{details}</div> : null} {(subtitle || statusText) && (
<div className="mt-4">{footer}</div> <div style={{ fontSize: 12, color: '#6b6e7d', marginTop: 4 }}>
{statusText && status && (
<span style={{ color: statusColors[status], fontWeight: 700 }}>{statusText}</span>
)}{' '}
{subtitle}
</div>
)}
{chart && <div style={{ marginTop: 12, marginBottom: 6 }}>{chart}</div>}
{details && <div style={{ marginTop: 16 }}>{details}</div>}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingTop: 4, marginTop: 'auto' }}>
<span
onClick={onClick}
style={{ fontSize: 13, color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}
>
Details
</span>
<div className="arrow-btn" onClick={onClick}>
<svg viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6" />
</svg>
</div>
</div>
</div> </div>
); );
} }
@@ -1,5 +1,4 @@
import { Check, X, Loader2, Box, HelpCircle, type LucideIcon } from 'lucide-react'; import { Check, X, Loader2, Box, HelpCircle, type LucideIcon } from 'lucide-react';
import type { PillTone } from './sentry-utils';
type ServiceStatus = 'running' | 'building' | 'failed' | 'stopped' | 'unknown'; type ServiceStatus = 'running' | 'building' | 'failed' | 'stopped' | 'unknown';
@@ -13,29 +12,43 @@ interface StatusBadgeProps {
const statusConfig: Record< const statusConfig: Record<
ServiceStatus, ServiceStatus,
{ tone: PillTone; Icon: LucideIcon; label: string; animate?: boolean } { color: string; bg: string; Icon: LucideIcon; label: string; animate?: boolean; glow?: string }
> = { > = {
running: { tone: 'ok', Icon: Check, label: 'Active' }, running: {
building: { tone: 'warn', Icon: Loader2, label: 'Building', animate: true }, color: 'var(--success)',
failed: { tone: 'err', Icon: X, label: 'Failed' }, bg: 'var(--success-soft)',
stopped: { tone: 'off', Icon: Box, label: 'Stopped' }, Icon: Check,
unknown: { tone: 'off', Icon: HelpCircle, label: 'Unknown' }, label: 'Active',
}; animate: true,
glow: 'rgba(61, 214, 140, 0.4)',
const TONE_CLS: Record<PillTone, string> = { },
ok: 'text-[var(--success)] bg-[var(--success-soft)] border-[color-mix(in_srgb,var(--success)_16%,transparent)]', building: {
warn: 'text-[var(--warning)] bg-[var(--warning-soft)] border-[color-mix(in_srgb,var(--warning)_16%,transparent)]', color: 'var(--warning)',
err: 'text-[var(--error)] bg-[var(--error-soft)] border-[color-mix(in_srgb,var(--error)_16%,transparent)]', bg: 'var(--warning-soft)',
info: 'text-[var(--info)] bg-[var(--info-soft)] border-[color-mix(in_srgb,var(--info)_16%,transparent)]', Icon: Loader2,
off: 'text-[var(--text-tertiary)] bg-[var(--tint-06)] border-[var(--border-subtle)]', label: 'Building',
}; animate: true,
glow: 'rgba(255, 112, 67, 0.4)',
const TONE_DOT: Record<PillTone, string> = { },
ok: 'var(--success)', failed: {
warn: 'var(--warning)', color: 'var(--error)',
err: 'var(--error)', bg: 'var(--error-soft)',
info: 'var(--info)', Icon: X,
off: 'var(--text-tertiary)', label: 'Failed',
glow: 'rgba(255, 107, 91, 0.4)',
},
stopped: {
color: 'var(--text-muted)',
bg: 'var(--surface-muted)',
Icon: Box,
label: 'Stopped',
},
unknown: {
color: 'var(--text-muted)',
bg: 'var(--surface-muted)',
Icon: HelpCircle,
label: 'Unknown',
},
}; };
export function StatusBadge({ export function StatusBadge({
@@ -49,26 +62,36 @@ export function StatusBadge({
const { Icon } = config; const { Icon } = config;
const sizeClasses = { const sizeClasses = {
sm: 'px-2 py-[2px] text-[10px] gap-1.5', sm: 'px-2 py-0.5 text-[10px] gap-1.5',
md: 'px-2 py-[3.5px] text-[10.5px] gap-1.5', md: 'px-2.5 py-1 text-xs gap-1.5',
lg: 'px-2.5 py-1 text-[11.5px] gap-1.5', lg: 'px-3 py-1.5 text-sm gap-2',
}; };
const iconSizes = { sm: 10, md: 11, lg: 12 }; const iconSizes = {
sm: 10,
md: 12,
lg: 14,
};
return ( return (
<div <div
className={`inline-flex items-center rounded-[5px] border font-medium transition-colors ${sizeClasses[size]} ${TONE_CLS[config.tone]} ${className}`} className={`inline-flex items-center rounded-full font-bold uppercase tracking-wider ring-1 ring-inset transition-all duration-200 ${sizeClasses[size]} ${className}`}
style={{
background: config.bg,
color: config.color,
borderColor: `${config.color}20`,
boxShadow: status === 'running' && pulse ? `0 0 12px ${config.glow}` : 'none',
}}
> >
{status === 'running' && pulse && ( {status === 'running' && pulse && (
<span <span
className="w-1.5 h-1.5 rounded-full live-pulse" className="w-1.5 h-1.5 rounded-full live-pulse"
style={{ background: TONE_DOT[config.tone] }} style={{ background: config.color, boxShadow: `0 0 6px ${config.color}` }}
/> />
)} )}
<Icon <Icon
size={iconSizes[size]} size={iconSizes[size]}
className={config.animate ? 'animate-spin' : ''} className={config.animate && status === 'building' ? 'animate-spin' : ''}
/> />
{showLabel && <span>{config.label}</span>} {showLabel && <span>{config.label}</span>}
</div> </div>
@@ -83,19 +106,27 @@ interface LiveIndicatorProps {
export function LiveIndicator({ isLive, label, size = 'md' }: LiveIndicatorProps) { export function LiveIndicator({ isLive, label, size = 'md' }: LiveIndicatorProps) {
const sizeClasses = { const sizeClasses = {
sm: 'px-2 py-[2px] text-[10px] gap-1.5', sm: 'px-2 py-0.5 text-[10px] gap-1.5',
md: 'px-2 py-[3.5px] text-[10.5px] gap-1.5', md: 'px-2.5 py-1 text-xs gap-2',
lg: 'px-2.5 py-1 text-[11.5px] gap-1.5', lg: 'px-3 py-1.5 text-sm gap-2',
}; };
const tone: PillTone = isLive ? 'ok' : 'off';
return ( return (
<div <div
className={`inline-flex items-center rounded-[5px] border font-medium transition-colors ${sizeClasses[size]} ${TONE_CLS[tone]}`} className={`inline-flex items-center rounded-full font-bold uppercase tracking-wider ring-1 ring-inset transition-all duration-300 ${sizeClasses[size]}`}
style={{
background: isLive ? 'var(--success-soft)' : 'var(--surface-muted)',
color: isLive ? 'var(--success)' : 'var(--text-muted)',
borderColor: isLive ? 'rgba(61, 214, 140, 0.2)' : 'rgba(255, 255, 255, 0.05)',
boxShadow: isLive ? '0 0 12px rgba(61, 214, 140, 0.3)' : 'none',
}}
> >
<span <span
className={`w-1.5 h-1.5 rounded-full ${isLive ? 'live-pulse' : ''}`} className={`w-1.5 h-1.5 rounded-full ${isLive ? 'live-pulse' : ''}`}
style={{ background: TONE_DOT[tone] }} style={{
background: isLive ? 'var(--success)' : 'var(--text-muted)',
boxShadow: isLive ? '0 0 6px var(--success)' : 'none',
}}
/> />
{label || (isLive ? 'Active' : 'Stopped')} {label || (isLive ? 'Active' : 'Stopped')}
</div> </div>
@@ -12,4 +12,3 @@ export { useToast } from '../hooks/use-toast';
export type { ToastType, ToastContextValue } from '../hooks/use-toast'; export type { ToastType, ToastContextValue } from '../hooks/use-toast';
export * from './BrandWordmark'; export * from './BrandWordmark';
export * from './FilterBar'; export * from './FilterBar';
export * from './sentry';
@@ -1,18 +0,0 @@
/* Non-component helpers for the Sentry kit — kept out of sentry.tsx so the
component file stays fast-refresh clean. */
export type PillTone = 'ok' | 'warn' | 'err' | 'off' | 'info';
export function statusTone(status: string): PillTone {
const s = status.toLowerCase();
if (['running', 'healthy', 'online', 'active', 'deployed', 'succeeded', 'success', 'live'].includes(s)) return 'ok';
if (['degraded', 'warning', 'building', 'deploying', 'pending', 'scheduled', 'queued', 'sleeping'].includes(s)) return 'warn';
if (['failed', 'critical', 'error', 'offline', 'stopped'].includes(s)) return s === 'stopped' || s === 'offline' ? 'off' : 'err';
return 'info';
}
/* CSS var color helper for charts. */
export function cssVar(name: string, fallback = ''): string {
if (typeof window === 'undefined') return fallback;
return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
}
@@ -1,372 +0,0 @@
import { useState, type ReactNode } from 'react';
import { ChevronDown, MoreHorizontal } from 'lucide-react';
import type { PillTone } from './sentry-utils';
export type { PillTone };
/* ============================================================
Sentry kit — React bindings for the 23-sentry.html language.
Every page composes these; no page invents its own chrome.
============================================================ */
export function Ibox({ children }: { children: ReactNode }) {
return <span className="s-ibox">{children}</span>;
}
/* ---------- buttons ---------- */
type BtnProps = {
children: ReactNode;
onClick?: () => void;
disabled?: boolean;
title?: string;
className?: string;
type?: 'button' | 'submit';
};
export function GhostBtn({ children, onClick, disabled, title, className = '', type = 'button' }: BtnProps) {
return (
<button type={type} className={`s-btn-accent ${className}`} onClick={onClick} disabled={disabled} title={title}>
{children}
</button>
);
}
export function QuietBtn({ children, onClick, disabled, title, className = '', type = 'button' }: BtnProps) {
return (
<button type={type} className={`s-btn-quiet ${className}`} onClick={onClick} disabled={disabled} title={title}>
{children}
</button>
);
}
export function IconBtn({ children, onClick, disabled, title, className = '', type = 'button' }: BtnProps) {
return (
<button type={type} className={`s-icon-btn ${className}`} onClick={onClick} disabled={disabled} title={title}>
{children}
</button>
);
}
/* Filter/sort chip with trailing chevron — the `.sel` in sentry. */
export function SelectChip({ children, onClick, title, icon }: BtnProps & { icon?: ReactNode }) {
return (
<button className="s-chip" onClick={onClick} title={title}>
{icon}
{children}
<ChevronDown size={10} style={{ opacity: 0.55 }} />
</button>
);
}
/* Segmented icon buttons — chart-type switchers. */
export function Seg({ options, value, onChange }: {
options: { key: string; icon: ReactNode; title?: string }[];
value: string;
onChange: (key: string) => void;
}) {
return (
<span className="s-seg">
{options.map((o) => (
<button
key={o.key}
className={o.key === value ? 'on' : ''}
onClick={() => onChange(o.key)}
title={o.title}
>
{o.icon}
</button>
))}
</span>
);
}
/* ---------- stat card ---------- */
export function SStat({ icon, label, value, unit, foot, delta, extra, onMenu }: {
icon: ReactNode;
label: string;
value: ReactNode;
unit?: string;
foot: ReactNode;
delta?: { dir: 'up' | 'down'; text: string };
extra?: ReactNode;
onMenu?: () => void;
}) {
return (
<div className="s-stat">
<div className="flex items-center gap-2.5 px-4 pt-4">
<Ibox>{icon}</Ibox>
<span className="text-[11.5px] font-medium text-[var(--text-secondary)] whitespace-nowrap">{label}</span>
<button
className="ml-auto text-[var(--text-tertiary)] hover:text-[var(--text-primary)] leading-none"
onClick={onMenu}
title="More"
>
<MoreHorizontal size={14} />
</button>
</div>
<div className="flex items-baseline gap-1.5 px-4 mt-4">
<span className="font-headline text-[34px] font-bold leading-none tracking-[-0.025em] text-[var(--text-primary)]">
{value}
</span>
{unit && <span className="text-[12px] font-medium text-[var(--text-tertiary)]">{unit}</span>}
</div>
{extra && <div className="px-4 mt-3">{extra}</div>}
<div className="s-stat-foot mt-3.5">
<span>{foot}</span>
{delta && <SDelta dir={delta.dir}>{delta.text}</SDelta>}
</div>
</div>
);
}
export function SDelta({ dir, children }: { dir: 'up' | 'down'; children: ReactNode }) {
return (
<span className={`s-delta ${dir}`}>
{dir === 'up' ? '▲' : '▼'}{children}
</span>
);
}
/* ---------- card ---------- */
export function SCard({ icon, title, accent, trail, children, pad = true, className = '' }: {
icon?: ReactNode;
title?: ReactNode;
accent?: ReactNode;
trail?: ReactNode;
children: ReactNode;
pad?: boolean;
className?: string;
}) {
return (
<div className={`s-card ${className}`}>
{(title !== undefined || icon) && (
<div className="s-cardhead">
{icon && <Ibox>{icon}</Ibox>}
<span className="s-t">{title}{accent && <> <span className="s-ta">{accent}</span></>}</span>
{trail && <span className="s-trail">{trail}</span>}
</div>
)}
<div className={pad ? 'px-4 pb-4' : ''}>{children}</div>
</div>
);
}
/* ---------- ticks / bars ---------- */
export function Ticks({ pct, count = 56, warn = false }: { pct: number; count?: number; warn?: boolean }) {
const on = Math.round((Math.max(0, Math.min(100, pct)) / 100) * count);
return (
<span className="s-ticks" role="img" aria-label={`${pct}%`}>
{Array.from({ length: count }, (_, i) => (
<i key={i} className={i < on ? (warn ? 'warn' : 'on') : ''} />
))}
</span>
);
}
/* Ascending mini bar glyph used inside table metric cells. */
export function MiniBars({ tone = 'var(--accent-primary)' }: { tone?: string }) {
return (
<span className="inline-flex items-end gap-[1.5px] h-3" aria-hidden>
{[9, 5, 12, 7].map((h, i) => (
<i key={i} className="w-[2.5px] rounded-[0.5px]" style={{ height: h, background: tone }} />
))}
</span>
);
}
/* ---------- status pill ---------- */
const PILL_CLS: Record<PillTone, string> = {
ok: 'text-[var(--success)] bg-[var(--success-soft)] border-[color-mix(in_srgb,var(--success)_16%,transparent)]',
warn: 'text-[var(--warning)] bg-[var(--warning-soft)] border-[color-mix(in_srgb,var(--warning)_16%,transparent)]',
err: 'text-[var(--error)] bg-[var(--error-soft)] border-[color-mix(in_srgb,var(--error)_16%,transparent)]',
info: 'text-[var(--info)] bg-[var(--info-soft)] border-[color-mix(in_srgb,var(--info)_16%,transparent)]',
off: 'text-[var(--text-tertiary)] bg-[var(--tint-06)] border-[var(--border-subtle)]',
};
export function SPill({ tone, children }: { tone: PillTone; children: ReactNode }) {
return (
<span className={`inline-block text-[10.5px] font-medium rounded-[5px] px-2 py-[3.5px] border ${PILL_CLS[tone]}`}>
{children}
</span>
);
}
/* ---------- inset callout ---------- */
export function SAnomaly({ icon, title, items }: { icon?: ReactNode; title: ReactNode; items: ReactNode[] }) {
return (
<div className="s-inset">
<div className="flex items-center gap-2 pb-2 mb-2 border-b border-[var(--border-subtle)] text-[11.5px] text-[var(--text-secondary)]">
{icon}
<span>{title}</span>
</div>
{items.map((it, i) => (
<div key={i} className="s-anomaly-item">{it}</div>
))}
</div>
);
}
/* ---------- data table (inset header bar, sortable) ---------- */
export type SCol<T> = {
key: string;
label: ReactNode;
width?: string;
sortable?: boolean;
sortValue?: (row: T) => string | number;
render: (row: T) => ReactNode;
};
const CARET = (
<svg viewBox="0 0 7 10" width="7" height="10" className="shrink-0">
<path d="M3.5 .8 5.8 3.6H1.2zM3.5 9.2 1.2 6.4h4.6z" fill="currentColor" opacity="0.45" />
</svg>
);
export function STable<T extends { id?: string | number }>({ cols, rows, rowKey, selectable, onRowClick }: {
cols: SCol<T>[];
rows: T[];
rowKey?: (row: T, i: number) => string | number;
selectable?: boolean;
onRowClick?: (row: T) => void;
}) {
const [sortKey, setSortKey] = useState<string | null>(null);
const [sortDir, setSortDir] = useState<1 | -1>(1);
const [checked, setChecked] = useState<Set<number>>(new Set());
const allCols: SCol<T>[] = selectable
? [{ key: '$sel', label: '', width: '30px', render: () => null }, ...cols]
: cols;
const template = allCols.map((c) => c.width ?? '1fr').join(' ');
const sorted = [...rows];
const sc = cols.find((c) => c.key === sortKey);
if (sc?.sortValue) {
sorted.sort((a, b) => {
const x = sc.sortValue!(a); const y = sc.sortValue!(b);
return (x > y ? 1 : x < y ? -1 : 0) * sortDir;
});
}
const toggleAll = () =>
setChecked(checked.size === rows.length ? new Set() : new Set(rows.map((_, i) => i)));
return (
<div className="s-dt pb-2.5">
<div className="s-dt-head" style={{ gridTemplateColumns: template }}>
{allCols.map((c) =>
c.key === '$sel' ? (
<span key="$sel" className="s-dt-h">
<span className={`s-ck ${checked.size === rows.length && rows.length > 0 ? 'on' : ''}`} onClick={toggleAll} />
</span>
) : (
<span
key={c.key}
className={`s-dt-h ${c.sortable !== false ? 'sortable' : ''} ${sortKey === c.key ? 'on' : ''}`}
onClick={() => {
if (c.sortable === false) return;
setSortDir(sortKey === c.key ? (sortDir === 1 ? -1 : 1) : 1);
setSortKey(c.key);
}}
>
{c.label}{c.sortable !== false && CARET}
</span>
),
)}
</div>
{sorted.map((row, i) => {
const key = rowKey ? rowKey(row, i) : row.id ?? i;
const ri = rows.indexOf(row);
return (
<div
key={key}
className={`s-dt-row ${onRowClick ? 'clickable' : ''}`}
style={{ gridTemplateColumns: template }}
onClick={() => onRowClick?.(row)}
>
{allCols.map((c) =>
c.key === '$sel' ? (
<span key="$sel">
<span
className={`s-ck ${checked.has(ri) ? 'on' : ''}`}
onClick={(e) => {
e.stopPropagation();
setChecked((prev) => {
const n = new Set(prev);
if (n.has(ri)) n.delete(ri); else n.add(ri);
return n;
});
}}
/>
</span>
) : (
<span key={c.key} className="min-w-0">{c.render(row)}</span>
),
)}
</div>
);
})}
</div>
);
}
/* ---------- page header (Welcome back … + actions) ---------- */
export function SPageHead({ title, titleAccent, sub, subAccent, actions }: {
title: ReactNode;
titleAccent?: ReactNode;
sub?: ReactNode;
subAccent?: ReactNode;
actions?: ReactNode;
}) {
return (
<div className="flex items-center gap-4 mb-5 flex-wrap">
<div>
<h1 className="font-headline text-[24px] font-bold tracking-[-0.02em] text-[var(--text-primary)]">
{title}{titleAccent && <> <em className="not-italic text-[var(--accent-primary)]">{titleAccent}</em></>}
</h1>
{sub && (
<div className="text-[12px] text-[var(--text-tertiary)] mt-[3px]">
{sub}{subAccent && <> <b className="text-[var(--accent-primary)] font-semibold">{subAccent}</b></>}
</div>
)}
</div>
{actions && <div className="ml-auto flex items-center gap-2.5">{actions}</div>}
</div>
);
}
/* Section label — small caps with accent square. */
export function SSection({ children, right }: { children: ReactNode; right?: ReactNode }) {
return (
<div className="flex items-center justify-between mb-3 mt-1">
<div className="flex items-center gap-2 text-[10.5px] font-medium uppercase tracking-[0.1em] text-[var(--text-tertiary)]">
<span className="w-[5px] h-[5px] rounded-[1px] bg-[var(--accent-primary)]" />
{children}
</div>
{right && <span className="v-mono text-[10.5px] text-[var(--text-muted)]">{right}</span>}
</div>
);
}
/* Legend row for charts. */
export function SLegend({ items }: { items: { color: string; label: string }[] }) {
return (
<div className="flex gap-5 px-4 pb-2.5 pt-0.5 text-[11.5px] font-medium text-[var(--text-secondary)]">
{items.map((it) => (
<span key={it.label}>
<i className="inline-block w-[7px] h-[7px] rounded-[1.5px] mr-2" style={{ background: it.color }} />
{it.label}
</span>
))}
</div>
);
}
+1 -5
View File
@@ -59,12 +59,8 @@ export default defineConfig({
}, },
proxy: { proxy: {
// Same-origin API path for dev - matches the nginx proxy in the shipped image // Same-origin API path for dev - matches the nginx proxy in the shipped image
'/health': {
target: process.env.VITE_API_PROXY ?? 'http://localhost:8082',
changeOrigin: true,
},
'/api': { '/api': {
target: process.env.VITE_API_PROXY ?? 'http://localhost:8082', target: 'http://localhost:8082',
changeOrigin: true, changeOrigin: true,
ws: true, ws: true,
}, },