Compare commits

..
Author SHA1 Message Date
Tomas DvorakandDevin 5b1cf0402c perf(ui): route-level code splitting via React.lazy
CI / Changed paths (push) Canceled after 0s
CI / Infra (Compose + Scripts) (push) Canceled after 0s
CI / Backend (Go) (push) Canceled after 0s
CI / Frontend (React) (push) Canceled after 0s
CI / Mobile (Expo) (push) Canceled after 0s
CI / Compose boot smoke (push) Canceled after 0s
CI / Build & publish images (push) Canceled after 0s
CI / Release (push) Canceled after 0s
Eager page imports put the entire app plus recharts/xyflow into the
1.3MB index chunk. Lazying every route drops the entry to 332kB and
defers charts, canvas, and service-detail code to first visit.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-10 19:36:31 +02:00
Tomáš DvořákandDevin 9271d0e3ac feat(ui): full frontend redesign to the Sentry-style design system (#86)
* feat(ui): full frontend redesign to the Sentry-style design system

Replace the legacy UI with the dark, mono-accent design language from
design/23-sentry.html across every non-canvas surface.

- PlatformShell: fixed 252px sidebar with grouped nav (Platform /
  Operate / Workspace), brand mark, user card, and a topbar with
  search (Cmd+K palette), env badge, upgrade, notifications, and
  theme toggle; mobile collapses to a top bar with horizontal nav
- New shared component kit in shared/components/sentry.tsx:
  SPageHead, SStat, SCard, STable, SSection, SPill, Ticks, Seg,
  GhostBtn/QuietBtn/IconBtn, status tone helpers
- New DashboardPage at / — stats row, per-node CPU chart with
  1h/24h/7d ranges, resource usage tick strips, anomaly box,
  node summary table, recent deployments and project cards;
  real API data with demo-mode fixtures
- Landing page moved to /landing and rebuilt: live host telemetry
  window, install strip, feature grid, template catalog, docs
- Every page (Projects, Templates, Builds, Databases, HA,
  Operations, Activity, Security, Usage, Docs, People, Settings,
  Audit Logs, Webhooks, Admin, NodeDetail, ServiceDetail, auth,
  setup, not-found) migrated to the new kit and tokens
- Nav active state resolves by longest matching href
- Canvas internals intentionally untouched — separate pass

Verified: tsc --noEmit, eslint, vite build, Playwright walkthrough
of every route at desktop and mobile widths (demo + live session).

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(ui): deep audit pass — palette navigation, crash fix, honest badges

- CommandPalette: full route navigation, project deep-links fetched on
  open, Create group only where onAddService is wired, dead Connect
  items removed, decorative kbd hints dropped; workspace passes its own
  view targets
- NodeDetailPage crash: container status arrives as a Docker state
  object, not a string — flattened in listAgentContainers and guarded
  at render for persisted-cache payloads
- env badge reflects import.meta.env.MODE instead of hardcoded
  'production'
- Relative times capped: >1y or future timestamps render 'n/a'/'—'
  (zero-time heartbeats no longer show 739898d ago)
- Post-auth and setup redirects land on / (dashboard is the index)

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(ui): use SPageHead actions prop, satisfy tsc -b literal types

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-10 17:20:44 +02:00
Tomas DvorakandDevin a05d225016 docs(roadmap): mark UI/UX audit + design previews shipped, fix stale multi-node note
CI / Changed paths (push) Canceled after 0s
CI / Infra (Compose + Scripts) (push) Canceled after 0s
CI / Backend (Go) (push) Canceled after 0s
CI / Frontend (React) (push) Canceled after 0s
CI / Mobile (Expo) (push) Canceled after 0s
CI / Compose boot smoke (push) Canceled after 0s
CI / Build & publish images (push) Canceled after 0s
CI / Release (push) Canceled after 0s
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-10 10:57:33 +02:00
Tomas DvorakandDevin 61a33a066f chore: gitignore local /design preview folder
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-10 10:57:02 +02:00
Tomáš DvořákandDevin f88b4f1bf3 fix(ui): UI/UX pass — connectivity probe, mobile overflow, real metrics, deep links (#85)
Found during a browser-driven audit of the whole app:

- connectivity banner showed permanently: pingServer() resolved
  undefined, which React Query treats as an error; returns boolean now
- Vite dev proxy now forwards /health so the probe actually measures
  the backend, and the API target is env-overridable (VITE_API_PROXY)
- auth-session removed from the persisted query cache — stale sessions
  no longer rehydrate into a false signed-in flash
- workspace Observability tab no longer shows fabricated random metrics
  ("87K Active User", fake performance/speed-test cards); the dashboard
  now aggregates real per-service metrics (CPU, memory, network, instances)
- service detail tabs write ?section= so tab state is shareable/deep-linked
- Security page auto-selects the first project instead of rendering blank
- People page user-creation form no longer overflows its card
  (minmax(0,1fr) grid tracks; same fix on env-var rows)
- page padding is responsive (px-4 sm:px-8) so mobile keeps content width;
  Projects header wraps instead of clipping the "+ new project" button
- template deploy modal shows a loading state while the session resolves
  instead of flashing the sign-in prompt
- audit log table strips the CIDR suffix from displayed IPs
- docs list excerpts no longer repeat the document title
- activity filter placeholder "Search search…" → "Search events…"
- workspace view tabs get more room: ⌘K button now waits for lg:

Generated with [Devin](https://devin.ai)

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-10 10:56:33 +02:00
Tomáš Dvořák 0dc5857b5f refactor(db): port operations, settings, audit, and misc metadata to sqlc (#84)
* refactor(db): port operations, settings, audit, activity, and misc service metadata to sqlc

Covers the cross-cutting surface: operations board reads, app settings,
notification producers, activity/audit inserts, builds upsert/cancel,
git clone credential resolution, invites, environments, registry auth,
project/service variables, HA resolver, boot sweeps, and admin user ops.
Dynamic filtered lists and Better Auth table probes stay raw by design.

Authored By: TDvorak <[email protected]>

* refactor(db): port apwhy gateway handlers and proxy data plane to sqlc

Final tranche — every static call site now uses generated queries. Raw SQL
remains only where the statement is assembled at runtime (filtered lists,
sparse updates, admin count closure) or probes Better Auth's auth_users
table, which lives outside the local schema.

Authored By: TDvorak <[email protected]>
2026-10-10 09:27:30 +02:00
Tomáš Dvořák 66301bb68a refactor(db): port auth and security handlers to sqlc (#83)
- 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:53:06 +02:00
94 changed files with 7761 additions and 4174 deletions

No files matched your search

+3
View File
@@ -124,3 +124,6 @@ ref/
.playwright-mcp/ .playwright-mcp/
canvas-*.png canvas-*.png
.vercel .vercel
# Local design previews (not shipped)
design/
+14 -4
View File
@@ -1,6 +1,6 @@
# Containr — Roadmap 2: The dflow Convergence # Containr — Roadmap 2: The dflow Convergence
Last verified: **2026-10-08** — deep pass over the full dflow codebase Last verified: **2026-10-10** — 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,8 +337,9 @@ 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. Remaining: build-on-node, service-detail placement-tags input. Build-on-node and remote
remote sleep/wake, spread weighting by real capacity. sleep/wake both shipped (see below). Remaining: spread weighting
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/
@@ -537,9 +538,18 @@ 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.
- [ ] **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. - [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.
- [ ] **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) →
+21 -30
View File
@@ -12,6 +12,7 @@ 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"
@@ -180,13 +181,13 @@ func handleGetProjectActivity(c *gin.Context) {
} }
// Access check: approved / owner / member / admin. // Access check: approved / owner / member / admin.
var visible bool userUUID, _ := uuid.Parse(userID)
if err := db.QueryRow( visible, err := sqlcdb.New(db.DB).CheckSecurityProjectAccess(context.Background(), sqlcdb.CheckSecurityProjectAccessParams{
`SELECT p.is_approved OR p.owner_id = $2::uuid OR $3::bool ID: uuid.MustParse(projectID),
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $2::uuid) OwnerID: userUUID,
FROM projects p WHERE p.id = $1`, Column3: contextIsAdmin(c),
projectID, userID, contextIsAdmin(c), })
).Scan(&visible); err != nil || !visible { if err != nil || !visible {
respondError(c, http.StatusNotFound, "NOT_FOUND", "project not found") respondError(c, http.StatusNotFound, "NOT_FOUND", "project not found")
return return
} }
@@ -237,12 +238,12 @@ func StartAuditRetention(ctx context.Context, db *database.DB) {
} }
prune := func() { prune := func() {
res, err := db.Exec(`DELETE FROM audit_logs WHERE created_at < NOW() - ($1::int * INTERVAL '1 day')`, days) n, err := sqlcdb.New(db.DB).PruneAuditLogs(context.Background(), int32(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, _ := res.RowsAffected(); n > 0 { if 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)
} }
} }
@@ -265,34 +266,24 @@ 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) {
cursor := uuid.Nil.String() q := sqlcdb.New(db.DB)
cursor := uuid.Nil
for { for {
rows, err := db.Query( rows, err := q.ListAuditBackfillBatch(context.Background(), cursor)
`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
} }
type row struct{ id, resource, action string } if len(rows) == 0 {
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 batch { for _, r := range rows {
cursor = r.id id, _ := uuid.Parse(r.ID)
sev, cat, label := classifyAuditEvent(r.resource, r.action) cursor = id
if _, err := db.Exec( sev, cat, label := classifyAuditEvent(r.Resource, r.Action)
`UPDATE audit_logs SET severity = $1, category = $2, label = $3 WHERE id = $4::uuid`, if err := q.SetAuditLogMetadata(context.Background(), sqlcdb.SetAuditLogMetadataParams{
sev, cat, label, r.id, Severity: sev, Category: cat, Label: label, Column4: id,
); err != nil { }); err != nil {
log.Printf("audit backfill update failed: %v", err) log.Printf("audit backfill update failed: %v", err)
} }
} }
+23 -34
View File
@@ -52,15 +52,11 @@ func EnsureAdminAccount(ctx context.Context, db *database.DB, cfg *config.Config
return return
} }
var userID string if _, err := sqlcdb.New(db.DB).UpsertAdminUser(ctx, sqlcdb.UpsertAdminUserParams{
err = db.QueryRowContext(ctx, ` Email: email,
INSERT INTO users (email, password_hash, name, is_admin) PasswordHash: string(hashed),
VALUES ($1, $2, $3, true) Name: name,
ON CONFLICT (email) DO UPDATE }); err != nil {
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
} }
@@ -185,24 +181,18 @@ 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 := db.QueryContext(c.Request.Context(), ` rows, err := sqlcdb.New(db.DB).ListAdminUsers(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) users := make([]adminUserRow, 0, len(rows))
for rows.Next() { for _, r := range rows {
var u adminUserRow users = append(users, adminUserRow{
if err := rows.Scan(&u.ID, &u.Email, &u.Name, &u.AvatarURL, &u.IsAdmin, &u.CreatedAt); err != nil { ID: r.ID.String(), Email: r.Email, Name: r.Name,
continue AvatarURL: r.AvatarUrl, IsAdmin: r.IsAdmin, CreatedAt: r.CreatedAt,
} })
users = append(users, u)
} }
c.JSON(http.StatusOK, gin.H{"users": users}) c.JSON(http.StatusOK, gin.H{"users": users})
} }
@@ -229,15 +219,16 @@ func handleAdminSetUserAdmin(c *gin.Context) {
return return
} }
result, err := db.ExecContext(c.Request.Context(), targetUUID, _ := uuid.Parse(targetID)
`UPDATE users SET is_admin = $1, updated_at = NOW() WHERE id = $2`, n, err := sqlcdb.New(db.DB).SetUserAdmin(c.Request.Context(), sqlcdb.SetUserAdminParams{
req.IsAdmin, targetID, IsAdmin: req.IsAdmin,
) 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, _ := result.RowsAffected(); n == 0 { if n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return return
} }
@@ -258,15 +249,13 @@ func handleAdminImpersonateUser(c *gin.Context) {
return return
} }
var email, name string userUUID, _ := uuid.Parse(targetID)
var isAdmin bool info, err := sqlcdb.New(db.DB).GetAdminUserInfo(c.Request.Context(), userUUID)
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
@@ -286,9 +275,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,
+121 -189
View File
@@ -2,6 +2,7 @@ package api
import ( import (
"containr/internal/database" "containr/internal/database"
"containr/internal/database/sqlcdb"
"context" "context"
"crypto/rand" "crypto/rand"
"database/sql" "database/sql"
@@ -14,6 +15,7 @@ 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"
) )
@@ -160,21 +162,22 @@ 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()
var upstreamURL, healthPath string svcUUID, parseErr := uuid.Parse(serviceID)
var authHeader, authValue sql.NullString var svc sqlcdb.GetAPServiceForValidateRow
var timeoutMs sql.NullInt64 var err error
err := db.QueryRowContext(ctx, ` if parseErr == nil {
SELECT upstream_url, health_path, upstream_auth_header, upstream_auth_value, request_timeout_ms svc, err = sqlcdb.New(db.DB).GetAPServiceForValidate(ctx, svcUUID)
FROM api_services WHERE id = $1 }
`, serviceID).Scan(&upstreamURL, &healthPath, &authHeader, &authValue, &timeoutMs) if parseErr != nil || err != nil {
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 timeoutMs.Valid && timeoutMs.Int64 > 0 { if svc.RequestTimeoutMs.Valid && svc.RequestTimeoutMs.Int32 > 0 {
timeout = int(timeoutMs.Int64) timeout = int(svc.RequestTimeoutMs.Int32)
} }
target := strings.TrimRight(upstreamURL, "/") + "/" + strings.TrimLeft(healthPath, "/") target := strings.TrimRight(upstreamURL, "/") + "/" + strings.TrimLeft(healthPath, "/")
@@ -203,17 +206,19 @@ func handleAPwhyServiceValidate(c *gin.Context) {
validationStatus = "healthy" validationStatus = "healthy"
} }
_, _ = db.ExecContext(ctx, ` q := sqlcdb.New(db.DB)
UPDATE api_services _ = q.SetAPServiceValidation(ctx, sqlcdb.SetAPServiceValidationParams{
SET last_validation_at = NOW(), last_validation_status = $1, last_validation_message = $2, updated_at = NOW() LastValidationStatus: sql.NullString{String: validationStatus, Valid: true},
WHERE id = $3 LastValidationMessage: sql.NullString{String: message, Valid: true},
`, validationStatus, message, serviceID) ID: svcUUID,
})
if !ok { if !ok {
_, _ = db.ExecContext(ctx, ` _ = q.InsertServiceIncident(ctx, sqlcdb.InsertServiceIncidentParams{
INSERT INTO incident_events (service_id, code, message, severity, http_status, occurred_at) ServiceID: uuid.NullUUID{UUID: svcUUID, Valid: true},
VALUES ($1, 'SERVICE_VALIDATION_FAILED', $2, 'medium', $3, NOW()) Message: message,
`, serviceID, message, status) HttpStatus: sql.NullInt32{Int32: int32(status), Valid: true},
})
} }
sendSuccessResponse(c, http.StatusOK, map[string]interface{}{ sendSuccessResponse(c, http.StatusOK, map[string]interface{}{
@@ -231,36 +236,24 @@ func handleAPwhyServicesList(c *gin.Context) {
ctx := context.Background() ctx := context.Background()
// Query services from database // Query services from database
rows, err := db.QueryContext(ctx, ` rows, err := sqlcdb.New(db.DB).ListAPServices(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": id, "id": r.ID.String(),
"name": name, "name": r.Name,
"slug": slug, "slug": r.Slug,
"upstreamUrl": upstreamURL, "upstreamUrl": r.UpstreamUrl,
"routePrefix": routePrefix, "routePrefix": r.RoutePrefix,
"enabled": enabled != 0, "enabled": r.Enabled != 0,
"createdAt": createdAt, "createdAt": r.CreatedAt,
"updatedAt": updatedAt, "updatedAt": r.UpdatedAt,
}) })
} }
@@ -299,14 +292,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.
var id string svcID, err := sqlcdb.New(db.DB).InsertAPService(ctx, sqlcdb.InsertAPServiceParams{
err := db.QueryRowContext(ctx, ` Name: req.Name,
INSERT INTO api_services ( Slug: slug,
name, slug, upstream_url, route_prefix, health_path, UpstreamUrl: req.UpstreamURL,
rpm_limit, monthly_quota, created_at, updated_at RoutePrefix: req.RoutePrefix,
) VALUES ($1, $2, $3, $4, '/health', $5, $6, NOW(), NOW()) RpmLimit: sql.NullInt32{Int32: int32(rpmLimit), Valid: true},
RETURNING id MonthlyQuota: sql.NullInt32{Int32: int32(monthlyQuota), Valid: true},
`, req.Name, slug, req.UpstreamURL, req.RoutePrefix, rpmLimit, monthlyQuota).Scan(&id) })
if err != nil { if err != nil {
if isUniqueViolation(err) { if isUniqueViolation(err) {
@@ -320,7 +313,7 @@ func handleAPwhyServicesCreate(c *gin.Context) {
} }
serviceData := map[string]interface{}{ serviceData := map[string]interface{}{
"id": id, "id": svcID.String(),
"name": req.Name, "name": req.Name,
"slug": slug, "slug": slug,
"upstreamUrl": req.UpstreamURL, "upstreamUrl": req.UpstreamURL,
@@ -356,13 +349,15 @@ 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 := 0 enabled := int32(0)
if *input.Enabled { if *input.Enabled {
enabled = 1 enabled = 1
} }
_, err := db.ExecContext(context.Background(), svcUUID, _ := uuid.Parse(serviceID)
"UPDATE api_services SET enabled = $1, updated_at = NOW() WHERE id = $2", err := sqlcdb.New(db.DB).SetAPServiceEnabled(context.Background(), sqlcdb.SetAPServiceEnabledParams{
enabled, serviceID) Enabled: enabled,
ID: svcUUID,
})
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
@@ -383,11 +378,7 @@ 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 := db.QueryContext(context.Background(), ` rows, err := sqlcdb.New(db.DB).ListAPKeys(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,
@@ -395,34 +386,24 @@ 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": id, "id": r.ID.String(),
"name": name, "name": r.Name,
"keyPrefix": keyPrefix, "keyPrefix": r.KeyPrefix,
"plan": plan, "plan": r.Plan,
"enabled": enabled != 0, "enabled": r.Enabled != 0,
"createdAt": createdAt, "createdAt": r.CreatedAt,
"updatedAt": updatedAt, "updatedAt": r.UpdatedAt,
} }
if rpmLimit.Valid { if r.RpmLimit.Valid {
key["rpmLimit"] = rpmLimit.Int64 key["rpmLimit"] = int64(r.RpmLimit.Int32)
} }
if monthlyQuota.Valid { if r.MonthlyQuota.Valid {
key["monthlyQuota"] = monthlyQuota.Int64 key["monthlyQuota"] = int64(r.MonthlyQuota.Int32)
} }
keys = append(keys, key) keys = append(keys, key)
@@ -481,14 +462,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.
var id string keyID, err := sqlcdb.New(db.DB).InsertAPKey(context.Background(), sqlcdb.InsertAPKeyParams{
err = db.QueryRowContext(context.Background(), ` Name: input.Name,
INSERT INTO api_keys ( KeyHash: keyHash,
name, key_hash, key_prefix, plan, allowed_service_ids, KeyPrefix: keyPrefix,
rpm_limit, monthly_quota, created_at, updated_at Plan: plan,
) VALUES ($1, $2, $3, $4, '[]', $5, $6, NOW(), NOW()) RpmLimit: sql.NullInt32{Int32: int32(rpmLimit), Valid: true},
RETURNING id MonthlyQuota: sql.NullInt32{Int32: int32(monthlyQuota), Valid: true},
`, 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{
@@ -501,7 +482,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": id, "id": keyID.String(),
"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
@@ -532,13 +513,15 @@ 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 := 0 enabled := int32(0)
if *input.Enabled { if *input.Enabled {
enabled = 1 enabled = 1
} }
_, err := db.ExecContext(context.Background(), keyUUID, _ := uuid.Parse(keyID)
"UPDATE api_keys SET enabled = $1, updated_at = NOW() WHERE id = $2", err := sqlcdb.New(db.DB).SetAPKeyEnabled(context.Background(), sqlcdb.SetAPKeyEnabledParams{
enabled, keyID) Enabled: enabled,
ID: keyUUID,
})
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
@@ -560,23 +543,27 @@ 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
var totalServices, totalKeys, totalUsers int q := sqlcdb.New(db.DB)
ctx := context.Background()
if err := db.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM api_services").Scan(&totalServices); err != nil { totalServices, err := q.CountAPServices(ctx)
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
} }
if err := db.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM api_keys").Scan(&totalKeys); err != nil { totalKeys, err := q.CountAPKeys(ctx)
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
} }
if err := db.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM users").Scan(&totalUsers); err != nil { totalUsers, err := q.CountUsers(ctx)
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(),
@@ -584,8 +571,8 @@ func handleAPwhyAnalyticsOps(c *gin.Context) {
return return
} }
var totalRequests int totalRequests, err := q.SumUsageCounters(ctx)
if err := db.QueryRowContext(context.Background(), "SELECT COALESCE(SUM(request_count), 0) FROM usage_counters").Scan(&totalRequests); err != nil { if 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(),
@@ -593,12 +580,8 @@ func handleAPwhyAnalyticsOps(c *gin.Context) {
return return
} }
var requestsToday int requestsToday, err := q.SumRequestMetricSince(ctx, "day")
if err := db.QueryRowContext(context.Background(), ` if err != nil {
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(),
@@ -606,12 +589,8 @@ func handleAPwhyAnalyticsOps(c *gin.Context) {
return return
} }
var requestsThisMonth int requestsThisMonth, err := q.SumRequestMetricSince(ctx, "month")
if err := db.QueryRowContext(context.Background(), ` if err != nil {
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(),
@@ -636,94 +615,47 @@ 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)
serviceRows, err := db.QueryContext(context.Background(), ` if serviceRows, err := q.ListTopAPServices(ctx); err == nil {
SELECT s.id, s.name, COALESCE(SUM(u.request_count), 0) AS total_requests for _, r := range serviceRows {
FROM api_services s topServices = append(topServices, map[string]interface{}{
LEFT JOIN usage_counters u ON u.service_id = s.id "service_id": r.ID.String(),
GROUP BY s.id, s.name "name": r.Name,
ORDER BY total_requests DESC, s.name ASC "requests": r.TotalRequests,
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)
trafficRows, err := db.QueryContext(context.Background(), ` if trafficRows, err := q.ListRequestsByDay(ctx); err == nil {
SELECT TO_CHAR(DATE_TRUNC('day', occurred_at), 'YYYY-MM-DD') AS day_bucket, COUNT(*) AS total for _, r := range trafficRows {
FROM metrics_timeseries requestsByDay = append(requestsByDay, map[string]interface{}{
WHERE metric = 'request_total' AND occurred_at >= NOW() - INTERVAL '7 days' "day": r.DayBucket,
GROUP BY DATE_TRUNC('day', occurred_at) "requests": r.Total,
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)
statusRows, err := db.QueryContext(context.Background(), ` if statusRows, err := q.ListIncidentStatusCodes(ctx); err == nil {
SELECT COALESCE(http_status, 0) AS status_code, COALESCE(SUM(count), 0) AS total for _, r := range statusRows {
FROM incident_events statusCodes = append(statusCodes, map[string]interface{}{
WHERE occurred_at >= NOW() - INTERVAL '7 days' "status_code": r.StatusCode,
GROUP BY COALESCE(http_status, 0) "count": r.Total,
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)
eventRows, err := db.QueryContext(context.Background(), ` if eventRows, err := q.ListClientEventPaths(ctx); err == nil {
SELECT COALESCE((labels_json::jsonb ->> 'path'), 'unknown') AS path, COUNT(*) AS total for _, r := range eventRows {
FROM metrics_timeseries clientEvents = append(clientEvents, map[string]interface{}{
WHERE metric = 'client_event' AND occurred_at >= NOW() - INTERVAL '7 days' "path": r.Path,
GROUP BY COALESCE((labels_json::jsonb ->> 'path'), 'unknown') "count": r.Total,
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,
})
}
} }
} }
+54 -34
View File
@@ -2,6 +2,9 @@ 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"
@@ -11,6 +14,7 @@ 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 {
@@ -98,16 +102,20 @@ 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)
auditID := uuid.New().String() err := sqlcdb.New(db.DB).InsertAuditLog(context.Background(), sqlcdb.InsertAuditLogParams{
_, err := db.Exec( ID: uuid.New(),
`INSERT INTO audit_logs (id, user_id, resource, resource_id, action, details, severity, category, label, created_at) UserID: nullUUIDOrNil(userID),
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, Resource: resource,
auditID, userUUID, resource, resourceUUID, action, string(detailsJSON), severity, category, label, time.Now().UTC(), ResourceID: nullUUIDOrNil(resourceID),
) 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 {
} }
@@ -131,16 +139,24 @@ 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)
auditID := uuid.New().String() var inet pqtype.Inet
_, err := db.Exec( _ = inet.Scan(c.ClientIP())
`INSERT INTO audit_logs (id, user_id, resource, resource_id, action, details, ip_address, user_agent, severity, category, label, created_at) err := sqlcdb.New(db.DB).InsertAuditLogWithRequest(context.Background(), sqlcdb.InsertAuditLogWithRequestParams{
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9, $10, $11, $12)`, ID: uuid.New(),
auditID, userUUID, resource, resourceUUID, action, string(detailsJSON), c.ClientIP(), c.GetHeader("User-Agent"), severity, category, label, time.Now().UTC(), UserID: nullUUIDOrNil(userIDStr),
) 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 {
} }
@@ -255,30 +271,29 @@ func handleGetResourceAuditLogs(c *gin.Context) {
resource := c.Param("resource") resource := c.Param("resource")
resourceID := c.Param("id") resourceID := c.Param("id")
rows, err := db.Query( rows, err := sqlcdb.New(db.DB).ListResourceAuditLogs(context.Background(), sqlcdb.ListResourceAuditLogsParams{
`SELECT id, COALESCE(user_id::text, ''), resource, COALESCE(resource_id::text, ''), action, COALESCE(details::text, '{}'), UserID: nullUUIDOrNil(userID),
COALESCE(ip_address::text, ''), COALESCE(user_agent, ''), created_at Resource: resource,
FROM audit_logs ResourceID: nullUUIDOrNil(resourceID),
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 rows.Next() { for _, row := range rows {
var log AuditLog logs = append(logs, AuditLog{
err := rows.Scan(&log.ID, &log.UserID, &log.Resource, &log.ResourceID, &log.Action, &log.Details, &log.IPAddress, &log.UserAgent, &log.CreatedAt) ID: row.ID.String(),
if err != nil { UserID: row.UserID,
continue Resource: row.Resource,
} ResourceID: row.ResourceID,
logs = append(logs, log) Action: row.Action,
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})
@@ -302,3 +317,8 @@ 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}
}
+55 -65
View File
@@ -16,6 +16,7 @@ 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"
@@ -669,39 +670,22 @@ func (h *BuildHandler) upsertBuild(status *BuildStatusResponse) error {
} }
now := time.Now().UTC() now := time.Now().UTC()
_, err := h.db.Exec( err := sqlcdb.New(h.db.DB).UpsertBuild(context.Background(), sqlcdb.UpsertBuildParams{
`INSERT INTO builds ID: status.ID,
(id, project_id, service_id, status, progress, started_at, completed_at, image_name, image_tag, size, error, log, metadata, created_at, updated_at) ProjectID: nsNullIfEmpty(status.ProjectID),
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb, $14, $14) ServiceID: nsNullIfEmpty(status.ServiceID),
ON CONFLICT (id) DO UPDATE SET Status: status.Status,
project_id = EXCLUDED.project_id, Progress: int32(status.Progress),
service_id = EXCLUDED.service_id, StartedAt: status.StartedAt,
status = EXCLUDED.status, CompletedAt: ntPtr(status.CompletedAt),
progress = EXCLUDED.progress, ImageName: status.ImageName,
started_at = EXCLUDED.started_at, ImageTag: status.ImageTag,
completed_at = EXCLUDED.completed_at, Size: status.Size,
image_name = EXCLUDED.image_name, Error: nsNullIfEmpty(status.Error),
image_tag = EXCLUDED.image_tag, Log: status.Log,
size = EXCLUDED.size, Column13: json.RawMessage(jsonOrEmptyObject(metadataRaw)),
error = EXCLUDED.error, CreatedAt: now,
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
} }
@@ -713,18 +697,37 @@ func (h *BuildHandler) getBuildFromDB(buildID string) (BuildStatusResponse, bool
return BuildStatusResponse{}, false, nil return BuildStatusResponse{}, false, nil
} }
row := h.db.QueryRow( row, err := sqlcdb.New(h.db.DB).GetBuild(context.Background(), buildID)
`SELECT id, project_id, service_id, status, progress, started_at, completed_at, image_name, image_tag, size, error, log, metadata if err != nil {
FROM builds if errors.Is(err, sql.ErrNoRows) || h.isMissingBuildsTable(err) {
WHERE id = $1`, return BuildStatusResponse{}, false, nil
buildID, }
) return BuildStatusResponse{}, false, err
build, found, err := scanBuildRow(row.Scan)
if err != nil && h.isMissingBuildsTable(err) {
return BuildStatusResponse{}, false, nil
} }
return build, found, err build := BuildStatusResponse{
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) {
@@ -808,19 +811,11 @@ func (h *BuildHandler) cancelBuildInDB(buildID string) bool {
return false return false
} }
now := time.Now().UTC() now := time.Now().UTC()
result, err := h.db.Exec( affected, err := sqlcdb.New(h.db.DB).CancelBuild(context.Background(), sqlcdb.CancelBuildParams{
`UPDATE builds CompletedAt: sql.NullTime{Time: now, Valid: true},
SET status = 'cancelled', Log: h.formatLogLine("Cancellation requested") + "\n",
progress = 100, ID: buildID,
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
@@ -828,9 +823,7 @@ 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
} }
@@ -909,12 +902,9 @@ func scanBuildRow(scan func(dest ...interface{}) error) (BuildStatusResponse, bo
return parsed, true, nil return parsed, true, nil
} }
func nullIfEmptyString(value string) interface{} { func nsNullIfEmpty(value string) sql.NullString {
trimmed := strings.TrimSpace(value) trimmed := strings.TrimSpace(value)
if trimmed == "" { return sql.NullString{String: trimmed, Valid: trimmed != ""}
return nil
}
return trimmed
} }
func jsonOrEmptyObject(raw []byte) string { func jsonOrEmptyObject(raw []byte) string {
+18 -23
View File
@@ -1,9 +1,11 @@
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"
@@ -57,15 +59,12 @@ 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) {
var isApproved bool q := sqlcdb.New(db.DB)
var ownerID string row, err := q.GetProjectReadAccess(context.Background(), projectID)
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
} }
@@ -76,11 +75,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
} }
var member bool userUUID, _ := uuid.Parse(userID)
if err := db.QueryRow( if member, err := q.ProjectMemberExists(context.Background(), sqlcdb.ProjectMemberExistsParams{
`SELECT EXISTS(SELECT 1 FROM project_members WHERE project_id = $1 AND user_id = $2)`, ProjectID: projectID,
projectID, userID, UserID: userUUID,
).Scan(&member); err == nil && member { }); err == nil && member {
return true, true return true, true
} }
return true, false return true, false
@@ -91,11 +90,8 @@ 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) {
var ownerID string q := sqlcdb.New(db.DB)
err := db.QueryRow( ownerID, err := q.GetProjectOwnerText(context.Background(), projectID)
`SELECT owner_id::text FROM projects WHERE id = $1`,
projectID,
).Scan(&ownerID)
if err != nil { if err != nil {
return false, false return false, false
} }
@@ -109,11 +105,11 @@ func projectManageAccess(c *gin.Context, db *database.DB, projectID uuid.UUID) (
if ownerID == userID { if ownerID == userID {
return true, true return true, true
} }
var member bool userUUID, _ := uuid.Parse(userID)
if err := db.QueryRow( if member, err := q.ProjectMemberExists(context.Background(), sqlcdb.ProjectMemberExistsParams{
`SELECT EXISTS(SELECT 1 FROM project_members WHERE project_id = $1 AND user_id = $2)`, ProjectID: projectID,
projectID, userID, UserID: userUUID,
).Scan(&member); err == nil && member { }); err == nil && member {
return true, true return true, true
} }
return true, false return true, false
@@ -121,8 +117,7 @@ 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) {
var projectID uuid.UUID projectID, err := sqlcdb.New(db.DB).GetServiceProjectID(context.Background(), serviceID)
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 err := db.QueryRow(`SELECT project_id FROM environments WHERE id = $1`, envID).Scan(&projectID); err != nil { if projectID, err = sqlcdb.New(db.DB).GetEnvironmentProjectID(context.Background(), envID); err != nil {
respondError(c, http.StatusNotFound, "NOT_FOUND", "Environment not found") respondError(c, http.StatusNotFound, "NOT_FOUND", "Environment not found")
return return
} }
+65 -52
View File
@@ -17,9 +17,11 @@ 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"
) )
@@ -107,23 +109,28 @@ 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) {
var s gatewayService r, err := sqlcdb.New(db.DB).GetAPServiceBySlug(ctx, slug)
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.Enabled = enabled != 0 s := gatewayService{
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) {
@@ -131,26 +138,24 @@ 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 := db.QueryContext(ctx, ` rows, err := sqlcdb.New(db.DB).ListAPKeysByPrefix(ctx, prefix)
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
} }
defer rows.Close() for _, r := range rows {
for rows.Next() { k := gatewayKey{
var k gatewayKey ID: r.ID.String(),
var enabled int Hash: r.KeyHash,
if err := rows.Scan(&k.ID, &k.Hash, &enabled, &k.RPMLimit, &k.MonthlyQuota, &k.AllowedServiceIDs); err != nil { Enabled: r.Enabled != 0,
return nil, err RPMLimit: nullInt32To64(r.RpmLimit),
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, rows.Err() return nil, nil
} }
// keyAllowsService applies the allowed_service_ids JSON allowlist. An empty // keyAllowsService applies the allowed_service_ids JSON allowlist. An empty
@@ -168,48 +173,56 @@ func keyAllowsService(k *gatewayKey, serviceID string) bool {
return false return false
} }
// monthlyUsage sums a counter dimension for the current calendar month. // monthlyKeyUsage / monthlyServiceUsage sum a counter dimension for the
func monthlyUsage(ctx context.Context, db *database.DB, column, id, period string) (int, error) { // current calendar month.
var total int func monthlyKeyUsage(ctx context.Context, db *database.DB, id, period string) (int64, error) {
err := db.QueryRowContext(ctx, ` keyUUID, _ := uuid.Parse(id)
SELECT COALESCE(SUM(request_count), 0) FROM usage_counters return sqlcdb.New(db.DB).SumUsageByKey(ctx, sqlcdb.SumUsageByKeyParams{
WHERE `+column+` = $1 AND period_month = $2 ApiKeyID: keyUUID, PeriodMonth: period,
`, 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) {
_, _ = db.ExecContext(ctx, ` q := sqlcdb.New(db.DB)
INSERT INTO usage_counters (api_key_id, service_id, period_month, request_count, updated_at) keyUUID, _ := uuid.Parse(k.ID)
VALUES ($1, $2, $3, 1, NOW()) svcUUID, _ := uuid.Parse(svc.ID)
ON CONFLICT (api_key_id, service_id, period_month) _ = q.UpsertUsageCounter(ctx, sqlcdb.UpsertUsageCounterParams{
DO UPDATE SET request_count = usage_counters.request_count + 1, updated_at = NOW() ApiKeyID: keyUUID, ServiceID: svcUUID, PeriodMonth: period,
`, k.ID, svc.ID, period) })
_, _ = db.ExecContext(ctx, ` _ = q.TouchAPKeyLastUsed(ctx, keyUUID)
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,
}) })
_, _ = db.ExecContext(ctx, ` _ = q.InsertMetricPoint(ctx, sql.NullString{String: string(labels), Valid: true})
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 interface{} var kid uuid.NullUUID
if keyID != nil { if keyID != nil {
kid = *keyID if u, err := uuid.Parse(*keyID); err == nil {
kid = uuid.NullUUID{UUID: u, Valid: true}
}
} }
_, _ = db.ExecContext(ctx, ` svcUUID, _ := uuid.Parse(svcID)
INSERT INTO incident_events (service_id, api_key_id, code, message, severity, http_status, occurred_at) _ = sqlcdb.New(db.DB).InsertGatewayIncident(ctx, sqlcdb.InsertGatewayIncidentParams{
VALUES ($1, $2, $3, $4, $5, $6, NOW()) ServiceID: uuid.NullUUID{UUID: svcUUID, Valid: svcUUID != uuid.Nil},
`, svcID, kid, code, msg, severity, status) ApiKeyID: kid,
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
@@ -267,13 +280,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 := monthlyUsage(ctx, db, "api_key_id", key.ID, period); err == nil && int64(used) >= key.MonthlyQuota.Int64 { if used, err := monthlyKeyUsage(ctx, db, key.ID, period); err == nil && 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 := monthlyUsage(ctx, db, "service_id", svc.ID, period); err == nil && int64(used) >= svc.MonthlyQuota.Int64 { if used, err := monthlyServiceUsage(ctx, db, svc.ID, period); err == nil && 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
} }
+27 -18
View File
@@ -1,12 +1,16 @@
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
@@ -22,26 +26,31 @@ 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()
var providerID string ownerUUID, _ := uuid.Parse(ownerID)
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.
err = db.QueryRow( row, err := q.GetGitRepoCloneByFullName(ctx, sqlcdb.GetGitRepoCloneByFullNameParams{
`SELECT clone_url, provider_id FROM git_repositories FullName: cloneURL,
WHERE full_name = $1 AND user_id = $2 LIMIT 1`, UserID: ownerUUID,
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.
_ = db.QueryRow( if pid, err := q.GetGitRepoProviderByCloneURL(ctx, sqlcdb.GetGitRepoProviderByCloneURLParams{
`SELECT provider_id FROM git_repositories CloneUrl: cloneURL,
WHERE clone_url = $1 AND user_id = $2 LIMIT 1`, UserID: ownerUUID,
cloneURL, ownerID, }); err == nil {
).Scan(&providerID) providerID = pid
}
} }
if err := source.ValidateRemoteURL(cloneURL); err != nil { if err := source.ValidateRemoteURL(cloneURL); err != nil {
@@ -49,17 +58,17 @@ func resolveGitCloneSpec(db *database.DB, ownerID, gitRepo, branch, commit, buil
} }
spec.CloneURL = cloneURL spec.CloneURL = cloneURL
if providerID == "" { if providerID == uuid.Nil {
return spec, nil return spec, nil
} }
var name, apiURL, token string prov, err := q.GetGitProviderForFetch(ctx, sqlcdb.GetGitProviderForFetchParams{
if err := db.QueryRow( ID: providerID,
`SELECT name, api_url, access_token FROM git_providers UserID: ownerUUID,
WHERE id = $1 AND user_id = $2`, })
providerID, ownerID, if err != nil {
).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,6 +15,7 @@ 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"
@@ -88,8 +89,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,
@@ -206,9 +207,8 @@ 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 err := db.QueryRow(`SELECT value FROM app_settings WHERE key = $1`, settingGitHubAppID).Scan(&stored); err == nil && strings.TrimSpace(stored) != "" { if v, err := sqlcdb.New(db.DB).GetAppSetting(c.Request.Context(), settingGitHubAppID); err == nil && strings.TrimSpace(v) != "" {
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"
+15 -9
View File
@@ -193,8 +193,8 @@ func handleAcceptInvite(c *gin.Context) {
return return
} }
var existing int existing, err := q.CountUsersByEmail(c.Request.Context(), req.Email)
if err := db.QueryRow(`SELECT COUNT(*) FROM users WHERE email = $1`, req.Email).Scan(&existing); err != nil { if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "database error") respondError(c, http.StatusInternalServerError, "INTERNAL", "database error")
return return
} }
@@ -214,17 +214,23 @@ func handleAcceptInvite(c *gin.Context) {
return return
} }
var user User row, err := q.InsertUser(c.Request.Context(), sqlcdb.InsertUserParams{
err = db.QueryRow(` Email: req.Email,
INSERT INTO users (email, password_hash, name) PasswordHash: string(hashedPassword),
VALUES ($1, $2, $3) Name: strings.TrimSpace(req.Name),
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{
+3 -7
View File
@@ -9,6 +9,7 @@ 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"
@@ -60,17 +61,12 @@ func handleGetServiceMetrics(c *gin.Context) {
return return
} }
var projectID uuid.UUID svc, err := sqlcdb.New(db.DB).GetServiceProjectAndStatus(context.Background(), serviceID)
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
+3 -4
View File
@@ -315,10 +315,9 @@ 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{} {
var ownerID string pid, _ := uuid.Parse(projectID)
if err := r.db.QueryRow( ownerID, err := sqlcdb.New(r.db.DB).GetProjectOwnerText(context.Background(), pid)
`SELECT owner_id FROM projects WHERE id = $1`, projectID, if err != nil {
).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,6 +9,7 @@ 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"
@@ -29,15 +30,13 @@ func notifyAdmins(db *database.DB, kind, title, body, resourceType, resourceID s
if db == nil { if db == nil {
return return
} }
rows, err := db.Query(`SELECT id FROM users WHERE is_admin`) adminIDs, err := sqlcdb.New(db.DB).ListAdminUserIDs(context.Background())
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
} }
defer rows.Close() for _, id := range adminIDs {
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)
} }
} }
@@ -73,34 +72,32 @@ 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
rows, err := db.Query( q := sqlcdb.New(db.DB)
`SELECT id, COALESCE(name, hostname, id::text), COALESCE(status, ''), agentRows, err := q.ListAgentHealthRows(context.Background(), sql.NullTime{Time: cutoff, Valid: true})
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 rows.Next() { for _, r := range agentRows {
var a agentHealthRow agents = append(agents, agentHealthRow{
if err := rows.Scan(&a.id, &a.name, &a.status, &a.stale, &a.autoPrune); err == nil { id: r.ID, name: r.Name, status: r.Status, stale: r.Stale, autoPrune: r.AutoPrune,
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 := db.Exec( if err := q.SetAgentStatus(context.Background(), sqlcdb.SetAgentStatusParams{
`UPDATE node_agents SET status = 'offline', updated_at = NOW() WHERE id = $1`, a.id); err != nil { Status: sql.NullString{String: "offline", Valid: true}, ID: a.id,
}); 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 := db.Exec( if err := q.SetAgentStatus(context.Background(), sqlcdb.SetAgentStatusParams{
`UPDATE node_agents SET status = 'online', updated_at = NOW() WHERE id = $1`, a.id); err != nil { Status: sql.NullString{String: "online", Valid: true}, ID: a.id,
}); err != nil {
continue continue
} }
notifyAdmins(db, "agent", "Node agent back online", notifyAdmins(db, "agent", "Node agent back online",
@@ -186,17 +183,17 @@ func checkForUpgrade(db *database.DB, currentVersion string) {
return return
} }
var lastNotified string lastNotified, _ := sqlcdb.New(db.DB).GetAppSetting(context.Background(), "upgrade_notified_tag")
_ = 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)
_, _ = db.Exec( _ = sqlcdb.New(db.DB).UpsertAppSettingValue(context.Background(), sqlcdb.UpsertAppSettingValueParams{
`INSERT INTO app_settings (key, value, updated_at) VALUES ('upgrade_notified_tag', $1, NOW()) Key: "upgrade_notified_tag",
ON CONFLICT (key) DO UPDATE SET value = $1, updated_at = NOW()`, release.TagName) Value: release.TagName,
})
} }
// semverNewer reports whether candidate is a higher semver than current. // semverNewer reports whether candidate is a higher semver than current.
+40 -69
View File
@@ -1,13 +1,16 @@
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
@@ -55,91 +58,59 @@ func handleGetOperations(c *gin.Context) {
db := c.MustGet("db").(*database.DB) db := c.MustGet("db").(*database.DB)
isAdmin := contextIsAdmin(c) isAdmin := contextIsAdmin(c)
// Same visibility rule as the deployment list endpoint. userUUID, _ := uuid.Parse(userID)
visibility := `p.is_approved OR p.owner_id = $1 OR $2::bool q := sqlcdb.New(db.DB)
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1)` visibility := sqlcdb.ListActiveOperationDeploymentsParams{OwnerID: userUUID, Column2: isAdmin}
active := []operationDeployment{} active := []operationDeployment{}
rows, err := db.Query( if rows, err := q.ListActiveOperationDeployments(context.Background(), visibility); err == nil {
`SELECT d.id, d.service_id, s.name, p.name, d.status, for _, r := range rows {
COALESCE(d.image_name, ''), d.error, d.started_at, d.completed_at, d.created_at active = append(active, operationDeployment{
FROM deployments d ID: r.ID.String(), ServiceID: r.ServiceID.String(), ServiceName: r.ServiceName,
JOIN services s ON s.id = d.service_id ProjectName: r.ProjectName, Status: r.Status.String, Image: r.Image,
JOIN projects p ON p.id = s.project_id Error: strPtrNS(r.Error), StartedAt: timePtrNT(r.StartedAt),
WHERE d.status IN ('queued','pending','building','deploying','rolling_back') CompletedAt: timePtrNT(r.CompletedAt), CreatedAt: r.CreatedAt.Time,
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{}
rows2, err2 := db.Query( if rows, err := q.ListRecentFailedDeployments(context.Background(), sqlcdb.ListRecentFailedDeploymentsParams{
`SELECT d.id, d.service_id, s.name, p.name, d.status, OwnerID: userUUID, Column2: isAdmin,
COALESCE(d.image_name, ''), d.error, d.started_at, d.completed_at, d.created_at }); err == nil {
FROM deployments d for _, r := range rows {
JOIN services s ON s.id = d.service_id recentFailed = append(recentFailed, operationDeployment{
JOIN projects p ON p.id = s.project_id ID: r.ID.String(), ServiceID: r.ServiceID.String(), ServiceName: r.ServiceName,
WHERE d.status IN ('failed','cancelled','rolled_back') ProjectName: r.ProjectName, Status: r.Status.String, Image: r.Image,
AND d.updated_at > NOW() - INTERVAL '24 hours' Error: strPtrNS(r.Error), StartedAt: timePtrNT(r.StartedAt),
AND (`+visibility+`) CompletedAt: timePtrNT(r.CompletedAt), CreatedAt: r.CreatedAt.Time,
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{}
rows3, err3 := db.Query( if rows, err := q.ListRecentCronRuns(context.Background(), sqlcdb.ListRecentCronRunsParams{
`SELECT e.id, j.name, j.schedule, e.status, e.started_at, e.finished_at, e.error OwnerID: userUUID, Column2: isAdmin,
FROM cron_executions e }); err == nil {
JOIN cron_jobs j ON j.id = e.cron_job_id for _, r := range rows {
JOIN projects p ON p.id = j.project_id cronRuns = append(cronRuns, operationCronRun{
WHERE e.started_at > NOW() - INTERVAL '24 hours' ID: r.ID.String(), JobName: r.JobName, Schedule: r.Schedule, Status: r.Status.String,
AND (`+visibility+`) StartedAt: r.StartedAt, FinishedAt: timePtrNT(r.FinishedAt), Error: strPtrNS(r.Error),
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{}
rows4, err4 := db.Query( if rows, err := q.ListRecentOperationBackups(context.Background(), sqlcdb.ListRecentOperationBackupsParams{
`SELECT b.id, b.database_id, ds.name, b.status, b.size, b.created_at, b.completed_at UserID: userID, Column2: isAdmin,
FROM database_backups b }); err == nil {
JOIN database_services ds ON ds.id = b.database_id for _, r := range rows {
WHERE b.created_at > NOW() - INTERVAL '24 hours' backups = append(backups, operationBackup{
AND (ds.user_id = $1 OR $2::bool) ID: r.ID, DatabaseID: r.DatabaseID, DBName: r.DbName, Status: r.Status,
ORDER BY b.created_at DESC Size: r.Size, CreatedAt: r.CreatedAt.Time, Completed: timePtrNT(r.CompletedAt),
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()
+17 -11
View File
@@ -1,10 +1,12 @@
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"
@@ -112,7 +114,8 @@ func handleUpdateProjectVariables(c *gin.Context) {
} }
defer tx.Rollback() defer tx.Rollback()
if _, err = tx.Exec(`DELETE FROM project_variables WHERE project_id = $1`, projectID); err != nil { txq := sqlcdb.New(tx)
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
} }
@@ -127,11 +130,15 @@ func handleUpdateProjectVariables(c *gin.Context) {
if v.IsSecret { if v.IsSecret {
value = secrets.Encrypt(value) value = secrets.Encrypt(value)
} }
if _, err = tx.Exec( if err = txq.InsertProjectVariable(c.Request.Context(), sqlcdb.InsertProjectVariableParams{
`INSERT INTO project_variables (id, project_id, key, value, is_secret, created_at, updated_at) ID: uuid.New(),
VALUES ($1, $2, $3, $4, $5, $6, $7)`, ProjectID: projectID,
uuid.New(), projectID, v.Key, value, v.IsSecret, now, now, Key: v.Key,
); err != nil { Value: value,
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
} }
@@ -168,11 +175,10 @@ 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) {
var value string value, err := sqlcdb.New(db.DB).GetProjectVariableValue(context.Background(), sqlcdb.GetProjectVariableValueParams{
err := db.QueryRow( ProjectID: projectID,
`SELECT value FROM project_variables WHERE project_id = $1 AND key = $2`, Key: key,
projectID, key, })
).Scan(&value)
if err != nil { if err != nil {
return "", false return "", false
} }
+9 -7
View File
@@ -1,11 +1,13 @@
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"
@@ -177,17 +179,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)
var username, password string ownerUUID, _ := uuid.Parse(ownerID)
err := db.QueryRow( row, err := sqlcdb.New(db.DB).GetRegistryAuth(context.Background(), sqlcdb.GetRegistryAuthParams{
`SELECT username, password FROM registries WHERE owner_id = $1 AND host = $2`, OwnerID: ownerUUID,
ownerID, host, Host: host,
).Scan(&username, &password) })
if err != nil { if err != nil {
return registry.AuthConfig{} return registry.AuthConfig{}
} }
return registry.AuthConfig{ return registry.AuthConfig{
Username: username, Username: row.Username,
Password: secrets.Decrypt(password), Password: secrets.Decrypt(row.Password),
ServerAddress: host, ServerAddress: host,
} }
} }
+23 -28
View File
@@ -11,6 +11,7 @@ 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"
@@ -21,6 +22,7 @@ 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) {
@@ -83,19 +85,18 @@ 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) {
var name, projectID string sid, err := uuid.Parse(serviceID)
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
} }
if err := docker.ConnectSelfToNetwork(ctx, deployment.ProjectNetworkName(projectID)); err != nil { svc, err := sqlcdb.New(db.DB).GetServiceNameProjectPort(ctx, sid)
if err != nil {
return "", 0, err return "", 0, err
} }
return name, port, nil if err := docker.ConnectSelfToNetwork(ctx, deployment.ProjectNetworkName(svc.ProjectID)); err != nil {
return "", 0, err
}
return svc.Name, int(svc.Port), nil
}) })
} }
haAPIManager := NewHAManager(haManager) haAPIManager := NewHAManager(haManager)
@@ -137,31 +138,25 @@ 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.
_, _ = db.Exec( bootQ := sqlcdb.New(db.DB)
`UPDATE deployments SET status = 'failed', error = 'Interrupted by server restart', completed_at = NOW(), updated_at = NOW() _ = bootQ.FailInterruptedDeployments(context.Background())
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 := db.Query(`SELECT id, value FROM environment_variables WHERE is_secret = TRUE`); err == nil { if encRows, err := bootQ.ListSecretEnvVars(context.Background()); err == nil {
type encRow struct { toEncrypt := 0
id string for _, r := range encRows {
value string if secrets.IsEncrypted(r.Value) {
} continue
var toEncrypt []encRow }
for encRows.Next() { if err := bootQ.UpdateEnvVarValue(context.Background(), sqlcdb.UpdateEnvVarValueParams{
var r encRow Value: secrets.Encrypt(r.Value), ID: r.ID,
if err := encRows.Scan(&r.id, &r.value); err == nil && !secrets.IsEncrypted(r.value) { }); err == nil {
toEncrypt = append(toEncrypt, r) toEncrypt++
} }
} }
encRows.Close() if toEncrypt > 0 {
for _, r := range toEncrypt { log.Printf("secrets: encrypted %d plaintext secret variable(s)", 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))
} }
} }
+22 -29
View File
@@ -2,6 +2,7 @@ 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"
@@ -70,20 +71,18 @@ 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) {
var idle int row, err := sqlcdb.New(db.DB).GetServiceSleep(context.Background(), s.ID)
if err := db.QueryRow( if err == nil {
`SELECT COALESCE(sleep_enabled, false), COALESCE(sleep_idle_minutes, 15) FROM services WHERE id = $1`, s.SleepEnabled = row.SleepEnabled
s.ID, s.SleepIdleMinutes = int(row.SleepIdleMinutes)
).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 {
var id sql.NullString id, err := sqlcdb.New(db.DB).GetServiceNodeID(context.Background(), serviceID)
if err := db.QueryRow(`SELECT node_id FROM services WHERE id = $1`, serviceID).Scan(&id); err == nil { if err == nil {
return id.String return id.String
} }
return "" return ""
@@ -91,17 +90,14 @@ 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 {
var spread bool spread, err := sqlcdb.New(db.DB).GetServiceSpread(context.Background(), serviceID)
if err := db.QueryRow(`SELECT COALESCE(spread, false) FROM services WHERE id = $1`, serviceID).Scan(&spread); err == nil { return err == nil && spread
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 {
var raw []byte raw, err := sqlcdb.New(db.DB).GetServicePlacementTags(context.Background(), serviceID)
if err := db.QueryRow(`SELECT placement_tags FROM services WHERE id = $1`, serviceID).Scan(&raw); err != nil { if err != nil {
return nil return nil
} }
var tags []string var tags []string
@@ -179,8 +175,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 {
var raw []byte raw, err := sqlcdb.New(db.DB).GetServiceTraefikLabels(context.Background(), serviceID)
if err := db.QueryRow(`SELECT traefik_labels FROM services WHERE id = $1`, serviceID).Scan(&raw); err != nil { if err != nil {
return nil return nil
} }
var labels map[string]string var labels map[string]string
@@ -211,7 +207,6 @@ 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)
@@ -219,8 +214,8 @@ func loadServiceNode(db *database.DB, s *Service) {
if s.NodeID == "" { if s.NodeID == "" {
return return
} }
if err := db.QueryRow(`SELECT name FROM node_agents WHERE id = $1`, s.NodeID).Scan(&name); err == nil { if name, err := sqlcdb.New(db.DB).GetNodeAgentName(context.Background(), s.NodeID); err == nil {
s.NodeName = name.String s.NodeName = name
} }
} }
@@ -235,8 +230,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 {
var raw []byte raw, err := sqlcdb.New(db.DB).GetServiceVolumes(context.Background(), serviceID)
if err := db.QueryRow(`SELECT volumes FROM services WHERE id = $1`, serviceID).Scan(&raw); err != nil { if err != nil {
return nil return nil
} }
var stored []ServiceVolume var stored []ServiceVolume
@@ -703,13 +698,11 @@ 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) {
var environmentID uuid.UUID row, err := sqlcdb.New(db.DB).GetProjectEnvironmentByName(context.Background(), sqlcdb.GetProjectEnvironmentByNameParams{
err := db.QueryRow( ProjectID: projectID,
"SELECT id FROM environments WHERE project_id = $1 AND name = $2", Name: environment,
projectID, })
environment, return row.ID, err
).Scan(&environmentID)
return environmentID, err
} }
func inferServiceSourceType(service Service) string { func inferServiceSourceType(service Service) string {
+14 -16
View File
@@ -10,11 +10,13 @@ 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
@@ -70,8 +72,7 @@ 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 {
var value string value, err := sqlcdb.New(db.DB).GetAppSetting(context.Background(), key)
err := db.QueryRow(`SELECT value FROM app_settings WHERE key = $1`, key).Scan(&value)
if err == nil { if err == nil {
return value return value
} }
@@ -88,18 +89,15 @@ 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 {
_, err := db.Exec(` return sqlcdb.New(db.DB).UpsertAppSetting(context.Background(), sqlcdb.UpsertAppSettingParams{
INSERT INTO app_settings (key, value, is_secret, updated_at) Key: key,
VALUES ($1, $2, $3, NOW()) Value: value,
ON CONFLICT (key) DO UPDATE IsSecret: secret,
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 {
_, err := db.Exec(`DELETE FROM app_settings WHERE key = $1`, key) return sqlcdb.New(db.DB).DeleteAppSetting(context.Background(), key)
return err
} }
// signupEnabled controls whether public registration stays open after the // signupEnabled controls whether public registration stays open after the
@@ -110,11 +108,12 @@ func signupEnabled(db *database.DB) bool {
} }
func isAdminUser(db *database.DB, userID string) bool { func isAdminUser(db *database.DB, userID string) bool {
var isAdmin bool parsed, err := uuid.Parse(userID)
if err := db.QueryRow(`SELECT is_admin FROM users WHERE id = $1`, userID).Scan(&isAdmin); err != nil { if err != nil {
return false return false
} }
return isAdmin isAdmin, err := sqlcdb.New(db.DB).GetUserIsAdmin(context.Background(), parsed)
return err == nil && isAdmin
} }
func requireAdmin(c *gin.Context) (*database.DB, bool) { func requireAdmin(c *gin.Context) (*database.DB, bool) {
@@ -242,8 +241,7 @@ 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 {
var value string if value, err := sqlcdb.New(db.DB).GetAppSetting(context.Background(), settingCloudflareTunnelToken); err == nil && strings.TrimSpace(value) != "" {
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"
} }
} }
+3 -2
View File
@@ -5,10 +5,12 @@ 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"
) )
@@ -25,8 +27,7 @@ func setupComplete(db *database.DB, hasUsers bool) bool {
if !hasUsers { if !hasUsers {
return false return false
} }
var projects int if projects, err := sqlcdb.New(db.DB).CountProjects(context.Background()); err == nil && projects > 0 {
if err := db.QueryRow(`SELECT COUNT(*) FROM projects`).Scan(&projects); err == nil && projects > 0 {
return true return true
} }
return false return false
+33 -18
View File
@@ -640,19 +640,20 @@ 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)
} }
_, _ = db.Exec(`DELETE FROM environment_variables WHERE service_id = $1`, sid) _ = q.DeleteEnvVarsForService(context.Background(), sid)
_, _ = db.Exec(`DELETE FROM services WHERE id = $1`, sid) _ = q.DeleteServiceByID(context.Background(), 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)
} }
_, _ = db.Exec(`DELETE FROM database_services WHERE id = $1`, did) _ = q.DeleteDatabaseServiceByID(context.Background(), did)
} }
} }
@@ -772,14 +773,19 @@ func deployTemplateGraph(
if builder == "" { if builder == "" {
builder = "auto" builder = "auto"
} }
if _, err = tx.ExecContext(ctx, var volumesRaw json.RawMessage
`UPDATE services SET port = $1, healthcheck_path = $2, volumes = COALESCE($3::jsonb, '[]'::jsonb), if s, ok := volumesJSON.(string); ok {
git_repo = NULLIF($4, ''), git_branch = NULLIF($5, ''), builder = $6 volumesRaw = json.RawMessage(s)
WHERE id = $7`, }
m.spec.Port, strings.TrimSpace(m.spec.HealthCheck), volumesJSON, if err = sqlcdb.New(tx).UpdateServiceDeployMeta(ctx, sqlcdb.UpdateServiceDeployMetaParams{
strings.TrimSpace(m.spec.Repo), strings.TrimSpace(m.spec.Branch), builder, Port: int32(m.spec.Port),
serviceID, HealthcheckPath: strings.TrimSpace(m.spec.HealthCheck),
); err != nil { Column3: volumesRaw,
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"}
@@ -861,15 +867,24 @@ func queueTemplateDeployment(c *gin.Context, db *database.DB, engine *deployment
CreatedAt: now, CreatedAt: now,
UpdatedAt: now, UpdatedAt: now,
} }
if _, err := db.Exec( q := sqlcdb.New(db.DB)
`INSERT INTO deployments if err := q.InsertDeployment(context.Background(), sqlcdb.InsertDeploymentParams{
(id, service_id, version, commit_hash, status, image_name, image_tag, created_at, updated_at) ID: dep.ID,
VALUES ($1, $2, $3, NULL, $4, '', '', $5, $6)`, ServiceID: dep.ServiceID,
dep.ID, dep.ServiceID, fmt.Sprintf("v%d", now.Unix()), dep.Status, dep.CreatedAt, dep.UpdatedAt, Version: fmt.Sprintf("v%d", now.Unix()),
); err != nil { Status: sql.NullString{String: dep.Status, Valid: true},
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
} }
_, _ = db.Exec(`UPDATE services SET status = 'queued', updated_at = $1 WHERE id = $2`, time.Now(), service.ID) _ = q.SetServiceStatus(context.Background(), sqlcdb.SetServiceStatusParams{
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,
+5 -6
View File
@@ -411,12 +411,11 @@ func handleCreateFromTemplate(c *gin.Context) {
return return
} }
// sqlc's schema predates the runtime columns; port and healthcheck_path are if err = sqlcdb.New(tx).UpdateServiceRuntimeMeta(ctx, sqlcdb.UpdateServiceRuntimeMetaParams{
// written via raw SQL like every other service mutation path. Port: int32(config.Port),
if _, err = tx.ExecContext(ctx, HealthcheckPath: strings.TrimSpace(config.HealthCheck),
`UPDATE services SET port = $1, healthcheck_path = $2 WHERE id = $3`, ID: serviceID,
config.Port, strings.TrimSpace(config.HealthCheck), serviceID, }); err != nil {
); 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
} }
+16 -9
View File
@@ -1,11 +1,14 @@
package api package api
import ( import (
"containr/internal/database" "database/sql"
"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"
) )
@@ -147,7 +150,8 @@ func handleUpdateVariables(c *gin.Context) {
} }
defer tx.Rollback() defer tx.Rollback()
_, err = tx.Exec("DELETE FROM environment_variables WHERE service_id = $1", serviceID) txq := sqlcdb.New(tx)
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
@@ -164,12 +168,15 @@ func handleUpdateVariables(c *gin.Context) {
if v.IsSecret { if v.IsSecret {
value = secrets.Encrypt(value) value = secrets.Encrypt(value)
} }
varID := uuid.New() err = txq.InsertEnvVar(c.Request.Context(), sqlcdb.InsertEnvVarParams{
_, err = tx.Exec( ID: uuid.New(),
`INSERT INTO environment_variables (id, service_id, key, value, is_secret, created_at, updated_at) ServiceID: serviceID,
VALUES ($1, $2, $3, $4, $5, $6, $7)`, Key: v.Key,
varID, serviceID, v.Key, value, v.IsSecret, now, now, Value: value,
) 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
@@ -0,0 +1,636 @@
// 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
}
@@ -0,0 +1,218 @@
// 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,6 +34,24 @@ 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
@@ -168,6 +186,69 @@ 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)
@@ -185,6 +266,27 @@ 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)
@@ -0,0 +1,138 @@
// 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,6 +157,28 @@ 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
` `
@@ -174,6 +196,23 @@ 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,
@@ -0,0 +1,174 @@
// 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,6 +49,66 @@ 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"`
@@ -78,6 +138,24 @@ 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"`
@@ -262,6 +340,18 @@ 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"`
@@ -287,6 +377,14 @@ 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"`
@@ -408,6 +506,16 @@ 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"`
@@ -535,6 +643,15 @@ 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"`
@@ -0,0 +1,258 @@
// 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,6 +16,7 @@ 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)
@@ -25,6 +26,8 @@ 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)
@@ -32,6 +35,7 @@ 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)
@@ -63,30 +67,39 @@ 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)
@@ -103,12 +116,15 @@ 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)
@@ -116,6 +132,7 @@ 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)
@@ -130,7 +147,10 @@ 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)
@@ -139,39 +159,67 @@ 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)
@@ -187,6 +235,7 @@ 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)
@@ -203,12 +252,18 @@ 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)
@@ -221,6 +276,7 @@ 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)
@@ -236,14 +292,21 @@ 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
@@ -260,8 +323,14 @@ 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
@@ -282,14 +351,21 @@ 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
@@ -300,6 +376,7 @@ 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)
@@ -0,0 +1,308 @@
// 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
}
@@ -0,0 +1,103 @@
// 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
@@ -0,0 +1,125 @@
-- 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
@@ -0,0 +1,28 @@
-- 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,3 +35,23 @@ 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
@@ -0,0 +1,33 @@
-- 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,3 +127,11 @@ 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
@@ -0,0 +1,29 @@
-- 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
@@ -0,0 +1,43 @@
-- 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;
@@ -0,0 +1,62 @@
-- 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
@@ -0,0 +1,21 @@
-- 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,6 +144,17 @@ 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,
@@ -590,3 +601,112 @@ 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,6 +5,12 @@
<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="#cdf06f"/> <stop offset="0" stop-color="#56c9f8"/>
<stop offset="1" stop-color="#7fb524"/> <stop offset="1" stop-color="#0e86c9"/>
</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="#131507" stroke-width="4.2" stroke-linecap="round" stroke-linejoin="round"> <g fill="none" stroke="#061018" 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

+66 -54
View File
@@ -1,39 +1,50 @@
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();
@@ -92,7 +103,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="text-lg font-semibold text-[var(--text-primary)]">Admin access required</h1> <h1 className="font-headline 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>
@@ -115,7 +126,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="text-lg font-semibold text-[var(--text-primary)]">Page not found</h1> <h1 className="font-headline 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>
@@ -135,43 +146,44 @@ export default function App() {
return ( return (
<ErrorBoundary> <ErrorBoundary>
<Routes> <Routes>
<Route path="/" element={<LandingPage />} /> <Route path="/landing" element={<Page><LandingPage /></Page>} />
<Route path="/auth/sign-in" element={<SignInPage />} /> <Route path="/auth/sign-in" element={<Page><SignInPage /></Page>} />
<Route path="/auth/sign-up" element={<SignUpPage />} /> <Route path="/auth/sign-up" element={<Page><SignUpPage /></Page>} />
<Route path="/auth/accept-invite" element={<AcceptInvitePage />} /> <Route path="/auth/accept-invite" element={<Page><AcceptInvitePage /></Page>} />
<Route path="/setup" element={<SetupWizardPage />} /> <Route path="/setup" element={<Page><SetupWizardPage /></Page>} />
{/* 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={<ProjectWorkspacePage />} /> <Route path="/projects/:projectId" element={<Page><ProjectWorkspacePage /></Page>} />
<Route path="/projects/:projectId/services/:serviceId" element={<ServiceDetailPage />} /> <Route path="/projects/:projectId/services/:serviceId" element={<Page><ServiceDetailPage /></Page>} />
<Route element={<PlatformShell />}> <Route element={<PlatformShell />}>
<Route path="/projects" element={<ProjectsPage />} /> <Route path="/" element={<Page><DashboardPage /></Page>} />
<Route path="/templates" element={<TemplatesPage />} /> <Route path="/projects" element={<Page><ProjectsPage /></Page>} />
<Route path="/databases" element={<DatabasesPage />} /> <Route path="/templates" element={<Page><TemplatesPage /></Page>} />
<Route path="/ha" element={<HighAvailabilityPage />} /> <Route path="/databases" element={<Page><DatabasesPage /></Page>} />
<Route path="/operations" element={<OperationsPage />} /> <Route path="/ha" element={<Page><HighAvailabilityPage /></Page>} />
<Route path="/activity" element={<ActivityPage />} /> <Route path="/operations" element={<Page><OperationsPage /></Page>} />
<Route path="/security" element={<SecurityPage />} /> <Route path="/activity" element={<Page><ActivityPage /></Page>} />
<Route path="/usage" element={<UsagePage />} /> <Route path="/security" element={<Page><SecurityPage /></Page>} />
<Route path="/docs" element={<DocsPage />} /> <Route path="/usage" element={<Page><UsagePage /></Page>} />
<Route path="/docs" element={<Page><DocsPage /></Page>} />
{/* Authenticated areas */} {/* Authenticated areas */}
<Route element={<AuthRequired />}> <Route element={<AuthRequired />}>
<Route path="/builds" element={<BuildsPage />} /> <Route path="/builds" element={<Page><BuildsPage /></Page>} />
<Route path="/people" element={<PeoplePage />} /> <Route path="/people" element={<Page><PeoplePage /></Page>} />
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<Page><SettingsPage /></Page>} />
<Route path="/git/github-app/callback" element={<GitHubAppCallbackPage />} /> <Route path="/git/github-app/callback" element={<Page><GitHubAppCallbackPage /></Page>} />
<Route path="/settings/audit-logs" element={<AuditLogsPage />} /> <Route path="/settings/audit-logs" element={<Page><AuditLogsPage /></Page>} />
<Route path="/settings/webhooks" element={<WebhooksPage />} /> <Route path="/settings/webhooks" element={<Page><WebhooksPage /></Page>} />
<Route path="/showcase" element={<ComponentShowcase />} /> <Route path="/showcase" element={<Page><ComponentShowcase /></Page>} />
</Route> </Route>
{/* Platform owner only */} {/* Platform owner only */}
<Route element={<AdminRequired />}> <Route element={<AdminRequired />}>
<Route path="/admin" element={<AdminPage />} /> <Route path="/admin" element={<Page><AdminPage /></Page>} />
<Route path="/nodes/:id" element={<NodeDetailPage />} /> <Route path="/nodes/:id" element={<Page><NodeDetailPage /></Page>} />
</Route> </Route>
<Route path="*" element={<NotFoundPage />} /> <Route path="*" element={<NotFoundPage />} />
@@ -179,7 +191,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={<ServiceMetricsDashboard />} /> <Route path="/metrics-demo" element={<Page><ServiceMetricsDashboard /></Page>} />
</Route> </Route>
</Routes> </Routes>
</ErrorBoundary> </ErrorBoundary>
+141 -119
View File
@@ -9,11 +9,13 @@ 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,
@@ -41,22 +43,26 @@ 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, useToast } from '@/shared/components'; import { BannerBar, BrandWordmark, CommandPalette, 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: 'Build', title: 'Platform',
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' },
], ],
}, },
{ {
@@ -64,7 +70,6 @@ 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' },
@@ -164,6 +169,18 @@ 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);
@@ -234,8 +251,19 @@ export function PlatformShell() {
} }
}; };
const isActiveRoute = (itemHref: string) => // Longest matching href wins so /settings/audit-logs lights only
location.pathname === itemHref || location.pathname.startsWith(`${itemHref}/`); // "Audit Logs", not "Settings" as well.
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';
@@ -246,14 +274,20 @@ export function PlatformShell() {
.slice(0, 2) .slice(0, 2)
.toUpperCase() || 'C'; .toUpperCase() || 'C';
const avatar = ( const paletteNavigate = (target: string) => {
<div navigate(href(target));
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.
{initials} const paletteProjectsQuery = useQuery({
</div> queryKey: ['palette-projects'],
); 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 />;
@@ -261,40 +295,46 @@ 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="hidden h-screen shrink-0 flex-col border-r py-4 transition-[width] duration-200 md:flex" className="shell-side hidden h-screen shrink-0 flex-col md:flex"
style={{ style={{ width: sidebarExpanded ? '252px' : '64px' }}
width: sidebarExpanded ? '218px' : '62px',
background: 'var(--bg-base)',
borderRightColor: 'var(--border-subtle)',
}}
> >
<div className={`flex items-center ${sidebarExpanded ? 'justify-between px-3' : 'flex-col items-center gap-2'}`}> <div
<NavLink to={href('/projects')} title="Containr"> className={`flex items-center ${
<img src="/containr.svg" alt="Containr" className="h-[38px] w-[38px] rounded-xl" /> sidebarExpanded ? 'gap-2.5 px-[18px] pb-5 pt-4' : 'flex-col gap-3 px-2 pb-4 pt-4'
}`}
>
<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="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)]" className={`shell-collapse flex items-center justify-center ${
sidebarExpanded ? 'ml-auto h-[22px] w-[22px]' : 'h-7 w-7'
}`}
> >
{sidebarExpanded ? <ChevronsLeft size={15} /> : <PanelLeft size={15} />} {sidebarExpanded ? <ChevronsLeft size={14} /> : <PanelLeft size={15} />}
</button> </button>
</div> </div>
<nav className="mt-3 flex flex-1 flex-col gap-4 overflow-y-auto px-2"> <nav className={`flex flex-1 flex-col overflow-y-auto ${sidebarExpanded ? 'px-3' : 'gap-4 px-2'}`}>
{visibleNavSections.map((section) => ( {visibleNavSections.map((section, si) => (
<div key={section.title}> <div key={section.title} className={si > 0 && sidebarExpanded ? 'mt-[26px]' : ''}>
{sidebarExpanded ? ( {sidebarExpanded ? (
<p className="mb-1 px-2 text-[10px] font-semibold uppercase tracking-[0.14em] text-[var(--text-tertiary)]"> <p className="shell-nav-group">{section.title}</p>
{section.title}
</p>
) : null} ) : null}
<div className={`flex flex-col gap-0.5 ${sidebarExpanded ? '' : 'items-center'}`}> <div className={`flex flex-col ${sidebarExpanded ? '' : 'items-center gap-1'}`}>
{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);
@@ -303,27 +343,12 @@ 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={`relative flex items-center rounded-[11px] transition-colors ${ className={`shell-ni ${isActive ? 'on' : ''} ${
sidebarExpanded ? 'gap-2.5 px-2 py-2' : 'h-10 w-10 justify-center' sidebarExpanded ? '' : 'h-10 w-10 justify-center !px-0'
} ${
isActive
? 'bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]'
: 'text-[var(--text-tertiary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-secondary)]'
}`} }`}
> >
{isActive ? ( <Icon size={16} className="shrink-0" />
<span {sidebarExpanded ? <span className="truncate">{item.label}</span> : null}
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>
); );
})} })}
@@ -332,23 +357,26 @@ export function PlatformShell() {
))} ))}
</nav> </nav>
<div className={`flex flex-col gap-2 px-2 pt-2 ${sidebarExpanded ? '' : 'items-center'}`}> <div className={`mt-auto ${sidebarExpanded ? 'px-3 pb-3.5' : 'flex justify-center px-2 pb-3'}`}>
{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={`flex items-center gap-2.5 rounded-[11px] text-left transition-colors hover:bg-[var(--surface-muted)] ${ className={`shell-user ${
sidebarExpanded ? 'w-full px-2 py-2' : 'h-10 w-10 justify-center' sidebarExpanded ? 'w-full' : 'h-10 w-10 justify-center border-transparent bg-transparent'
}`} }`}
> >
{avatar} <span className="shell-avatar">{initials}</span>
{sidebarExpanded ? ( {sidebarExpanded ? (
<span className="min-w-0 flex-1"> <>
<span className="block truncate text-[12.5px] font-medium text-[var(--text-primary)]">{userName}</span> <span className="min-w-0 flex-1 text-left">
<span className="block truncate text-[10.5px] text-[var(--text-tertiary)]">{userEmail}</span> <span className="block truncate text-[13px] font-medium text-[var(--text-primary)]">{userName}</span>
</span> <span className="block truncate text-[10.5px] text-[var(--text-tertiary)]">{userEmail}</span>
</span>
<ChevronsUpDown size={13} className="shrink-0 text-[var(--text-tertiary)]" />
</>
) : null} ) : null}
</button> </button>
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
@@ -427,12 +455,12 @@ export function PlatformShell() {
<NavLink <NavLink
to="/auth/sign-in" to="/auth/sign-in"
title="Sign in" title="Sign in"
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)] ${ className={`shell-ni ${
sidebarExpanded ? 'w-full px-2 py-2' : 'h-10 w-10 justify-center' sidebarExpanded ? 'w-full' : 'h-10 w-10 justify-center !px-0'
}`} }`}
> >
<LogIn size={16} className="shrink-0" /> <LogIn size={16} className="shrink-0" />
{sidebarExpanded ? <span className="text-[12.5px] font-medium">Sign in</span> : null} {sidebarExpanded ? <span className="text-[13px] font-medium">Sign in</span> : null}
</NavLink> </NavLink>
)} )}
</div> </div>
@@ -440,71 +468,46 @@ 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 />
<header {/* ── Topbar — search left, utility icons right ── */}
className="hidden h-[46px] shrink-0 items-center border-b px-5 md:flex" <header className="shell-top hidden h-[56px] shrink-0 items-center gap-3.5 md:flex">
style={{ <button
background: 'var(--bg-base)', type="button"
borderBottomColor: 'var(--border-subtle)', onClick={() => setPaletteOpen(true)}
fontFamily: "'JetBrains Mono', monospace", className="shell-search"
fontSize: '11px', >
}} <Search size={13} className="shrink-0" />
> Search anything
<NavLink to={href('/projects')} className="mr-5 text-sm font-extrabold tracking-tight text-[var(--text-primary)]" style={{ fontFamily: 'Inter, sans-serif' }}> <kbd>⌘ K</kbd>
<BrandWordmark /> </button>
</NavLink>
<div className="flex items-center gap-5"> <span className="v-env">{isDemoMode ? 'env:demo' : `env:${import.meta.env.MODE}`}</span>
<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="search-box ml-6 max-w-[300px]" style={{ height: 30 }}> <div className="flex-1" />
<Search size={13} />
<input type="text" placeholder="search…" className="v-mono" style={{ fontSize: 11.5 }} />
</div>
<div className="ml-auto flex items-center gap-4"> {isAdmin && !isDemoMode ? (
<span className="v-env">{isDemoMode ? 'env:demo' : 'env:production'}</span>
<button <button
type="button" type="button"
onClick={() => setTheme((current) => (current === 'dark' ? 'light' : 'dark'))} onClick={() => pullMutation.mutate()}
className="flex h-8 w-8 items-center justify-center rounded-[var(--radius-md)] border text-[var(--text-secondary)] hover:text-[var(--text-primary)]" disabled={pullMutation.isPending || !upgradeQuery.data?.imageRef}
style={{ borderColor: 'var(--border-subtle)' }} className="s-btn-accent"
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'} title={upgradeQuery.data?.message || 'Pull latest configured image'}
> >
{theme === 'dark' ? <Sun size={15} /> : <Moon size={15} />} {pullMutation.isPending ? <RefreshCw size={13} className="animate-spin" /> : <UploadCloud size={13} />}
Upgrade
</button> </button>
) : null}
{isAdmin && !isDemoMode ? ( {signedIn && !isDemoMode ? (
<div className="relative">
<button <button
type="button" type="button"
onClick={() => pullMutation.mutate()} onClick={() => setNotificationsOpen((open) => !open)}
disabled={pullMutation.isPending || !upgradeQuery.data?.imageRef} className="shell-tic relative"
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" aria-label="Notifications"
style={{ background: 'var(--accent-primary)' }}
title={upgradeQuery.data?.message || 'Pull latest configured image'}
> >
{pullMutation.isPending ? <RefreshCw size={14} className="animate-spin" /> : <UploadCloud size={14} />} <Bell size={16} />
Upgrade {unreadCount > 0 ? <span className="shell-tic-dot" /> : null}
</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">
@@ -566,13 +569,21 @@ 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('/projects')} className="flex items-center gap-3"> <NavLink to={href('/')} className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)]"> <div className="shell-mark flex h-9 w-9 items-center justify-center">
<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>
@@ -613,6 +624,17 @@ 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,6 +6,7 @@ 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,
@@ -24,8 +25,12 @@ 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: 'Search', type: 'search', searchFields: ['label', 'action', 'user_email'] }, { key: 'q', label: 'events', type: 'search', searchFields: ['label', 'action', 'user_email'] },
{ {
key: 'severity', label: 'Severity', type: 'select', key: 'severity', label: 'Severity', type: 'select',
options: [ options: [
@@ -58,46 +63,33 @@ export function ActivityPage() {
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<div className="border-b border-[var(--border-subtle)]"> <div className="w-full px-4 pt-6 sm:px-8">
<div className="w-full px-8 py-5"> <SPageHead
<h1 className="v-title">Activity<span className="v-cursor">_</span></h1> title="Activity"
<p className="v-mono mt-1.5 text-[11px] text-[var(--text-tertiary)]"> titleAccent="_"
platform events across your visible projects sub="Platform events across your visible projects"
</p> actions={query.isFetching ? <Loader2 size={14} className="animate-spin text-[var(--text-muted)]" /> : undefined}
</div> />
</div> </div>
<div className="px-8 py-6 space-y-4"> <div className="px-4 sm:px-8 space-y-4">
<div className="flex items-start gap-3"> <SCard icon={<Activity />} title="Event Feed" accent={`${entries.length} events`} pad={false}>
<div className="flex-1"> <div className="px-4 pt-1 pb-3">
<FilterBar {...filter} /> <FilterBar {...filter} />
</div> </div>
{query.isFetching && <Loader2 size={14} className="animate-spin text-[var(--text-muted)] mt-3" />} <div className="divide-y divide-[var(--border-subtle)] border-t border-[var(--border-subtle)]">
</div> {query.isLoading && <p className="px-5 py-8 text-xs text-[var(--text-muted)]">Loading…</p>}
{!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="panel p-0 divide-y divide-[var(--border-subtle)]"> <div className="flex items-center gap-2.5">
{query.isLoading && <p className="px-5 py-8 text-xs text-[var(--text-muted)]">Loading…</p>} <QuietBtn disabled={page <= 1} onClick={() => setPage(page - 1)}>← newer</QuietBtn>
{!query.isLoading && entries.length === 0 && ( <QuietBtn disabled={!query.data?.has_more} onClick={() => setPage(page + 1)}>older →</QuietBtn>
<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> <span className="v-mono text-[10.5px] text-[var(--text-muted)]">page {page}</span>
)}
{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>
@@ -108,14 +100,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-5 py-3"> <div className="flex items-center gap-3 px-4 py-3 hover:bg-[var(--tint-03)] transition-colors">
<div className={`w-8 h-8 rounded-md bg-[var(--bg-elevated)] flex items-center justify-center ${sev.cls}`}> <div className={`s-ibox ${sev.cls}`} style={{ width: 30, height: 30 }}>
<Cat size={15} /> <Cat size={14} />
</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] text-[var(--text-primary)] truncate">{entry.label || entry.action}</span> <span className="text-[13px] font-medium text-[var(--text-primary)] truncate">{entry.label || entry.action}</span>
<span className={`v-mono text-[9px] uppercase px-1.5 py-0.5 rounded ${sev.cls} bg-[var(--bg-elevated)]`}>{entry.severity}</span> <SPill tone={SEV_TONE[entry.severity] ?? 'info'}>{entry.severity}</SPill>
</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,6 +38,7 @@ 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',
@@ -199,7 +200,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 text-lg font-semibold text-[var(--text-primary)]">Admin access required</h1> <h1 className="mt-4 font-headline 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>
@@ -216,74 +217,74 @@ export function AdminPage() {
const invites = invitesQuery.data ?? []; const invites = invitesQuery.data ?? [];
return ( return (
<div className="mx-auto max-w-6xl px-5 py-8"> <div className="w-full px-4 py-6 sm:px-8">
<div className="mb-8"> <SPageHead
<h1 className="text-[24px] font-bold tracking-tight text-[var(--text-primary)]">Admin console</h1> title="Admin Console"
<p className="mt-1 text-sm text-[var(--text-secondary)]"> titleAccent="_"
Platform overview, project approvals, and user management. sub="Platform overview, project approvals, and user management."
</p> />
</div>
<div className="grid grid-cols-4 gap-3 max-lg:grid-cols-3 max-sm:grid-cols-2"> <div className="grid grid-cols-4 gap-3.5 max-lg:grid-cols-3 max-sm:grid-cols-2">
{Object.entries(statLabels).map(([key, label]) => ( {Object.entries(statLabels).map(([key, label]) => (
<div <div key={key} className="s-stat">
key={key} <div className="px-4 pb-1 pt-4">
className="rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)] px-4 py-3.5" <p className="text-[10.5px] font-medium uppercase tracking-[0.08em] text-[var(--text-tertiary)]">{label}</p>
> <p className="font-headline mt-3 text-[28px] font-bold leading-none tabular-nums text-[var(--text-primary)]">
<p className="text-[20px] font-bold tabular-nums text-[var(--text-primary)]">{stats[key] ?? 0}</p> {stats[key] ?? 0}
<p className="mt-0.5 text-[10.5px] uppercase tracking-wider text-[var(--text-tertiary)]">{label}</p> </p>
</div>
<div className="s-stat-foot mt-3">
<span>total</span>
</div>
</div> </div>
))} ))}
</div> </div>
<section className="mt-8"> <section className="s-card !p-0 mt-6 overflow-hidden">
<div className="mb-3 flex items-center gap-2"> <div className="s-cardhead border-b border-[var(--border-subtle)]">
<Clock size={15} className="text-[var(--warning)]" /> <span className="s-ibox"><Clock /></span>
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Pending approvals</h2> <h2 className="s-t">Pending Approvals</h2>
{pending.length > 0 ? ( <span className="s-trail">
<span className="rounded-full bg-[var(--warning-soft)] px-2 py-0.5 text-[10.5px] font-semibold text-[var(--warning)]"> {pending.length > 0 ? <SPill tone="warn">{pending.length}</SPill> : null}
{pending.length} </span>
</span>
) : null}
</div> </div>
{pending.length > 0 ? ( {pending.length > 0 ? (
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)]"> <div>
{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"
> >
<FolderKanban size={15} className="shrink-0 text-[var(--text-tertiary)]" /> <span className="s-ibox !h-7 !w-7"><FolderKanban /></span>
<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>
<button <GhostBtn
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
</button> </GhostBtn>
</div> </div>
))} ))}
</div> </div>
) : ( ) : (
<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)]"> <p className="px-4 py-6 text-center text-[13px] text-[var(--text-tertiary)]">
Nothing waiting for approval. Nothing waiting for approval.
</div> </p>
)} )}
</section> </section>
<section className="mt-8"> <section className="s-card !p-0 mt-6 overflow-hidden">
<div className="mb-3 flex items-center gap-2"> <div className="s-cardhead border-b border-[var(--border-subtle)]">
<FolderKanban size={15} className="text-[var(--accent-primary)]" /> <span className="s-ibox"><FolderKanban /></span>
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">All projects</h2> <h2 className="s-t">All Projects</h2>
<span className="s-trail"><span className="s-chip" style={{ cursor: 'default' }}>{allProjects.length}</span></span>
</div> </div>
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)]"> <div>
{allProjects.map((project) => ( {allProjects.map((project) => (
<div <div
key={project.id} key={project.id}
@@ -297,22 +298,16 @@ 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>
<span <SPill tone={project.isApproved ? 'ok' : 'warn'}>
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'}
</span> </SPill>
<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="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)]" className="s-icon-btn"
title="Edit project" title="Edit project"
> >
<Pencil size={12} /> <Pencil size={12} />
@@ -325,19 +320,17 @@ export function AdminPage() {
} }
}} }}
disabled={deleteMutation.isPending} disabled={deleteMutation.isPending}
className="inline-flex items-center gap-1 rounded-[var(--radius-md)] border border-[var(--error)] px-2.5 py-1.5 text-[12px] font-medium text-[var(--error)] hover:bg-[var(--error-soft)] disabled:opacity-50" className="s-icon-btn !text-[var(--text-tertiary)] hover:!text-[var(--error)] disabled:opacity-40"
title="Delete project" title="Delete project"
> >
<Trash2 size={12} /> <Trash2 size={12} />
</button> </button>
<button <QuietBtn
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'}
</button> </QuietBtn>
</div> </div>
))} ))}
{allProjects.length === 0 ? ( {allProjects.length === 0 ? (
@@ -346,12 +339,13 @@ export function AdminPage() {
</div> </div>
</section> </section>
<section className="mt-8"> <section className="s-card !p-0 mt-6 overflow-hidden">
<div className="mb-3 flex items-center gap-2"> <div className="s-cardhead border-b border-[var(--border-subtle)]">
<Megaphone size={15} className="text-[var(--accent-primary)]" /> <span className="s-ibox"><Megaphone /></span>
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Announcements</h2> <h2 className="s-t">Announcements</h2>
<span className="s-trail"><span className="s-chip" style={{ cursor: 'default' }}>{banners.length}</span></span>
</div> </div>
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)]"> <div>
<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}
@@ -375,14 +369,12 @@ 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>
<button <GhostBtn
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'}
</button> </GhostBtn>
</div> </div>
{banners.map((banner) => ( {banners.map((banner) => (
<div <div
@@ -396,14 +388,12 @@ export function AdminPage() {
{banner.body ? ` · ${banner.body}` : ''} {banner.body ? ` · ${banner.body}` : ''}
</p> </p>
</div> </div>
<button <QuietBtn
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'}
</button> </QuietBtn>
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
@@ -412,7 +402,7 @@ export function AdminPage() {
} }
}} }}
disabled={bannerDeleteMutation.isPending} disabled={bannerDeleteMutation.isPending}
className="inline-flex items-center gap-1 rounded-[var(--radius-md)] border border-[var(--error)] px-2.5 py-1.5 text-[12px] font-medium text-[var(--error)] hover:bg-[var(--error-soft)] disabled:opacity-50" className="s-icon-btn !text-[var(--text-tertiary)] hover:!text-[var(--error)] disabled:opacity-40"
title="Delete banner" title="Delete banner"
> >
<Trash2 size={12} /> <Trash2 size={12} />
@@ -425,12 +415,13 @@ export function AdminPage() {
</div> </div>
</section> </section>
<section className="mt-8"> <section className="s-card !p-0 mt-6 overflow-hidden">
<div className="mb-3 flex items-center gap-2"> <div className="s-cardhead border-b border-[var(--border-subtle)]">
<UserPlus size={15} className="text-[var(--accent-primary)]" /> <span className="s-ibox"><UserPlus /></span>
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Team invites</h2> <h2 className="s-t">Team Invites</h2>
<span className="s-trail"><span className="s-chip" style={{ cursor: 'default' }}>{invites.length}</span></span>
</div> </div>
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)]"> <div>
<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}
@@ -439,35 +430,25 @@ 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)]"
/> />
<button <GhostBtn
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'}
</button> </GhostBtn>
</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="min-w-0 flex-1 truncate text-[12px] text-[var(--accent-primary)]">{inviteLink}</code> <code className="v-mono min-w-0 flex-1 truncate text-[12px] text-[var(--accent-primary)]">{inviteLink}</code>
<button <QuietBtn
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
</button> </QuietBtn>
<button <QuietBtn onClick={() => setInviteLink(null)}>Dismiss</QuietBtn>
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) => {
@@ -486,26 +467,17 @@ export function AdminPage() {
expires {new Date(invite.expires_at).toLocaleString()} expires {new Date(invite.expires_at).toLocaleString()}
</p> </p>
</div> </div>
<span <SPill tone={used ? 'off' : expired ? 'err' : 'ok'}>
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'}
</span> </SPill>
{!used ? ( {!used ? (
<button <QuietBtn
type="button"
onClick={() => inviteRevokeMutation.mutate(invite.id)} onClick={() => inviteRevokeMutation.mutate(invite.id)}
disabled={inviteRevokeMutation.isPending} disabled={inviteRevokeMutation.isPending}
className="rounded-[var(--radius-md)] border border-[var(--error)] px-2.5 py-1.5 text-[12px] font-medium text-[var(--error)] hover:bg-[var(--error-soft)] disabled:opacity-50" className="hover:!text-[var(--error)] hover:!border-[var(--error)]"
> >
Revoke Revoke
</button> </QuietBtn>
) : null} ) : null}
</div> </div>
); );
@@ -516,12 +488,13 @@ export function AdminPage() {
</div> </div>
</section> </section>
<section className="mt-8 pb-10"> <section className="s-card !p-0 mt-6 mb-10 overflow-hidden">
<div className="mb-3 flex items-center gap-2"> <div className="s-cardhead border-b border-[var(--border-subtle)]">
<Users size={15} className="text-[var(--accent-tertiary)]" /> <span className="s-ibox"><Users /></span>
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Users</h2> <h2 className="s-t">Users</h2>
<span className="s-trail"><span className="s-chip" style={{ cursor: 'default' }}>{users.length}</span></span>
</div> </div>
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)]"> <div>
{users.map((user) => ( {users.map((user) => (
<UserRow <UserRow
key={user.id} key={user.id}
@@ -540,30 +513,29 @@ 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 overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--bg-base)] shadow-2xl"> <div className="w-full max-w-lg s-card !p-0 overflow-hidden">
<div className="border-b border-[var(--border-subtle)] px-5 py-4"> <div className="s-cardhead border-b border-[var(--border-subtle)]">
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]"> <span className="s-ibox"><ShieldCheck /></span>
Impersonating {impersonation.user.email} <h2 className="s-t">Impersonating {impersonation.user.email}</h2>
</h2> <span className="s-trail">
<p className="text-xs text-[var(--text-tertiary)]"> <span className="v-mono text-[10.5px] text-[var(--text-muted)]">
Token valid until {new Date(impersonation.expires_at).toLocaleString()} — audit-logged. expires {new Date(impersonation.expires_at).toLocaleString()}
</p> </span>
</span>
</div> </div>
<div className="space-y-3 px-5 py-4"> <div className="space-y-3 px-5 py-4">
<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"> <div className="s-inset flex items-center gap-2 px-3 py-2.5">
<code className="min-w-0 flex-1 truncate text-[12px] text-[var(--text-primary)]"> <code className="v-mono min-w-0 flex-1 truncate text-[12px] text-[var(--text-primary)]">
{impersonation.token} {impersonation.token}
</code> </code>
<button <QuietBtn
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
</button> </QuietBtn>
</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;
@@ -572,13 +544,7 @@ 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">
<button <GhostBtn onClick={() => setImpersonation(null)}>Done</GhostBtn>
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>
@@ -586,10 +552,13 @@ 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 overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--bg-base)] shadow-2xl"> <div className="w-full max-w-md s-card !p-0 overflow-hidden">
<div className="border-b border-[var(--border-subtle)] px-5 py-4"> <div className="s-cardhead border-b border-[var(--border-subtle)]">
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Edit project</h2> <span className="s-ibox"><Pencil /></span>
<p className="text-xs text-[var(--text-tertiary)]">Admin edit — changes apply immediately.</p> <h2 className="s-t">Edit Project</h2>
<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>
@@ -611,21 +580,13 @@ 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">
<button <QuietBtn onClick={() => setEditingProject(null)}>Cancel</QuietBtn>
type="button" <GhostBtn
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'}
</button> </GhostBtn>
</div> </div>
</div> </div>
</div> </div>
@@ -660,32 +621,22 @@ 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 ? (
<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)]"> <SPill tone="info"><ShieldCheck size={10} /> admin</SPill>
<ShieldCheck size={11} /> admin
</span>
) : null} ) : null}
{onImpersonate ? ( {onImpersonate ? (
<button <QuietBtn onClick={onImpersonate}>Impersonate</QuietBtn>
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}
<button <QuietBtn
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'
)} )}
</button> </QuietBtn>
</div> </div>
); );
} }
@@ -6,7 +6,9 @@ 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;
@@ -72,109 +74,101 @@ 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="border-b border-[var(--border-subtle)]"> <div className="w-full px-4 pt-6 sm:px-8">
<div className="w-full px-8 py-5"> <SPageHead
<h1 className="v-title"> title="Audit Logs"
Audit Logs<span className="v-cursor">_</span> titleAccent="_"
</h1> sub="Every authenticated action recorded by the platform"
<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-8 py-6"> <div className="w-full px-4 sm:px-8">
<section className="panel p-6"> <SCard icon={<ScrollText />} title="Event Log" accent={`page ${page}`} pad={false}>
<FilterBar {...filter} /> <div className="px-4 pt-1 pb-3">
<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">
<div className="mt-4 flex items-center justify-between"> {logsQuery.isLoading ? (
<p className="text-xs text-[var(--text-muted)]"> <div className="py-10 text-center 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">
<button <QuietBtn onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page <= 1}>
onClick={() => setPage((p) => Math.max(1, p - 1))} <ChevronLeft size={12} /> Prev
disabled={page <= 1} </QuietBtn>
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" <QuietBtn onClick={() => setPage((p) => p + 1)} disabled={!hasNext}>
> Next <ChevronRight size={12} />
<ChevronLeft size={12} /> </QuietBtn>
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>
</section> </SCard>
</div> </div>
</div> </div>
); );
+27 -46
View File
@@ -13,15 +13,16 @@ 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 '/projects'; return '/';
} }
const value = raw.trim(); const value = raw.trim();
if (!value.startsWith('/') || value.startsWith('//')) { if (!value.startsWith('/') || value.startsWith('//')) {
return '/projects'; return '/';
} }
return value; return value;
@@ -54,9 +55,11 @@ function AuthCard({
}) { }) {
return ( return (
<div className="w-full max-w-[400px]"> <div className="w-full max-w-[400px]">
<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-card shadow-2xl shadow-black/50">
<h2 className="font-headline text-xl font-semibold text-[var(--text-primary)]">{title}</h2> <div className="s-cardhead mb-1">
<p className="mb-6 mt-1.5 text-sm text-[var(--text-secondary)]">{subtitle}</p> <h2 className="font-headline text-[17px] font-semibold text-[var(--text-primary)]">{title}</h2>
</div>
<p className="mb-6 v-mono text-[11.5px] text-[var(--text-tertiary)]">{subtitle}</p>
{children} {children}
</div> </div>
</div> </div>
@@ -67,19 +70,14 @@ 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 text-xs text-[var(--text-muted)]">Self-hosted container platform</span> <span className="mt-1 v-mono text-[11px] uppercase tracking-[0.14em] text-[var(--text-muted)]">Self-hosted container platform</span>
</div> </div>
{children} {children}
<p className="mt-8 text-center text-[11px] text-[var(--text-muted)]"> <p className="mt-8 text-center v-mono text-[10.5px] 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>
@@ -89,8 +87,8 @@ function AuthLayout({ children }: { children: React.ReactNode }) {
function AuthErrorNotice({ message }: { message: string }) { function AuthErrorNotice({ message }: { message: string }) {
return ( return (
<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)]"> <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)]">
<AlertCircle size={16} className="mt-0.5 shrink-0" /> <AlertCircle size={15} className="mt-0.5 shrink-0" />
<span>{message}</span> <span>{message}</span>
</div> </div>
); );
@@ -98,7 +96,7 @@ function AuthErrorNotice({ message }: { message: string }) {
function AuthInfoNotice({ message }: { message: string }) { function AuthInfoNotice({ message }: { message: string }) {
return ( return (
<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)]"> <div className="mb-4 s-inset !border-[var(--success)]/30 px-3 py-2.5 text-[12.5px] text-[var(--success)]">
{message} {message}
</div> </div>
); );
@@ -206,15 +204,10 @@ export function SignInPage() {
/> />
</label> </label>
<button <GhostBtn type="submit" disabled={isSubmitting || sessionQuery.isPending} className="!h-10 w-full justify-center">
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
</button> </GhostBtn>
</form> </form>
{providers.length > 0 ? ( {providers.length > 0 ? (
@@ -227,24 +220,22 @@ 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') ? (
<button <QuietBtn
type="button"
onClick={() => void signInWithGitHubProvider()} onClick={() => void signInWithGitHubProvider()}
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)]" className="!h-10 justify-center text-sm"
> >
<Github size={15} /> <Github size={15} />
GitHub GitHub
</button> </QuietBtn>
) : null} ) : null}
{providers.includes('google') ? ( {providers.includes('google') ? (
<button <QuietBtn
type="button"
onClick={() => void signInWithGoogleProvider()} onClick={() => void signInWithGoogleProvider()}
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)]" className="!h-10 justify-center text-sm"
> >
<GoogleMark /> <GoogleMark />
Google Google
</button> </QuietBtn>
) : null} ) : null}
</div> </div>
</> </>
@@ -280,7 +271,7 @@ export function AcceptInvitePage() {
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
if (sessionQuery.data) { if (sessionQuery.data) {
return <Navigate to="/projects" replace />; return <Navigate to="/" replace />;
} }
const boundEmail = inviteQuery.data?.email ?? undefined; const boundEmail = inviteQuery.data?.email ?? undefined;
@@ -297,7 +288,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('/projects', { replace: true }); navigate('/', { 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 {
@@ -369,15 +360,10 @@ export function AcceptInvitePage() {
/> />
</label> </label>
<button <GhostBtn type="submit" disabled={isSubmitting} className="!h-10 w-full justify-center">
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
</button> </GhostBtn>
</form> </form>
) : null} ) : null}
@@ -487,15 +473,10 @@ export function SignUpPage() {
/> />
</label> </label>
<button <GhostBtn type="submit" disabled={isSubmitting || sessionQuery.isPending} className="!h-10 w-full justify-center">
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
</button> </GhostBtn>
</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,6 +14,18 @@ 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,
@@ -24,8 +36,6 @@ 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 }> = [
@@ -50,26 +60,11 @@ const BUILD_SCHEMA: FilterConfig<BuildEntity>[] = [
}, },
]; ];
function StatusBadge({ status }: { status: BuildStatus }) { function BuildIcon({ status }: { status: BuildStatus }) {
const config = { if (status === 'success') return <Check size={14} className="text-[var(--success)]" />;
success: { color: 'var(--success)', bg: 'var(--success-soft)', Icon: Check, animate: false }, if (status === 'failed') return <X size={14} className="text-[var(--error)]" />;
failed: { color: 'var(--error)', bg: 'var(--error-soft)', Icon: X, animate: false }, if (status === 'running') return <Loader2 size={14} className="text-[var(--warning)] animate-spin" />;
running: { color: 'var(--warning)', bg: 'var(--warning-soft)', Icon: Loader2, animate: true }, return <Clock size={14} className="text-[var(--text-tertiary)]" />;
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 {
@@ -149,109 +144,113 @@ 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">
{/* Header */} <div className="w-full px-4 pt-6 sm:px-8">
<div className="border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/50 backdrop-blur-sm"> <SPageHead
<div className="w-full px-8 py-4"> title="Build Pipeline"
<div className="flex items-center justify-between gap-4"> titleAccent="_"
<div> sub="Monitor build progress and manage jobs"
<h1 className="v-title">Build Pipeline<span className="v-cursor">_</span></h1> actions={
<p className="text-sm text-[var(--text-secondary)]">Monitor build progress and manage jobs</p> <>
</div> <div className="flex items-center gap-2 text-[11px] v-mono">
<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>
<button <IconBtn onClick={() => queryClient.invalidateQueries({ queryKey: ['builds-page'] })} title="Refresh builds">
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} />
Refresh </IconBtn>
</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="px-4 py-3 rounded-[var(--radius-md)] border border-[var(--warning-soft)] bg-[var(--warning-soft)]/50"> <div className="s-inset flex items-center gap-2 text-xs text-[var(--warning)]">
<div className="flex items-center gap-2 text-sm text-[var(--warning)]"> <Sparkles size={13} />
<Sparkles size={16} /> Demo mode active — using sample data
<span>Demo mode active — using sample data</span>
</div>
</div> </div>
</div> </div>
)} )}
{/* Stats Overview */} <div className="w-full px-4 py-6 sm:px-8 space-y-4">
<div className="w-full px-8 py-6"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-3.5">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> <SStat icon={<Box />} label="Total Builds" value={stats.total} foot="all jobs" />
<div className="panel p-4"> <SStat icon={<Loader2 />} label="In Progress" value={stats.running} foot="building now" />
<div className="flex items-center justify-between mb-3"> <SStat icon={<Check />} label="Successful" value={stats.success} foot="completed ok" />
<div className="w-8 h-8 rounded-[var(--radius-sm)] bg-[var(--accent-primary-soft)] flex items-center justify-center"> <SStat icon={<X />} label="Failed" value={stats.failed} foot="need attention" />
<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>
{/* Filters */} <SCard icon={<Filter />} title="Filters"
<div className="w-full px-8"> trail={<span className="v-mono text-[10.5px] text-[var(--text-muted)]">{builds.length} jobs shown</span>}
<div className="panel p-4"> >
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-end gap-3">
<Filter size={16} className="text-[var(--text-tertiary)]" /> <div className="flex-1 min-w-[280px]">
<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-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2"> <label className="block text-[10.5px] font-medium uppercase tracking-wider text-[var(--text-muted)] mb-1.5">
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="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" className="s-chip !cursor-pointer appearance-none pr-6"
> >
<option value="20">20</option> <option value="20">20</option>
<option value="50">50</option> <option value="50">50</option>
@@ -259,127 +258,41 @@ export function BuildsPage() {
</select> </select>
</div> </div>
</div> </div>
</div> </SCard>
</div>
{/* Build Table */} <SCard icon={<Box />} title="Build Queue" accent={`${builds.length} jobs`} pad={false}
<div className="w-full px-8 py-6"> trail={
<div className="panel overflow-hidden"> <IconBtn onClick={() => queryClient.invalidateQueries({ queryKey: ['builds-page'] })} title="Refresh">
<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>
) : null} ) : !isDemoMode && buildsQuery.isError ? (
<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>
) : null} ) : builds.length === 0 ? (
{builds.length === 0 && !buildsQuery.isLoading ? (
<div className="p-12 text-center"> <div className="p-12 text-center">
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-[var(--surface-muted)] flex items-center justify-center"> <div className="s-ibox mx-auto mb-3 !w-10 !h-10"><Box /></div>
<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">
{builds.length > 0 ? ( <STable
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 p-4"> cols={buildCols}
{builds.map((build) => ( rows={builds}
<div rowKey={(b) => b.id}
key={build.id} onRowClick={(b) => setSelectedBuild(b)}
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} )}
</div> </SCard>
</div> </div>
{/* Error Toast */} {/* Error Toast */}
@@ -393,27 +306,21 @@ 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 panel p-6"> <div className="relative w-full max-w-3xl s-card">
<div className="flex items-center justify-between mb-4"> <div className="s-cardhead">
<div className="flex items-center gap-3"> <span className="s-ibox"><FileText /></span>
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center"> <div>
<FileText size={20} className="text-[var(--accent-primary)]" /> <div className="s-t">Build Logs</div>
</div> <div className="v-mono mt-0.5 text-[11px] text-[var(--text-tertiary)]">{selectedBuild.id}</div>
<div> </div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Build Logs</h2> <div className="s-trail">
<p className="mono text-xs text-[var(--text-tertiary)]">{selectedBuild.id}</p> <QuietBtn onClick={() => setSelectedBuild(null)}>Close</QuietBtn>
</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="mono text-xs text-[var(--text-secondary)] whitespace-pre-wrap break-all"> <pre className="v-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
@@ -423,6 +330,7 @@ export function BuildsPage() {
: logsQuery.data || '(empty logs)'} : logsQuery.data || '(empty logs)'}
</pre> </pre>
</div> </div>
</div>
</div> </div>
</div> </div>
)} )}
@@ -0,0 +1,662 @@
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,6 +15,7 @@ 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, ' ')
@@ -119,7 +120,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-8 py-6"> <article className="docs-article px-4 py-6 sm:px-8">
<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>
+170 -102
View File
@@ -1,3 +1,4 @@
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 {
@@ -8,16 +9,18 @@ import {
GitBranch, GitBranch,
LayoutTemplate, LayoutTemplate,
Rocket, Rocket,
Server,
ShieldCheck, ShieldCheck,
Terminal,
Zap, Zap,
} from 'lucide-react'; } from 'lucide-react';
import { listProjects, listTemplates } from '@/lib/api-client'; import { getHostMonitoring, listProjects, listRecentDeployments, 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 } from '@/shared/components'; import { BrandWordmark, MiniBars, SPill, Ticks } from '@/shared/components';
import { demoProjects, demoTemplates } from '@/lib/demo-data'; import { demoHostMonitoring, 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.' },
@@ -28,7 +31,14 @@ 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 });
@@ -37,19 +47,35 @@ 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)]">
<div className="ambient-glow" /> {/* ── Top bar ── */}
<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="text-[15px] font-extrabold tracking-tight text-[var(--text-primary)]"> <span className="font-headline text-[15px] font-semibold tracking-tight text-[var(--text-primary)]">
<BrandWordmark /> <BrandWordmark />
</span> </span>
</Link> </Link>
@@ -57,112 +83,160 @@ 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 <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)]">
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 ? ( {signedIn || isDemoMode ? (
<Link <Link to={`/${demo}`} className="ml-1 s-btn-accent">
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 <Link to="/auth/sign-in" className="ml-1 s-btn-accent">
to={isDemoMode ? `/projects${demo}` : '/auth/sign-in'} Sign in <ArrowRight size={13} />
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 className="relative"> <main>
<section className="mx-auto max-w-6xl px-5 pb-16 pt-20 text-center"> {/* ── Hero + live instance window ── */}
<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)]"> <section className="border-b border-[var(--border-subtle)]">
<Container size={12} className="text-[var(--accent-primary)]" /> <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">
self-hosted PaaS <div>
</p> <p className="s-chip mb-6 inline-flex">
<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]"> <Container size={11} />
Deploy containers like it's{' '} self-hosted PaaS
<span style={{ color: 'var(--accent-primary)' }}>your own cloud</span> </p>
</h1> <h1 className="font-headline text-[44px] font-bold leading-[1.06] tracking-[-0.03em] text-[var(--text-primary)] max-sm:text-[32px]">
<p className="mx-auto mt-5 max-w-xl text-[15px] leading-relaxed text-[var(--text-secondary)]"> Deploy containers like it's{' '}
Containr turns any Docker host into a full deployment platform — git-push builds, <span className="text-[var(--accent-primary)]">your own cloud</span>
managed databases, preview environments, HA failover, and a searchable knowledge base. </h1>
</p> <p className="mt-5 max-w-lg text-[14px] leading-relaxed text-[var(--text-secondary)]">
<div className="mt-8 flex items-center justify-center gap-3"> Containr turns any Docker host into a full deployment platform — git-push builds,
<Link managed databases, preview environments, HA failover, and a searchable knowledge base.
to={`/projects${demo}`} </p>
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]" <div className="mt-8 flex items-center gap-3">
> <Link to={`/${demo}`} className="s-btn-accent !px-5 !h-10 text-sm">
{isDemoMode ? 'Explore the live demo' : 'Browse projects'} <ArrowRight size={15} /> {isDemoMode ? 'Explore the live demo' : 'Open the console'} <ArrowRight size={15} />
</Link> </Link>
<a <a href="#docs" className="s-btn-quiet !px-5 !h-10 text-sm">
href="#docs" <BookOpen size={15} /> Read the docs
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)]" </a>
> </div>
<BookOpen size={15} /> Read the docs <div className="v-mono mt-8 flex items-center gap-5 text-[11px] text-[var(--text-tertiary)]">
</a> <span className="flex items-center gap-2">
</div> <i className="inline-block h-1.5 w-1.5 rounded-full bg-[var(--success)]" />
{projects.length} public projects
{projects.length > 0 ? ( </span>
<div className="mx-auto mt-12 grid max-w-3xl grid-cols-3 gap-3 text-left max-sm:grid-cols-1"> <span>{projects.reduce((s, p) => s + (p.stats?.running_services ?? 0), 0)} services online</span>
<Stat label="Public projects" value={projects.length} /> <span className="max-sm:hidden">{projects.reduce((s, p) => s + (p.stats?.deployment_count ?? 0), 0)} deployments</span>
<Stat </div>
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> </div>
) : null}
{/* 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>
</section> </section>
<section id="features" className="border-t border-[var(--border-subtle)] bg-[var(--bg-base)]/60"> {/* ── Install strip ── */}
<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">
<h2 className="text-center text-[26px] font-bold tracking-tight text-[var(--text-primary)]"> <div className="flex items-end justify-between gap-6 max-sm:flex-col max-sm:items-start">
Everything a deployment platform needs <div>
</h2> <p className="v-mono mb-2 flex items-center gap-2 text-[10.5px] uppercase tracking-[0.1em] text-[var(--text-tertiary)]">
<p className="mx-auto mt-2 max-w-lg text-center text-sm text-[var(--text-secondary)]"> <i className="inline-block h-[5px] w-[5px] rounded-[1px] bg-[var(--accent-primary)]" />
One install, one dashboard, full control over your own infrastructure. platform
</p> </p>
<div className="mt-10 grid grid-cols-3 gap-4 max-md:grid-cols-2 max-sm:grid-cols-1"> <h2 className="font-headline text-[26px] font-bold tracking-tight text-[var(--text-primary)]">
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 <div key={feature.title} className="s-card">
key={feature.title} <div className="s-cardhead !mb-0">
className="rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)] p-5 transition-colors hover:border-[var(--border-default)]" <span className="s-ibox"><feature.icon size={13.5} /></span>
> <h3 className="s-t">{feature.title}</h3>
<feature.icon size={18} className="text-[var(--accent-primary)]" /> </div>
<h3 className="mt-3 text-[14.5px] font-semibold text-[var(--text-primary)]">{feature.title}</h3> <p className="mt-2 text-[12.5px] leading-relaxed text-[var(--text-tertiary)]">{feature.text}</p>
<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>
<section id="templates" className="border-t border-[var(--border-subtle)]"> {/* ── Templates ── */}
<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"> <div className="flex items-end justify-between gap-6">
<div> <div>
<h2 className="text-[26px] font-bold tracking-tight text-[var(--text-primary)]">Template catalog</h2> <p className="v-mono mb-2 flex items-center gap-2 text-[10.5px] uppercase tracking-[0.1em] text-[var(--text-tertiary)]">
<p className="mt-1 text-sm text-[var(--text-secondary)]"> <i className="inline-block h-[5px] w-[5px] rounded-[1px] bg-[var(--accent-primary)]" />
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="inline-flex items-center gap-1.5 text-[13px] font-medium text-[var(--accent-primary)] hover:underline" className="s-chip !normal-case !tracking-normal"
> >
Browse all <ArrowRight size={13} /> Browse all <ArrowRight size={11} />
</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">
@@ -170,22 +244,20 @@ 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="group rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)] p-4 transition-colors hover:border-[var(--border-strong)]" className="s-card group"
> >
<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-md" /> <img src={template.logo} alt="" className="h-7 w-7 rounded-[6px]" />
) : ( ) : (
<span className="flex h-7 w-7 items-center justify-center rounded-md bg-[var(--surface-muted)] text-[var(--accent-primary)]"> <span className="s-ibox !h-7 !w-7"><LayoutTemplate size={13.5} /></span>
<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="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)]"> <span className="s-chip mt-2" style={{ cursor: 'default' }}>
{template.category || 'app'} {template.category || 'app'}
</span> </span>
</Link> </Link>
@@ -199,10 +271,15 @@ export function LandingPage() {
</div> </div>
</section> </section>
<section id="docs" className="border-t border-[var(--border-subtle)] bg-[var(--bg-base)]/60"> {/* ── Docs ── */}
<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">
<h2 className="text-[26px] font-bold tracking-tight text-[var(--text-primary)]">Documentation</h2> <p className="v-mono mb-2 flex items-center gap-2 text-[10.5px] uppercase tracking-[0.1em] text-[var(--text-tertiary)]">
<p className="mt-1 text-sm text-[var(--text-secondary)]"> <i className="inline-block h-[5px] w-[5px] rounded-[1px] bg-[var(--accent-primary)]" />
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">
@@ -213,10 +290,10 @@ export function LandingPage() {
</main> </main>
<footer className="border-t border-[var(--border-subtle)]"> <footer className="border-t border-[var(--border-subtle)]">
<div className="mx-auto flex max-w-6xl items-center justify-between px-5 py-6 text-[12px] text-[var(--text-tertiary)]"> <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)]">
<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>
@@ -228,12 +305,3 @@ 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,8 +19,10 @@ 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 = [
@@ -129,8 +131,7 @@ export function NodeDetailPage() {
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<div className="border-b border-[var(--border-subtle)]"> <div className="w-full px-4 pt-6 sm:px-8">
<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
@@ -140,7 +141,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"> <h1 className="v-title font-headline">
{rename === null ? agent.name : rename} {rename === null ? agent.name : rename}
{isAdmin && rename === null && ( {isAdmin && rename === null && (
<button <button
@@ -163,7 +164,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-tertiary)] px-2 py-1 text-sm text-[var(--text-primary)]" className="rounded-md border border-[var(--border-subtle)] bg-[var(--bg-elevated)] 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>
@@ -209,7 +210,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-tertiary)] px-2 py-0.5 text-[11px] text-[var(--text-primary)] w-52" 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"
/> />
<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) })}
@@ -247,7 +248,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-tertiary)] px-2 py-0.5 text-[11px] text-[var(--text-primary)] w-64" 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"
/> />
<button <button
onClick={() => updateMutation.mutate({ default_domain: domainDraft.trim().toLowerCase() })} onClick={() => updateMutation.mutate({ default_domain: domainDraft.trim().toLowerCase() })}
@@ -258,17 +259,9 @@ export function NodeDetailPage() {
)} )}
</p> </p>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-2 flex-wrap">
<span className={`v-mono text-[11px] px-2 py-1 rounded-md border ${online <SPill tone={online ? 'ok' : 'err'}>{agent.status}</SPill>
? 'border-[var(--success)]/40 text-[var(--success)]' {!agent.schedulable && <SPill tone="warn">cordoned</SPill>}
: '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">
@@ -280,81 +273,79 @@ export function NodeDetailPage() {
/> />
auto-prune auto-prune
</label> </label>
<button <QuietBtn
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'}
</button> </QuietBtn>
<button <QuietBtn
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="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" className="!text-[var(--warning)] hover:!border-[var(--warning)]/60"
> >
drain drain
</button> </QuietBtn>
<button <GhostBtn
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'}
</button> </GhostBtn>
<button <QuietBtn
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
</button> </QuietBtn>
<button <QuietBtn
onClick={() => setConfirmDelete(true)} onClick={() => setConfirmDelete(true)}
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" className="!text-[var(--error)] hover:!border-[var(--error)]/60"
> >
remove remove
</button> </QuietBtn>
</> </>
)} )}
</div> </div>
</div> </div>
</div> </div>
</div>
<div className="px-8 py-6 space-y-6"> <div className="px-4 py-6 sm:px-8 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" value={latest ? `${latest.cpu.usage_percent.toFixed(0)}%` : '—'} sub={latest ? `${latest.cpu.cores} cores` : ''} /> <Stat label="cpu" icon={<Cpu />} value={latest ? `${latest.cpu.usage_percent.toFixed(0)}%` : '—'} sub={latest ? `${latest.cpu.cores} cores` : ''} />
<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="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="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="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="containers" value={latest ? String(latest.container_count) : String(containers.length)} sub="reported by agent" /> <Stat label="containers" icon={<ListOrdered />} value={latest ? String(latest.container_count) : String(containers.length)} sub="reported by agent" />
</div> </div>
<section className="panel p-5"> <section className="s-card !p-0">
<div className="flex items-center justify-between mb-4"> <div className="s-cardhead border-b border-[var(--border-subtle)]">
<h2 className="v-mono text-[11px] uppercase tracking-wider text-[var(--text-tertiary)]">telemetry</h2> <span className="s-ibox"><Activity /></span>
<div className="flex gap-1"> <h2 className="s-t">Telemetry</h2>
{TIME_RANGES.map((r) => ( <span className="s-trail">
<button <span className="flex gap-1">
key={r.value} {TIME_RANGES.map((r) => (
onClick={() => setTimeRange(r.value)} <button
className={`v-mono text-[11px] px-2.5 py-1 rounded-md border ${timeRange === r.value key={r.value}
? 'border-[var(--accent-primary)]/50 text-[var(--accent-primary)]' onClick={() => setTimeRange(r.value)}
: 'border-[var(--border-subtle)] text-[var(--text-muted)] hover:text-[var(--text-primary)]'}`} className={`s-chip v-mono ${timeRange === r.value ? 'on' : ''}`}
> >
{r.label} {r.label}
</button> </button>
))} ))}
</div> </span>
</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 ? (
@@ -365,12 +356,17 @@ 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="panel p-0"> <section className="s-card !p-0">
<h2 className="v-mono text-[11px] uppercase tracking-wider text-[var(--text-tertiary)] px-5 pt-4 pb-3">containers</h2> <div className="s-cardhead border-b border-[var(--border-subtle)]">
<div className="divide-y divide-[var(--border-subtle)] border-t border-[var(--border-subtle)]"> <span className="s-ibox"><Server /></span>
<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>
)} )}
@@ -380,15 +376,24 @@ 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>
<span className="v-mono text-[11px] text-[var(--text-secondary)]">{c.status ?? '—'}</span> {/* Persisted query cache can replay the raw status object — flatten defensively. */}
<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="panel p-0"> <section className="s-card !p-0">
<h2 className="v-mono text-[11px] uppercase tracking-wider text-[var(--text-tertiary)] px-5 pt-4 pb-3">command history</h2> <div className="s-cardhead border-b border-[var(--border-subtle)]">
<div className="divide-y divide-[var(--border-subtle)] border-t border-[var(--border-subtle)]"> <span className="s-ibox"><ListOrdered /></span>
<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>
)} )}
@@ -466,12 +471,19 @@ 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, value, sub }: { label: string; value: string; sub?: string }) { function Stat({ label, icon, value, sub }: { label: string; icon: React.ReactNode; value: string; sub?: string }) {
return ( return (
<div className="panel p-4"> <div className="s-stat">
<p className="v-mono text-[10px] uppercase tracking-wider text-[var(--text-tertiary)]">{label}</p> <div className="px-4 pb-1 pt-4">
<p className="mt-1 text-xl font-semibold text-[var(--text-primary)]">{value}</p> <div className="flex items-center gap-2.5">
{sub && <p className="mt-0.5 v-mono text-[11px] text-[var(--text-muted)]">{sub}</p>} <span className="s-ibox">{icon}</span>
<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>
); );
} }
@@ -510,24 +522,20 @@ 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" onClick={onCancel}> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm" onClick={onCancel}>
<div className="panel w-full max-w-sm p-5" onClick={(e) => e.stopPropagation()}> <div className="s-card w-full max-w-sm" onClick={(e) => e.stopPropagation()}>
<h3 className="text-sm font-semibold text-[var(--text-primary)]">{title}</h3> <h3 className="s-t">{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">
<button onClick={onCancel} className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--border-subtle)] text-[var(--text-secondary)]"> <QuietBtn onClick={onCancel}>cancel</QuietBtn>
cancel <GhostBtn
</button>
<button
onClick={onConfirm} onClick={onConfirm}
disabled={pending} disabled={pending}
className={`v-mono text-[11px] px-3 py-1.5 rounded-md border disabled:opacity-50 ${danger className={danger ? '!text-[var(--error)] !border-[var(--error)]/40 !bg-[var(--error)]/10' : undefined}
? '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}
</button> </GhostBtn>
</div> </div>
</div> </div>
</div> </div>
@@ -4,28 +4,10 @@ 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;
@@ -33,13 +15,15 @@ function Section({ title, icon: Icon, count, children }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<section className="panel p-5"> <section className="s-card overflow-hidden">
<div className="flex items-center gap-2 mb-4"> <div className="s-cardhead border-b border-[var(--border-subtle)]">
<Icon size={16} className="text-[var(--accent-primary)]" /> <span className="s-ibox"><Icon /></span>
<h2 className="text-sm font-semibold text-[var(--text-primary)]">{title}</h2> <h2 className="s-t">{title}</h2>
<span className="text-xs text-[var(--text-muted)] mono">({count})</span> <span className="s-trail">
<span className="s-chip" style={{ cursor: 'default' }}>{count}</span>
</span>
</div> </div>
{children} <div className="p-3">{children}</div>
</section> </section>
); );
} }
@@ -74,13 +58,8 @@ export function OperationsPage() {
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<div className="border-b border-[var(--border-subtle)]"> <div className="w-full px-4 pt-6 sm:px-8">
<div className="w-full px-8 py-5"> <SPageHead title="Operations" titleAccent="_" sub="Live view of deploys, queues, cron runs, and backups" />
<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 ? (
@@ -88,24 +67,12 @@ 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-8 py-6 space-y-6"> <div className="w-full px-4 py-6 sm:px-8 space-y-6">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> <div className="grid grid-cols-2 gap-3.5 md:grid-cols-4">
<div className="panel-soft p-4"> <SStat icon={<Activity />} label="In Flight" value={active.length} foot="deploying now" />
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">In flight</p> <SStat icon={<Clock />} label="Queued" value={queuedTotal} foot="waiting in queue" />
<p className="mt-1 text-2xl font-semibold text-[var(--accent-primary)]">{active.length}</p> <SStat icon={<AlertTriangle />} label="Failed 24h" value={failed.length} foot="need attention" />
</div> <SStat icon={<Database />} label="Backups 24h" value={backups.length} foot="completed runs" />
<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">
@@ -125,7 +92,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>
<span className={`text-xs mono ${statusClass(d.status)}`}>{d.status}</span> <SPill tone={statusTone(d.status)}>{d.status}</SPill>
<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>
))} ))}
@@ -152,7 +119,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>
<span className={`text-xs mono ${statusClass(d.status)}`}>{d.status}</span> <SPill tone={statusTone(d.status)}>{d.status}</SPill>
<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>
))} ))}
@@ -167,9 +134,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">
<span className={`mono ${statusClass(r.status)}`}>{r.status}</span> <SPill tone={statusTone(r.status)}>{r.status}</SPill>
<span className="text-[var(--text-primary)]">{r.job_name}</span> <span className="text-[var(--text-primary)]">{r.job_name}</span>
<span className="mono text-[var(--text-muted)]">{r.schedule}</span> <span className="v-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>
@@ -185,7 +152,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">
<span className={`mono ${statusClass(b.status)}`}>{b.status}</span> <SPill tone={statusTone(b.status)}>{b.status}</SPill>
<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>
@@ -198,14 +165,15 @@ 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-1.5"> <div className="space-y-2">
{queue.map((q) => ( {queue.map((q) => (
<div key={q.service_id} className="flex items-center gap-3 px-3 py-1.5 text-xs mono"> <div key={q.service_id} className="flex items-center gap-3 px-3 py-1.5 text-xs">
<span className="text-[var(--text-secondary)]">{q.service_id.slice(0, 8)}</span> <span className="v-mono text-[var(--text-secondary)]">{q.service_id.slice(0, 8)}</span>
<span className={q.running ? 'text-[var(--accent-primary)]' : 'text-[var(--text-muted)]'}> <SPill tone={q.running ? 'info' : 'off'}>{q.running ? 'running' : 'idle'}</SPill>
{q.running ? 'running' : 'idle'} <span className="flex-1 max-w-[220px]">
<Ticks pct={Math.min(100, q.queued * 25)} count={32} warn={!q.running} />
</span> </span>
<span className="text-[var(--warning)]">{q.queued} queued</span> <span className="v-mono text-[var(--warning)]">{q.queued} queued</span>
</div> </div>
))} ))}
</div> </div>
@@ -13,10 +13,31 @@ 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' },
@@ -25,6 +46,8 @@ 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';
@@ -32,19 +55,6 @@ 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();
@@ -54,6 +64,7 @@ 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;
@@ -84,40 +95,61 @@ 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 badge = healthBadge(health); const envLabel =
const envOk = health === 'healthy'; health === 'healthy' ? 'production' : health === 'idle' ? 'idle' : health === 'critical' ? 'down' : 'degraded';
const envLabel = envOk ? 'production' : health === 'idle' ? 'idle' : health === 'critical' ? 'down' : 'degraded';
return ( return (
<article <article
className="group panel p-4 cursor-pointer transition-all duration-200 hover:-translate-y-0.5 hover:border-[var(--border-strong)]" className="s-card group cursor-pointer transition-all duration-200 hover:-translate-y-0.5 hover:border-[var(--border-strong)]"
onClick={() => navigate(href)} onClick={() => navigate(href)}
> >
<div className="mb-3 flex items-center gap-2"> <div className="s-cardhead">
<h3 className="truncate text-[14.5px] font-bold tracking-tight text-[var(--text-primary)]"> <IboxStub />
{project.name} <h3 className="s-t truncate">{project.name}</h3>
</h3> <span className="s-trail">
<span className={`v-st ml-auto ${badge.cls}`}>{badge.label}</span> <SPill
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>
<MiniCanvas project={project} /> <div className="px-4">
<div className="v-mono mt-3 flex items-center gap-2 text-[10.5px] text-[var(--text-tertiary)]"> <MiniCanvas project={project} />
<i </div>
className="inline-block h-1.5 w-1.5 rounded-full" <div className="s-stat-foot mt-3.5">
style={{ background: envOk ? 'var(--success)' : health === 'idle' ? 'var(--text-tertiary)' : health === 'critical' ? 'var(--error)' : 'var(--warning)' }} <span className="flex items-center gap-2">
/> <i
<span className="text-[var(--text-secondary)]"> className="inline-block h-1.5 w-1.5 rounded-full"
{envLabel} style={{
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>·</span> <span>{formatRelative(project.updatedAt)}</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();
@@ -181,7 +213,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;
@@ -190,9 +222,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 = isDemoMode const feed: DeployRow[] = isDemoMode
? demoDeploys ? demoDeploys
: deployments.slice(0, 5).map((d) => ({ : deployments.slice(0, 6).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,
@@ -200,120 +232,188 @@ export function ProjectsPage() {
when: formatRelative(d.startedAt ?? d.completedAt ?? d.createdAt), when: formatRelative(d.startedAt ?? d.completedAt ?? d.createdAt),
})); }));
const statusClass = (status: string) => const statDefs = [
status === 'DEPLOYED' || status === 'SUCCESS' || status === 'SUCCEEDED' {
? 'v-st-ok' icon: <Cpu size={13.5} />,
: status === 'BUILDING' || status === 'RUNNING' || status === 'QUEUED' label: `${hostLabel} CPU`,
? 'v-st-run' value: cpuPct ?? '—',
: status === 'FAILED' || status === 'CANCELLED' unit: cpuPct !== null ? '%' : undefined,
? 'v-st-fail' ticks: cpuPct,
: 'v-st-off'; foot: isDemoMode || host ? `${host?.cpu.cores ?? 16} cores · load avg` : 'no telemetry',
},
{
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-8 py-6"> <div className="w-full px-4 py-6 sm:px-7">
{/* Page head */} <SPageHead
<div className="mb-6 flex items-center justify-between gap-5"> title="Projects"
<div> titleAccent="_"
<h1 className="v-title">Projects<span className="v-cursor">_</span></h1> sub={
<p className="v-mono mt-1.5 text-[11px] text-[var(--text-tertiary)]"> <>
<b className="text-[var(--text-secondary)]">{String(projects.length).padStart(2, '0')}</b> projects ·{' '} {String(projects.length).padStart(2, '0')} projects · {String(totalServices).padStart(2, '0')} services ·{' '}
<b className="text-[var(--text-secondary)]">{String(totalServices).padStart(2, '0')}</b> services ·{' '} </>
<b className="text-[var(--text-secondary)]">{runningServices}</b> online }
</p> subAccent={`${runningServices} online`}
</div> actions={
<div className="flex items-center gap-2.5"> <>
<div className="search-box" style={{ width: 'min(240px, 42vw)' }}> <div className="search-box" style={{ width: 'min(230px, 40vw)' }}>
<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" />
style={{ fontSize: 11.5 }} </div>
/> <IconBtn title="Refresh" onClick={() => { projectsQuery.refetch(); deploysQuery.refetch(); hostQuery.refetch(); }}>
</div> <RefreshCw size={14} />
{signedIn && ( </IconBtn>
<button onClick={openCreate} className="v-btn"> {signedIn && (
+ new project <GhostBtn onClick={openCreate}>+ New Project</GhostBtn>
</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>
{/* Host stat row */} {anomalies.length > 0 && (
<div className="mb-7 grid grid-cols-2 gap-3 lg:grid-cols-4"> <div className="mb-4">
<div className="v-stat"> <SAnomaly
<div className="v-k"><span>{hostLabel}.CPU</span><span className="v-live" /></div> icon={
<div className="v-v">{cpuPct ?? '—'}<span>%</span></div> <svg width="12.5" height="12.5" viewBox="0 0 16 16" fill="none" stroke="var(--warning)" strokeWidth="1.5">
{isDemoMode ? ( <path d="M8 2.4 2.2 13h11.6z" />
<div className="v-spark"> <path d="M8 6.6v3M8 11.4v.3" />
{[28, 44, 36, 58, 42, 66, 50, 34, 56, 40, 30, 20].map((h, i) => ( </svg>
<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</>}
</div> items={anomalies}
) : 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 */}
<p className="v-sect">PROJECTS <span className="v-sect-r">{filteredProjects.length} records</span></p> <SSection right={`${filteredProjects.length} records`}>Projects</SSection>
{!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="panel h-[210px] animate-pulse" /> <div key={i} className="s-card h-[210px] animate-pulse" />
))} ))}
</div> </div>
)} )}
{!isDemoMode && projectsQuery.isError && ( {!isDemoMode && projectsQuery.isError && (
<div className="panel p-8 text-center"> <div className="s-card p-8 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-[var(--error-soft)]"> <div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-[var(--error-soft)]">
<X size={24} className="text-[var(--error)]" /> <X size={22} className="text-[var(--error)]" />
</div> </div>
<p className="text-lg font-medium text-[var(--text-primary)]">Failed to load projects</p> <p className="text-[15px] font-semibold text-[var(--text-primary)]">Failed to load projects</p>
<p className="mt-2 text-sm text-[var(--text-secondary)]">Check API connectivity and try again</p> <p className="mt-1.5 text-[12px] text-[var(--text-tertiary)]">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="panel p-12 text-center"> <div className="s-card p-12 text-center">
<div className="mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-[var(--accent-primary-soft)]"> <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)]">
<FolderOpen size={28} className="text-[var(--accent-primary)]" /> <FolderOpen size={24} className="text-[var(--accent-primary)]" />
</div> </div>
<p className="text-xl font-semibold text-[var(--text-primary)]">No projects yet</p> <p className="font-headline text-[17px] font-bold text-[var(--text-primary)]">No projects yet</p>
<p className="mx-auto mt-2 max-w-md text-[var(--text-secondary)]"> <p className="mx-auto mt-2 max-w-md text-[12.5px] 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>
<button onClick={openCreate} className="v-btn mt-6"> <GhostBtn onClick={openCreate} className="mt-6">
{signedIn ? '+ create project' : 'sign in to create'} {signedIn ? '+ New Project' : 'Sign in to create'}
</button> </GhostBtn>
</div> </div>
) : null} ) : null}
@@ -326,24 +426,23 @@ export function ProjectsPage() {
)} )}
{/* Deploy feed */} {/* Deploy feed */}
<p className="v-sect mt-7">RECENT DEPLOYMENTS <span className="v-sect-r">latest</span></p> <div className="mt-6">
<div className="v-tbl"> <SCard
<table> icon={<Rocket size={13.5} />}
<thead> title="Recent Deployments"
<tr><th>SERVICE</th><th>STATUS</th><th style={{ textAlign: 'right' }}>WHEN</th></tr> accent={feed.length > 0 ? `(${feed.length})` : undefined}
</thead> trail={
<tbody> <IconBtn title="Refresh" onClick={() => deploysQuery.refetch()}>
{feed.length === 0 ? ( <RefreshCw size={13} />
<tr><td colSpan={3} className="text-center" style={{ color: 'var(--text-tertiary)' }}>no deployments yet</td></tr> </IconBtn>
) : feed.map((d) => ( }
<tr key={d.id}> pad={false}
<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> >
<td><span className={`v-st ${statusClass(d.status)}`}>{d.status}</span></td> <STable cols={deployCols} rows={feed} rowKey={(r) => r.id} />
<td style={{ textAlign: 'right' }}>{d.when}</td> {feed.length === 0 && (
</tr> <p className="px-7 pb-5 text-[12px] text-[var(--text-tertiary)]">No deployments yet.</p>
))} )}
</tbody> </SCard>
</table>
</div> </div>
</div> </div>
@@ -351,58 +450,55 @@ 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="relative w-full max-w-lg panel p-6"> <div className="s-card relative w-full max-w-lg p-6">
<h2 className="text-xl font-semibold text-[var(--text-primary)]">Create new project</h2> <h2 className="font-headline text-[19px] font-bold tracking-tight text-[var(--text-primary)]">New project</h2>
<p className="mt-1 text-sm text-[var(--text-secondary)]"> <p className="mt-1 text-[12.5px] 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>
<p className="mt-2 rounded-[var(--radius-md)] bg-[var(--warning-soft)] px-3 py-2 text-xs text-[var(--warning)]"> <div className="s-inset mt-3 text-[11.5px] 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.
</p> </div>
<div className="mt-6 space-y-4"> <div className="mt-5 space-y-4">
<div> <div>
<label className="mb-2 block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)]"> <label className="mb-2 block text-[11px] 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-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)]" 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"
/> />
</div> </div>
<div> <div>
<label className="mb-2 block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)]"> <label className="mb-2 block text-[11px] 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-[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)]" 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"
/> />
</div> </div>
</div> </div>
{createProjectMutation.isError && ( {createProjectMutation.isError && (
<div className="mt-4 rounded-[var(--radius-md)] bg-[var(--error-soft)] px-4 py-3 text-sm text-[var(--error)]"> <div className="s-inset mt-4 text-[12px] text-[var(--error)]">
{(createProjectMutation.error as Error).message} {(createProjectMutation.error as Error).message}
</div> </div>
)} )}
<div className="mt-6 flex justify-end gap-3"> <div className="mt-6 flex justify-end gap-2.5">
<button onClick={() => setCreateOpen(false)} className="v-btn-ghost"> <QuietBtn onClick={() => setCreateOpen(false)}>Cancel</QuietBtn>
Cancel <GhostBtn
</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'}
</button> </GhostBtn>
</div> </div>
</div> </div>
</div> </div>
+174 -196
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState, type ReactNode } 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,6 +104,7 @@ 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,
@@ -148,13 +149,10 @@ import {
Send, Send,
} from 'lucide-react'; } from 'lucide-react';
function SecondaryPageHeader({ title, description }: { title: string; description: string }) { function SecondaryPageHeader({ title, description, actions }: { title: string; description: string; actions?: ReactNode }) {
return ( return (
<div className="border-b border-[var(--border-subtle)]"> <div className="w-full px-4 pt-6 sm:px-8">
<div className="w-full px-8 py-5"> <SPageHead title={title} titleAccent="_" sub={description} actions={actions} />
<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>
); );
} }
@@ -180,15 +178,17 @@ function StatCard({
}; };
return ( return (
<div className="panel p-5"> <div className="s-stat">
<div className="flex items-start justify-between mb-3"> <div className="px-4 pb-1 pt-4">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--surface-muted)] flex items-center justify-center"> <div className="flex items-center gap-2.5">
<Icon size={18} className="text-[var(--text-tertiary)]" /> <span className="s-ibox"><Icon /></span>
<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-8 py-6"> <div className="w-full px-4 py-6 sm:px-8">
<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="panel p-8 text-center"> <div className="s-card 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="panel p-8 text-center"> <div className="s-card 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,14 +490,12 @@ export function UsagePage() {
/> />
</div> </div>
<section className="panel p-6"> <section className="s-card p-5">
<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">
<div className="flex h-10 w-10 items-center justify-center rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)]"> <span className="s-ibox !w-9 !h-9"><Server /></span>
<Server size={18} className="text-[var(--accent-primary)]" />
</div>
<div> <div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Host Monitoring</h2> <h2 className="s-t">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'}`
@@ -578,13 +576,11 @@ export function UsagePage() {
)} )}
</section> </section>
<section className="panel p-6"> <section className="s-card p-5">
<div className="mb-5 flex items-center gap-3"> <div className="mb-5 flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-[var(--radius-md)] bg-[var(--surface-muted)]"> <span className="s-ibox !w-9 !h-9"><Radio /></span>
<Radio size={18} className="text-[var(--text-tertiary)]" />
</div>
<div> <div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Node Agents</h2> <h2 className="s-t">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>
@@ -678,8 +674,8 @@ export function UsagePage() {
<button <button
onClick={() => createTokenMutation.mutate()} onClick={() => createTokenMutation.mutate()}
disabled={createTokenMutation.isPending} disabled={createTokenMutation.isPending}
className="h-8 px-3 rounded-[var(--radius-md)] text-xs font-medium text-[var(--accent-on)] transition-all disabled:opacity-50" className="s-btn-accent h-8 disabled:opacity-50"
style={{ background: 'var(--accent-primary)' }}
> >
{createTokenMutation.isPending ? 'Issuing…' : 'Issue token'} {createTokenMutation.isPending ? 'Issuing…' : 'Issue token'}
</button> </button>
@@ -688,7 +684,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="mono text-xs text-[var(--text-primary)] break-all">{issuedToken.token}</span> <span className="v-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"
@@ -706,11 +702,11 @@ export function UsagePage() {
</p> </p>
)} )}
<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">{installCommand}</pre>
<pre className="mono text-xs text-[var(--text-secondary)] whitespace-pre-wrap mt-2">{sshCommand}</pre> <pre className="v-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="mono text-[var(--text-primary)]">{agentEndpoint}</span></p> <p>Agent endpoint: <span className="v-mono text-[var(--text-primary)]">{agentEndpoint}</span></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>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>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>
@@ -844,7 +840,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-8 py-6"> <div className="w-full px-4 py-6 sm:px-8">
{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)]" />
@@ -876,19 +872,17 @@ export function PeoplePage() {
/> />
</div> </div>
<section className="panel p-6"> <section className="s-card p-5">
<div className="mb-5 flex items-center gap-3"> <div className="mb-5 flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)]"> <span className="s-ibox !w-9 !h-9"><UserPlus /></span>
<UserPlus size={18} className="text-[var(--accent-primary)]" />
</div>
<div> <div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Manual User Creation</h2> <h2 className="s-t">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-[1fr_1fr_1fr_auto]" className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_auto]"
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault(); event.preventDefault();
createUserMutation.mutate(); createUserMutation.mutate();
@@ -921,8 +915,8 @@ export function PeoplePage() {
<button <button
type="submit" type="submit"
disabled={createUserMutation.isPending} disabled={createUserMutation.isPending}
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" className="s-btn-accent h-10 justify-center 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
@@ -1057,14 +1051,12 @@ function GitProvidersSection() {
const selectedType = gitProviderTypes.find((t) => t.value === form.name)!; const selectedType = gitProviderTypes.find((t) => t.value === form.name)!;
return ( return (
<section className="panel p-6"> <section className="s-card p-5">
<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">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center"> <span className="s-ibox !w-9 !h-9"><GitBranch /></span>
<GitBranch size={18} className="text-[var(--accent-primary)]" />
</div>
<div> <div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Git Providers</h2> <h2 className="s-t">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>
@@ -1073,8 +1065,8 @@ function GitProvidersSection() {
<button <button
onClick={() => provisionMutation.mutate()} onClick={() => provisionMutation.mutate()}
disabled={provisionMutation.isPending} disabled={provisionMutation.isPending}
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" className="s-btn-accent h-9 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'}
@@ -1090,8 +1082,8 @@ function GitProvidersSection() {
)} )}
<button <button
onClick={() => setFormOpen((open) => !open)} onClick={() => setFormOpen((open) => !open)}
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" className="s-btn-accent h-9"
style={{ background: 'var(--accent-primary)' }}
> >
<Link2 size={14} /> <Link2 size={14} />
{formOpen ? 'Close' : 'Connect'} {formOpen ? 'Close' : 'Connect'}
@@ -1140,12 +1132,10 @@ function GitProvidersSection() {
{providers.map((provider) => ( {providers.map((provider) => (
<div <div
key={provider.id} key={provider.id}
className="flex items-center justify-between p-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]" className="s-inset flex items-center justify-between"
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-[var(--radius-sm)] bg-[var(--surface-card)] flex items-center justify-center"> <span className="s-ibox"><GitBranch /></span>
<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>
@@ -1201,7 +1191,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 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 v-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>
@@ -1214,7 +1204,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 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 v-mono focus:border-[var(--accent-primary)] transition-colors"
placeholder="https://gitea.example.com" placeholder="https://gitea.example.com"
/> />
</div> </div>
@@ -1228,8 +1218,8 @@ function GitProvidersSection() {
<button <button
onClick={() => createMutation.mutate()} onClick={() => createMutation.mutate()}
disabled={!form.accessToken.trim() || createMutation.isPending} disabled={!form.accessToken.trim() || createMutation.isPending}
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" className="s-btn-accent h-9 disabled:opacity-50 disabled:cursor-not-allowed"
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
@@ -1274,7 +1264,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 text-lg font-semibold text-[var(--text-primary)]">Missing code</h1> <h1 className="mt-4 font-headline 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>
@@ -1282,14 +1272,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 text-lg font-semibold text-[var(--text-primary)]">Setup failed</h1> <h1 className="mt-4 font-headline 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="mt-6 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-sm font-semibold text-[var(--accent-on)]" className="s-btn-accent mt-6"
> >
Back to settings Back to settings
</button> </button>
@@ -1297,7 +1287,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 text-lg font-semibold text-[var(--text-primary)]">Setting up GitHub App</h1> <h1 className="mt-4 font-headline 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>
</> </>
)} )}
@@ -1353,13 +1343,11 @@ function PlatformSettingsSection() {
}; };
return ( return (
<section className="panel p-6"> <section className="s-card p-5">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center"> <span className="s-ibox !w-9 !h-9"><Cloud /></span>
<Cloud size={18} className="text-[var(--accent-primary)]" />
</div>
<div> <div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Platform</h2> <h2 className="s-t">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>
@@ -1412,8 +1400,8 @@ function PlatformSettingsSection() {
<button <button
onClick={saveToken} onClick={saveToken}
disabled={saveMutation.isPending || !tokenDraft.trim()} disabled={saveMutation.isPending || !tokenDraft.trim()}
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" className="s-btn-accent h-9 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
style={{ background: 'var(--accent-primary)' }}
> >
Save Save
</button> </button>
@@ -1475,8 +1463,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="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" className="s-btn-accent h-9 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
style={{ background: 'var(--accent-primary)' }}
> >
Save branding Save branding
</button> </button>
@@ -1548,13 +1536,11 @@ function UserTokensSection({ isAdmin }: { isAdmin: boolean }) {
]; ];
return ( return (
<section className="panel p-6"> <section className="s-card p-5">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center"> <span className="s-ibox !w-9 !h-9"><Key /></span>
<Key size={18} className="text-[var(--accent-primary)]" />
</div>
<div> <div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Personal Access Tokens</h2> <h2 className="s-t">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>
@@ -1588,8 +1574,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="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" className="s-btn-accent h-9 disabled:opacity-50 disabled:cursor-not-allowed"
style={{ background: 'var(--accent-primary)' }}
> >
{createMutation.isPending ? 'Creating…' : 'Create token'} {createMutation.isPending ? 'Creating…' : 'Create token'}
</button> </button>
@@ -1598,7 +1584,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="mono text-xs text-[var(--text-primary)] break-all">{issuedToken.token}</span> <span className="v-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"
@@ -1608,7 +1594,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="mono">containr auth login --token</span> or as a Bearer token. Shown once - store it now. Use with <span className="v-mono">containr auth login --token</span> or as a Bearer token.
</p> </p>
</div> </div>
) : null} ) : null}
@@ -1630,7 +1616,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 mono text-[var(--text-muted)]">{token.key_prefix}…</span> <span className="ml-2 v-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'}
@@ -1692,13 +1678,11 @@ function RegistriesSection() {
const registries = registriesQuery.data ?? []; const registries = registriesQuery.data ?? [];
return ( return (
<section className="panel p-6"> <section className="s-card p-5">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center"> <span className="s-ibox !w-9 !h-9"><Archive /></span>
<Archive size={18} className="text-[var(--accent-primary)]" />
</div>
<div> <div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Registry credentials</h2> <h2 className="s-t">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>
@@ -1733,8 +1717,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="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" className="s-btn-accent h-9 disabled:opacity-50 disabled:cursor-not-allowed"
style={{ background: 'var(--accent-primary)' }}
> >
{createMutation.isPending ? 'Adding…' : 'Add registry'} {createMutation.isPending ? 'Adding…' : 'Add registry'}
</button> </button>
@@ -1760,7 +1744,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 mono text-[var(--text-muted)]">{r.host}</span> <span className="ml-2 v-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'}
@@ -1828,13 +1812,11 @@ 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="panel p-6"> <section className="s-card p-5">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center"> <span className="s-ibox !w-9 !h-9"><HardDrive /></span>
<HardDrive size={18} className="text-[var(--accent-primary)]" />
</div>
<div> <div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Backup targets</h2> <h2 className="s-t">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>
@@ -1853,8 +1835,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="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" className="s-btn-accent h-9 disabled:opacity-50 disabled:cursor-not-allowed"
style={{ background: 'var(--accent-primary)' }}
> >
{createMutation.isPending ? 'Checking…' : 'Add target'} {createMutation.isPending ? 'Checking…' : 'Add target'}
</button> </button>
@@ -1880,7 +1862,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 mono text-[var(--text-muted)]">{t.endpoint}/{t.bucket}{t.prefix ? `/${t.prefix}` : ''}</span> <span className="ml-2 v-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>
@@ -1961,13 +1943,11 @@ function NotificationChannelsSection() {
const channels = channelsQuery.data ?? []; const channels = channelsQuery.data ?? [];
return ( return (
<section className="panel p-6"> <section className="s-card p-5">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center"> <span className="s-ibox !w-9 !h-9"><Bell /></span>
<Bell size={18} className="text-[var(--accent-primary)]" />
</div>
<div> <div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Push notifications</h2> <h2 className="s-t">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>
@@ -1998,8 +1978,8 @@ function NotificationChannelsSection() {
<button <button
onClick={() => createMutation.mutate()} onClick={() => createMutation.mutate()}
disabled={createMutation.isPending || !endpoint.trim()} disabled={createMutation.isPending || !endpoint.trim()}
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" className="s-btn-accent h-9 disabled:opacity-50 disabled:cursor-not-allowed"
style={{ background: 'var(--accent-primary)' }}
> >
{createMutation.isPending ? 'Adding…' : 'Add channel'} {createMutation.isPending ? 'Adding…' : 'Add channel'}
</button> </button>
@@ -2027,7 +2007,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 mono text-[var(--text-primary)] break-all">{ch.endpoint}</span> <span className="ml-2 v-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] ? (
@@ -2145,16 +2125,14 @@ 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-8 py-6"> <div className="w-full px-4 py-6 sm:px-8">
<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="panel p-6"> <section className="s-card p-5">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center"> <span className="s-ibox !w-9 !h-9"><User /></span>
<User size={18} className="text-[var(--accent-primary)]" />
</div>
<div> <div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Profile</h2> <h2 className="s-t">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>
@@ -2196,8 +2174,8 @@ export function SettingsPage() {
<button <button
onClick={() => updateProfileMutation.mutate()} onClick={() => updateProfileMutation.mutate()}
disabled={!hasProfileChanges || updateProfileMutation.isPending} disabled={!hasProfileChanges || updateProfileMutation.isPending}
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" className="s-btn-accent h-9 disabled:opacity-50 disabled:cursor-not-allowed"
style={{ background: 'var(--accent-primary)' }}
> >
{updateProfileMutation.isPending ? ( {updateProfileMutation.isPending ? (
<> <>
@@ -2248,13 +2226,11 @@ export function SettingsPage() {
<GitProvidersSection /> <GitProvidersSection />
{/* Audit Logs Section */} {/* Audit Logs Section */}
<section className="panel p-6"> <section className="s-card p-5">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center"> <span className="s-ibox !w-9 !h-9"><ScrollText /></span>
<ScrollText size={18} className="text-[var(--accent-primary)]" />
</div>
<div> <div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Audit Logs</h2> <h2 className="s-t">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>
@@ -2271,13 +2247,11 @@ export function SettingsPage() {
</section> </section>
{/* Runtime & Local State Section */} {/* Runtime & Local State Section */}
<section className="panel p-6"> <section className="s-card p-5">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--surface-muted)] flex items-center justify-center"> <span className="s-ibox !w-9 !h-9"><Settings /></span>
<Settings size={18} className="text-[var(--text-tertiary)]" />
</div>
<div> <div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Runtime & Local State</h2> <h2 className="s-t">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>
@@ -2288,7 +2262,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="mono text-xs text-[var(--text-primary)] break-all">{getApiBaseUrl()}</p> <p className="v-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)]">
@@ -2296,7 +2270,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="mono text-xs text-[var(--text-primary)] break-all">{getAuthBaseUrl()}</p> <p className="v-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)]">
@@ -2350,7 +2324,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="mono text-xs text-[var(--text-tertiary)] truncate"> <li key={key} className="v-mono text-xs text-[var(--text-tertiary)] truncate">
{key} {key}
</li> </li>
))} ))}
@@ -2376,7 +2350,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-8 py-6"> <div className="w-full px-4 py-6 sm:px-8">
<DocsBrowser /> <DocsBrowser />
</div> </div>
</div> </div>
@@ -2513,7 +2487,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-8 py-6"> <div className="w-full px-4 py-6 sm:px-8">
<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'}
@@ -2522,7 +2496,7 @@ export function DatabasesPage() {
<button <button
type="button" type="button"
onClick={() => setCreateOpen(true)} onClick={() => setCreateOpen(true)}
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)]" className="s-btn-accent h-9 text-[12.5px]"
> >
<Database size={13} /> New database <Database size={13} /> New database
</button> </button>
@@ -2540,7 +2514,7 @@ export function DatabasesPage() {
<button <button
type="button" type="button"
onClick={() => setCreateOpen(true)} onClick={() => setCreateOpen(true)}
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)]" className="s-btn-accent mt-4 text-[12.5px]"
> >
<Database size={13} /> Create your first database <Database size={13} /> Create your first database
</button> </button>
@@ -2574,7 +2548,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)] 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)] v-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>
)} )}
@@ -2594,7 +2568,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="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="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)]">
{db.connection_url || '—'} {db.connection_url || '—'}
</code> </code>
{db.connection_url && ( {db.connection_url && (
@@ -2617,7 +2591,7 @@ export function DatabasesPage() {
<button <button
onClick={() => testByIdMutation.mutate(db.id ?? '')} onClick={() => testByIdMutation.mutate(db.id ?? '')}
disabled={testByIdMutation.isPending} disabled={testByIdMutation.isPending}
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" className="s-btn-quiet"
> >
<RefreshCw size={11} /> Test connection <RefreshCw size={11} /> Test connection
</button> </button>
@@ -2634,21 +2608,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="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" className="s-btn-quiet"
> >
<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="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" className="s-btn-quiet"
> >
<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="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" className="s-btn-quiet"
> >
<RefreshCw size={11} /> Restart <RefreshCw size={11} /> Restart
</button> </button>
@@ -2665,7 +2639,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="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" className="s-btn-quiet"
> >
<Link2 size={11} /> Bind <Link2 size={11} /> Bind
</button> </button>
@@ -2697,7 +2671,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="mono text-sm text-[var(--text-primary)]">{value}</p> <p className="v-mono text-sm text-[var(--text-primary)]">{value}</p>
</div> </div>
))} ))}
</div> </div>
@@ -2743,8 +2717,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="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" className="s-btn-accent disabled:opacity-40"
style={{ background: 'var(--accent-primary)' }}
> >
<Archive size={11} /> <Archive size={11} />
{backupMutation.isPending ? 'Starting…' : 'New backup'} {backupMutation.isPending ? 'Starting…' : 'New backup'}
@@ -2764,7 +2738,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="mono text-[var(--text-muted)]">{b.size}</span> <span className="v-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' && (
@@ -2994,7 +2968,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="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" className="s-btn-accent 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'}
@@ -3041,10 +3015,10 @@ function BindDatabasePanel({
const services = servicesQuery.data ?? []; const services = servicesQuery.data ?? [];
return ( return (
<div className="rounded-[var(--radius-md)] border border-[var(--border-default)] bg-[var(--surface-card)] p-4 space-y-3"> <div className="s-card space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-xs font-medium text-[var(--text-primary)]">Bind to service</p> <p className="s-t">Bind to service</p>
<button onClick={onClose} className="text-xs text-[var(--text-muted)] hover:text-[var(--text-primary)]">Close</button> <button onClick={onClose} className="v-mono text-[11px] 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.
@@ -3082,13 +3056,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 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 v-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="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" className="s-btn-accent 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>
@@ -3129,7 +3103,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 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 v-mono text-[var(--text-primary)]"
/> />
<button <button
onClick={() => mutation.mutate(value.trim())} onClick={() => mutation.mutate(value.trim())}
@@ -3251,7 +3225,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-8 space-y-8"> <div className="p-4 space-y-8 sm:p-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>
@@ -3297,17 +3271,17 @@ export function HighAvailabilityPage() {
</div> </div>
{isAdmin ? ( {isAdmin ? (
<div className="panel p-6"> <div className="s-card p-5">
<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="text-base font-semibold text-[var(--text-primary)]">Manager controls</h2> <h2 className="s-t">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="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50" className="s-btn-accent disabled:opacity-50"
> >
{status?.enabled ? 'Disable HA manager' : 'Enable HA manager'} {status?.enabled ? 'Disable HA manager' : 'Enable HA manager'}
</button> </button>
@@ -3338,10 +3312,10 @@ export function HighAvailabilityPage() {
</div> </div>
) : null} ) : null}
<div className="panel p-6"> <div className="s-card p-5">
<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="text-base font-semibold text-[var(--text-primary)]">Active alerts</h2> <h2 className="s-t">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>
@@ -3365,7 +3339,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="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" className="s-btn-quiet"
> >
Resolve Resolve
</button> </button>
@@ -3376,17 +3350,17 @@ export function HighAvailabilityPage() {
)} )}
</div> </div>
<div className="panel p-6"> <div className="s-card p-5">
<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="text-base font-semibold text-[var(--text-primary)]">Failover policies</h2> <h2 className="s-t">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="px-3 py-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-xs font-medium" className="s-btn-accent "
> >
New policy New policy
</button> </button>
@@ -3480,14 +3454,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="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50" className="s-btn-accent 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="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]" className="s-btn-quiet"
> >
Cancel Cancel
</button> </button>
@@ -3526,7 +3500,7 @@ export function HighAvailabilityPage() {
enabled: policy.enabled ?? true, enabled: policy.enabled ?? true,
editing: true, editing: true,
})} })}
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs text-[var(--text-primary)]" className="s-btn-quiet"
> >
Edit Edit
</button> </button>
@@ -3553,10 +3527,10 @@ export function HighAvailabilityPage() {
)} )}
</div> </div>
<div className="panel p-6"> <div className="s-card p-5">
<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="text-base font-semibold text-[var(--text-primary)]">Health check results</h2> <h2 className="s-t">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>
@@ -3615,39 +3589,43 @@ 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', projectId], queryKey: ['services', effectiveProjectId],
queryFn: () => listServicesByProject(projectId), queryFn: () => listServicesByProject(effectiveProjectId),
enabled: !isDemoMode && Boolean(projectId), enabled: !isDemoMode && Boolean(effectiveProjectId),
}); });
const services = isDemoMode ? getDemoServicesByProject(projectId) : servicesQuery.data ?? []; const services = isDemoMode ? getDemoServicesByProject(effectiveProjectId) : servicesQuery.data ?? [];
const metricsQuery = useQuery({ const metricsQuery = useQuery({
queryKey: ['security-metrics', projectId], queryKey: ['security-metrics', effectiveProjectId],
queryFn: () => getSecurityMetrics(projectId), queryFn: () => getSecurityMetrics(effectiveProjectId),
enabled: !isDemoMode && Boolean(projectId), enabled: !isDemoMode && Boolean(effectiveProjectId),
refetchInterval: 15_000, refetchInterval: 15_000,
}); });
const historyQuery = useQuery({ const historyQuery = useQuery({
queryKey: ['security-history', projectId], queryKey: ['security-history', effectiveProjectId],
queryFn: () => getSecurityHistory(projectId), queryFn: () => getSecurityHistory(effectiveProjectId),
enabled: !isDemoMode && Boolean(projectId), enabled: !isDemoMode && Boolean(effectiveProjectId),
refetchInterval: 15_000, refetchInterval: 15_000,
}); });
const vulnsQuery = useQuery({ const vulnsQuery = useQuery({
queryKey: ['security-vulns', projectId], queryKey: ['security-vulns', effectiveProjectId],
queryFn: () => listVulnerabilities(projectId), queryFn: () => listVulnerabilities(effectiveProjectId),
enabled: !isDemoMode && Boolean(projectId), enabled: !isDemoMode && Boolean(effectiveProjectId),
}); });
const invalidate = () => { const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ['security-metrics', projectId] }); queryClient.invalidateQueries({ queryKey: ['security-metrics', effectiveProjectId] });
queryClient.invalidateQueries({ queryKey: ['security-history', projectId] }); queryClient.invalidateQueries({ queryKey: ['security-history', effectiveProjectId] });
queryClient.invalidateQueries({ queryKey: ['security-vulns', projectId] }); queryClient.invalidateQueries({ queryKey: ['security-vulns', effectiveProjectId] });
}; };
const scanMutation = useMutation({ const scanMutation = useMutation({
mutationFn: () => startSecurityScan({ mutationFn: () => startSecurityScan({
project_id: projectId, project_id: effectiveProjectId,
service_id: scanServiceId || undefined, service_id: scanServiceId || undefined,
scan_type: scanType as 'dependency' | 'configuration' | 'comprehensive', scan_type: scanType as 'dependency' | 'configuration' | 'comprehensive',
}), }),
@@ -3661,10 +3639,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 === projectId) ? demoSecurityScans.filter((s) => s.project_id === effectiveProjectId)
: historyQuery.data ?? []; : historyQuery.data ?? [];
const vulns = (isDemoMode const vulns = (isDemoMode
? demoVulnerabilities.filter((v) => v.project_id === projectId) ? demoVulnerabilities.filter((v) => v.project_id === effectiveProjectId)
: vulnsQuery.data ?? [] : vulnsQuery.data ?? []
).filter((v) => v.status !== 'resolved'); ).filter((v) => v.status !== 'resolved');
const pageError = const pageError =
@@ -3677,7 +3655,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-8 space-y-8"> <div className="p-4 space-y-8 sm:p-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>
@@ -3695,7 +3673,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={projectId} value={effectiveProjectId}
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"
> >
@@ -3705,7 +3683,7 @@ export function SecurityPage() {
))} ))}
</select> </select>
</div> </div>
{projectId && ( {effectiveProjectId && (
<> <>
<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>
@@ -3737,7 +3715,7 @@ export function SecurityPage() {
type="button" type="button"
disabled={scanMutation.isPending} disabled={scanMutation.isPending}
onClick={() => scanMutation.mutate()} onClick={() => scanMutation.mutate()}
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50" className="s-btn-accent disabled:opacity-50"
> >
{scanMutation.isPending ? 'Starting…' : 'Run scan'} {scanMutation.isPending ? 'Starting…' : 'Run scan'}
</button> </button>
@@ -3748,7 +3726,7 @@ export function SecurityPage() {
)} )}
</div> </div>
{projectId && metrics && ( {effectiveProjectId && 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"
@@ -3779,11 +3757,11 @@ export function SecurityPage() {
</div> </div>
)} )}
{projectId && ( {effectiveProjectId && (
<div className="panel p-6"> <div className="s-card p-5">
<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="text-base font-semibold text-[var(--text-primary)]">Findings</h2> <h2 className="s-t">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>
@@ -3822,7 +3800,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="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs text-[var(--text-secondary)] disabled:opacity-50" className="s-btn-quiet"
> >
Ignore Ignore
</button> </button>
@@ -3835,11 +3813,11 @@ export function SecurityPage() {
</div> </div>
)} )}
{projectId && ( {effectiveProjectId && (
<div className="panel p-6"> <div className="s-card p-5">
<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="text-base font-semibold text-[var(--text-primary)]">Scan history</h2> <h2 className="s-t">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,8 +34,7 @@ export function ComponentShowcase() {
}; };
return ( return (
<div className="min-h-screen p-8" style={{ background: '#16171c' }}> <div className="min-h-screen p-8" style={{ background: 'var(--bg-void)' }}>
<div className="ambient-glow" />
<div className="w-full relative z-10"> <div className="w-full relative z-10">
{/* Header */} {/* Header */}
@@ -91,25 +90,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="panel p-6"> <div className="s-card p-6">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4"> <h3 className="s-t mb-4">
Basic Line Chart (CPU Style) Basic Line Chart (CPU Style)
</h3> </h3>
<LineChart <LineChart
data={sampleLineData} data={sampleLineData}
color="#b4e34a" color="#38bdf8"
height={120} height={120}
showDots={true} showDots={true}
smooth={true} smooth={true}
/> />
</div> </div>
<div className="panel p-6"> <div className="s-card p-6">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4"> <h3 className="s-t mb-4">
Line Chart (No Dots) Line Chart (No Dots)
</h3> </h3>
<LineChart <LineChart
data={sampleLineData} data={sampleLineData}
color="#7ab8ff" color="var(--accent-primary)"
height={120} height={120}
showDots={false} showDots={false}
smooth={true} smooth={true}
@@ -123,8 +122,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="panel p-6"> <div className="s-card p-6">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4"> <h3 className="s-t mb-4">
Area Chart (Active Users Style) Area Chart (Active Users Style)
</h3> </h3>
<LineAreaChart <LineAreaChart
@@ -134,8 +133,8 @@ export function ComponentShowcase() {
height={150} height={150}
/> />
</div> </div>
<div className="panel p-6"> <div className="s-card p-6">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4"> <h3 className="s-t mb-4">
Area Chart (Success Color) Area Chart (Success Color)
</h3> </h3>
<LineAreaChart <LineAreaChart
@@ -153,20 +152,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="panel p-6"> <div className="s-card p-6">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4"> <h3 className="s-t 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: '#7ab8ff', fillOpacity: 0.15 }, { data: sampleLineData.map(v => v + 10), color: 'var(--accent-primary)', fillOpacity: 0.15 },
{ data: sampleLineData, color: '#f2c94c', fillOpacity: 0.15 } { data: sampleLineData, color: 'var(--warning)', fillOpacity: 0.15 }
]} ]}
height={120} height={120}
/> />
</div> </div>
<div className="panel p-6"> <div className="s-card p-6">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4"> <h3 className="s-t mb-4">
Bar Chart (Timeline Style) Bar Chart (Timeline Style)
</h3> </h3>
<BarChart <BarChart
@@ -184,23 +183,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="panel p-6 flex flex-col items-center"> <div className="s-card p-6 flex flex-col items-center">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4"> <h3 className="s-t mb-4">
RAM Usage (65%) RAM Usage (65%)
</h3> </h3>
<DonutChart percentage={65} color="#f2c94c" size={160} /> <DonutChart percentage={65} color="var(--warning)" size={160} />
</div> </div>
<div className="panel p-6 flex flex-col items-center"> <div className="s-card p-6 flex flex-col items-center">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4"> <h3 className="s-t mb-4">
Disk Usage (82%) Disk Usage (82%)
</h3> </h3>
<DonutChart percentage={82} color="#b4e34a" size={160} /> <DonutChart percentage={82} color="#38bdf8" size={160} />
</div> </div>
<div className="panel p-6 flex flex-col items-center"> <div className="s-card p-6 flex flex-col items-center">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4"> <h3 className="s-t mb-4">
Network (45%) Network (45%)
</h3> </h3>
<DonutChart percentage={45} color="#7ab8ff" size={160} /> <DonutChart percentage={45} color="var(--accent-primary)" size={160} />
</div> </div>
</div> </div>
</section> </section>
@@ -222,7 +221,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="#b4e34a" height={76} />} chart={<LineChart data={sampleLineData} color="#38bdf8" height={76} />}
onClick={() => handleCardClick('CPU Usage')} onClick={() => handleCardClick('CPU Usage')}
animationDelay={0.04} animationDelay={0.04}
/> />
@@ -233,7 +232,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="#f2c94c" height={76} />} chart={<LineChart data={sampleLineData.map(v => v + 20)} color="var(--warning)" height={76} />}
onClick={() => handleCardClick('Memory')} onClick={() => handleCardClick('Memory')}
animationDelay={0.09} animationDelay={0.09}
/> />
@@ -276,7 +275,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="#7ab8ff" height={130} />} chart={<LineAreaChart data={sampleAreaData.map(v => v + 10)} color="var(--accent-primary)" 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">
@@ -313,8 +312,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: '#7ab8ff', fillOpacity: 0.15 }, { data: sampleLineData.map(v => v + 20), color: 'var(--accent-primary)', fillOpacity: 0.15 },
{ data: sampleLineData.map(v => v + 10), color: '#f2c94c', fillOpacity: 0.15 } { data: sampleLineData.map(v => v + 10), color: 'var(--warning)', fillOpacity: 0.15 }
]} ]}
onClick={() => handleCardClick('Performance')} onClick={() => handleCardClick('Performance')}
animationDelay={0.09} animationDelay={0.09}
@@ -331,7 +330,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="panel p-6 space-y-4"> <div className="s-card 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" />
@@ -358,7 +357,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="panel p-6 space-y-4"> <div className="s-card 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">
@@ -387,7 +386,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="panel p-6 space-y-6"> <div className="s-card 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">
@@ -420,9 +419,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="panel p-6 space-y-4"> <div className="s-card 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="#6b6e7d" strokeWidth="2" strokeLinecap="round"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" 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>
@@ -435,7 +434,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="panel p-6"> <div className="s-card 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: '#7ab8ff', fillOpacity: 0.15 }, { data: perfData1, color: 'var(--accent-primary)', fillOpacity: 0.15 },
{ data: perfData2, color: '#f2c94c', fillOpacity: 0.15 } { data: perfData2, color: 'var(--warning)', 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,9 +107,8 @@ export function ServiceMetricsDashboard() {
}; };
return ( return (
<div className="min-h-screen" style={{ background: '#16171c' }}> <div className="min-h-screen" style={{ background: 'var(--bg-void)' }}>
{/* 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 */}
@@ -117,8 +116,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: '#111217', background: 'var(--bg-surface)',
borderRight: '1px solid rgba(255,255,255,0.07)', borderRight: '1px solid var(--border-subtle)',
padding: '16px 0', padding: '16px 0',
gap: '5px' gap: '5px'
}} }}
@@ -169,10 +168,10 @@ export function ServiceMetricsDashboard() {
style={{ style={{
width: '34px', width: '34px',
height: '34px', height: '34px',
background: '#22233a', background: 'var(--accent-primary-soft)',
fontSize: '11px', fontSize: '11px',
fontWeight: 700, fontWeight: 700,
color: '#9295a4', color: 'var(--text-tertiary)',
marginTop: '4px', marginTop: '4px',
letterSpacing: '-0.3px' letterSpacing: '-0.3px'
}} }}
@@ -188,8 +187,8 @@ export function ServiceMetricsDashboard() {
className="shrink-0 flex items-center" className="shrink-0 flex items-center"
style={{ style={{
height: '52px', height: '52px',
background: '#111217', background: 'var(--bg-surface)',
borderBottom: '1px solid rgba(255,255,255,0.07)', borderBottom: '1px solid var(--border-subtle)',
padding: '0 22px', padding: '0 22px',
gap: '14px' gap: '14px'
}} }}
@@ -201,22 +200,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-[#9295a4] font-medium cursor-pointer" className="rounded-[9px] border bg-transparent text-[var(--text-tertiary)] font-medium cursor-pointer"
style={{ style={{
height: '32px', height: '32px',
padding: '0 14px', padding: '0 14px',
border: '1px solid rgba(255,255,255,0.1)', border: '1px solid var(--border-default)',
fontSize: '13px' fontSize: '13px'
}} }}
> >
Support Support
</button> </button>
<button <button
className="rounded-[9px] border-none flex items-center text-[#e8e9f0] font-medium cursor-pointer" className="rounded-[9px] border-none flex items-center text-[var(--text-primary)] font-medium cursor-pointer"
style={{ style={{
height: '32px', height: '32px',
padding: '0 14px', padding: '0 14px',
background: 'rgba(255,255,255,0.08)', background: 'var(--bg-overlay)',
fontSize: '13px', fontSize: '13px',
gap: '6px' gap: '6px'
}} }}
@@ -229,10 +228,10 @@ export function ServiceMetricsDashboard() {
style={{ style={{
width: '32px', width: '32px',
height: '32px', height: '32px',
border: '1px solid rgba(255,255,255,0.1)' border: '1px solid var(--border-default)'
}} }}
> >
<Bell size={15} color="#9295a4" /> <Bell size={15} />
</button> </button>
</div> </div>
</header> </header>
@@ -245,22 +244,22 @@ export function ServiceMetricsDashboard() {
style={{ style={{
gap: '6px', gap: '6px',
padding: '14px 0 10px', padding: '14px 0 10px',
color: '#6b6e7d', color: 'var(--text-tertiary)',
fontSize: '13px' fontSize: '13px'
}} }}
> >
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#6b6e7d" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" 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: '#6b6e7d' }} style={{ color: 'var(--text-tertiary)' }}
className="hover:text-[#9295a4] transition-colors" className="hover:text-[var(--text-tertiary)] transition-colors"
> >
Servers Servers
</button> </button>
<span style={{ opacity: 0.4 }}>/</span> <span style={{ opacity: 0.4 }}>/</span>
<span style={{ color: '#9295a4' }}>[NuFest] - App Project</span> <span style={{ color: 'var(--text-tertiary)' }}>[NuFest] - App Project</span>
</div> </div>
{/* Project Header */} {/* Project Header */}
@@ -280,7 +279,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: '#e8e9f0' }}> <span style={{ fontSize: '20px', fontWeight: 800, letterSpacing: '-0.5px', color: 'var(--text-primary)' }}>
[NuFest] - App Project [NuFest] - App Project
</span> </span>
<span className={`badge-${isSystemRunning ? 'active' : 'stopped'}`}> <span className={`badge-${isSystemRunning ? 'active' : 'stopped'}`}>
@@ -293,8 +292,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-[#9295a4] transition-colors" className="flex items-center hover:text-[var(--text-tertiary)] transition-colors"
style={{ color: '#6b6e7d', fontSize: '12.5px', textDecoration: 'none', gap: '4px' }} style={{ color: 'var(--text-tertiary)', 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">
@@ -304,8 +303,8 @@ export function ServiceMetricsDashboard() {
</svg> </svg>
</a> </a>
<button <button
className="flex items-center hover:text-[#9295a4] transition-colors" className="flex items-center hover:text-[var(--text-tertiary)] transition-colors"
style={{ color: '#6b6e7d', fontSize: '12.5px', gap: '4px' }} style={{ color: 'var(--text-tertiary)', 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">
@@ -344,7 +343,7 @@ export function ServiceMetricsDashboard() {
<div <div
className="flex" className="flex"
style={{ style={{
borderBottom: '1px solid rgba(255,255,255,0.07)', borderBottom: '1px solid var(--border-subtle)',
marginBottom: '18px' marginBottom: '18px'
}} }}
> >
@@ -365,16 +364,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: '#e8e9f0' }}>Metrics</span> <span style={{ fontSize: '15px', fontWeight: 700, color: 'var(--text-primary)' }}>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 rgba(255,255,255,0.09)', border: '1px solid var(--border-default)',
background: 'rgba(255,255,255,0.04)', background: 'var(--bg-elevated)',
color: '#9295a4', color: 'var(--text-tertiary)',
fontSize: '12.5px', fontSize: '12.5px',
gap: '6px' gap: '6px'
}} }}
@@ -408,7 +407,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="#b4e34a" height={76} />} chart={<LineChart data={metrics.cpuData} color="#38bdf8" height={76} />}
animationDelay={0.04} animationDelay={0.04}
/> />
@@ -418,23 +417,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: '#e8e9f0' }}>RAM Usage</span> <span style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)' }}>RAM Usage</span>
</div> </div>
<div style={{ fontSize: 38, fontWeight: 900, letterSpacing: '-1.5px', lineHeight: 1, color: '#e8e9f0' }}> <div style={{ fontSize: 38, fontWeight: 900, letterSpacing: '-1.5px', lineHeight: 1, color: 'var(--text-primary)' }}>
{metrics.ram}% {metrics.ram}%
</div> </div>
<div style={{ fontSize: 12, color: '#6b6e7d', marginTop: 4 }}> <div style={{ fontSize: 12, color: 'var(--text-tertiary)', marginTop: 4 }}>
<span style={{ color: '#f2c94c', fontWeight: 700 }}>Average</span> Daily usage <span style={{ color: 'var(--warning)', 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="#f2c94c" /> <DonutChart percentage={metrics.ram} color="var(--warning)" />
<div style={{ position: 'absolute', bottom: 14, textAlign: 'center' }}> <div style={{ position: 'absolute', bottom: 14, textAlign: 'center' }}>
<div style={{ fontSize: 10.5, color: '#6b6e7d', marginBottom: 1 }}>Used</div> <div style={{ fontSize: 10.5, color: 'var(--text-tertiary)', marginBottom: 1 }}>Used</div>
<div style={{ fontSize: 12.5, fontWeight: 700, color: '#e8e9f0' }}>{metrics.ramUsedGB} GB / 8GB</div> <div style={{ fontSize: 12.5, fontWeight: 700, color: 'var(--text-primary)' }}>{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: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}>Details</span> <span style={{ fontSize: 13, color: 'var(--text-tertiary)', 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,6 +9,7 @@ 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.
@@ -39,7 +40,7 @@ export function SetupWizardPage() {
mutationFn: completeSetup, mutationFn: completeSetup,
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['setup-status'] }); queryClient.invalidateQueries({ queryKey: ['setup-status'] });
navigate('/projects', { replace: true }); navigate('/', { replace: true });
}, },
}); });
@@ -53,7 +54,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="/projects" replace />; return <Navigate to="/" replace />;
} }
const steps: { key: Step; label: string }[] = [ const steps: { key: Step; label: string }[] = [
@@ -92,9 +93,12 @@ export function SetupWizardPage() {
</div> </div>
{step === 'account' ? ( {step === 'account' ? (
<section className="panel p-6"> <section className="s-card">
<h1 className="text-xl font-bold text-[var(--text-primary)]">Welcome to Containr</h1> <div className="s-cardhead mb-1">
<p className="mt-2 text-sm text-[var(--text-secondary)]"> <span className="s-ibox"><Rocket /></span>
<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>
@@ -105,8 +109,7 @@ export function SetupWizardPage() {
</p> </p>
<Link <Link
to="/auth/sign-in?redirect=/setup" to="/auth/sign-in?redirect=/setup"
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)]" className="mt-4 s-btn-accent inline-flex items-center gap-2"
style={{ background: 'var(--accent-primary)' }}
> >
Sign in <ChevronRight size={14} /> Sign in <ChevronRight size={14} />
</Link> </Link>
@@ -114,8 +117,7 @@ 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 inline-flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)]" className="mt-4 s-btn-accent inline-flex items-center gap-2"
style={{ background: 'var(--accent-primary)' }}
> >
Create admin account <ChevronRight size={14} /> Create admin account <ChevronRight size={14} />
</Link> </Link>
@@ -127,9 +129,9 @@ export function SetupWizardPage() {
{step === 'tunnel' ? <TunnelStep onNext={() => setOverride('done')} /> : null} {step === 'tunnel' ? <TunnelStep onNext={() => setOverride('done')} /> : null}
{step === 'done' ? ( {step === 'done' ? (
<section className="panel p-6 text-center"> <section className="s-card text-center">
<Rocket size={28} className="mx-auto text-[var(--accent-primary)]" /> <div className="s-ibox mx-auto !h-10 !w-10"><ShieldCheck /></div>
<h1 className="mt-4 text-xl font-bold text-[var(--text-primary)]">All set</h1> <h1 className="mt-4 font-headline text-lg 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.
@@ -141,12 +143,10 @@ export function SetupWizardPage() {
: 'Failed to save setup state'} : 'Failed to save setup state'}
</p> </p>
) : null} ) : null}
<button <GhostBtn
type="button"
onClick={() => completeMutation.mutate()} onClick={() => completeMutation.mutate()}
disabled={completeMutation.isPending} disabled={completeMutation.isPending}
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" className="mt-5 !h-10 !px-6"
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
</button> </GhostBtn>
</section> </section>
) : null} ) : null}
</div> </div>
@@ -185,12 +185,12 @@ function GitHubStep({ onNext }: { onNext: () => void }) {
}); });
return ( return (
<section className="panel p-6"> <section className="s-card">
<div className="flex items-center gap-3 mb-3"> <div className="s-cardhead mb-1">
<Github size={20} className="text-[var(--text-primary)]" /> <span className="s-ibox"><Github /></span>
<h1 className="text-xl font-bold text-[var(--text-primary)]">Connect a git provider</h1> <h1 className="s-t">Connect a Git Provider</h1>
</div> </div>
<p className="text-sm text-[var(--text-secondary)]"> <p className="mt-1 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,24 +201,15 @@ 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-3"> <div className="mt-5 flex items-center gap-2">
<button <GhostBtn
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={14} className="animate-spin" /> : null} {provisionMutation.isPending ? <Loader2 size={13} className="animate-spin" /> : null}
Set up GitHub App Set up GitHub App
</button> </GhostBtn>
<button <QuietBtn onClick={onNext}>Skip for now</QuietBtn>
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>
); );
@@ -237,9 +228,12 @@ function TunnelStep({ onNext }: { onNext: () => void }) {
}); });
return ( return (
<section className="panel p-6"> <section className="s-card">
<h1 className="text-xl font-bold text-[var(--text-primary)]">Public access (optional)</h1> <div className="s-cardhead mb-1">
<p className="mt-2 text-sm text-[var(--text-secondary)]"> <span className="s-ibox"><ShieldCheck /></span>
<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>
@@ -248,31 +242,22 @@ 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)] 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)] v-mono 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-3"> <div className="mt-5 flex items-center gap-2">
<button <GhostBtn
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={14} className="animate-spin" /> : null} {saveMutation.isPending ? <Loader2 size={13} className="animate-spin" /> : null}
{token.trim() ? 'Save tunnel token' : 'Continue'} {token.trim() ? 'Save tunnel token' : 'Continue'}
</button> </GhostBtn>
<button <QuietBtn onClick={onNext}>Skip</QuietBtn>
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,6 +17,7 @@ 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,
@@ -57,7 +58,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="mono max-w-[120px] truncate text-[10.5px] text-[var(--text-secondary)]"> <span className="v-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>
@@ -120,8 +121,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(180,227,74,0.55)' }, markerEnd: { type: MarkerType.ArrowClosed, width: 14, height: 14, color: 'rgba(56, 189, 248,0.55)' },
style: { stroke: 'rgba(180,227,74,0.42)', strokeWidth: 1.6 }, style: { stroke: 'rgba(56, 189, 248,0.42)', strokeWidth: 1.6 },
className: 'edge-premium', className: 'edge-premium',
})), })),
), ),
@@ -239,7 +240,7 @@ function BuilderInner({
key={t} key={t}
type="button" type="button"
onClick={() => addService(t)} onClick={() => addService(t)}
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)]" className="s-chip !py-1.5"
> >
<Icon size={12} className={meta.cls} /> <Icon size={12} className={meta.cls} />
{meta.label} {meta.label}
@@ -263,7 +264,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="rgba(255,255,255,0.07)" /> <Background variant={BackgroundVariant.Dots} gap={22} size={1.2} color="var(--tint-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
@@ -295,21 +296,16 @@ function BuilderInner({
</div> </div>
) : null} ) : null}
<div className="flex gap-2"> <div className="flex gap-2">
<button <QuietBtn onClick={onCancel} className="flex-1 justify-center">
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
</button> </QuietBtn>
<button <GhostBtn
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 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-3 py-2 text-xs font-semibold text-[var(--accent-on)] disabled:opacity-50" className="flex-1 justify-center"
> >
Apply to JSON Apply to JSON
</button> </GhostBtn>
</div> </div>
</div> </div>
</div> </div>
@@ -409,7 +405,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} mono min-h-[70px] resize-y`} className={`${FIELD_CLS} v-mono min-h-[70px] resize-y`}
defaultValue={envText} defaultValue={envText}
spellCheck={false} spellCheck={false}
onBlur={(e) => { onBlur={(e) => {
@@ -425,7 +421,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} mono min-h-[50px] resize-y`} className={`${FIELD_CLS} v-mono min-h-[50px] resize-y`}
defaultValue={volText} defaultValue={volText}
spellCheck={false} spellCheck={false}
onBlur={(e) => { onBlur={(e) => {
@@ -18,6 +18,7 @@ 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,
@@ -489,67 +490,51 @@ export function TemplatesPage() {
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
{/* Header */} <div className="w-full px-4 pt-6 sm:px-8">
<div className="border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/50 backdrop-blur-sm"> <SPageHead
<div className="w-full px-8 py-4"> title="Template Catalog"
<div className="flex items-center justify-between gap-4"> titleAccent="_"
<div> sub={`${filteredTemplates.length} templates · deploy services from pre-configured stacks`}
<h1 className="v-title">Template Catalog<span className="v-cursor">_</span></h1> actions={signedIn && !isDemoMode ? (
<p className="text-sm text-[var(--text-secondary)]">Deploy services from pre-configured templates</p> <>
</div> <QuietBtn
<div className="flex items-center gap-3 text-sm text-[var(--text-tertiary)]"> onClick={() => {
<Layers size={16} /> setComposeYaml('');
<span>{filteredTemplates.length} templates</span> setComposeWarnings([]);
{signedIn && !isDemoMode ? ( setComposeError(null);
<> setComposeOpen(true);
<button }}
type="button" >
onClick={() => { <FileCode size={13} /> Import Compose
setComposeYaml(''); </QuietBtn>
setComposeWarnings([]); <GhostBtn onClick={openCreateEditor}>
setComposeError(null); <Plus size={13} /> New template
setComposeOpen(true); </GhostBtn>
}} </>
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)]" ) : undefined}
> />
<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="px-4 py-3 rounded-[var(--radius-md)] border border-[var(--warning-soft)] bg-[var(--warning-soft)]/50"> <div className="s-inset flex items-center gap-2 text-xs text-[var(--warning)]">
<div className="flex items-center gap-2 text-sm text-[var(--warning)]"> <Sparkles size={13} />
<Sparkles size={16} /> Demo mode active — using sample data
<span>Demo mode active — using sample data</span>
</div>
</div> </div>
</div> </div>
)} )}
{/* Main Content */} {/* Main Content */}
<div className="w-full px-8 py-6"> <div className="w-full px-4 py-6 sm:px-8">
<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="panel overflow-hidden"> <section className="s-card 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">
@@ -614,21 +599,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' ? '#7ab8ff' : template.category === 'frontend' ? '#f2c94c' : template.category === 'web' ? 'var(--accent-primary)' : '#9295a4'; const categoryColor = template.category === 'database' ? 'var(--info)' : template.category === 'frontend' ? 'var(--warning)' : template.category === 'web' ? 'var(--accent-primary)' : 'var(--text-tertiary)';
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)] bg-[var(--accent-primary-soft)] shadow-lg shadow-[var(--accent-primary-glow)]' ? 'border-[var(--accent-primary)]/60 bg-[var(--accent-primary-soft)]'
: '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-2 ring-white/20' : '' selected ? 'ring-1 ring-[var(--accent-primary)]/40' : ''
}`} }`}
style={{ style={{
background: selected ? categoryColor : `${categoryColor}20`, background: selected ? categoryColor : `${categoryColor}20`,
@@ -691,12 +676,10 @@ export function TemplatesPage() {
</section> </section>
{/* Template Detail */} {/* Template Detail */}
<section className="panel p-6"> <section className="s-card p-5">
{!selectedTemplateId ? ( {!selectedTemplateId ? (
<div className="py-16 text-center"> <div className="py-16 text-center">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-[var(--surface-muted)] flex items-center justify-center"> <div className="s-ibox mx-auto mb-4 !w-14 !h-14"><Layers size={22} /></div>
<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}
@@ -722,15 +705,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="w-14 h-14 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center"> <div className="s-ibox !w-14 !h-14 !rounded-xl text-[var(--accent-primary)]">
{(() => { {(() => {
const Icon = categoryIcon(selectedDetail.template.category); const Icon = categoryIcon(selectedDetail.template.category);
return <Icon size={24} className="text-[var(--accent-primary)]" />; return <Icon size={24} />;
})()} })()}
</div> </div>
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h2 className="text-xl font-semibold text-[var(--text-primary)]">{selectedDetail.template.name}</h2> <h2 className="font-headline text-lg 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)]" />
@@ -738,7 +721,7 @@ export function TemplatesPage() {
</span> </span>
)} )}
{!selectedDetail.template.isOfficial && selectedDetail.template.isPublic && ( {!selectedDetail.template.isOfficial && selectedDetail.template.isPublic && (
<span className="px-2 py-0.5 rounded-full bg-[var(--info-soft)] text-[var(--info)] text-xs font-medium"> <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]">
Public Public
</span> </span>
)} )}
@@ -747,10 +730,10 @@ export function TemplatesPage() {
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="px-3 py-1 rounded-full border border-[var(--border-subtle)] text-xs text-[var(--text-tertiary)]"> <span className="s-chip v-mono !text-[10.5px]">
{categoryLabel(selectedDetail.template.category)} {categoryLabel(selectedDetail.template.category)}
</span> </span>
<span className="px-3 py-1 rounded-full border border-[var(--border-subtle)] text-xs text-[var(--text-tertiary)]"> <span className="s-chip v-mono !text-[10.5px]">
{selectedDetail.config.runtime || 'n/a'} {selectedDetail.config.runtime || 'n/a'}
</span> </span>
{canManageTemplate(selectedDetail.template) ? ( {canManageTemplate(selectedDetail.template) ? (
@@ -777,25 +760,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="panel-soft p-4"> <div className="s-inset">
<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="mono text-sm text-[var(--text-primary)] mt-2">{selectedDetail.config.type || '—'}</p> <p className="v-mono text-sm text-[var(--text-primary)] mt-2">{selectedDetail.config.type || '—'}</p>
</div> </div>
<div className="panel-soft p-4"> <div className="s-inset">
<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="mono text-sm text-[var(--text-primary)] mt-2">{selectedDetail.config.port || '—'}</p> <p className="v-mono text-sm text-[var(--text-primary)] mt-2">{selectedDetail.config.port || '—'}</p>
</div> </div>
<div className="panel-soft p-4"> <div className="s-inset">
<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="mono text-xs text-[var(--text-primary)] mt-2 truncate">{selectedDetail.config.buildCommand || '—'}</p> <p className="v-mono text-xs text-[var(--text-primary)] mt-2 truncate">{selectedDetail.config.buildCommand || '—'}</p>
</div> </div>
<div className="panel-soft p-4"> <div className="s-inset">
<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="mono text-xs text-[var(--text-primary)] mt-2 truncate">{selectedDetail.config.startCommand || '—'}</p> <p className="v-mono text-xs text-[var(--text-primary)] mt-2 truncate">{selectedDetail.config.startCommand || '—'}</p>
</div> </div>
</div> </div>
<div className="panel-soft p-4 mb-6"> <div className="s-inset 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.
@@ -808,7 +791,7 @@ export function TemplatesPage() {
</div> </div>
{isGraphTemplate ? ( {isGraphTemplate ? (
<div className="panel-soft p-4 mb-6"> <div className="s-inset 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)]">
@@ -824,14 +807,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="mono text-[var(--text-primary)] truncate">{member.name || member.key}</span> <span className="v-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="mono text-[11px] text-[var(--text-tertiary)] truncate"> <span className="v-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>
@@ -845,26 +828,28 @@ 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="flex items-center gap-3 mb-4"> <div className="s-cardhead !mb-4">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--success-soft)] flex items-center justify-center"> <span className="s-ibox"><Play /></span>
<Play size={18} className="text-[var(--success)]" />
</div>
<div> <div>
<h3 className="text-lg font-semibold text-[var(--text-primary)]">Deploy from Template</h3> <span className="s-t">Deploy from Template</span>
<p className="text-sm text-[var(--text-secondary)]"> <p className="v-mono text-[10.5px] text-[var(--text-tertiary)] mt-0.5">
{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>
{!signedIn ? ( {sessionQuery.isPending && !isDemoMode ? (
<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="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)]" className="s-btn-accent mt-3 text-[12.5px]"
> >
Sign in <ArrowRight size={13} /> Sign in <ArrowRight size={13} />
</Link> </Link>
@@ -970,7 +955,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="mono font-medium">{lastDeployment.serviceName}</span> created</>} : <>Service <span className="v-mono font-medium">{lastDeployment.serviceName}</span> created</>}
</span> </span>
<Link <Link
to={lastDeployment.created to={lastDeployment.created
@@ -986,7 +971,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="mono">{m.name}</span> <span className="v-mono">{m.name}</span>
<span className="opacity-70">({m.kind})</span> <span className="opacity-70">({m.kind})</span>
</div> </div>
))} ))}
@@ -999,8 +984,8 @@ export function TemplatesPage() {
<button <button
onClick={() => deployMutation.mutate()} onClick={() => deployMutation.mutate()}
disabled={isDeployDisabled} disabled={isDeployDisabled}
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" className="s-btn-accent h-11 disabled:opacity-50 disabled:cursor-not-allowed"
style={{ background: 'var(--accent-primary)' }}
> >
{deployMutation.isPending ? ( {deployMutation.isPending ? (
<> <>
@@ -1133,7 +1118,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="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="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"
/> />
)} )}
{composeWarnings.length > 0 ? ( {composeWarnings.length > 0 ? (
@@ -1171,7 +1156,7 @@ export function TemplatesPage() {
type="button" type="button"
onClick={submitEditor} onClick={submitEditor}
disabled={saveTemplateMutation.isPending} disabled={saveTemplateMutation.isPending}
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" className="s-btn-accent 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'}
@@ -1223,7 +1208,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="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="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"
/> />
) : ( ) : (
<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">
@@ -1234,7 +1219,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="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="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)]"
/> />
</label> </label>
<label className="text-xs text-[var(--text-tertiary)]"> <label className="text-xs text-[var(--text-tertiary)]">
@@ -1244,7 +1229,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="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="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)]"
/> />
</label> </label>
<label className="text-xs text-[var(--text-tertiary)]"> <label className="text-xs text-[var(--text-tertiary)]">
@@ -1254,7 +1239,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="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="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)]"
/> />
</label> </label>
<p className="text-[11px] text-[var(--text-tertiary)]"> <p className="text-[11px] text-[var(--text-tertiary)]">
@@ -1279,7 +1264,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="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" className="s-btn-accent 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,11 +8,12 @@ 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,
@@ -30,17 +31,6 @@ 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,
@@ -63,59 +53,50 @@ function WebhookRow({
}); });
return ( return (
<div className="panel p-4"> <div className="s-card !p-0">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3 px-4 py-3">
<button onClick={() => setExpanded((v) => !v)} className="text-[var(--text-tertiary)]"> <span className="s-ibox shrink-0"><Webhook /></span>
{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 && ( {!webhook.enabled && <SPill tone="off">disabled</SPill>}
<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="text-xs text-[var(--text-muted)] mono truncate">{webhook.url}</p> <p className="v-mono text-[10.5px] text-[var(--text-muted)] truncate">{webhook.url}</p>
</div> </div>
<div className="hidden md:flex gap-1"> <div className="hidden md:flex gap-1.5">
{webhook.events.map((ev) => ( {webhook.events.map((ev) => (
<span <span key={ev} className="s-chip">{ev}</span>
key={ev}
className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--accent-primary-soft)] text-[var(--accent-primary)] mono"
>
{ev}
</span>
))} ))}
</div> </div>
<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="p-2 rounded-[var(--radius-md)] text-[var(--text-tertiary)] hover:text-[var(--accent-primary)] disabled:opacity-40 transition-colors" className="s-icon-btn disabled:opacity-40"
title="Send test ping" title="Send test ping"
> >
{testing ? <Loader2 size={15} className="animate-spin" /> : <Send size={15} />} {testing ? <Loader2 size={13} className="animate-spin" /> : <Send size={13} />}
</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="p-2 rounded-[var(--radius-md)] text-[var(--text-tertiary)] hover:text-[var(--error)] transition-colors" className="s-icon-btn !text-[var(--text-tertiary)] hover:!text-[var(--error)]"
title="Delete webhook" title="Delete webhook"
> >
<Trash2 size={15} /> <Trash2 size={13} />
</button> </button>
</div> </div>
</div> </div>
{expanded && ( {expanded && (
<div className="mt-3 border-t border-[var(--border-subtle)] pt-3"> <div className="border-t border-[var(--border-subtle)] bg-[var(--surface-muted)]/40 px-4 py-3">
<p className="text-[11px] uppercase tracking-wider text-[var(--text-muted)] mb-2"> <p className="v-mono text-[10px] uppercase tracking-[0.12em] text-[var(--text-muted)] mb-2">
Recent deliveries Recent deliveries
</p> </p>
{deliveriesQuery.isLoading ? ( {deliveriesQuery.isLoading ? (
@@ -123,10 +104,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"> <div className="space-y-1.5">
{(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-xs mono"> <div key={d.id} className="flex items-center gap-3 text-[11px] v-mono">
<span className={statusClass(d.status)}>{d.status}</span> <SPill tone={statusTone(d.status)}>{d.status}</SPill>
<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>
@@ -207,46 +188,41 @@ export function WebhooksPage() {
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<div className="border-b border-[var(--border-subtle)]"> <div className="w-full px-4 pt-6 sm:px-8">
<div className="w-full px-8 py-5 flex items-center justify-between"> <SPageHead
<div> title="Webhooks"
<h1 className="v-title">Webhooks<span className="v-cursor">_</span></h1> titleAccent="_"
<p className="v-mono mt-1.5 text-[11px] text-[var(--text-tertiary)]"> sub="Signed HTTP delivery for every platform event — deploys, restarts, deletes"
Signed HTTP delivery for every platform event — deploys, restarts, deletes actions={
</p> <GhostBtn onClick={() => setCreating(true)}>
</div> <Plus size={13} /> New webhook
<button </GhostBtn>
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-8 py-6 space-y-4 max-w-4xl"> <div className="w-full px-4 sm:px-8 space-y-4 max-w-4xl">
{secret && ( {secret && (
<div className="panel-soft p-4 flex items-center gap-3"> <div className="s-inset 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="text-xs text-[var(--text-muted)]"> <p className="v-mono text-[11px] text-[var(--text-muted)]">
Verify deliveries with <span className="mono">X-Containr-Signature</span> Verify deliveries with <span className="v-mono">X-Containr-Signature</span>
</p> </p>
<code className="block mt-2 text-xs mono text-[var(--accent-primary)] break-all">{secret}</code> <code className="block mt-2 v-mono text-xs 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="p-2 text-[var(--text-tertiary)] hover:text-[var(--text-primary)]" className="s-icon-btn"
> >
<Copy size={15} /> <Copy size={13} />
</button> </button>
<button onClick={() => setSecret(null)} className="p-2 text-[var(--text-tertiary)]"> <button onClick={() => setSecret(null)} className="s-icon-btn">
<X size={15} /> <X size={13} />
</button> </button>
</div> </div>
)} )}
@@ -254,12 +230,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="panel p-8 text-center"> <div className="s-card py-10 text-center">
<Webhook size={28} className="mx-auto text-[var(--text-muted)] mb-3" /> <div className="s-ibox mx-auto mb-3"><Webhook /></div>
<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="text-xs text-[var(--text-muted)] mt-1"> <p className="v-mono text-[11px] text-[var(--text-muted)] mt-1">
Subscribe an HTTP endpoint to platform events like <span className="mono">service.*</span> or{' '} Subscribe an HTTP endpoint to platform events like <span className="v-mono">service.*</span> or{' '}
<span className="mono">deployment.fail</span>. <span className="v-mono">deployment.fail</span>.
</p> </p>
</div> </div>
) : ( ) : (
@@ -283,8 +259,11 @@ 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 panel p-6"> <div className="relative w-full max-w-md s-card">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">New webhook</h3> <div className="s-cardhead mb-4">
<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">
@@ -304,7 +283,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 mono`} className={`${inputClass} w-full v-mono`}
placeholder="https://example.com/hooks/containr" placeholder="https://example.com/hooks/containr"
/> />
</div> </div>
@@ -317,30 +296,21 @@ export function WebhooksPage() {
<button <button
key={ev} key={ev}
onClick={() => toggleEvent(ev)} onClick={() => toggleEvent(ev)}
className={`px-2.5 py-1 rounded-[var(--radius-md)] border text-xs mono transition-colors ${ className={`s-chip v-mono ${form.events.includes(ev) ? 'on' : ''}`}
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 text-[11px] text-[var(--text-muted)]"> <p className="mt-2 v-mono text-[10.5px] text-[var(--text-muted)]">
Events are <span className="mono">resource.action</span> — wildcards like{' '} Events are <span className="v-mono">resource.action</span> — wildcards like{' '}
<span className="mono">service.*</span> match the whole resource. <span className="v-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">
<button <QuietBtn onClick={() => setCreating(false)}>Cancel</QuietBtn>
onClick={() => setCreating(false)} <GhostBtn
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 ||
@@ -348,12 +318,10 @@ 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={14} className="animate-spin" />} {createMutation.isPending && <Loader2 size={13} className="animate-spin" />}
Create Create
</button> </GhostBtn>
</div> </div>
</div> </div>
</div> </div>
@@ -167,12 +167,12 @@ function toFlowEdges(
}, },
markerEnd: { markerEnd: {
type: MarkerType.ArrowClosed, type: MarkerType.ArrowClosed,
color: 'rgba(180, 227, 74, 0.55)', color: 'color-mix(in srgb, var(--accent-primary) 55%, transparent)',
width: 13, width: 13,
height: 13, height: 13,
}, },
style: { style: {
stroke: 'rgba(180, 227, 74, 0.42)', stroke: 'color-mix(in srgb, var(--accent-primary) 42%, transparent)',
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 mono w-12 text-[11px]" className="canvas-pill-btn v-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 'rgba(255,255,255,0.08)'; return 'var(--tint-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="rgba(255,255,255,0.055)" /> <Background variant={BackgroundVariant.Dots} gap={26} size={1.2} color="var(--tint-05)" />
<MiniMap <MiniMap
position="bottom-right" position="bottom-right"
pannable pannable
zoomable zoomable
nodeColor={minimapNodeColor} nodeColor={minimapNodeColor}
bgColor="rgba(14, 15, 18, 0.92)" bgColor="var(--surface-glass)"
maskColor="rgba(12, 13, 15, 0.72)" maskColor="var(--surface-glass)"
className="canvas-minimap hidden md:block" className="canvas-minimap hidden md:block"
/> />
</ReactFlow> </ReactFlow>
@@ -937,21 +937,19 @@ 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 flex items-center gap-0.5 p-1"> <div className="canvas-pill !rounded-[9px] p-1">
{['all', ...environments].map((env) => ( <span className="s-seg">
<button {['all', ...environments].map((env) => (
key={env} <button
type="button" key={env}
onClick={() => setEnvFilter(env)} type="button"
className={`px-2.5 py-1 rounded-full text-[11px] font-medium capitalize transition-all ${ onClick={() => setEnvFilter(env)}
envFilter === env className={`!w-auto !h-auto px-2.5 py-1 capitalize v-mono text-[10.5px] ${envFilter === env ? 'on' : ''}`}
? 'bg-[var(--surface-card)] text-[var(--text-primary)] shadow-sm' >
: 'text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]' {env}
}`} </button>
> ))}
{env} </span>
</button>
))}
</div> </div>
)} )}
{!readOnly && ( {!readOnly && (
@@ -1018,12 +1016,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"> <div className="text-center pointer-events-auto s-card !py-8 !px-10">
<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"> <div className="s-ibox mx-auto mb-4 !h-11 !w-11">
<Rocket size={24} className="text-[var(--accent-primary)]" /> <Rocket />
</div> </div>
<h3 className="text-base font-semibold text-[var(--text-primary)]">Deploy your first service</h3> <h3 className="font-headline text-base font-semibold text-[var(--text-primary)]">Deploy your first service</h3>
<p className="text-sm text-[var(--text-tertiary)] mt-1 mb-4 max-w-xs"> <p className="v-mono text-[11.5px] text-[var(--text-tertiary)] mt-1.5 mb-5 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>
@@ -1031,10 +1029,9 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
<button <button
type="button" type="button"
onClick={onAddService} onClick={onAddService}
className="inline-flex items-center gap-2 h-9 px-4 rounded-lg text-[var(--accent-on)] text-sm font-medium shadow-lg" className="s-btn-accent inline-flex items-center gap-2"
style={{ background: 'var(--accent-primary)' }}
> >
<Plus size={15} /> <Plus size={13} />
Add Service Add Service
</button> </button>
)} )}
@@ -1,6 +1,7 @@
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';
@@ -63,28 +64,11 @@ 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="text-[10px] uppercase tracking-wider text-[var(--text-muted)]">{title}</p> <p className="v-mono text-[10px] uppercase tracking-[0.14em] text-[var(--text-muted)]">{title}</p>
{action} {action}
</div> </div>
{children} {children}
@@ -163,48 +147,49 @@ 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="flex items-center gap-3 px-4 pt-4 pb-3 border-b border-[var(--border-subtle)]"> <div className="s-cardhead border-b border-[var(--border-subtle)]">
<div <span
className="w-9 h-9 rounded-[var(--radius-md)] flex items-center justify-center flex-shrink-0" className="s-ibox flex-shrink-0"
style={{ background: `${accent}1c`, color: accent }} style={{ background: `${accent}14`, color: accent, borderColor: `${accent}33` }}
> >
<ServiceIcon service={service} size={16} /> <ServiceIcon service={service} size={15} />
</div> </span>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<h3 className="text-sm font-semibold text-[var(--text-primary)] truncate">{service.name}</h3> <h3 className="text-[13px] font-semibold text-[var(--text-primary)] truncate leading-tight">{service.name}</h3>
<div className="flex items-center gap-1.5 text-[11px] text-[var(--text-tertiary)]"> <div className="flex items-center gap-1.5 mt-1">
<span className={`status-dot ${serviceStatusClass(service.status)}`} /> <SPill tone={statusTone(service.status)}>{statusLabel(service.status)}</SPill>
<span style={{ color: statusColor(service.status) }}>{statusLabel(service.status)}</span> <span className="v-mono text-[10px] text-[var(--text-tertiary)]">
<span>· {service.type}</span> {service.type}
{service.environment && service.environment !== 'production' && <span>· {service.environment}</span>} {service.environment && service.environment !== 'production' ? ` · ${service.environment}` : ''}
</span>
</div> </div>
</div> </div>
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
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)]" className="s-icon-btn"
title="Close (Esc)" title="Close (Esc)"
> >
<X size={14} /> <X size={12} />
</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="flex items-center gap-1.5 group"> <div className="s-inset flex items-center gap-1.5 px-2.5 py-2 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="mono text-[11px] text-[var(--accent-primary)] hover:underline break-all" className="v-mono text-[11px] text-[var(--accent-primary)] hover:underline break-all"
> >
{publicUrl.replace(/^https?:\/\//, '')} {publicUrl.replace(/^https?:\/\//, '')}
</a> </a>
) : ( ) : (
<span className="mono text-[11px] text-[var(--text-secondary)]"> <span className="v-mono text-[11px] text-[var(--text-secondary)]">
{service.name}:{service.port} {service.name}:{service.port}
</span> </span>
)} )}
@@ -218,7 +203,7 @@ export function ServiceInspector({
</button> </button>
</div> </div>
{publicUrl && service.port ? ( {publicUrl && service.port ? (
<p className="mono text-[10.5px] text-[var(--text-tertiary)] mt-0.5 pl-4"> <p className="v-mono text-[10.5px] text-[var(--text-tertiary)] mt-1 pl-1">
{service.name}:{service.port} {service.name}:{service.port}
</p> </p>
) : null} ) : null}
@@ -266,13 +251,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="flex items-center gap-2 px-2 py-1.5 rounded-[var(--radius-sm)] bg-[var(--surface-muted)]/50" className="s-inset !rounded-[6px] flex items-center gap-2 px-2 py-1.5"
> >
<KeyRound size={9} className="text-[var(--text-tertiary)] flex-shrink-0" /> <KeyRound size={9} className="text-[var(--text-tertiary)] flex-shrink-0" />
<span className="mono text-[10px] text-[var(--text-primary)] truncate font-medium"> <span className="v-mono text-[10px] text-[var(--text-primary)] truncate font-medium">
{variable.key} {variable.key}
</span> </span>
<span className="mono text-[10px] text-[var(--text-tertiary)] truncate ml-auto max-w-[130px]" title={variable.isSecret ? 'Secret' : variable.value}> <span className="v-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>
@@ -299,7 +284,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="mono text-[10.5px] text-[var(--text-secondary)] truncate" title={value}> <dd className="v-mono text-[10.5px] text-[var(--text-secondary)] truncate" title={value}>
{value} {value}
</dd> </dd>
</div> </div>
@@ -307,7 +292,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="mono text-[10.5px] text-[var(--text-secondary)]">{service.replicas}</dd> <dd className="v-mono text-[10.5px] text-[var(--text-secondary)]">{service.replicas}</dd>
</div> </div>
)} )}
</dl> </dl>
@@ -318,7 +303,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="mono">{'{{variable}}'}</span> placeholders in env vars. <span className="v-mono">{'{{variable}}'}</span> placeholders in env vars.
</p> </p>
) : ( ) : (
<div className="space-y-1.5"> <div className="space-y-1.5">
@@ -327,13 +312,13 @@ export function ServiceInspector({
key={conn.id} key={conn.id}
type="button" type="button"
onClick={() => onSelectPeer(conn.peerId)} onClick={() => onSelectPeer(conn.peerId)}
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" className="s-inset !rounded-[6px] w-full text-left px-2.5 py-2 hover:border-[var(--border-default)] 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="mono text-[9.5px] text-[var(--text-tertiary)] truncate mt-0.5 pl-4"> <p className="v-mono text-[9.5px] text-[var(--text-tertiary)] truncate mt-0.5 pl-4">
{conn.reasons.join(' · ')} {conn.reasons.join(' · ')}
</p> </p>
</button> </button>
@@ -350,65 +335,58 @@ 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">
<button <GhostBtn
type="button"
onClick={() => onOpenService(service.id)} onClick={() => onOpenService(service.id)}
className="w-full flex items-center justify-center gap-2 h-9 rounded-[var(--radius-md)] text-[var(--accent-on)] text-[13px] font-medium" className="w-full justify-center !h-9"
style={{ background: 'var(--accent-primary)' }}
> >
<ArrowUpRight size={14} /> <ArrowUpRight size={14} />
Open service Open service
</button> </GhostBtn>
<div className="grid grid-cols-3 gap-1.5"> <div className="grid grid-cols-3 gap-1.5">
<button <QuietBtn
type="button"
disabled={actions.pending || readOnly} disabled={actions.pending || readOnly}
onClick={actions.deploy} onClick={actions.deploy}
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" className="justify-center"
> >
{actions.deployPending ? <Loader2 size={11} className="animate-spin" /> : <Rocket size={11} />} {actions.deployPending ? <Loader2 size={11} className="animate-spin" /> : <Rocket size={11} />}
Deploy Deploy
</button> </QuietBtn>
{service.status !== 'running' ? ( {service.status !== 'running' ? (
<button <QuietBtn
type="button"
disabled={actions.pending || readOnly} disabled={actions.pending || readOnly}
onClick={actions.start} onClick={actions.start}
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" className="justify-center"
> >
<Play size={11} /> <Play size={11} />
Start Start
</button> </QuietBtn>
) : ( ) : (
<button <QuietBtn
type="button"
disabled={actions.pending || readOnly} disabled={actions.pending || readOnly}
onClick={actions.restart} onClick={actions.restart}
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" className="justify-center"
> >
{actions.restartPending ? <Loader2 size={11} className="animate-spin" /> : <Play size={11} />} {actions.restartPending ? <Loader2 size={11} className="animate-spin" /> : <Play size={11} />}
Restart Restart
</button> </QuietBtn>
)} )}
<button <QuietBtn
type="button"
disabled={actions.pending || readOnly || service.status !== 'running'} disabled={actions.pending || readOnly || service.status !== 'running'}
onClick={actions.stop} onClick={actions.stop}
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" className="justify-center"
> >
<Square size={11} /> <Square size={11} />
Stop Stop
</button> </QuietBtn>
</div> </div>
<button <QuietBtn
type="button"
disabled={actions.pending || readOnly} disabled={actions.pending || readOnly}
onClick={actions.remove} onClick={actions.remove}
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" className="w-full justify-center !text-[var(--error)] hover:!border-[var(--error)]/50"
> >
<Trash2 size={11} /> <Trash2 size={11} />
Delete service Delete service
</button> </QuietBtn>
</div> </div>
</aside> </aside>
); );
@@ -1,10 +1,11 @@
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;
@@ -65,10 +66,10 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
return ( return (
<div <div
className={`w-full rounded-[var(--radius-lg)] border transition-[border-color,box-shadow,transform] duration-150 group relative overflow-hidden ${ className={`w-full rounded-[10px] border transition-[border-color,box-shadow] duration-150 group relative overflow-hidden ${
selected selected
? '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(--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(--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' : 'border-[var(--border-subtle)] bg-[var(--surface-card)] hover:border-[var(--border-default)]'
}`} }`}
> >
<div <div
@@ -86,39 +87,39 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
style={{ background: stateColor }} style={{ background: stateColor }}
/> />
<div className="relative px-3.5 pt-3.5 pb-3"> <div className="relative">
<div className="flex items-center gap-3 mb-2.5"> <div className="flex items-center gap-2.5 px-3.5 pt-3 pb-2.5">
<div <div
className="w-9 h-9 rounded-[var(--radius-md)] flex items-center justify-center flex-shrink-0 transition-all duration-200" className="w-8 h-8 rounded-[7px] border border-[var(--border-subtle)] flex items-center justify-center flex-shrink-0 transition-all duration-200"
style={{ style={{
background: selected && !brand ? typeColor : `${typeColor}1c`, background: selected && !brand ? typeColor : `${typeColor}14`,
color: selected && !brand ? 'var(--bg-void)' : typeColor, color: selected && !brand ? 'var(--bg-void)' : typeColor,
}} }}
> >
{isTransient ? ( {isTransient ? (
<Loader2 size={16} className="animate-spin" /> <Loader2 size={14} className="animate-spin" />
) : ( ) : (
<ServiceIcon service={service} size={17} /> <ServiceIcon service={service} size={15} />
)} )}
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<h4 className="font-semibold text-[13px] leading-tight text-[var(--text-primary)] truncate tracking-tight"> <h4 className="font-semibold text-[12.5px] leading-tight text-[var(--text-primary)] truncate tracking-tight">
{service.name} {service.name}
</h4> </h4>
<p className="text-[10.5px] text-[var(--text-tertiary)] truncate mt-0.5"> <p className="v-mono text-[10px] 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="flex items-center gap-1 px-1.5 py-0.5 rounded bg-[var(--surface-muted)] text-[10px] font-semibold text-[var(--text-secondary)]"> <span className="s-chip">
<Layers size={9} /> <Layers size={9} />
{replicas} {replicas}
</span> </span>
)} )}
</div> </div>
<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"> <div className="mx-3 mb-2.5 s-inset !rounded-[6px] flex items-center gap-2 px-2.5 py-1.5 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
@@ -126,12 +127,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="text-[10.5px] text-[var(--accent-primary)] truncate mono hover:underline" className="v-mono text-[10.5px] text-[var(--accent-primary)] truncate hover:underline"
> >
{publicUrl.replace(/^https?:\/\//, '')} {publicUrl.replace(/^https?:\/\//, '')}
</a> </a>
) : ( ) : (
<span className="text-[10.5px] text-[var(--text-secondary)] truncate mono" title="Internal network address"> <span className="v-mono text-[10.5px] text-[var(--text-secondary)] truncate" title="Internal network address">
{internalAddr} {internalAddr}
</span> </span>
)} )}
@@ -148,15 +149,10 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
</button> </button>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between px-3.5 py-2 border-t border-[var(--border-subtle)]">
<div className="flex items-center gap-1.5"> <SPill tone={statusTone(service.status)}>{statusLabel(service.status)}</SPill>
<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="text-[9.5px] text-[var(--text-muted)] truncate mono max-w-[110px]" title={service.image}> <span className="v-mono text-[9.5px] text-[var(--text-muted)] truncate max-w-[110px]" title={service.image}>
{service.image} {service.image}
</span> </span>
)} )}
@@ -191,25 +187,25 @@ export function GroupNode({ id, data }: NodeProps<GroupNodeType>) {
return ( return (
<div <div
className="h-full w-full rounded-[var(--radius-xl)] border-2 border-dashed transition-all duration-200 relative" className="h-full w-full rounded-[12px] border 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)' : 'rgba(255,255,255,0.02)', background: isHovered ? 'var(--accent-primary-soft)' : 'var(--tint-03)',
}} }}
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">
<div <span
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors duration-200" className="s-ibox !h-7 !w-7 transition-colors duration-200"
style={{ style={{
background: isHovered ? 'var(--accent-primary-soft)' : 'var(--surface-card)', background: isHovered ? 'var(--accent-primary-soft)' : undefined,
color: isHovered ? 'var(--accent-primary)' : 'var(--text-tertiary)', color: isHovered ? 'var(--accent-primary)' : undefined,
}} }}
> >
<Box size={14} /> <Box />
</div> </span>
{editing ? ( {editing ? (
<input <input
autoFocus autoFocus
@@ -227,7 +223,7 @@ export function GroupNode({ id, data }: NodeProps<GroupNodeType>) {
/> />
) : ( ) : (
<h3 <h3
className="text-sm font-semibold text-[var(--text-primary)] tracking-tight" className="font-headline text-sm font-semibold text-[var(--text-primary)] tracking-tight"
onDoubleClick={(e) => { onDoubleClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setDraft(data.title); setDraft(data.title);
@@ -241,7 +237,7 @@ export function GroupNode({ id, data }: NodeProps<GroupNodeType>) {
</div> </div>
<p <p
className="text-[10px] text-[var(--text-tertiary)] mt-2 transition-opacity duration-200" className="v-mono text-[10px] uppercase tracking-[0.12em] 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 '#7ab8ff'; return 'var(--info)';
case 'database': case 'database':
return '#b4e34a'; return '#38bdf8';
case 'worker': case 'worker':
return '#f2c94c'; return 'var(--warning)';
case 'cron': case 'cron':
return '#f2994a'; return 'var(--accent-secondary)';
default: default:
return '#9295a4'; return 'var(--text-tertiary)';
} }
} }
@@ -1,306 +1,198 @@
import { useEffect, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { import { useQueries } from '@tanstack/react-query';
LineMetricChart, import { Cpu, HardDrive, ArrowDownUp, Boxes } from 'lucide-react';
DualLineChart, import { LineMetricChart, DonutChart } from '@/shared/components';
DonutChart, import { getServiceMetrics, type ServiceMetrics } from '@/lib/api-client';
} from '@/shared/components'; import { formatBytes } from '@/lib/time';
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 {
projectId?: string; services: { id: string; name: string; status: string }[];
isRunning?: boolean; isDemoMode?: boolean;
onStop?: () => void;
onRestart?: () => void;
} }
function getRandom(min: number, max: number) { function healthLabel(pct: number): { text: string; color: string } {
return Math.floor(Math.random() * (max - min + 1)) + min; if (pct < 50) return { text: 'Good', color: 'var(--success)' };
if (pct < 80) return { text: 'Average', color: 'var(--warning)' };
return { text: 'High', color: 'var(--error)' };
} }
function generateInitialData(): MetricData { function Tile({ icon: Icon, label, children }: {
return { icon: React.ElementType;
cpu: Array.from({ length: 24 }, () => getRandom(10, 45)), label: string;
ram: getRandom(50, 85), children: React.ReactNode;
ramUsed: '5.4 GB', }) {
cache: 352,
cacheBreakdown: { cache: 212, nonCache: 85.5, total: 1750 },
users: Array.from({ length: 15 }, () => getRandom(40, 110)),
performance: Array.from({ length: 12 }, () => getRandom(70, 95)),
performanceAlt: Array.from({ length: 12 }, () => getRandom(60, 85)),
upSpeed: 10.4,
downSpeed: 5.2,
};
}
export function MetricsDashboard({
isRunning = true,
}: MetricsDashboardProps) {
const [data, setData] = useState<MetricData>(generateInitialData);
const [timeRange, setTimeRange] = useState<'day' | 'month' | 'year'>('day');
useEffect(() => {
if (!isRunning) return;
const interval = setInterval(() => {
setData((prev) => {
const newCpu = [...prev.cpu.slice(1), getRandom(10, 45)];
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 className="s-card !p-0 overflow-hidden">
{/* Metrics Header - self.html exact match */} <div className="s-cardhead">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '16px' }}> <span className="s-ibox"><Icon /></span>
<span style={{ fontSize: '15px', fontWeight: 700, color: '#e8e9f0' }}>Metrics</span> <span className="s-t">{label}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<button style={{
height: '32px',
padding: '0 12px',
borderRadius: '9px',
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>
{/* Row 1: CPU, RAM, Cache - self.html exact: 13px gap */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1.95fr', gap: '13px', marginBottom: '13px' }}>
{/* CPU Card */}
<div className="card">
<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 style={{ fontSize: '38px', fontWeight: 900, letterSpacing: '-1.5px', lineHeight: 1, color: '#e8e9f0' }}>{currentCpu}%</div>
<div style={{ fontSize: '12px', color: '#6b6e7d', marginTop: '4px' }}>
<span style={{ color: currentCpu < 50 ? '#5ee6a0' : currentCpu < 75 ? '#f2c94c' : '#b4e34a', fontWeight: 700 }}>
{currentCpu < 50 ? 'Good' : currentCpu < 75 ? 'Average' : 'High'}
</span>{' '}
Daily usage
</div>
<div style={{ height: 76, margin: '12px 0 6px' }}>
<LineMetricChart data={data.cpu} color="#b4e34a" height={76} />
</div>
<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>
{/* RAM Card */}
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '12px' }}>
<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 style={{ fontSize: '38px', fontWeight: 900, letterSpacing: '-1.5px', lineHeight: 1, color: '#e8e9f0' }}>{data.ram}%</div>
<div style={{ fontSize: '12px', color: '#6b6e7d', marginTop: '4px' }}>
<span style={{ color: data.ram < 60 ? '#5ee6a0' : '#f2c94c', fontWeight: 700 }}>
{data.ram < 60 ? 'Good' : 'Average'}
</span>{' '}
Daily usage
</div>
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', margin: '10px 0 4px', position: 'relative' }}>
<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 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>
{/* Cache Card */}
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '12px' }}>
<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 style={{ fontSize: '38px', fontWeight: 900, letterSpacing: '-1.5px', lineHeight: 1, color: '#e8e9f0' }}>{data.cache} MB</div>
<div style={{ fontSize: '12px', color: '#6b6e7d', marginTop: '4px' }}>
<span style={{ color: '#f2c94c', fontWeight: 700 }}>220MB Average</span>{' '}
cached images and files
</div>
{/* Segmented Bar */}
<div style={{ display: 'flex', alignItems: 'center', gap: '5px', margin: '16px 0 15px', height: '32px' }}>
<div className="cache-seg" style={{ width: '43%', background: '#ff6b5b', borderRadius: '10px 4px 4px 10px' }} />
<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>
{/* Stats row */}
<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 style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingTop: '16px' }}>
<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>
{/* Row 2: Active Users, Performance - self.html exact: 13px gap */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '13px' }}>
{/* Active Users - horizontal layout */}
<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 className="px-4 pb-4 pt-3">{children}</div>
</div>
);
}
export function MetricsDashboard({ services, isDemoMode = false }: MetricsDashboardProps) {
const liveServices = services.filter((s) => s.status === 'running');
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(() => {
if (!samplesKey) 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 memHealth = healthLabel(memPct);
const reporting = samples.length;
const noData = !isDemoMode && liveServices.length > 0 && reporting === 0;
return (
<div>
<div className="flex items-center justify-between mb-4">
<span className="v-mono text-[10.5px] uppercase tracking-[0.1em] text-[var(--text-tertiary)]">Metrics</span>
<span className="v-mono text-[10.5px] text-[var(--text-muted)]">
{isDemoMode
? 'sample data'
: reporting > 0
? `${reporting} service${reporting === 1 ? '' : 's'} reporting · every 5s`
: 'no running services'}
</span>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
{/* CPU */}
<Tile icon={Cpu} label="CPU Usage">
<div className="font-headline text-[30px] font-bold leading-none tracking-[-0.02em] text-[var(--text-primary)]">
{avgCpu.toFixed(1)}<span className="text-[13px] font-medium text-[var(--text-tertiary)]">%</span>
</div>
<div className="mt-1.5 v-mono text-[10.5px] text-[var(--text-tertiary)]">
<span style={{ color: cpuHealth.color }}>{cpuHealth.text}</span>
{' '}· avg across running services
</div>
<div style={{ height: 56, marginTop: 12 }}>
{cpuHistory.length > 1 ? (
<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>
</Tile>
{/* Memory */}
<Tile icon={HardDrive} label="Memory">
<div className="font-headline text-[30px] font-bold leading-none tracking-[-0.02em] text-[var(--text-primary)]">
{memPct.toFixed(0)}<span className="text-[13px] font-medium text-[var(--text-tertiary)]">%</span>
</div>
<div className="mt-1.5 v-mono text-[10.5px] text-[var(--text-tertiary)]">
<span style={{ color: memHealth.color }}>{memHealth.text}</span>
{' '}· {formatBytes(memUsed)} used
</div>
<div className="mt-2 flex justify-center">
<DonutChart percentage={memPct} size={100} thickness={11} />
</div>
<div className="mt-1 text-center v-mono text-[10.5px] text-[var(--text-tertiary)]">
{formatBytes(memUsed)} / {memLimit > 0 ? formatBytes(memLimit) : '∞'}
</div>
</Tile>
{/* Network */}
<Tile icon={ArrowDownUp} label="Network">
<div className="font-headline text-[30px] font-bold leading-none tracking-[-0.02em] text-[var(--text-primary)]">
{formatBytes(netRx)}
</div>
<div className="mt-1.5 v-mono text-[10.5px] text-[var(--text-tertiary)]">
<span className="text-[var(--accent-primary)]">RX</span>
{' '}total · TX {formatBytes(netTx)}
</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 */}
<Tile icon={Boxes} label="Instances">
<div className="font-headline text-[30px] font-bold leading-none tracking-[-0.02em] text-[var(--text-primary)]">
{isDemoMode ? liveServices.length : instanceCount}
</div>
<div className="mt-1.5 v-mono text-[10.5px] text-[var(--text-tertiary)]">
<span className="text-[var(--success)]">Running</span>
{' '}across {liveServices.length} service{liveServices.length === 1 ? '' : 's'}
</div>
<ul className="mt-3 space-y-1.5">
{liveServices.slice(0, 4).map((service, i) => {
const m = samples.find((s) => s.service_id === service.id);
return (
<li key={service.id} className="flex items-center justify-between text-[11.5px]">
<span className="truncate text-[var(--text-secondary)]">
<span className="mr-1.5 inline-block h-1.5 w-1.5 rounded-full bg-[var(--success)] align-middle" />
{service.name}
</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>
{noData && (
<p className="mt-3 v-mono text-[11px] text-[var(--text-muted)]">
Metrics collector is not reporting yet — check that the node agent is online.
</p>
)}
</div> </div>
); );
} }
@@ -2,6 +2,7 @@ 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 };
@@ -42,26 +43,23 @@ 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="text-sm font-medium text-[var(--text-secondary)]">Shared Variables</h3> <h3 className="s-t">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="mono text-[var(--accent-primary)]">{'${{shared.KEY}}'}</code>. Applied on next <code className="v-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">
<button <QuietBtn onClick={() => setDrafts([...rows, { key: '', value: '', isSecret: false }])}>
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
</button> </QuietBtn>
<button <button
onClick={() => saveMutation.mutate(rows)} onClick={() => saveMutation.mutate(rows)}
disabled={!dirty || saveMutation.isPending} disabled={!dirty || saveMutation.isPending}
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" className="s-btn-accent disabled:opacity-50"
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
@@ -78,12 +76,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-[1fr_1fr_auto_auto] gap-2 items-center"> <div key={i} className="grid grid-cols-[minmax(0,1fr)_minmax(0,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)] 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)] 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"
/> />
<input <input
value={row.value} value={row.value}
@@ -91,13 +89,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)] 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)] 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"
/> />
<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-[var(--radius-md)] border flex items-center justify-center transition-colors ${ className={`w-9 h-9 rounded-lg 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)]'
@@ -108,7 +106,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-[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" 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"
> >
<Trash2 size={12} /> <Trash2 size={12} />
</button> </button>
@@ -50,7 +50,9 @@ 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';
@@ -146,12 +148,10 @@ 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 panel p-6"> <div className="relative w-full max-w-lg s-card !p-6">
<div className="flex items-center gap-3 mb-1"> <div className="flex items-center gap-2.5 mb-1">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center"> <span className="s-ibox !w-9 !h-9"><Plus /></span>
<Plus size={20} className="text-[var(--accent-primary)]" /> <h3 className="font-headline text-lg font-semibold text-[var(--text-primary)]">Add Service</h3>
</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="space-y-3 p-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]/50"> <div className="s-inset space-y-3">
<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 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 v-mono text-sm"
placeholder="ghcr.io/org/app:latest" placeholder="ghcr.io/org/app:latest"
/> />
</div> </div>
) : ( ) : (
<div className="space-y-3 p-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]/50"> <div className="s-inset space-y-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">
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 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 v-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="mono text-xs truncate">{repo.full_name}</span> <span className="v-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 mono" className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm v-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 mono" className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm v-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 mono" className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm v-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 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 v-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="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" className="s-btn-quiet"
> >
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="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" className="s-btn-accent disabled:opacity-50 disabled:cursor-not-allowed"
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="panel p-8 text-center max-w-md"> <div className="s-card 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="text-lg font-medium text-[var(--text-primary)]">Project not found</p> <p className="font-headline 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="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" className="s-btn-quiet"
> >
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 text-sm shrink-0 min-w-0 truncate">{project.name}<span className="v-cursor">_</span></h1> <h1 className="v-title font-headline 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 rounded-full bg-[var(--surface-muted)] border border-[var(--border-subtle)] p-0.5 mx-2 overflow-x-auto min-w-0"> <nav className="flex items-center gap-0.5 mx-2 overflow-x-auto min-w-0 self-stretch [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{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,11 +742,7 @@ export function ProjectWorkspacePage() {
next.set('view', item.key); next.set('view', item.key);
return next; return next;
})} })}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-all whitespace-nowrap ${ className={`tab ${active ? 'active' : ''} !py-0 h-full !text-[11.5px]`}
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>
@@ -765,29 +761,21 @@ export function ProjectWorkspacePage() {
</span> </span>
<button <button
onClick={() => setCommandPaletteOpen(true)} onClick={() => setCommandPaletteOpen(true)}
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" className="s-chip hidden lg:flex"
> >
<Search size={12} /> <Search size={12} />
<kbd className="px-1 py-0.5 rounded bg-[var(--surface-card)] text-[10px] font-mono">⌘K</kbd> <kbd className="px-1 py-px rounded border border-[var(--border-subtle)] bg-[var(--bg-surface)] text-[10px] font-mono">⌘K</kbd>
</button> </button>
{!isDemoMode && signedIn && ( {!isDemoMode && signedIn && (
<> <>
<button <QuietBtn onClick={() => setImportOpen(true)} title="Deploy a docker-compose file into this project">
onClick={() => setImportOpen(true)} <FileUp size={13} />
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>
</button> </QuietBtn>
<button <GhostBtn onClick={openAddService}>
onClick={openAddService} <Plus size={13} />
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>
</button> </GhostBtn>
</> </>
)} )}
</div> </div>
@@ -818,67 +806,48 @@ 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="px-4 py-3 rounded-[var(--radius-md)] border border-[var(--warning-soft)] bg-[var(--warning-soft)]/50"> <div className="s-inset flex items-center gap-2 text-xs text-[var(--warning)]">
<div className="flex items-center gap-2 text-sm text-[var(--warning)]"> <Sparkles size={13} />
<Sparkles size={16} /> Demo mode active — using sample data for preview
<span>Demo mode active - using sample data for preview</span>
</div>
</div> </div>
</div> </div>
)} )}
<div className="w-full px-8 py-6"> <div className="w-full px-4 py-6 sm:px-8">
<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-4"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-3.5">
<div className="panel-soft p-4"> {([
<div className="flex items-center gap-2"> { label: 'Healthy', color: 'var(--success)', value: runningServices, pulse: true },
<div className="w-2 h-2 rounded-full bg-[var(--success)] live-pulse" /> { label: 'Failed', color: 'var(--error)', value: services.filter((s) => s.status === 'failed').length, pulse: false },
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Healthy</p> { label: 'Building', color: 'var(--warning)', value: services.filter((s) => s.status === 'building').length, pulse: true },
]).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 <MetricsDashboard services={visibleServices} isDemoMode={isDemoMode} />
isRunning={runningServices > 0}
onStop={() => toast.showToast('Services stopped', 'warning')}
onRestart={() => toast.showToast('Services restarted', 'success')}
/>
{/* Service List */} {/* Service List */}
<div className="panel p-6"> <div className="s-card">
<div className="flex items-center justify-between mb-3"> <div className="s-cardhead">
<h3 className="text-sm font-medium text-[var(--text-secondary)]">Services</h3> <span className="s-ibox"><Layers /></span>
<span className="s-t">Services</span>
{!isDemoMode && environments.length > 0 && ( {!isDemoMode && environments.length > 0 && (
<div className="flex items-center gap-1.5 flex-wrap"> <div className="s-trail flex-wrap">
<button <button
onClick={() => setEnvFilter(null)} onClick={() => setEnvFilter(null)}
className={`px-2.5 py-1 rounded-full text-xs font-medium transition-colors ${ className={`s-chip !py-1 !px-2.5 ${envFilter === null ? '!text-[var(--accent-primary)] !border-[var(--accent-primary)]/40' : ''}`}
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>
@@ -886,11 +855,7 @@ 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={`px-2.5 py-1 rounded-full text-xs font-medium transition-colors ${ className={`s-chip !py-1 !px-2.5 ${envFilter === env.name ? '!text-[var(--accent-primary)] !border-[var(--accent-primary)]/40' : ''}`}
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>
@@ -899,6 +864,7 @@ 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">
@@ -918,9 +884,7 @@ 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">
<div className="w-8 h-8 rounded-[var(--radius-sm)] bg-[var(--surface-card)] border border-[var(--border-subtle)] flex items-center justify-center"> <span className="s-ibox"><Box /></span>
<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>
@@ -931,33 +895,31 @@ export function ProjectWorkspacePage() {
)) ))
)} )}
</div> </div>
</div>
</div> </div>
</div> </div>
)} )}
{activeView === 'logs' && ( {activeView === 'logs' && (
<div className="panel p-6"> <div className="s-card">
<div className="flex items-center justify-between mb-4"> <div className="s-cardhead">
<div className="flex items-center gap-3"> <span className="s-ibox"><FileText /></span>
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center"> <div>
<FileText size={20} className="text-[var(--accent-primary)]" /> <div className="s-t">Logs</div>
</div> <div className="mt-0.5 text-[11px] text-[var(--text-tertiary)]">Aggregated service output</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 && (
<button <div className="s-trail">
onClick={() => workspaceLogsQuery.refetch()} <QuietBtn onClick={() => workspaceLogsQuery.refetch()}>
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" <RefreshCw size={13} />
> Refresh
Refresh </QuietBtn>
</button> </div>
)} )}
</div> </div>
<div className="px-4 pb-4">
<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"> <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">
{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 ? (
@@ -998,20 +960,20 @@ export function ProjectWorkspacePage() {
</div> </div>
)} )}
</div> </div>
</div>
</div> </div>
)} )}
{activeView === 'activity' && ( {activeView === 'activity' && (
<div className="panel p-6"> <div className="s-card">
<div className="flex items-center gap-3 mb-4"> <div className="s-cardhead">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center"> <span className="s-ibox"><Clock /></span>
<Clock size={20} className="text-[var(--accent-primary)]" />
</div>
<div> <div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Activity</h2> <div className="s-t">Activity</div>
<p className="text-sm text-[var(--text-secondary)]">Project timeline — deploys, config, membership</p> <div className="mt-0.5 text-[11px] text-[var(--text-tertiary)]">Project timeline — deploys, config, membership</div>
</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>
@@ -1034,40 +996,40 @@ export function ProjectWorkspacePage() {
)) ))
)} )}
</div> </div>
</div>
</div> </div>
)} )}
{activeView === 'settings' && ( {activeView === 'settings' && (
<div className="panel p-6"> <div className="s-card">
<div className="flex items-center gap-3 mb-6"> <div className="s-cardhead">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center"> <span className="s-ibox"><Settings /></span>
<Settings size={20} className="text-[var(--accent-primary)]" />
</div>
<div> <div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Settings</h2> <div className="s-t">Settings</div>
<p className="text-sm text-[var(--text-secondary)]">Project configuration</p> <div className="mt-0.5 text-[11px] text-[var(--text-tertiary)]">Project configuration</div>
</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="panel-soft p-4"> <div className="s-inset">
<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="mono mt-2 text-sm text-[var(--text-primary)] break-all">{project.id}</p> <p className="v-mono mt-2 text-sm text-[var(--text-primary)] break-all">{project.id}</p>
</div> </div>
<div className="panel-soft p-4"> <div className="s-inset">
<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="panel-soft p-4 md:col-span-2"> <div className="s-inset 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="mono mt-2 text-xs text-[var(--text-secondary)] break-all">{canvasStorageKey(project.id)}</p> <p className="v-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="text-sm font-medium text-[var(--text-secondary)] mb-3">Local Data</h3> <h3 className="s-t mb-3">Local Data</h3>
<button <button
onClick={() => { onClick={() => {
clearCanvasMetadata(project.id); clearCanvasMetadata(project.id);
@@ -1079,6 +1041,7 @@ export function ProjectWorkspacePage() {
Reset Canvas Layout Reset Canvas Layout
</button> </button>
</div> </div>
</div>
</div> </div>
)} )}
</section> </section>
@@ -1105,12 +1068,10 @@ 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 panel p-6"> <div className="relative w-full max-w-2xl s-card !p-6">
<div className="flex items-center gap-3 mb-1"> <div className="flex items-center gap-2.5 mb-1">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center"> <span className="s-ibox !w-9 !h-9"><FileUp /></span>
<FileUp size={20} className="text-[var(--accent-primary)]" /> <h3 className="font-headline text-lg font-semibold text-[var(--text-primary)]">Import docker-compose</h3>
</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,
@@ -1120,7 +1081,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 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 v-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">
@@ -1133,8 +1094,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="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" className="s-btn-accent h-10 disabled:opacity-50"
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
@@ -1163,6 +1124,9 @@ 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 */}
+715 -194
View File
File diff suppressed because it is too large. Load diff
+12 -3
View File
@@ -492,11 +492,12 @@ 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<void> { export async function pingServer(): Promise<boolean> {
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 = {
@@ -1238,9 +1239,17 @@ 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?: AgentContainer[] }>(`/agents/${id}/containers`); const payload = await requestJson<{ containers?: RawAgentContainer[] }>(`/agents/${id}/containers`);
return payload.containers ?? []; // The wire sends status as a Docker state object — flatten to its state string.
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,6 +13,11 @@ 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,6 +23,7 @@ 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-white/5" style={{ background: 'rgba(255,255,255,0.07)' }}> <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)' }}>
{icon} {icon}
</div> </div>
)} )}
<div> <div>
<h3 className="text-lg font-semibold text-[var(--text-primary)] tracking-tight">{title}</h3> <h3 className="font-headline text-base 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 = '#b4e34a', color = '#38bdf8',
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 = '#b4e34a', color = '#38bdf8',
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 = '#7ab8ff', color1 = 'var(--accent-primary)',
color2 = '#f2c94c', color2 = 'var(--warning)',
fillOpacity1 = 0.15, fillOpacity1 = 0.15,
fillOpacity2 = 0.15, fillOpacity2 = 0.15,
}: DualLineChartProps) { }: DualLineChartProps) {
@@ -1,24 +1,25 @@
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;
} }
@@ -27,14 +28,39 @@ interface CommandPaletteProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
onAddService?: (type: string) => void; onAddService?: (type: string) => void;
onNavigate?: (path: string) => void; onNavigate?: (target: 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;
@@ -43,6 +69,8 @@ export function CommandPalette({
onClose={onClose} onClose={onClose}
onAddService={onAddService} onAddService={onAddService}
onNavigate={onNavigate} onNavigate={onNavigate}
navTargets={navTargets}
openTargets={openTargets}
/> />
); );
} }
@@ -51,6 +79,8 @@ function CommandPaletteContent({
onClose, onClose,
onAddService, onAddService,
onNavigate, onNavigate,
navTargets,
openTargets,
}: Omit<CommandPaletteProps, 'open'>) { }: Omit<CommandPaletteProps, 'open'>) {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
@@ -66,119 +96,69 @@ function CommandPaletteContent({
return () => document.removeEventListener('keydown', down); return () => document.removeEventListener('keydown', down);
}, [onClose]); }, [onClose]);
const commands: CommandItem[] = [ const commands: CommandItem[] = [];
{
id: 'add-web', // Create items only exist where onAddService is wired — elsewhere the
label: 'Add Web Service', // entries would silently no-op.
description: 'Deploy a web application', if (onAddService) {
icon: Box, commands.push(
shortcut: 'W', {
category: 'Create', id: 'add-web',
action: () => { label: 'Add Web Service',
onAddService?.('web'); description: 'Deploy a web application',
onClose(); icon: Box,
category: 'Create',
action: () => { onAddService('web'); onClose(); },
}, },
}, {
{ id: 'add-worker',
id: 'add-worker', label: 'Add Worker Service',
label: 'Add Worker Service', description: 'Deploy a background worker',
description: 'Deploy a background worker', icon: Clock,
icon: Clock, category: 'Create',
shortcut: 'K', action: () => { onAddService('worker'); onClose(); },
category: 'Create',
action: () => {
onAddService?.('worker');
onClose();
}, },
}, {
{ id: 'add-database',
id: 'add-database', label: 'Add Database',
label: 'Add Database', description: 'Provision a PostgreSQL database',
description: 'Provision a PostgreSQL database', icon: Database,
icon: Database, category: 'Create',
shortcut: 'D', action: () => { onAddService('database'); onClose(); },
category: 'Create',
action: () => {
onAddService?.('database');
onClose();
}, },
}, {
{ id: 'add-cron',
id: 'add-cron', label: 'Add Cron Job',
label: 'Add Cron Job', description: 'Schedule a recurring task',
description: 'Schedule a recurring task', icon: Clock,
icon: Clock, category: 'Create',
category: 'Create', action: () => { onAddService('cron'); onClose(); },
action: () => {
onAddService?.('cron');
onClose();
}, },
}, );
{ }
id: 'connect-github',
label: 'Connect GitHub Repo', if (onNavigate) {
description: 'Link a repository for auto-deploy', (navTargets ?? APP_NAV).forEach((t, i) =>
icon: Github, commands.push({
category: 'Connect', id: `nav-${i}`,
action: () => { label: t.label,
onClose(); description: t.description,
}, icon: CornerDownRight,
}, category: 'Navigate',
{ action: () => { onNavigate(t.target); onClose(); },
id: 'deploy-image', }),
label: 'Deploy Docker Image', );
description: 'Deploy from a container registry', (openTargets ?? []).forEach((t, i) =>
icon: Image, commands.push({
category: 'Connect', id: `open-${i}`,
action: () => { label: t.label,
onClose(); description: t.description,
}, 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) =>
@@ -197,6 +177,10 @@ 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
@@ -205,7 +189,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="panel overflow-hidden" className="s-card !p-0 overflow-hidden shadow-2xl shadow-black/50"
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)]">
@@ -213,7 +197,7 @@ function CommandPaletteContent({
<Command.Input <Command.Input
value={search} value={search}
onValueChange={setSearch} onValueChange={setSearch}
placeholder="What would you like to create?" placeholder={placeholder}
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)]">
@@ -228,7 +212,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 text-[10px] font-semibold uppercase tracking-widest text-[var(--text-muted)]"> <div className="px-2 py-1.5 v-mono text-[10px] uppercase tracking-[0.14em] text-[var(--text-muted)]">
{category} {category}
</div> </div>
{items.map((item) => { {items.map((item) => {
@@ -238,20 +222,15 @@ 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.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" 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"
> >
<Icon size={18} className="text-[var(--text-muted)]" /> <span className="s-ibox !h-7 !w-7"><Icon size={13.5} /></span>
<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="panel max-w-md p-8 text-center"> <div className="s-card max-w-md text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-[var(--accent-primary-soft)]"> <div className="s-ibox mx-auto mb-4 !h-10 !w-10">
<Sparkles size={22} className="text-[var(--accent-primary)]" /> <Sparkles size={18} />
</div> </div>
<p className="text-lg font-semibold text-[var(--text-primary)]">Not part of the demo</p> <p className="font-headline text-[17px] 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="v-btn mt-5 inline-flex"> <Link to="/projects?demo=1" className="s-btn-accent 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 = '#b4e34a', color = '#38bdf8',
trackColor = 'rgba(255, 255, 255, 0.07)', trackColor = 'var(--tint-07)',
segments = 28, segments = 28,
gap = 0.048, gap = 0.048,
animated = true, animated = true,
@@ -1,4 +1,5 @@
import { type ReactNode } from 'react'; import { type ReactNode } from 'react';
import { ChevronRight, MoreHorizontal } from 'lucide-react';
interface EnhancedMetricCardProps { interface EnhancedMetricCardProps {
title: string; title: string;
@@ -15,6 +16,42 @@ 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,
@@ -29,75 +66,23 @@ 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 <div className={`s-card flex flex-row overflow-hidden ${className}`} style={{ animationDelay: `${animationDelay}s` }}>
className={`card ${className}`} <div className="flex flex-1 flex-col">
style={{ <CardHead icon={icon} title={title} />
display: 'flex', <div className="px-4">
flexDirection: 'row', <div className="font-headline text-[32px] font-bold leading-none tracking-[-0.025em] text-[var(--text-primary)]">
padding: 0, {value}
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 style={{ <div className="flex w-1/2 items-end py-3 pr-3.5">
width: '50%',
padding: '16px 14px 46px 0',
display: 'flex',
alignItems: 'flex-end',
}}>
{chart} {chart}
</div> </div>
)} )}
@@ -105,64 +90,25 @@ export function EnhancedMetricCard({
); );
} }
// Standard vertical card layout - matching self.html exactly
return ( return (
<div <div className={`s-card flex flex-col ${className}`} style={{ animationDelay: `${animationDelay}s` }}>
className={`card ${className}`} <CardHead icon={icon} title={title} />
style={{ <div className="px-4 pb-1">
display: 'flex', <div className="font-headline text-[32px] font-bold leading-none tracking-[-0.025em] text-[var(--text-primary)]">
flexDirection: 'column', {value}
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>
)}
{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>
{(subtitle || statusText) && (
<div className="mt-1.5 text-[11.5px] text-[var(--text-tertiary)]">
{statusText && status && (
<span className="font-semibold" style={{ color: statusColors[status] }}>{statusText}</span>
)}{' '}
{subtitle}
</div>
)}
{chart && <div className="mt-3 mb-1">{chart}</div>}
{details && <div className="mt-3">{details}</div>}
</div> </div>
<CardFoot label="Details" onClick={onClick} />
</div> </div>
); );
} }
@@ -186,118 +132,56 @@ export function CacheMetricCard({
const nonCachePercent = Math.round((nonCacheMB / totalMB) * 100); const nonCachePercent = Math.round((nonCacheMB / totalMB) * 100);
return ( return (
<div <div className="s-card flex flex-col" style={{ animationDelay: `${animationDelay}s` }}>
className="card" <CardHead
style={{ icon={
display: 'flex', <svg viewBox="0 0 24 24" width="13.5" height="13.5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
flexDirection: 'column', <polyline points="3 6 5 6 21 6" />
padding: 20, <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" />
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>
<span style={{ fontSize: 14, fontWeight: 600, color: '#e8e9f0' }}>Cache</span> <div className="mt-1.5 text-[11.5px] text-[var(--text-tertiary)]">
</div> <span className="font-semibold text-[var(--warning)]">{Math.round(totalMB * 0.625)}MB average</span> cached images and files
<div style={{
fontSize: 38,
fontWeight: 900,
letterSpacing: '-1.5px',
lineHeight: 1,
color: '#e8e9f0',
}}>
{totalMB} MB
</div>
<div style={{ fontSize: 12, color: '#6b6e7d', marginTop: 4 }}>
<span style={{ color: '#f2c94c', fontWeight: 700 }}>{Math.round(totalMB * 0.625)}MB Average</span> cached images and files
</div>
{/* Segmented Bar */}
<div style={{ display: 'flex', alignItems: 'center', gap: 5, margin: '16px 0 15px', height: 32 }}>
<div
className="cache-seg"
style={{
width: `${cachePercent}%`,
background: '#ff6b5b',
borderRadius: '10px 4px 4px 10px'
}}
/>
<div
className="cache-seg"
style={{
width: `${nonCachePercent}%`,
background: '#8c6ef0',
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>
<div style={{ width: 1, background: 'rgba(255,255,255,0.08)', margin: '0 16px' }} />
<div> {/* Segmented bar */}
<div style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 11, color: '#6b6e7d', marginBottom: 5 }}> <div className="my-4 flex h-8 items-center gap-1">
<div className="stat-dot" style={{ background: '#8c6ef0' }} /> Non-Cache <div className="h-full rounded-l-md" style={{ width: `${cachePercent}%`, background: 'var(--error)' }} />
</div> <div className="h-full" style={{ width: `${nonCachePercent}%`, background: 'var(--accent-tertiary)' }} />
<div style={{ display: 'flex', alignItems: 'baseline', gap: 4 }}> <div className="h-full flex-1 rounded-r-md bg-[var(--tint-07)]" />
<span style={{ fontSize: 15, fontWeight: 800 }}>{nonCacheMB} MB</span>
<span style={{ fontSize: 11, color: '#6b6e7d' }}>{nonCachePercent}%</span>
</div>
</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={{ {/* Stats row */}
display: 'flex', <div className="grid grid-cols-[1fr_auto_1fr_auto_1fr] items-stretch">
alignItems: 'center', {[
justifyContent: 'space-between', { dot: 'var(--error)', label: 'Cache', value: `${cacheMB} MB`, pct: `${cachePercent}%` },
paddingTop: 16 { dot: 'var(--accent-tertiary)', label: 'Non-Cache', value: `${nonCacheMB} MB`, pct: `${nonCachePercent}%` },
}}> { dot: null, label: 'Total', value: `${totalMB * 5} GB`, pct: '' },
<span ].map((s, i) => (
onClick={onClick} <div key={s.label} className="contents">
style={{ fontSize: 13, color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }} {i > 0 && <div className="mx-4 w-px bg-[var(--border-subtle)]" />}
> <div>
Details <div className="mb-1 flex items-center gap-1.5 text-[10.5px] text-[var(--text-tertiary)]">
</span> {s.dot && <i className="h-1.5 w-1.5 rounded-full" style={{ background: s.dot }} />}
<div className="arrow-btn" onClick={onClick}> {s.label}
<svg viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round"> </div>
<polyline points="9 18 15 12 9 6" /> <div className="flex items-baseline gap-1">
</svg> <span className="text-[14px] font-bold text-[var(--text-primary)]">{s.value}</span>
{s.pct && <span className="text-[10.5px] text-[var(--text-tertiary)]">{s.pct}</span>}
</div>
</div>
</div>
))}
</div> </div>
</div> </div>
<div className="mt-4" />
<CardFoot label="Details" onClick={onClick} />
</div> </div>
); );
} }
@@ -319,86 +203,47 @@ 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 ? '#5ee6a0' : percentage >= 70 ? '#f2c94c' : '#ff6f5c'; const statusColor = percentage >= 85 ? 'var(--success)' : percentage >= 70 ? 'var(--warning)' : 'var(--error)';
return ( return (
<div <div className="s-card flex flex-col" style={{ animationDelay: `${animationDelay}s` }}>
className="card" <CardHead
style={{ icon={
display: 'flex', <svg viewBox="0 0 24 24" width="13.5" height="13.5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
flexDirection: 'column', <circle cx="12" cy="12" r="10" />
padding: 20, <line x1="2" y1="12" x2="22" y2="12" />
animationDelay: `${animationDelay}s`, <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" />
}}
>
<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> }
<span style={{ fontSize: 14, fontWeight: 600, color: '#e8e9f0' }}>Performance</span> title="Performance"
</div> />
<div className="flex flex-1 items-start gap-4 px-4">
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 16, flex: 1 }}> <div className="flex-1">
<div style={{ flex: 1 }}> <div className="font-headline text-[32px] font-bold leading-none tracking-[-0.025em] text-[var(--text-primary)]">
<div style={{
fontSize: 36,
fontWeight: 900,
letterSpacing: '-1.5px',
lineHeight: 1,
color: '#e8e9f0',
}}>
{percentage}% {percentage}%
</div> </div>
<div style={{ fontSize: 12, color: '#6b6e7d', marginTop: 4 }}> <div className="mt-1.5 text-[11.5px] text-[var(--text-tertiary)]">
<span style={{ color: statusColor, fontWeight: 700 }}>{status}</span> Last scan on {new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })} <span className="font-semibold" style={{ color: statusColor }}>{status}</span>
{' '}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 style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 8 }}> <div className="h-[58px] w-[134px] rounded-lg bg-[var(--tint-03)]" />
{/* Chart placeholder - will be replaced with actual chart */} <div className="flex flex-col items-end gap-1">
<div style={{ width: 134, height: 58, background: 'rgba(255,255,255,0.03)', borderRadius: 8 }} /> {[{ v: upSpeed, up: false }, { v: downSpeed, up: true }].map((s, i) => (
<div key={i} className="flex items-center gap-1 text-[11px] font-medium text-[var(--accent-primary)]">
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, alignItems: 'flex-end' }}> <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
<div className="speed-row" style={{ color: '#7ab8ff' }}> <line x1="12" y1={s.up ? 5 : 19} x2="12" y2={s.up ? 19 : 5} />
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"> {s.up ? <polyline points="19 12 12 5 5 12" /> : <polyline points="5 12 12 19 19 12" />}
<line x1="12" y1="19" x2="12" y2="5"/> </svg>
<polyline points="5 12 12 19 19 12"/> <span>{s.v}</span> Mbps
</svg> </div>
<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" />
<div style={{ <CardFoot label="Check Speed" onClick={onClick} />
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="max-w-md w-full bg-[var(--bg-primary)] border border-[var(--border-subtle)] rounded-lg p-6 space-y-4"> <div className="s-card max-w-md w-full space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<h2 className="text-xl font-semibold text-[var(--text-primary)]"> <h2 className="font-headline 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,24 +72,18 @@ 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="bg-[var(--surface-muted)] border border-[var(--border-subtle)] rounded p-3"> <div className="s-inset">
<p className="text-xs font-mono text-[var(--error)] break-all"> <p className="v-mono text-[11px] 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 <button onClick={this.handleReset} className="s-btn-accent flex-1 justify-center">
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 <button onClick={() => window.location.reload()} className="s-btn-quiet flex-1 justify-center">
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>
+50 -119
View File
@@ -1,4 +1,5 @@
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;
@@ -15,6 +16,12 @@ 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,
@@ -49,140 +56,64 @@ export function MetricCard({
return () => observer.disconnect(); return () => observer.disconnect();
}, [animationDelay]); }, [animationDelay]);
const statusColors: Record<string, string> = { const reveal = {
good: '#5ee6a0', opacity: isVisible ? 1 : 0,
average: '#f2c94c', transform: isVisible ? 'translateY(0)' : 'translateY(12px)',
warning: '#f2c94c', transition: 'all 0.5s ease',
}; } 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 <div ref={cardRef} className={`s-stat ${className}`} style={{ ...reveal, display: 'flex' }}>
ref={cardRef} <div className="flex-1 px-4 pt-4 pb-2 flex flex-col">
className={`panel ${className}`} <div className="flex items-center gap-2.5">
style={{ <span className="s-ibox">{icon}</span>
display: 'flex', <span className="text-[11.5px] font-medium text-[var(--text-secondary)]">{title}</span>
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 style={{ <div className="mt-4 font-headline text-[30px] font-bold leading-none tracking-[-0.025em] text-[var(--text-primary)]">
fontSize: 36,
fontWeight: 900,
letterSpacing: '-1.5px',
lineHeight: 1,
color: '#e8e9f0',
}}>
{value} {value}
</div> </div>
{subtitle && ( {details ? <div className="mt-3">{details}</div> : null}
<div style={{ fontSize: 12, color: '#6b6e7d', marginTop: 4 }}>{subtitle}</div> <div className="mt-auto">{footer}</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 style={{ <div className="w-1/2 flex items-end pr-3 pb-10 pt-3">{chart}</div>
width: '50%', ) : null}
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 <div ref={cardRef} className={`s-stat ${className}`} style={reveal}>
ref={cardRef} <div className="flex items-center gap-2.5 px-4 pt-4">
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: '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}
{(subtitle || statusText) && ( {details ? <div className="px-4 mt-3">{details}</div> : null}
<div style={{ fontSize: 12, color: '#6b6e7d', marginTop: 4 }}> <div className="mt-4">{footer}</div>
{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,4 +1,5 @@
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';
@@ -12,43 +13,29 @@ interface StatusBadgeProps {
const statusConfig: Record< const statusConfig: Record<
ServiceStatus, ServiceStatus,
{ color: string; bg: string; Icon: LucideIcon; label: string; animate?: boolean; glow?: string } { tone: PillTone; Icon: LucideIcon; label: string; animate?: boolean }
> = { > = {
running: { running: { tone: 'ok', Icon: Check, label: 'Active' },
color: 'var(--success)', building: { tone: 'warn', Icon: Loader2, label: 'Building', animate: true },
bg: 'var(--success-soft)', failed: { tone: 'err', Icon: X, label: 'Failed' },
Icon: Check, stopped: { tone: 'off', Icon: Box, label: 'Stopped' },
label: 'Active', unknown: { tone: 'off', Icon: HelpCircle, label: 'Unknown' },
animate: true, };
glow: 'rgba(61, 214, 140, 0.4)',
}, const TONE_CLS: Record<PillTone, string> = {
building: { ok: 'text-[var(--success)] bg-[var(--success-soft)] border-[color-mix(in_srgb,var(--success)_16%,transparent)]',
color: 'var(--warning)', warn: 'text-[var(--warning)] bg-[var(--warning-soft)] border-[color-mix(in_srgb,var(--warning)_16%,transparent)]',
bg: 'var(--warning-soft)', err: 'text-[var(--error)] bg-[var(--error-soft)] border-[color-mix(in_srgb,var(--error)_16%,transparent)]',
Icon: Loader2, info: 'text-[var(--info)] bg-[var(--info-soft)] border-[color-mix(in_srgb,var(--info)_16%,transparent)]',
label: 'Building', off: 'text-[var(--text-tertiary)] bg-[var(--tint-06)] border-[var(--border-subtle)]',
animate: true, };
glow: 'rgba(255, 112, 67, 0.4)',
}, const TONE_DOT: Record<PillTone, string> = {
failed: { ok: 'var(--success)',
color: 'var(--error)', warn: 'var(--warning)',
bg: 'var(--error-soft)', err: 'var(--error)',
Icon: X, info: 'var(--info)',
label: 'Failed', off: 'var(--text-tertiary)',
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({
@@ -62,36 +49,26 @@ export function StatusBadge({
const { Icon } = config; const { Icon } = config;
const sizeClasses = { const sizeClasses = {
sm: 'px-2 py-0.5 text-[10px] gap-1.5', sm: 'px-2 py-[2px] text-[10px] gap-1.5',
md: 'px-2.5 py-1 text-xs gap-1.5', md: 'px-2 py-[3.5px] text-[10.5px] gap-1.5',
lg: 'px-3 py-1.5 text-sm gap-2', lg: 'px-2.5 py-1 text-[11.5px] gap-1.5',
}; };
const iconSizes = { const iconSizes = { sm: 10, md: 11, lg: 12 };
sm: 10,
md: 12,
lg: 14,
};
return ( return (
<div <div
className={`inline-flex items-center rounded-full font-bold uppercase tracking-wider ring-1 ring-inset transition-all duration-200 ${sizeClasses[size]} ${className}`} className={`inline-flex items-center rounded-[5px] border font-medium transition-colors ${sizeClasses[size]} ${TONE_CLS[config.tone]} ${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: config.color, boxShadow: `0 0 6px ${config.color}` }} style={{ background: TONE_DOT[config.tone] }}
/> />
)} )}
<Icon <Icon
size={iconSizes[size]} size={iconSizes[size]}
className={config.animate && status === 'building' ? 'animate-spin' : ''} className={config.animate ? 'animate-spin' : ''}
/> />
{showLabel && <span>{config.label}</span>} {showLabel && <span>{config.label}</span>}
</div> </div>
@@ -106,27 +83,19 @@ 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-0.5 text-[10px] gap-1.5', sm: 'px-2 py-[2px] text-[10px] gap-1.5',
md: 'px-2.5 py-1 text-xs gap-2', md: 'px-2 py-[3.5px] text-[10.5px] gap-1.5',
lg: 'px-3 py-1.5 text-sm gap-2', lg: 'px-2.5 py-1 text-[11.5px] gap-1.5',
}; };
const tone: PillTone = isLive ? 'ok' : 'off';
return ( return (
<div <div
className={`inline-flex items-center rounded-full font-bold uppercase tracking-wider ring-1 ring-inset transition-all duration-300 ${sizeClasses[size]}`} className={`inline-flex items-center rounded-[5px] border font-medium transition-colors ${sizeClasses[size]} ${TONE_CLS[tone]}`}
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={{ style={{ background: TONE_DOT[tone] }}
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,3 +12,4 @@ 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';
@@ -0,0 +1,18 @@
/* 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;
}
@@ -0,0 +1,372 @@
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>
);
}
+5 -1
View File
@@ -59,8 +59,12 @@ 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: 'http://localhost:8082', target: process.env.VITE_API_PROXY ?? 'http://localhost:8082',
changeOrigin: true, changeOrigin: true,
ws: true, ws: true,
}, },