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 7607 additions and 4020 deletions

No files matched your search

+3
View File
@@ -124,3 +124,6 @@ ref/
.playwright-mcp/
canvas-*.png
.vercel
# Local design previews (not shipped)
design/
+14 -4
View File
@@ -1,6 +1,6 @@
# 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
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:
`--placement-tags` on services create/update, `--tags` on
`nodes update`, MCP args, OpenAPI, node-detail tag editor +
service-detail placement-tags input. Remaining: build-on-node,
remote sleep/wake, spread weighting by real capacity.
service-detail placement-tags input. Build-on-node and remote
sleep/wake both shipped (see below). Remaining: spread weighting
by real capacity.
- [x] **Agent self-upgrade** — `POST /agents/:id/upgrade` enqueues a
`self_upgrade` command carrying platform + sha256 + server version;
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
service's own scope (one env per service); promote ships via preview
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/
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
`POST /setup/complete`; `/setup` page steps admin account → GitHub App
manifest flow (callback returns to the wizard via a session marker) →
+21 -30
View File
@@ -12,6 +12,7 @@ import (
"time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
@@ -180,13 +181,13 @@ func handleGetProjectActivity(c *gin.Context) {
}
// Access check: approved / owner / member / admin.
var visible bool
if err := db.QueryRow(
`SELECT p.is_approved OR p.owner_id = $2::uuid OR $3::bool
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $2::uuid)
FROM projects p WHERE p.id = $1`,
projectID, userID, contextIsAdmin(c),
).Scan(&visible); err != nil || !visible {
userUUID, _ := uuid.Parse(userID)
visible, err := sqlcdb.New(db.DB).CheckSecurityProjectAccess(context.Background(), sqlcdb.CheckSecurityProjectAccessParams{
ID: uuid.MustParse(projectID),
OwnerID: userUUID,
Column3: contextIsAdmin(c),
})
if err != nil || !visible {
respondError(c, http.StatusNotFound, "NOT_FOUND", "project not found")
return
}
@@ -237,12 +238,12 @@ func StartAuditRetention(ctx context.Context, db *database.DB) {
}
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 {
log.Printf("audit retention prune failed: %v", err)
return
}
if n, _ := res.RowsAffected(); n > 0 {
if n > 0 {
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
// existed so filters and the feed see uniform metadata.
func backfillAuditMetadata(db *database.DB) {
cursor := uuid.Nil.String()
q := sqlcdb.New(db.DB)
cursor := uuid.Nil
for {
rows, err := db.Query(
`SELECT id::text, resource, action FROM audit_logs
WHERE label = '' AND id > $1::uuid ORDER BY id LIMIT 500`, cursor)
rows, err := q.ListAuditBackfillBatch(context.Background(), cursor)
if err != nil {
log.Printf("audit backfill scan failed: %v", err)
return
}
type row struct{ id, resource, action string }
var batch []row
for rows.Next() {
var r row
if rows.Scan(&r.id, &r.resource, &r.action) == nil {
batch = append(batch, r)
}
}
rows.Close()
if len(batch) == 0 {
if len(rows) == 0 {
return
}
for _, r := range batch {
cursor = r.id
sev, cat, label := classifyAuditEvent(r.resource, r.action)
if _, err := db.Exec(
`UPDATE audit_logs SET severity = $1, category = $2, label = $3 WHERE id = $4::uuid`,
sev, cat, label, r.id,
); err != nil {
for _, r := range rows {
id, _ := uuid.Parse(r.ID)
cursor = id
sev, cat, label := classifyAuditEvent(r.Resource, r.Action)
if err := q.SetAuditLogMetadata(context.Background(), sqlcdb.SetAuditLogMetadataParams{
Severity: sev, Category: cat, Label: label, Column4: id,
}); err != nil {
log.Printf("audit backfill update failed: %v", err)
}
}
+21 -32
View File
@@ -52,15 +52,11 @@ func EnsureAdminAccount(ctx context.Context, db *database.DB, cfg *config.Config
return
}
var userID string
err = db.QueryRowContext(ctx, `
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
`, email, string(hashed), name).Scan(&userID)
if err != nil {
if _, err := sqlcdb.New(db.DB).UpsertAdminUser(ctx, sqlcdb.UpsertAdminUserParams{
Email: email,
PasswordHash: string(hashed),
Name: name,
}); err != nil {
log.Printf("Admin bootstrap failed to upsert user %s: %v", email, err)
return
}
@@ -185,24 +181,18 @@ func handleAdminOverview(c *gin.Context) {
func handleAdminListUsers(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
rows, err := db.QueryContext(c.Request.Context(), `
SELECT id, email, name, COALESCE(avatar_url, ''), is_admin, COALESCE(to_char(created_at, 'YYYY-MM-DD"T"HH24:MI:SS"Z"'), '')
FROM users
ORDER BY created_at ASC
`)
rows, err := sqlcdb.New(db.DB).ListAdminUsers(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list users"})
return
}
defer rows.Close()
users := make([]adminUserRow, 0)
for rows.Next() {
var u adminUserRow
if err := rows.Scan(&u.ID, &u.Email, &u.Name, &u.AvatarURL, &u.IsAdmin, &u.CreatedAt); err != nil {
continue
}
users = append(users, u)
users := make([]adminUserRow, 0, len(rows))
for _, r := range rows {
users = append(users, adminUserRow{
ID: r.ID.String(), Email: r.Email, Name: r.Name,
AvatarURL: r.AvatarUrl, IsAdmin: r.IsAdmin, CreatedAt: r.CreatedAt,
})
}
c.JSON(http.StatusOK, gin.H{"users": users})
}
@@ -229,15 +219,16 @@ func handleAdminSetUserAdmin(c *gin.Context) {
return
}
result, err := db.ExecContext(c.Request.Context(),
`UPDATE users SET is_admin = $1, updated_at = NOW() WHERE id = $2`,
req.IsAdmin, targetID,
)
targetUUID, _ := uuid.Parse(targetID)
n, err := sqlcdb.New(db.DB).SetUserAdmin(c.Request.Context(), sqlcdb.SetUserAdminParams{
IsAdmin: req.IsAdmin,
ID: targetUUID,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user"})
return
}
if n, _ := result.RowsAffected(); n == 0 {
if n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
@@ -258,15 +249,13 @@ func handleAdminImpersonateUser(c *gin.Context) {
return
}
var email, name string
var isAdmin bool
err := db.QueryRowContext(c.Request.Context(),
`SELECT email, COALESCE(name, ''), is_admin FROM users WHERE id = $1`, targetID).
Scan(&email, &name, &isAdmin)
userUUID, _ := uuid.Parse(targetID)
info, err := sqlcdb.New(db.DB).GetAdminUserInfo(c.Request.Context(), userUUID)
if err != nil {
respondError(c, http.StatusNotFound, "NOT_FOUND", "user not found")
return
}
email, name, isAdmin := info.Email, info.Name, info.IsAdmin
if isAdmin {
respondError(c, http.StatusForbidden, "FORBIDDEN", "admin accounts cannot be impersonated")
return
+113 -181
View File
@@ -2,6 +2,7 @@ package api
import (
"containr/internal/database"
"containr/internal/database/sqlcdb"
"context"
"crypto/rand"
"database/sql"
@@ -14,6 +15,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
"github.com/google/uuid"
"github.com/lib/pq"
"golang.org/x/crypto/bcrypt"
)
@@ -160,21 +162,22 @@ func handleAPwhyServiceValidate(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
ctx := c.Request.Context()
var upstreamURL, healthPath string
var authHeader, authValue sql.NullString
var timeoutMs sql.NullInt64
err := db.QueryRowContext(ctx, `
SELECT upstream_url, health_path, upstream_auth_header, upstream_auth_value, request_timeout_ms
FROM api_services WHERE id = $1
`, serviceID).Scan(&upstreamURL, &healthPath, &authHeader, &authValue, &timeoutMs)
if err != nil {
svcUUID, parseErr := uuid.Parse(serviceID)
var svc sqlcdb.GetAPServiceForValidateRow
var err error
if parseErr == nil {
svc, err = sqlcdb.New(db.DB).GetAPServiceForValidate(ctx, svcUUID)
}
if parseErr != nil || err != nil {
sendErrorResponse(c, http.StatusNotFound, "SERVICE_NOT_FOUND", "Service not found", nil)
return
}
upstreamURL, healthPath := svc.UpstreamUrl, svc.HealthPath
authHeader, authValue := svc.UpstreamAuthHeader, svc.UpstreamAuthValue
timeout := 8000
if timeoutMs.Valid && timeoutMs.Int64 > 0 {
timeout = int(timeoutMs.Int64)
if svc.RequestTimeoutMs.Valid && svc.RequestTimeoutMs.Int32 > 0 {
timeout = int(svc.RequestTimeoutMs.Int32)
}
target := strings.TrimRight(upstreamURL, "/") + "/" + strings.TrimLeft(healthPath, "/")
@@ -203,17 +206,19 @@ func handleAPwhyServiceValidate(c *gin.Context) {
validationStatus = "healthy"
}
_, _ = db.ExecContext(ctx, `
UPDATE api_services
SET last_validation_at = NOW(), last_validation_status = $1, last_validation_message = $2, updated_at = NOW()
WHERE id = $3
`, validationStatus, message, serviceID)
q := sqlcdb.New(db.DB)
_ = q.SetAPServiceValidation(ctx, sqlcdb.SetAPServiceValidationParams{
LastValidationStatus: sql.NullString{String: validationStatus, Valid: true},
LastValidationMessage: sql.NullString{String: message, Valid: true},
ID: svcUUID,
})
if !ok {
_, _ = db.ExecContext(ctx, `
INSERT INTO incident_events (service_id, code, message, severity, http_status, occurred_at)
VALUES ($1, 'SERVICE_VALIDATION_FAILED', $2, 'medium', $3, NOW())
`, serviceID, message, status)
_ = q.InsertServiceIncident(ctx, sqlcdb.InsertServiceIncidentParams{
ServiceID: uuid.NullUUID{UUID: svcUUID, Valid: true},
Message: message,
HttpStatus: sql.NullInt32{Int32: int32(status), Valid: true},
})
}
sendSuccessResponse(c, http.StatusOK, map[string]interface{}{
@@ -231,36 +236,24 @@ func handleAPwhyServicesList(c *gin.Context) {
ctx := context.Background()
// Query services from database
rows, err := db.QueryContext(ctx, `
SELECT id, name, slug, upstream_url, route_prefix, enabled, created_at, updated_at
FROM api_services
ORDER BY created_at DESC
`)
rows, err := sqlcdb.New(db.DB).ListAPServices(ctx)
if err != nil {
sendErrorResponse(c, http.StatusInternalServerError, "DATABASE_ERROR",
"Failed to query services", err.Error())
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{}{
"id": id,
"name": name,
"slug": slug,
"upstreamUrl": upstreamURL,
"routePrefix": routePrefix,
"enabled": enabled != 0,
"createdAt": createdAt,
"updatedAt": updatedAt,
"id": r.ID.String(),
"name": r.Name,
"slug": r.Slug,
"upstreamUrl": r.UpstreamUrl,
"routePrefix": r.RoutePrefix,
"enabled": r.Enabled != 0,
"createdAt": r.CreatedAt,
"updatedAt": r.UpdatedAt,
})
}
@@ -299,14 +292,14 @@ func handleAPwhyServicesCreate(c *gin.Context) {
}
// id defaults to gen_random_uuid(); enabled defaults to 1.
var id string
err := db.QueryRowContext(ctx, `
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
`, req.Name, slug, req.UpstreamURL, req.RoutePrefix, rpmLimit, monthlyQuota).Scan(&id)
svcID, err := sqlcdb.New(db.DB).InsertAPService(ctx, sqlcdb.InsertAPServiceParams{
Name: req.Name,
Slug: slug,
UpstreamUrl: req.UpstreamURL,
RoutePrefix: req.RoutePrefix,
RpmLimit: sql.NullInt32{Int32: int32(rpmLimit), Valid: true},
MonthlyQuota: sql.NullInt32{Int32: int32(monthlyQuota), Valid: true},
})
if err != nil {
if isUniqueViolation(err) {
@@ -320,7 +313,7 @@ func handleAPwhyServicesCreate(c *gin.Context) {
}
serviceData := map[string]interface{}{
"id": id,
"id": svcID.String(),
"name": req.Name,
"slug": slug,
"upstreamUrl": req.UpstreamURL,
@@ -356,13 +349,15 @@ func handleAPwhyServicesPatch(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
if input.Enabled != nil {
enabled := 0
enabled := int32(0)
if *input.Enabled {
enabled = 1
}
_, err := db.ExecContext(context.Background(),
"UPDATE api_services SET enabled = $1, updated_at = NOW() WHERE id = $2",
enabled, serviceID)
svcUUID, _ := uuid.Parse(serviceID)
err := sqlcdb.New(db.DB).SetAPServiceEnabled(context.Background(), sqlcdb.SetAPServiceEnabledParams{
Enabled: enabled,
ID: svcUUID,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
@@ -383,11 +378,7 @@ func handleAPwhyServicesPatch(c *gin.Context) {
func handleAPwhyKeysList(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
rows, err := db.QueryContext(context.Background(), `
SELECT id, name, key_prefix, plan, enabled, rpm_limit, monthly_quota, created_at, updated_at
FROM api_keys
ORDER BY created_at DESC
`)
rows, err := sqlcdb.New(db.DB).ListAPKeys(context.Background())
if err != nil {
c.JSON(http.StatusOK, gin.H{
"ok": true,
@@ -395,34 +386,24 @@ func handleAPwhyKeysList(c *gin.Context) {
})
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{}{
"id": id,
"name": name,
"keyPrefix": keyPrefix,
"plan": plan,
"enabled": enabled != 0,
"createdAt": createdAt,
"updatedAt": updatedAt,
"id": r.ID.String(),
"name": r.Name,
"keyPrefix": r.KeyPrefix,
"plan": r.Plan,
"enabled": r.Enabled != 0,
"createdAt": r.CreatedAt,
"updatedAt": r.UpdatedAt,
}
if rpmLimit.Valid {
key["rpmLimit"] = rpmLimit.Int64
if r.RpmLimit.Valid {
key["rpmLimit"] = int64(r.RpmLimit.Int32)
}
if monthlyQuota.Valid {
key["monthlyQuota"] = monthlyQuota.Int64
if r.MonthlyQuota.Valid {
key["monthlyQuota"] = int64(r.MonthlyQuota.Int32)
}
keys = append(keys, key)
@@ -481,14 +462,14 @@ func handleAPwhyKeysCreate(c *gin.Context) {
}
// id defaults to gen_random_uuid(); enabled defaults to 1.
var id string
err = db.QueryRowContext(context.Background(), `
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
`, input.Name, keyHash, keyPrefix, plan, rpmLimit, monthlyQuota).Scan(&id)
keyID, err := sqlcdb.New(db.DB).InsertAPKey(context.Background(), sqlcdb.InsertAPKeyParams{
Name: input.Name,
KeyHash: keyHash,
KeyPrefix: keyPrefix,
Plan: plan,
RpmLimit: sql.NullInt32{Int32: int32(rpmLimit), Valid: true},
MonthlyQuota: sql.NullInt32{Int32: int32(monthlyQuota), Valid: true},
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
@@ -501,7 +482,7 @@ func handleAPwhyKeysCreate(c *gin.Context) {
c.JSON(http.StatusCreated, gin.H{
"ok": true,
"data": gin.H{
"id": id,
"id": keyID.String(),
"name": input.Name,
"plan": plan,
"key": apiKey, // Only return the actual key once
@@ -532,13 +513,15 @@ func handleAPwhyKeysPatch(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
if input.Enabled != nil {
enabled := 0
enabled := int32(0)
if *input.Enabled {
enabled = 1
}
_, err := db.ExecContext(context.Background(),
"UPDATE api_keys SET enabled = $1, updated_at = NOW() WHERE id = $2",
enabled, keyID)
keyUUID, _ := uuid.Parse(keyID)
err := sqlcdb.New(db.DB).SetAPKeyEnabled(context.Background(), sqlcdb.SetAPKeyEnabledParams{
Enabled: enabled,
ID: keyUUID,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
@@ -560,23 +543,27 @@ func handleAPwhyAnalyticsOps(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
// 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{
"ok": false,
"error": "Failed to count services: " + err.Error(),
})
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{
"ok": false,
"error": "Failed to count api keys: " + err.Error(),
})
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{
"ok": false,
"error": "Failed to count users: " + err.Error(),
@@ -584,8 +571,8 @@ func handleAPwhyAnalyticsOps(c *gin.Context) {
return
}
var totalRequests int
if err := db.QueryRowContext(context.Background(), "SELECT COALESCE(SUM(request_count), 0) FROM usage_counters").Scan(&totalRequests); err != nil {
totalRequests, err := q.SumUsageCounters(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"ok": false,
"error": "Failed to aggregate request counters: " + err.Error(),
@@ -593,12 +580,8 @@ func handleAPwhyAnalyticsOps(c *gin.Context) {
return
}
var requestsToday int
if err := db.QueryRowContext(context.Background(), `
SELECT COALESCE(SUM(value), 0)::int
FROM metrics_timeseries
WHERE metric = 'request_total' AND occurred_at >= DATE_TRUNC('day', NOW())
`).Scan(&requestsToday); err != nil {
requestsToday, err := q.SumRequestMetricSince(ctx, "day")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"ok": false,
"error": "Failed to aggregate today's requests: " + err.Error(),
@@ -606,12 +589,8 @@ func handleAPwhyAnalyticsOps(c *gin.Context) {
return
}
var requestsThisMonth int
if err := db.QueryRowContext(context.Background(), `
SELECT COALESCE(SUM(value), 0)::int
FROM metrics_timeseries
WHERE metric = 'request_total' AND occurred_at >= DATE_TRUNC('month', NOW())
`).Scan(&requestsThisMonth); err != nil {
requestsThisMonth, err := q.SumRequestMetricSince(ctx, "month")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"ok": false,
"error": "Failed to aggregate monthly requests: " + err.Error(),
@@ -636,96 +615,49 @@ func handleAPwhyAnalyticsOps(c *gin.Context) {
func handleAPwhyAnalyticsTraffic(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
q := sqlcdb.New(db.DB)
ctx := context.Background()
topServices := make([]map[string]interface{}, 0)
serviceRows, err := db.QueryContext(context.Background(), `
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
`)
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 {
if serviceRows, err := q.ListTopAPServices(ctx); err == nil {
for _, r := range serviceRows {
topServices = append(topServices, map[string]interface{}{
"service_id": serviceID,
"name": serviceName,
"requests": totalRequests,
"service_id": r.ID.String(),
"name": r.Name,
"requests": r.TotalRequests,
})
}
}
}
requestsByDay := make([]map[string]interface{}, 0)
trafficRows, err := db.QueryContext(context.Background(), `
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
`)
if err == nil {
defer trafficRows.Close()
for trafficRows.Next() {
var day string
var count int
if scanErr := trafficRows.Scan(&day, &count); scanErr == nil {
if trafficRows, err := q.ListRequestsByDay(ctx); err == nil {
for _, r := range trafficRows {
requestsByDay = append(requestsByDay, map[string]interface{}{
"day": day,
"requests": count,
"day": r.DayBucket,
"requests": r.Total,
})
}
}
}
statusCodes := make([]map[string]interface{}, 0)
statusRows, err := db.QueryContext(context.Background(), `
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
`)
if err == nil {
defer statusRows.Close()
for statusRows.Next() {
var code int
var total int
if scanErr := statusRows.Scan(&code, &total); scanErr == nil {
if statusRows, err := q.ListIncidentStatusCodes(ctx); err == nil {
for _, r := range statusRows {
statusCodes = append(statusCodes, map[string]interface{}{
"status_code": code,
"count": total,
"status_code": r.StatusCode,
"count": r.Total,
})
}
}
}
clientEvents := make([]map[string]interface{}, 0)
eventRows, err := db.QueryContext(context.Background(), `
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
`)
if err == nil {
defer eventRows.Close()
for eventRows.Next() {
var path string
var total int
if scanErr := eventRows.Scan(&path, &total); scanErr == nil {
if eventRows, err := q.ListClientEventPaths(ctx); err == nil {
for _, r := range eventRows {
clientEvents = append(clientEvents, map[string]interface{}{
"path": path,
"count": total,
"path": r.Path,
"count": r.Total,
})
}
}
}
c.JSON(http.StatusOK, gin.H{
"ok": true,
+54 -34
View File
@@ -2,6 +2,9 @@ package api
import (
"containr/internal/database"
"containr/internal/database/sqlcdb"
"context"
"database/sql"
"encoding/json"
"fmt"
"net/http"
@@ -11,6 +14,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/sqlc-dev/pqtype"
)
type AuditLog struct {
@@ -98,16 +102,20 @@ func LogAudit(userID, resource, resourceID, action string, details map[string]in
}
detailsJSON, _ := json.Marshal(details)
resourceUUID := parseUUIDOrNil(resourceID)
userUUID := parseUUIDOrNil(userID)
severity, category, label := classifyAuditEvent(resource, action)
auditID := uuid.New().String()
_, err := db.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)`,
auditID, userUUID, resource, resourceUUID, action, string(detailsJSON), severity, category, label, time.Now().UTC(),
)
err := sqlcdb.New(db.DB).InsertAuditLog(context.Background(), sqlcdb.InsertAuditLogParams{
ID: uuid.New(),
UserID: nullUUIDOrNil(userID),
Resource: resource,
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 {
}
@@ -131,16 +139,24 @@ func LogAuditWithRequest(c *gin.Context, resource, resourceID, action string, de
if uid, ok := userID.(string); ok {
userIDStr = uid
}
userUUID := parseUUIDOrNil(userIDStr)
resourceUUID := parseUUIDOrNil(resourceID)
severity, category, label := classifyAuditEvent(resource, action)
auditID := uuid.New().String()
_, err := db.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, $8, $9, $10, $11, $12)`,
auditID, userUUID, resource, resourceUUID, action, string(detailsJSON), c.ClientIP(), c.GetHeader("User-Agent"), severity, category, label, time.Now().UTC(),
)
var inet pqtype.Inet
_ = inet.Scan(c.ClientIP())
err := sqlcdb.New(db.DB).InsertAuditLogWithRequest(context.Background(), sqlcdb.InsertAuditLogWithRequestParams{
ID: uuid.New(),
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 {
}
@@ -255,30 +271,29 @@ func handleGetResourceAuditLogs(c *gin.Context) {
resource := c.Param("resource")
resourceID := c.Param("id")
rows, err := db.Query(
`SELECT id, COALESCE(user_id::text, ''), resource, COALESCE(resource_id::text, ''), action, COALESCE(details::text, '{}'),
COALESCE(ip_address::text, ''), COALESCE(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`,
userID, resource, resourceID,
)
rows, err := sqlcdb.New(db.DB).ListResourceAuditLogs(context.Background(), sqlcdb.ListResourceAuditLogsParams{
UserID: nullUUIDOrNil(userID),
Resource: resource,
ResourceID: nullUUIDOrNil(resourceID),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch audit logs"})
return
}
defer rows.Close()
var logs []AuditLog
for rows.Next() {
var log AuditLog
err := rows.Scan(&log.ID, &log.UserID, &log.Resource, &log.ResourceID, &log.Action, &log.Details, &log.IPAddress, &log.UserAgent, &log.CreatedAt)
if err != nil {
continue
}
logs = append(logs, log)
for _, row := range rows {
logs = append(logs, AuditLog{
ID: row.ID.String(),
UserID: row.UserID,
Resource: row.Resource,
ResourceID: row.ResourceID,
Action: row.Action,
Details: row.Details,
IPAddress: row.IpAddress,
UserAgent: row.UserAgent,
CreatedAt: row.CreatedAt,
})
}
c.JSON(http.StatusOK, gin.H{"audit_logs": logs})
@@ -302,3 +317,8 @@ func parseUUIDOrNil(raw string) interface{} {
}
return trimmed
}
func nullUUIDOrNil(raw string) uuid.NullUUID {
parsed, err := uuid.Parse(strings.TrimSpace(raw))
return uuid.NullUUID{UUID: parsed, Valid: err == nil}
}
+54 -64
View File
@@ -16,6 +16,7 @@ import (
"containr/internal/build"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/docker"
"containr/internal/types"
@@ -669,39 +670,22 @@ func (h *BuildHandler) upsertBuild(status *BuildStatusResponse) error {
}
now := time.Now().UTC()
_, err := h.db.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`,
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,
)
err := sqlcdb.New(h.db.DB).UpsertBuild(context.Background(), sqlcdb.UpsertBuildParams{
ID: status.ID,
ProjectID: nsNullIfEmpty(status.ProjectID),
ServiceID: nsNullIfEmpty(status.ServiceID),
Status: status.Status,
Progress: int32(status.Progress),
StartedAt: status.StartedAt,
CompletedAt: ntPtr(status.CompletedAt),
ImageName: status.ImageName,
ImageTag: status.ImageTag,
Size: status.Size,
Error: nsNullIfEmpty(status.Error),
Log: status.Log,
Column13: json.RawMessage(jsonOrEmptyObject(metadataRaw)),
CreatedAt: now,
})
if err != nil && h.isMissingBuildsTable(err) {
return nil
}
@@ -713,18 +697,37 @@ func (h *BuildHandler) getBuildFromDB(buildID string) (BuildStatusResponse, bool
return BuildStatusResponse{}, false, nil
}
row := h.db.QueryRow(
`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`,
buildID,
)
build, found, err := scanBuildRow(row.Scan)
if err != nil && h.isMissingBuildsTable(err) {
row, err := sqlcdb.New(h.db.DB).GetBuild(context.Background(), buildID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) || h.isMissingBuildsTable(err) {
return BuildStatusResponse{}, false, nil
}
return build, found, err
return BuildStatusResponse{}, false, 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) {
@@ -808,19 +811,11 @@ func (h *BuildHandler) cancelBuildInDB(buildID string) bool {
return false
}
now := time.Now().UTC()
result, err := h.db.Exec(
`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')`,
now,
h.formatLogLine("Cancellation requested")+"\n",
buildID,
)
affected, err := sqlcdb.New(h.db.DB).CancelBuild(context.Background(), sqlcdb.CancelBuildParams{
CompletedAt: sql.NullTime{Time: now, Valid: true},
Log: h.formatLogLine("Cancellation requested") + "\n",
ID: buildID,
})
if err != nil {
if h.isMissingBuildsTable(err) {
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)
return false
}
affected, err := result.RowsAffected()
if err != nil || affected == 0 {
if affected == 0 {
return false
}
@@ -909,12 +902,9 @@ func scanBuildRow(scan func(dest ...interface{}) error) (BuildStatusResponse, bo
return parsed, true, nil
}
func nullIfEmptyString(value string) interface{} {
func nsNullIfEmpty(value string) sql.NullString {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return nil
}
return trimmed
return sql.NullString{String: trimmed, Valid: trimmed != ""}
}
func jsonOrEmptyObject(raw []byte) string {
+18 -23
View File
@@ -1,9 +1,11 @@
package api
import (
"context"
"net/http"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"github.com/gin-gonic/gin"
"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
// platform admins only.
func projectReadAccess(c *gin.Context, db *database.DB, projectID uuid.UUID) (bool, bool) {
var isApproved bool
var ownerID string
err := db.QueryRow(
`SELECT is_approved, owner_id::text FROM projects WHERE id = $1`,
projectID,
).Scan(&isApproved, &ownerID)
q := sqlcdb.New(db.DB)
row, err := q.GetProjectReadAccess(context.Background(), projectID)
if err != nil {
return false, false
}
isApproved, ownerID := row.IsApproved, row.OwnerID
if isApproved || contextIsAdmin(c) {
return true, true
}
@@ -76,11 +75,11 @@ func projectReadAccess(c *gin.Context, db *database.DB, projectID uuid.UUID) (bo
if ownerID == userID {
return true, true
}
var member bool
if err := db.QueryRow(
`SELECT EXISTS(SELECT 1 FROM project_members WHERE project_id = $1 AND user_id = $2)`,
projectID, userID,
).Scan(&member); err == nil && member {
userUUID, _ := uuid.Parse(userID)
if member, err := q.ProjectMemberExists(context.Background(), sqlcdb.ProjectMemberExistsParams{
ProjectID: projectID,
UserID: userUUID,
}); err == nil && member {
return true, true
}
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
// such as variables and runtime logs.
func projectManageAccess(c *gin.Context, db *database.DB, projectID uuid.UUID) (bool, bool) {
var ownerID string
err := db.QueryRow(
`SELECT owner_id::text FROM projects WHERE id = $1`,
projectID,
).Scan(&ownerID)
q := sqlcdb.New(db.DB)
ownerID, err := q.GetProjectOwnerText(context.Background(), projectID)
if err != nil {
return false, false
}
@@ -109,11 +105,11 @@ func projectManageAccess(c *gin.Context, db *database.DB, projectID uuid.UUID) (
if ownerID == userID {
return true, true
}
var member bool
if err := db.QueryRow(
`SELECT EXISTS(SELECT 1 FROM project_members WHERE project_id = $1 AND user_id = $2)`,
projectID, userID,
).Scan(&member); err == nil && member {
userUUID, _ := uuid.Parse(userID)
if member, err := q.ProjectMemberExists(context.Background(), sqlcdb.ProjectMemberExistsParams{
ProjectID: projectID,
UserID: userUUID,
}); err == nil && member {
return true, true
}
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.
func serviceProjectID(db *database.DB, serviceID uuid.UUID) (uuid.UUID, bool) {
var projectID uuid.UUID
err := db.QueryRow(`SELECT project_id FROM services WHERE id = $1`, serviceID).Scan(&projectID)
projectID, err := sqlcdb.New(db.DB).GetServiceProjectID(context.Background(), serviceID)
if err != nil {
return uuid.Nil, false
}
+1 -1
View File
@@ -116,7 +116,7 @@ func handleDeleteEnvironment(c *gin.Context) {
return
}
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")
return
}
+65 -52
View File
@@ -17,9 +17,11 @@ import (
"time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/gateway"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
@@ -107,23 +109,28 @@ func extractGatewayKey(c *gin.Context) string {
// lookupGatewayService resolves the slug to an enabled service row.
func lookupGatewayService(ctx context.Context, db *database.DB, slug string) (*gatewayService, error) {
var s gatewayService
var enabled int
err := db.QueryRowContext(ctx, `
SELECT id, upstream_url, route_prefix, upstream_auth_header,
upstream_auth_value, enabled, rpm_limit, monthly_quota,
request_timeout_ms
FROM api_services WHERE slug = $1
`, slug).Scan(&s.ID, &s.UpstreamURL, &s.RoutePrefix,
&s.UpstreamAuthHeader, &s.UpstreamAuthValue, &enabled,
&s.RPMLimit, &s.MonthlyQuota, &s.RequestTimeoutMS)
r, err := sqlcdb.New(db.DB).GetAPServiceBySlug(ctx, slug)
if err != nil {
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
}
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
// bcrypt-verifies candidates. Returns nil when nothing matches.
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 {
prefix = prefix[:8]
}
rows, err := db.QueryContext(ctx, `
SELECT id, key_hash, enabled, rpm_limit, monthly_quota, allowed_service_ids
FROM api_keys WHERE key_prefix = $1
`, prefix)
rows, err := sqlcdb.New(db.DB).ListAPKeysByPrefix(ctx, prefix)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var k gatewayKey
var enabled int
if err := rows.Scan(&k.ID, &k.Hash, &enabled, &k.RPMLimit, &k.MonthlyQuota, &k.AllowedServiceIDs); err != nil {
return nil, err
for _, r := range rows {
k := gatewayKey{
ID: r.ID.String(),
Hash: r.KeyHash,
Enabled: r.Enabled != 0,
RPMLimit: nullInt32To64(r.RpmLimit),
MonthlyQuota: nullInt32To64(r.MonthlyQuota),
AllowedServiceIDs: r.AllowedServiceIds,
}
k.Enabled = enabled != 0
if bcrypt.CompareHashAndPassword([]byte(k.Hash), []byte(presented)) == nil {
return &k, nil
}
}
return nil, rows.Err()
return nil, nil
}
// keyAllowsService applies the allowed_service_ids JSON allowlist. An empty
@@ -168,48 +173,56 @@ func keyAllowsService(k *gatewayKey, serviceID string) bool {
return false
}
// monthlyUsage sums a counter dimension for the current calendar month.
func monthlyUsage(ctx context.Context, db *database.DB, column, id, period string) (int, error) {
var total int
err := db.QueryRowContext(ctx, `
SELECT COALESCE(SUM(request_count), 0) FROM usage_counters
WHERE `+column+` = $1 AND period_month = $2
`, id, period).Scan(&total)
return total, err
// monthlyKeyUsage / monthlyServiceUsage sum a counter dimension for the
// current calendar month.
func monthlyKeyUsage(ctx context.Context, db *database.DB, id, period string) (int64, error) {
keyUUID, _ := uuid.Parse(id)
return sqlcdb.New(db.DB).SumUsageByKey(ctx, sqlcdb.SumUsageByKeyParams{
ApiKeyID: keyUUID, PeriodMonth: period,
})
}
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
// 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) {
_, _ = db.ExecContext(ctx, `
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()
`, k.ID, svc.ID, period)
_, _ = db.ExecContext(ctx, `
UPDATE api_keys SET last_used_at = NOW() WHERE id = $1
`, k.ID)
q := sqlcdb.New(db.DB)
keyUUID, _ := uuid.Parse(k.ID)
svcUUID, _ := uuid.Parse(svc.ID)
_ = q.UpsertUsageCounter(ctx, sqlcdb.UpsertUsageCounterParams{
ApiKeyID: keyUUID, ServiceID: svcUUID, PeriodMonth: period,
})
_ = q.TouchAPKeyLastUsed(ctx, keyUUID)
labels, _ := json.Marshal(map[string]interface{}{
"service_id": svc.ID,
"key_id": k.ID,
"status": status,
})
_, _ = db.ExecContext(ctx, `
INSERT INTO metrics_timeseries (metric, value, labels_json, occurred_at)
VALUES ('gateway_request', 1, $1, NOW())
`, string(labels))
_ = q.InsertMetricPoint(ctx, sql.NullString{String: string(labels), Valid: true})
}
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 {
kid = *keyID
if u, err := uuid.Parse(*keyID); err == nil {
kid = uuid.NullUUID{UUID: u, Valid: true}
}
_, _ = db.ExecContext(ctx, `
INSERT INTO incident_events (service_id, api_key_id, code, message, severity, http_status, occurred_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW())
`, svcID, kid, code, msg, severity, status)
}
svcUUID, _ := uuid.Parse(svcID)
_ = sqlcdb.New(db.DB).InsertGatewayIncident(ctx, sqlcdb.InsertGatewayIncidentParams{
ServiceID: uuid.NullUUID{UUID: svcUUID, Valid: svcUUID != uuid.Nil},
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
@@ -267,13 +280,13 @@ func handleGatewayProxy(c *gin.Context) {
period := now.Format("2006-01")
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")
return
}
}
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")
return
}
+27 -18
View File
@@ -1,12 +1,16 @@
package api
import (
"context"
"fmt"
"strconv"
"strings"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/source"
"github.com/google/uuid"
)
// 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}
q := sqlcdb.New(db.DB)
ctx := context.Background()
var providerID string
ownerUUID, _ := uuid.Parse(ownerID)
var providerID uuid.UUID
if !strings.Contains(cloneURL, "://") {
// Bare owner/repo — the connected repository row knows the host.
err = db.QueryRow(
`SELECT clone_url, provider_id FROM git_repositories
WHERE full_name = $1 AND user_id = $2 LIMIT 1`,
cloneURL, ownerID,
).Scan(&cloneURL, &providerID)
row, err := q.GetGitRepoCloneByFullName(ctx, sqlcdb.GetGitRepoCloneByFullNameParams{
FullName: cloneURL,
UserID: ownerUUID,
})
if err != nil {
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 {
// URL form — match a connected repo for its provider when possible;
// an unmatched public URL still clones, just without credentials.
_ = db.QueryRow(
`SELECT provider_id FROM git_repositories
WHERE clone_url = $1 AND user_id = $2 LIMIT 1`,
cloneURL, ownerID,
).Scan(&providerID)
if pid, err := q.GetGitRepoProviderByCloneURL(ctx, sqlcdb.GetGitRepoProviderByCloneURLParams{
CloneUrl: cloneURL,
UserID: ownerUUID,
}); err == nil {
providerID = pid
}
}
if err := source.ValidateRemoteURL(cloneURL); err != nil {
@@ -49,17 +58,17 @@ func resolveGitCloneSpec(db *database.DB, ownerID, gitRepo, branch, commit, buil
}
spec.CloneURL = cloneURL
if providerID == "" {
if providerID == uuid.Nil {
return spec, nil
}
var name, apiURL, token string
if err := db.QueryRow(
`SELECT name, api_url, access_token FROM git_providers
WHERE id = $1 AND user_id = $2`,
providerID, ownerID,
).Scan(&name, &apiURL, &token); err != nil {
prov, err := q.GetGitProviderForFetch(ctx, sqlcdb.GetGitProviderForFetchParams{
ID: providerID,
UserID: ownerUUID,
})
if err != nil {
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) {
case "github_app":
+2 -2
View File
@@ -15,6 +15,7 @@ import (
"time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/deployment"
"github.com/gin-gonic/gin"
@@ -206,9 +207,8 @@ func handleAdminGitHubAppConvert(c *gin.Context) {
// GET /admin/git/github-app — provisioning status for the settings UI.
func handleAdminGetGitHubApp(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
var stored string
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"
} else if strings.TrimSpace(os.Getenv("GITHUB_APP_ID")) != "" {
source = "env"
+15 -9
View File
@@ -193,8 +193,8 @@ func handleAcceptInvite(c *gin.Context) {
return
}
var existing int
if err := db.QueryRow(`SELECT COUNT(*) FROM users WHERE email = $1`, req.Email).Scan(&existing); err != nil {
existing, err := q.CountUsersByEmail(c.Request.Context(), req.Email)
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "database error")
return
}
@@ -214,17 +214,23 @@ func handleAcceptInvite(c *gin.Context) {
return
}
var user User
err = db.QueryRow(`
INSERT INTO users (email, password_hash, name)
VALUES ($1, $2, $3)
RETURNING id, email, name, COALESCE(avatar_url, ''), is_admin, created_at
`, req.Email, string(hashedPassword), strings.TrimSpace(req.Name)).
Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt)
row, err := q.InsertUser(c.Request.Context(), sqlcdb.InsertUserParams{
Email: req.Email,
PasswordHash: string(hashedPassword),
Name: strings.TrimSpace(req.Name),
})
if err != nil {
respondError(c, http.StatusConflict, "CONFLICT", "user already exists")
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)
if err := q.MarkUserInviteUsed(c.Request.Context(), sqlcdb.MarkUserInviteUsedParams{
+3 -7
View File
@@ -9,6 +9,7 @@ import (
"time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/docker"
containertypes "github.com/docker/docker/api/types/container"
@@ -60,17 +61,12 @@ func handleGetServiceMetrics(c *gin.Context) {
return
}
var projectID uuid.UUID
var status string
err = db.QueryRow(
`SELECT s.project_id, COALESCE(s.status, '')
FROM services s WHERE s.id = $1`,
serviceID,
).Scan(&projectID, &status)
svc, err := sqlcdb.New(db.DB).GetServiceProjectAndStatus(context.Background(), serviceID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
}
projectID := svc.ProjectID
if _, allowed := projectReadAccess(c, db, projectID); !allowed {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
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
// host so private images pull on remote nodes. Empty map → anonymous pull.
func (r *agentNodeRunner) pullCredentials(projectID, imageRef string) map[string]interface{} {
var ownerID string
if err := r.db.QueryRow(
`SELECT owner_id FROM projects WHERE id = $1`, projectID,
).Scan(&ownerID); err != nil {
pid, _ := uuid.Parse(projectID)
ownerID, err := sqlcdb.New(r.db.DB).GetProjectOwnerText(context.Background(), pid)
if err != nil {
return map[string]interface{}{}
}
auth := registryAuthFor(r.db, ownerID, imageRef)
@@ -9,6 +9,7 @@ import (
"containr/internal/database"
"containr/internal/database/sqlcdb"
"context"
"database/sql"
"encoding/json"
"fmt"
"log"
@@ -29,15 +30,13 @@ func notifyAdmins(db *database.DB, kind, title, body, resourceType, resourceID s
if db == nil {
return
}
rows, err := db.Query(`SELECT id FROM users WHERE is_admin`)
adminIDs, err := sqlcdb.New(db.DB).ListAdminUserIDs(context.Background())
if err != nil {
log.Printf("containr: notify admins lookup failed: %v", err)
return
}
defer rows.Close()
for rows.Next() {
var id uuid.UUID
if err := rows.Scan(&id); err == nil {
for _, id := range adminIDs {
{
insertUserNotification(db, id.String(), kind, title, body, resourceType, resourceID)
}
}
@@ -73,34 +72,32 @@ type agentHealthRow struct {
func reconcileAgentHealth(db *database.DB, staleAfter time.Duration) {
cutoff := time.Now().Add(-staleAfter)
var agents []agentHealthRow
rows, err := db.Query(
`SELECT id, COALESCE(name, hostname, id::text), COALESCE(status, ''),
COALESCE(last_heartbeat < $1, TRUE), auto_prune
FROM node_agents`, cutoff)
q := sqlcdb.New(db.DB)
agentRows, err := q.ListAgentHealthRows(context.Background(), sql.NullTime{Time: cutoff, Valid: true})
if err != nil {
log.Printf("containr: agent health sweep: %v", err)
return
}
for rows.Next() {
var a agentHealthRow
if err := rows.Scan(&a.id, &a.name, &a.status, &a.stale, &a.autoPrune); err == nil {
agents = append(agents, a)
for _, r := range agentRows {
agents = append(agents, agentHealthRow{
id: r.ID, name: r.Name, status: r.Status, stale: r.Stale, autoPrune: r.AutoPrune,
})
}
}
rows.Close()
for _, a := range agents {
switch {
case a.stale && a.status != "offline":
if _, err := db.Exec(
`UPDATE node_agents SET status = 'offline', updated_at = NOW() WHERE id = $1`, a.id); err != nil {
if err := q.SetAgentStatus(context.Background(), sqlcdb.SetAgentStatusParams{
Status: sql.NullString{String: "offline", Valid: true}, ID: a.id,
}); err != nil {
continue
}
notifyAdmins(db, "agent", "Node agent offline",
fmt.Sprintf("Agent %s missed its heartbeat for over %s.", a.name, staleAfter), "agent", a.id)
case !a.stale && a.status == "offline":
if _, err := db.Exec(
`UPDATE node_agents SET status = 'online', updated_at = NOW() WHERE id = $1`, a.id); err != nil {
if err := q.SetAgentStatus(context.Background(), sqlcdb.SetAgentStatusParams{
Status: sql.NullString{String: "online", Valid: true}, ID: a.id,
}); err != nil {
continue
}
notifyAdmins(db, "agent", "Node agent back online",
@@ -186,17 +183,17 @@ func checkForUpgrade(db *database.DB, currentVersion string) {
return
}
var lastNotified string
_ = db.QueryRow(`SELECT value FROM app_settings WHERE key = 'upgrade_notified_tag'`).Scan(&lastNotified)
lastNotified, _ := sqlcdb.New(db.DB).GetAppSetting(context.Background(), "upgrade_notified_tag")
if lastNotified == release.TagName {
return
}
notifyAdmins(db, "upgrade", "Containr update available",
fmt.Sprintf("Release %s is available (running %s). %s", release.TagName, currentVersion, release.HTMLURL),
"release", release.TagName)
_, _ = db.Exec(
`INSERT INTO app_settings (key, value, updated_at) VALUES ('upgrade_notified_tag', $1, NOW())
ON CONFLICT (key) DO UPDATE SET value = $1, updated_at = NOW()`, release.TagName)
_ = sqlcdb.New(db.DB).UpsertAppSettingValue(context.Background(), sqlcdb.UpsertAppSettingValueParams{
Key: "upgrade_notified_tag",
Value: release.TagName,
})
}
// semverNewer reports whether candidate is a higher semver than current.
+40 -69
View File
@@ -1,13 +1,16 @@
package api
import (
"context"
"net/http"
"time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/deployqueue"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// 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)
isAdmin := contextIsAdmin(c)
// Same visibility rule as the deployment list endpoint.
visibility := `p.is_approved OR p.owner_id = $1 OR $2::bool
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1)`
userUUID, _ := uuid.Parse(userID)
q := sqlcdb.New(db.DB)
visibility := sqlcdb.ListActiveOperationDeploymentsParams{OwnerID: userUUID, Column2: isAdmin}
active := []operationDeployment{}
rows, err := db.Query(
`SELECT d.id, d.service_id, s.name, p.name, d.status,
COALESCE(d.image_name, ''), d.error, d.started_at, d.completed_at, d.created_at
FROM deployments d
JOIN services s ON s.id = d.service_id
JOIN projects p ON p.id = s.project_id
WHERE d.status IN ('queued','pending','building','deploying','rolling_back')
AND (`+visibility+`)
ORDER BY d.created_at ASC`, userID, isAdmin)
if err == nil {
defer rows.Close()
for rows.Next() {
var d operationDeployment
var sid string
_ = rows.Scan(&d.ID, &sid, &d.ServiceName, &d.ProjectName, &d.Status, &d.Image, &d.Error, &d.StartedAt, &d.CompletedAt, &d.CreatedAt)
d.ServiceID = sid
active = append(active, d)
if rows, err := q.ListActiveOperationDeployments(context.Background(), visibility); err == nil {
for _, r := range rows {
active = append(active, operationDeployment{
ID: r.ID.String(), ServiceID: r.ServiceID.String(), ServiceName: r.ServiceName,
ProjectName: r.ProjectName, Status: r.Status.String, Image: r.Image,
Error: strPtrNS(r.Error), StartedAt: timePtrNT(r.StartedAt),
CompletedAt: timePtrNT(r.CompletedAt), CreatedAt: r.CreatedAt.Time,
})
}
}
recentFailed := []operationDeployment{}
rows2, err2 := db.Query(
`SELECT d.id, d.service_id, s.name, p.name, d.status,
COALESCE(d.image_name, ''), d.error, d.started_at, d.completed_at, d.created_at
FROM deployments d
JOIN services s ON s.id = d.service_id
JOIN projects p ON p.id = s.project_id
WHERE d.status IN ('failed','cancelled','rolled_back')
AND d.updated_at > NOW() - INTERVAL '24 hours'
AND (`+visibility+`)
ORDER BY d.updated_at DESC
LIMIT 20`, userID, isAdmin)
if err2 == nil {
defer rows2.Close()
for rows2.Next() {
var d operationDeployment
var sid string
_ = rows2.Scan(&d.ID, &sid, &d.ServiceName, &d.ProjectName, &d.Status, &d.Image, &d.Error, &d.StartedAt, &d.CompletedAt, &d.CreatedAt)
d.ServiceID = sid
recentFailed = append(recentFailed, d)
if rows, err := q.ListRecentFailedDeployments(context.Background(), sqlcdb.ListRecentFailedDeploymentsParams{
OwnerID: userUUID, Column2: isAdmin,
}); err == nil {
for _, r := range rows {
recentFailed = append(recentFailed, operationDeployment{
ID: r.ID.String(), ServiceID: r.ServiceID.String(), ServiceName: r.ServiceName,
ProjectName: r.ProjectName, Status: r.Status.String, Image: r.Image,
Error: strPtrNS(r.Error), StartedAt: timePtrNT(r.StartedAt),
CompletedAt: timePtrNT(r.CompletedAt), CreatedAt: r.CreatedAt.Time,
})
}
}
cronRuns := []operationCronRun{}
rows3, err3 := db.Query(
`SELECT e.id, j.name, j.schedule, e.status, e.started_at, e.finished_at, e.error
FROM cron_executions e
JOIN cron_jobs j ON j.id = e.cron_job_id
JOIN projects p ON p.id = j.project_id
WHERE e.started_at > NOW() - INTERVAL '24 hours'
AND (`+visibility+`)
ORDER BY e.started_at DESC
LIMIT 30`, userID, isAdmin)
if err3 == nil {
defer rows3.Close()
for rows3.Next() {
var r operationCronRun
_ = rows3.Scan(&r.ID, &r.JobName, &r.Schedule, &r.Status, &r.StartedAt, &r.FinishedAt, &r.Error)
cronRuns = append(cronRuns, r)
if rows, err := q.ListRecentCronRuns(context.Background(), sqlcdb.ListRecentCronRunsParams{
OwnerID: userUUID, Column2: isAdmin,
}); err == nil {
for _, r := range rows {
cronRuns = append(cronRuns, operationCronRun{
ID: r.ID.String(), JobName: r.JobName, Schedule: r.Schedule, Status: r.Status.String,
StartedAt: r.StartedAt, FinishedAt: timePtrNT(r.FinishedAt), Error: strPtrNS(r.Error),
})
}
}
backups := []operationBackup{}
rows4, err4 := db.Query(
`SELECT b.id, b.database_id, ds.name, b.status, b.size, b.created_at, b.completed_at
FROM database_backups b
JOIN database_services ds ON ds.id = b.database_id
WHERE b.created_at > NOW() - INTERVAL '24 hours'
AND (ds.user_id = $1 OR $2::bool)
ORDER BY b.created_at DESC
LIMIT 20`, userID, isAdmin)
if err4 == nil {
defer rows4.Close()
for rows4.Next() {
var b operationBackup
_ = rows4.Scan(&b.ID, &b.DatabaseID, &b.DBName, &b.Status, &b.Size, &b.CreatedAt, &b.Completed)
backups = append(backups, b)
if rows, err := q.ListRecentOperationBackups(context.Background(), sqlcdb.ListRecentOperationBackupsParams{
UserID: userID, Column2: isAdmin,
}); err == nil {
for _, r := range rows {
backups = append(backups, operationBackup{
ID: r.ID, DatabaseID: r.DatabaseID, DBName: r.DbName, Status: r.Status,
Size: r.Size, CreatedAt: r.CreatedAt.Time, Completed: timePtrNT(r.CompletedAt),
})
}
}
var queue []deployqueue.ServiceQueueState
if queueValue, exists := c.Get("deploy_queue"); exists && queueValue != nil {
queue = queueValue.(*deployqueue.Queue).Snapshot()
+17 -11
View File
@@ -1,10 +1,12 @@
package api
import (
"context"
"net/http"
"time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/secrets"
"github.com/gin-gonic/gin"
@@ -112,7 +114,8 @@ func handleUpdateProjectVariables(c *gin.Context) {
}
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"})
return
}
@@ -127,11 +130,15 @@ func handleUpdateProjectVariables(c *gin.Context) {
if v.IsSecret {
value = secrets.Encrypt(value)
}
if _, err = tx.Exec(
`INSERT INTO project_variables (id, project_id, key, value, is_secret, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
uuid.New(), projectID, v.Key, value, v.IsSecret, now, now,
); err != nil {
if err = txq.InsertProjectVariable(c.Request.Context(), sqlcdb.InsertProjectVariableParams{
ID: uuid.New(),
ProjectID: projectID,
Key: v.Key,
Value: value,
IsSecret: v.IsSecret,
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to insert variable: " + v.Key})
return
}
@@ -168,11 +175,10 @@ func handleUpdateProjectVariables(c *gin.Context) {
// resolveSharedVar returns the decrypted value of a project variable.
func resolveSharedVar(db *database.DB, projectID uuid.UUID, key string) (string, bool) {
var value string
err := db.QueryRow(
`SELECT value FROM project_variables WHERE project_id = $1 AND key = $2`,
projectID, key,
).Scan(&value)
value, err := sqlcdb.New(db.DB).GetProjectVariableValue(context.Background(), sqlcdb.GetProjectVariableValueParams{
ProjectID: projectID,
Key: key,
})
if err != nil {
return "", false
}
+9 -7
View File
@@ -1,11 +1,13 @@
package api
import (
"context"
"net/http"
"strings"
"time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/secrets"
"github.com/docker/docker/api/types/registry"
@@ -177,17 +179,17 @@ func handleDeleteRegistry(c *gin.Context) {
// bare image names (nginx:latest).
func registryAuthFor(db *database.DB, ownerID, imageRef string) registry.AuthConfig {
host := registryHost(imageRef)
var username, password string
err := db.QueryRow(
`SELECT username, password FROM registries WHERE owner_id = $1 AND host = $2`,
ownerID, host,
).Scan(&username, &password)
ownerUUID, _ := uuid.Parse(ownerID)
row, err := sqlcdb.New(db.DB).GetRegistryAuth(context.Background(), sqlcdb.GetRegistryAuthParams{
OwnerID: ownerUUID,
Host: host,
})
if err != nil {
return registry.AuthConfig{}
}
return registry.AuthConfig{
Username: username,
Password: secrets.Decrypt(password),
Username: row.Username,
Password: secrets.Decrypt(row.Password),
ServerAddress: host,
}
}
+22 -27
View File
@@ -11,6 +11,7 @@ import (
"containr/internal/build"
"containr/internal/config"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/deployment"
"containr/internal/deployqueue"
"containr/internal/docker"
@@ -21,6 +22,7 @@ import (
"containr/internal/secrets"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
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.
docker := dockerClient
haManager.WithServiceResolver(func(ctx context.Context, serviceID string) (string, int, error) {
var name, projectID string
var port int
err := db.QueryRowContext(ctx,
`SELECT name, project_id::text, COALESCE(port, 0) FROM services WHERE id = $1`,
serviceID,
).Scan(&name, &projectID, &port)
sid, err := uuid.Parse(serviceID)
if err != nil {
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 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)
@@ -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
// looked in-flight is actually orphaned — mark it failed rather than
// leaving phantom 'building'/'queued' rows.
_, _ = db.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')`,
)
bootQ := sqlcdb.New(db.DB)
_ = bootQ.FailInterruptedDeployments(context.Background())
// Boot sweep: encrypt any plaintext secret values left over from before
// encryption-at-rest landed. Transparent and idempotent.
if encRows, err := db.Query(`SELECT id, value FROM environment_variables WHERE is_secret = TRUE`); err == nil {
type encRow struct {
id string
value string
if encRows, err := bootQ.ListSecretEnvVars(context.Background()); err == nil {
toEncrypt := 0
for _, r := range encRows {
if secrets.IsEncrypted(r.Value) {
continue
}
var toEncrypt []encRow
for encRows.Next() {
var r encRow
if err := encRows.Scan(&r.id, &r.value); err == nil && !secrets.IsEncrypted(r.value) {
toEncrypt = append(toEncrypt, r)
if err := bootQ.UpdateEnvVarValue(context.Background(), sqlcdb.UpdateEnvVarValueParams{
Value: secrets.Encrypt(r.Value), ID: r.ID,
}); err == nil {
toEncrypt++
}
}
encRows.Close()
for _, r := range toEncrypt {
_, _ = db.Exec(`UPDATE environment_variables SET value = $1, updated_at = NOW() WHERE id = $2`, secrets.Encrypt(r.value), r.id)
}
if len(toEncrypt) > 0 {
log.Printf("secrets: encrypted %d plaintext secret variable(s)", len(toEncrypt))
if toEncrypt > 0 {
log.Printf("secrets: encrypted %d plaintext secret variable(s)", toEncrypt)
}
}
+22 -29
View File
@@ -2,6 +2,7 @@ package api
import (
"containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/deployment"
"containr/internal/deployqueue"
"context"
@@ -70,20 +71,18 @@ type Service struct {
// loadServiceSleep fills the sleep-mode columns, which live outside the
// wide service SELECTs. Missing columns (pre-migration) degrade to off.
func loadServiceSleep(db *database.DB, s *Service) {
var idle int
if err := db.QueryRow(
`SELECT COALESCE(sleep_enabled, false), COALESCE(sleep_idle_minutes, 15) FROM services WHERE id = $1`,
s.ID,
).Scan(&s.SleepEnabled, &idle); err == nil {
s.SleepIdleMinutes = idle
row, err := sqlcdb.New(db.DB).GetServiceSleep(context.Background(), s.ID)
if err == nil {
s.SleepEnabled = row.SleepEnabled
s.SleepIdleMinutes = int(row.SleepIdleMinutes)
}
}
// serviceNodeID reads the pin column directly — kept out of the wide service
// SELECTs so pre-migration schemas keep working.
func serviceNodeID(db *database.DB, serviceID uuid.UUID) string {
var id sql.NullString
if err := db.QueryRow(`SELECT node_id FROM services WHERE id = $1`, serviceID).Scan(&id); err == nil {
id, err := sqlcdb.New(db.DB).GetServiceNodeID(context.Background(), serviceID)
if err == nil {
return id.String
}
return ""
@@ -91,17 +90,14 @@ func serviceNodeID(db *database.DB, serviceID uuid.UUID) string {
// serviceSpread reads the spread column with the same lazy pattern.
func serviceSpread(db *database.DB, serviceID uuid.UUID) bool {
var spread bool
if err := db.QueryRow(`SELECT COALESCE(spread, false) FROM services WHERE id = $1`, serviceID).Scan(&spread); err == nil {
return spread
}
return false
spread, err := sqlcdb.New(db.DB).GetServiceSpread(context.Background(), serviceID)
return err == nil && spread
}
// servicePlacementTags reads the placement_tags column with the lazy pattern.
func servicePlacementTags(db *database.DB, serviceID uuid.UUID) []string {
var raw []byte
if err := db.QueryRow(`SELECT placement_tags FROM services WHERE id = $1`, serviceID).Scan(&raw); err != nil {
raw, err := sqlcdb.New(db.DB).GetServicePlacementTags(context.Background(), serviceID)
if err != nil {
return nil
}
var tags []string
@@ -179,8 +175,8 @@ func normalizeTraefikLabels(raw map[string]string) (map[string]string, error) {
// serviceTraefikLabels lazy-reads the traefik_labels column.
func serviceTraefikLabels(db *database.DB, serviceID uuid.UUID) map[string]string {
var raw []byte
if err := db.QueryRow(`SELECT traefik_labels FROM services WHERE id = $1`, serviceID).Scan(&raw); err != nil {
raw, err := sqlcdb.New(db.DB).GetServiceTraefikLabels(context.Background(), serviceID)
if err != nil {
return nil
}
var labels map[string]string
@@ -211,7 +207,6 @@ func tagsJSON(tags []string) []byte {
// loadServiceNode fills the pin + spread + placement tags + the agent's
// display name for responses.
func loadServiceNode(db *database.DB, s *Service) {
var name sql.NullString
s.NodeID = serviceNodeID(db, s.ID)
s.Spread = serviceSpread(db, s.ID)
s.PlacementTags = servicePlacementTags(db, s.ID)
@@ -219,8 +214,8 @@ func loadServiceNode(db *database.DB, s *Service) {
if s.NodeID == "" {
return
}
if err := db.QueryRow(`SELECT name FROM node_agents WHERE id = $1`, s.NodeID).Scan(&name); err == nil {
s.NodeName = name.String
if name, err := sqlcdb.New(db.DB).GetNodeAgentName(context.Background(), s.NodeID); err == nil {
s.NodeName = name
}
}
@@ -235,8 +230,8 @@ type ServiceVolume struct {
// loadServiceVolumes reads the JSONB column; absent/invalid data degrades
// to no mounts rather than failing the request.
func loadServiceVolumes(db *database.DB, serviceID uuid.UUID) []deployment.VolumeMount {
var raw []byte
if err := db.QueryRow(`SELECT volumes FROM services WHERE id = $1`, serviceID).Scan(&raw); err != nil {
raw, err := sqlcdb.New(db.DB).GetServiceVolumes(context.Background(), serviceID)
if err != nil {
return nil
}
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) {
var environmentID uuid.UUID
err := db.QueryRow(
"SELECT id FROM environments WHERE project_id = $1 AND name = $2",
projectID,
environment,
).Scan(&environmentID)
return environmentID, err
row, err := sqlcdb.New(db.DB).GetProjectEnvironmentByName(context.Background(), sqlcdb.GetProjectEnvironmentByNameParams{
ProjectID: projectID,
Name: environment,
})
return row.ID, err
}
func inferServiceSourceType(service Service) string {
+14 -16
View File
@@ -10,11 +10,13 @@ import (
"time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/docker"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/api/types/registry"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// 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.
func settingValue(db *database.DB, key, envKey, fallback string) string {
if db != nil && db.DB != nil {
var value string
err := db.QueryRow(`SELECT value FROM app_settings WHERE key = $1`, key).Scan(&value)
value, err := sqlcdb.New(db.DB).GetAppSetting(context.Background(), key)
if err == nil {
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 {
_, err := db.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()
`, key, value, secret)
return err
return sqlcdb.New(db.DB).UpsertAppSetting(context.Background(), sqlcdb.UpsertAppSettingParams{
Key: key,
Value: value,
IsSecret: secret,
})
}
func deleteSetting(db *database.DB, key string) error {
_, err := db.Exec(`DELETE FROM app_settings WHERE key = $1`, key)
return err
return sqlcdb.New(db.DB).DeleteAppSetting(context.Background(), key)
}
// 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 {
var isAdmin bool
if err := db.QueryRow(`SELECT is_admin FROM users WHERE id = $1`, userID).Scan(&isAdmin); err != nil {
parsed, err := uuid.Parse(userID)
if err != nil {
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) {
@@ -242,8 +241,7 @@ func handleUpdateSettings(c *gin.Context) {
// resolvedTunnelToken prefers the in-app value and falls back to the env.
func resolvedTunnelToken(db *database.DB) (string, string) {
if db != nil && db.DB != nil {
var value string
if err := db.QueryRow(`SELECT value FROM app_settings WHERE key = $1`, settingCloudflareTunnelToken).Scan(&value); err == nil && strings.TrimSpace(value) != "" {
if value, err := sqlcdb.New(db.DB).GetAppSetting(context.Background(), settingCloudflareTunnelToken); err == nil && strings.TrimSpace(value) != "" {
return strings.TrimSpace(value), "app"
}
}
+3 -2
View File
@@ -5,10 +5,12 @@ package api
// lives in app_settings so it survives restarts.
import (
"context"
"net/http"
"strings"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"github.com/gin-gonic/gin"
)
@@ -25,8 +27,7 @@ func setupComplete(db *database.DB, hasUsers bool) bool {
if !hasUsers {
return false
}
var projects int
if err := db.QueryRow(`SELECT COUNT(*) FROM projects`).Scan(&projects); err == nil && projects > 0 {
if projects, err := sqlcdb.New(db.DB).CountProjects(context.Background()); err == nil && projects > 0 {
return true
}
return false
+33 -18
View File
@@ -640,19 +640,20 @@ func deployTemplateGraph(
createdServiceIDs := []uuid.UUID{}
createdDatabaseIDs := []string{}
cleanup := func() {
q := sqlcdb.New(db.DB)
for _, sid := range createdServiceIDs {
if engine != nil {
removeServiceRuntime(context.Background(), db, engine, sid)
}
_, _ = db.Exec(`DELETE FROM environment_variables WHERE service_id = $1`, sid)
_, _ = db.Exec(`DELETE FROM services WHERE id = $1`, sid)
_ = q.DeleteEnvVarsForService(context.Background(), sid)
_ = q.DeleteServiceByID(context.Background(), sid)
}
for _, did := range createdDatabaseIDs {
if dbHandler != nil && dbHandler.dockerClient != nil {
_ = dbHandler.dockerClient.RemoveContainer(context.Background(), managedDatabaseContainerName(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 == "" {
builder = "auto"
}
if _, err = tx.ExecContext(ctx,
`UPDATE services SET port = $1, healthcheck_path = $2, volumes = COALESCE($3::jsonb, '[]'::jsonb),
git_repo = NULLIF($4, ''), git_branch = NULLIF($5, ''), builder = $6
WHERE id = $7`,
m.spec.Port, strings.TrimSpace(m.spec.HealthCheck), volumesJSON,
strings.TrimSpace(m.spec.Repo), strings.TrimSpace(m.spec.Branch), builder,
serviceID,
); err != nil {
var volumesRaw json.RawMessage
if s, ok := volumesJSON.(string); ok {
volumesRaw = json.RawMessage(s)
}
if err = sqlcdb.New(tx).UpdateServiceDeployMeta(ctx, sqlcdb.UpdateServiceDeployMetaParams{
Port: int32(m.spec.Port),
HealthcheckPath: strings.TrimSpace(m.spec.HealthCheck),
Column3: volumesRaw,
Column4: strings.TrimSpace(m.spec.Repo),
Column5: strings.TrimSpace(m.spec.Branch),
Builder: builder,
ID: serviceID,
}); err != nil {
tx.Rollback()
cleanup()
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,
UpdatedAt: now,
}
if _, err := db.Exec(
`INSERT INTO deployments
(id, service_id, version, commit_hash, status, image_name, image_tag, created_at, updated_at)
VALUES ($1, $2, $3, NULL, $4, '', '', $5, $6)`,
dep.ID, dep.ServiceID, fmt.Sprintf("v%d", now.Unix()), dep.Status, dep.CreatedAt, dep.UpdatedAt,
); err != nil {
q := sqlcdb.New(db.DB)
if err := q.InsertDeployment(context.Background(), sqlcdb.InsertDeploymentParams{
ID: dep.ID,
ServiceID: dep.ServiceID,
Version: fmt.Sprintf("v%d", now.Unix()),
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
}
_, _ = 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{
DeploymentID: dep.ID,
+5 -6
View File
@@ -411,12 +411,11 @@ func handleCreateFromTemplate(c *gin.Context) {
return
}
// sqlc's schema predates the runtime columns; port and healthcheck_path are
// written via raw SQL like every other service mutation path.
if _, err = tx.ExecContext(ctx,
`UPDATE services SET port = $1, healthcheck_path = $2 WHERE id = $3`,
config.Port, strings.TrimSpace(config.HealthCheck), serviceID,
); err != nil {
if err = sqlcdb.New(tx).UpdateServiceRuntimeMeta(ctx, sqlcdb.UpdateServiceRuntimeMetaParams{
Port: int32(config.Port),
HealthcheckPath: strings.TrimSpace(config.HealthCheck),
ID: serviceID,
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create service from template"})
return
}
+16 -9
View File
@@ -1,11 +1,14 @@
package api
import (
"containr/internal/database"
"containr/internal/secrets"
"database/sql"
"net/http"
"time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/secrets"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
@@ -147,7 +150,8 @@ func handleUpdateVariables(c *gin.Context) {
}
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 {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clear existing variables"})
return
@@ -164,12 +168,15 @@ func handleUpdateVariables(c *gin.Context) {
if v.IsSecret {
value = secrets.Encrypt(value)
}
varID := uuid.New()
_, err = tx.Exec(
`INSERT INTO environment_variables (id, service_id, key, value, is_secret, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
varID, serviceID, v.Key, value, v.IsSecret, now, now,
)
err = txq.InsertEnvVar(c.Request.Context(), sqlcdb.InsertEnvVarParams{
ID: uuid.New(),
ServiceID: serviceID,
Key: v.Key,
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 {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to insert variable: " + v.Key})
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
}
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
SELECT id, email, password_hash, name, COALESCE(avatar_url, '') AS avatar_url, is_admin, created_at
FROM users WHERE email = $1
@@ -168,6 +186,69 @@ func (q *Queries) InsertUserAdmin(ctx context.Context, arg InsertUserAdminParams
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
UPDATE users
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
}
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
INSERT INTO users (email, password_hash, name, avatar_url, is_admin)
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
}
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
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
}
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
SELECT id, provider_id, name, full_name, COALESCE(description, '') AS description,
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"`
}
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 {
ID string `json:"id"`
UserID string `json:"user_id"`
@@ -78,6 +138,24 @@ type Banner struct {
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 {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
@@ -262,6 +340,18 @@ type GitWebhook struct {
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 {
ServiceID string `json:"service_id"`
Timestamp time.Time `json:"timestamp"`
@@ -287,6 +377,14 @@ type InstanceMetric struct {
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 {
ID string `json:"id"`
Name string `json:"name"`
@@ -408,6 +506,16 @@ type ProjectMember struct {
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 {
ID uuid.UUID `json:"id"`
OwnerID uuid.UUID `json:"owner_id"`
@@ -535,6 +643,15 @@ type ServiceTemplate struct {
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 {
ID uuid.UUID `json:"id"`
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
// bucket-sized buckets, then back to timestamptz.
AggregateNodeMetrics(ctx context.Context, arg AggregateNodeMetricsParams) ([]AggregateNodeMetricsRow, error)
CancelBuild(ctx context.Context, arg CancelBuildParams) (int64, error)
CheckSecurityProjectAccess(ctx context.Context, arg CheckSecurityProjectAccessParams) (bool, error)
ClearServiceDomainDefault(ctx context.Context, serviceID uuid.UUID) 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)
CompleteDeployment(ctx context.Context, arg CompleteDeploymentParams) 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)
CountActivePreviewsForBranch(ctx context.Context, arg CountActivePreviewsForBranchParams) (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)
CountEnvironmentServices(ctx context.Context, environmentID uuid.UUID) (int64, error)
CountGitRepositories(ctx context.Context, arg CountGitRepositoriesParams) (int64, error)
CountProjects(ctx context.Context) (int64, error)
CountProjectsByUser(ctx context.Context, arg CountProjectsByUserParams) (int64, error)
CountServiceDomains(ctx context.Context, serviceID uuid.UUID) (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)
DeleteAgent(ctx context.Context, id string) error
DeleteAllServiceDomains(ctx context.Context, serviceID uuid.UUID) error
DeleteAppSetting(ctx context.Context, key string) error
DeleteBackupTargetByIDAndUser(ctx context.Context, arg DeleteBackupTargetByIDAndUserParams) error
DeleteBanner(ctx context.Context, id uuid.UUID) error
DeleteCronJob(ctx context.Context, id uuid.UUID) error
DeleteDatabaseServiceByID(ctx context.Context, id string) error
DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) error
DeleteEnvVarsForService(ctx context.Context, serviceID uuid.UUID) error
DeleteGitProvider(ctx context.Context, arg DeleteGitProviderParams) (int64, error)
DeleteNotificationChannel(ctx context.Context, arg DeleteNotificationChannelParams) (int64, error)
DeleteOutboundWebhookByIDAndUser(ctx context.Context, arg DeleteOutboundWebhookByIDAndUserParams) error
DeletePreviewEnvironment(ctx context.Context, id uuid.UUID) error
DeleteProjectByID(ctx context.Context, projectID uuid.UUID) (int64, error)
DeleteProjectEnvironment(ctx context.Context, arg DeleteProjectEnvironmentParams) error
DeleteProjectVariables(ctx context.Context, projectID uuid.UUID) error
DeleteServiceByID(ctx context.Context, id uuid.UUID) error
DeleteServiceContainersOnAgent(ctx context.Context, arg DeleteServiceContainersOnAgentParams) error
DeleteServiceDomain(ctx context.Context, arg DeleteServiceDomainParams) (int64, error)
DeleteUserInvite(ctx context.Context, id uuid.UUID) error
DeleteUserTemplate(ctx context.Context, arg DeleteUserTemplateParams) (int64, error)
FailDeployment(ctx context.Context, arg FailDeploymentParams) error
FailInterruptedDeployments(ctx context.Context) 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)
GetAdminUserInfo(ctx context.Context, id uuid.UUID) (GetAdminUserInfoRow, error)
GetAgent(ctx context.Context, id string) (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)
GetBackupTargetByIDAndUser(ctx context.Context, arg GetBackupTargetByIDAndUserParams) (BackupTarget, 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)
GetComplianceReportProjectID(ctx context.Context, id uuid.UUID) (uuid.UUID, 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)
GetDeploymentForRollback(ctx context.Context, id uuid.UUID) (GetDeploymentForRollbackRow, 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)
GetGitProviderForRepos(ctx context.Context, arg GetGitProviderForReposParams) (GetGitProviderForReposRow, error)
GetGitProviderName(ctx context.Context, arg GetGitProviderNameParams) (string, error)
GetGitProviderNameByID(ctx context.Context, id uuid.UUID) (string, 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)
GetGitRepoProviderByCloneURL(ctx context.Context, arg GetGitRepoProviderByCloneURLParams) (uuid.UUID, error)
GetGitRepositoryByFullName(ctx context.Context, arg GetGitRepositoryByFullNameParams) (GetGitRepositoryByFullNameRow, error)
GetGitRepositoryProviderID(ctx context.Context, arg GetGitRepositoryProviderIDParams) (uuid.UUID, 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)
GetLatestComplianceReport(ctx context.Context, projectID uuid.UUID) (GetLatestComplianceReportRow, 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)
GetOutboundWebhookByIDAndUser(ctx context.Context, arg GetOutboundWebhookByIDAndUserParams) (OutboundWebhook, 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)
GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUID, 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)
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)
GetServiceAccess(ctx context.Context, id uuid.UUID) (GetServiceAccessRow, 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)
GetServiceForDeploy(ctx context.Context, id uuid.UUID) (GetServiceForDeployRow, 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)
GetServicePlacementTags(ctx context.Context, id uuid.UUID) (json.RawMessage, 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)
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)
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)
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)
GetUserByID(ctx context.Context, id uuid.UUID) (GetUserByIDRow, 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)
GetVulnerabilityMetrics(ctx context.Context, projectID uuid.UUID) (GetVulnerabilityMetricsRow, 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
InsertAuditLog(ctx context.Context, arg InsertAuditLogParams) error
InsertAuditLogWithRequest(ctx context.Context, arg InsertAuditLogWithRequestParams) error
InsertCronExecution(ctx context.Context, arg InsertCronExecutionParams) error
InsertCronJob(ctx context.Context, arg InsertCronJobParams) 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
InsertGitRepository(ctx context.Context, arg InsertGitRepositoryParams) (InsertGitRepositoryRow, error)
InsertMetricPoint(ctx context.Context, labelsJson sql.NullString) error
InsertNotificationChannel(ctx context.Context, arg InsertNotificationChannelParams) (NotificationChannel, error)
InsertPendingDeployment(ctx context.Context, arg InsertPendingDeploymentParams) 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)
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)
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)
ListAgentHealthRows(ctx context.Context, lastHeartbeat sql.NullTime) ([]ListAgentHealthRowsRow, error)
ListAgentHeartbeatsSince(ctx context.Context, arg ListAgentHeartbeatsSinceParams) ([]AgentHeartbeat, error)
ListAgents(ctx context.Context) ([]NodeAgent, error)
ListAllDatabaseServices(ctx context.Context) ([]DatabaseService, 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)
ListBanners(ctx context.Context) ([]Banner, error)
ListClientEventPaths(ctx context.Context) ([]ListClientEventPathsRow, error)
ListCommandsForAgent(ctx context.Context, nodeAgentID string) ([]AgentCommand, error)
ListComplianceFrameworks(ctx context.Context) ([]ListComplianceFrameworksRow, error)
ListConnectedRepositories(ctx context.Context, arg ListConnectedRepositoriesParams) ([]ListConnectedRepositoriesRow, error)
@@ -187,6 +235,7 @@ type Querier interface {
ListExpiredPreviewsForUser(ctx context.Context, arg ListExpiredPreviewsForUserParams) ([]ListExpiredPreviewsForUserRow, error)
ListGitProviders(ctx context.Context, userID uuid.UUID) ([]ListGitProvidersRow, error)
ListGitRepositories(ctx context.Context, arg ListGitRepositoriesParams) ([]ListGitRepositoriesRow, error)
ListIncidentStatusCodes(ctx context.Context) ([]ListIncidentStatusCodesRow, error)
ListInstanceMetrics(ctx context.Context, arg ListInstanceMetricsParams) ([]ListInstanceMetricsRow, error)
ListNodeAgentsForService(ctx context.Context, serviceID string) ([]string, 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.
ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, 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
// placement tags — auto-domain candidates for spread services.
ListSchedulableAgentDomains(ctx context.Context, dollar_1 json.RawMessage) ([]string, error)
ListSchedulableAgents(ctx context.Context) ([]ListSchedulableAgentsRow, error)
// Only agents carrying every required placement tag (jsonb array containment).
ListSchedulableAgentsMatching(ctx context.Context, dollar_1 json.RawMessage) ([]ListSchedulableAgentsMatchingRow, error)
ListSecretEnvVars(ctx context.Context) ([]ListSecretEnvVarsRow, error)
// Distinct agents holding inventory rows for a service.
ListServiceAgents(ctx context.Context, serviceID string) ([]string, error)
ListServiceContainers(ctx context.Context, serviceID string) ([]ListServiceContainersRow, error)
@@ -221,6 +276,7 @@ type Querier interface {
ListServicesForPush(ctx context.Context, arg ListServicesForPushParams) ([]ListServicesForPushRow, error)
ListSleepCandidates(ctx context.Context) ([]ListSleepCandidatesRow, error)
ListSweepablePreviews(ctx context.Context) ([]ListSweepablePreviewsRow, error)
ListTopAPServices(ctx context.Context) ([]ListTopAPServicesRow, error)
ListUserInvites(ctx context.Context) ([]UserInvite, error)
ListUserTokens(ctx context.Context, userID uuid.UUID) ([]UserToken, 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
// carrying every required placement tag.
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)
RevokeUserToken(ctx context.Context, arg RevokeUserTokenParams) (int64, error)
ScrubCommandPayload(ctx context.Context, arg ScrubCommandPayloadParams) error
ServiceExistsInProject(ctx context.Context, arg ServiceExistsInProjectParams) (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
SetAgentSchedulable(ctx context.Context, arg SetAgentSchedulableParams) error
SetAgentStatus(ctx context.Context, arg SetAgentStatusParams) error
SetAgentTags(ctx context.Context, arg SetAgentTagsParams) error
SetAuditLogMetadata(ctx context.Context, arg SetAuditLogMetadataParams) error
SetDatabaseBackupRemoteKeyByID(ctx context.Context, arg SetDatabaseBackupRemoteKeyByIDParams) error
SetDatabaseBackupScheduleByIDAndUser(ctx context.Context, arg SetDatabaseBackupScheduleByIDAndUserParams) error
SetDatabaseBackupStatusByID(ctx context.Context, arg SetDatabaseBackupStatusByIDParams) error
@@ -260,8 +323,14 @@ type Querier interface {
SetServicePublishedPort(ctx context.Context, arg SetServicePublishedPortParams) error
SetServiceReplicas(ctx context.Context, arg SetServiceReplicasParams) 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
SyncPreviewStatuses(ctx context.Context) error
TouchAPKeyLastUsed(ctx context.Context, id uuid.UUID) error
TouchAgentAuthToken(ctx context.Context, tokenHash string) error
TouchServiceDomainChecked(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
UpdateDatabaseServiceNameByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameByIDAndUserParams) error
UpdateDatabaseServicePlanByIDAndUser(ctx context.Context, arg UpdateDatabaseServicePlanByIDAndUserParams) error
UpdateEnvVarValue(ctx context.Context, arg UpdateEnvVarValueParams) error
UpdateOutboundWebhook(ctx context.Context, arg UpdateOutboundWebhookParams) error
UpdatePreviewEnvironment(ctx context.Context, arg UpdatePreviewEnvironmentParams) error
UpdateProjectByID(ctx context.Context, arg UpdateProjectByIDParams) (int64, error)
UpdateServiceDeployMeta(ctx context.Context, arg UpdateServiceDeployMetaParams) error
UpdateServicePreviewBranch(ctx context.Context, arg UpdateServicePreviewBranchParams) error
UpdateServiceRuntimeMeta(ctx context.Context, arg UpdateServiceRuntimeMetaParams) error
UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) error
UpdateUserTemplate(ctx context.Context, arg UpdateUserTemplateParams) (int64, error)
UpdateVulnerabilityStatus(ctx context.Context, arg UpdateVulnerabilityStatusParams) 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
UpsertGitHubAppProvider(ctx context.Context, arg UpsertGitHubAppProviderParams) (UpsertGitHubAppProviderRow, error)
UpsertGitRepository(ctx context.Context, arg UpsertGitRepositoryParams) error
@@ -300,6 +376,7 @@ type Querier interface {
UpsertServiceContainer(ctx context.Context, arg UpsertServiceContainerParams) error
UpsertServiceMetrics(ctx context.Context, arg UpsertServiceMetricsParams) error
UpsertServiceTemplate(ctx context.Context, arg UpsertServiceTemplateParams) error
UpsertUsageCounter(ctx context.Context, arg UpsertUsageCounterParams) error
}
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
SET name = COALESCE($1::varchar, name), avatar_url = COALESCE($2::varchar, avatar_url)
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
FROM services s
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)
);
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 (
id VARCHAR(50) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
@@ -590,3 +601,112 @@ CREATE TABLE vulnerabilities (
created_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" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>containr</title>
<script>
try {
document.documentElement.dataset.theme =
localStorage.getItem('containr.theme') === 'light' ? 'light' : 'dark';
} catch { document.documentElement.dataset.theme = 'dark'; }
</script>
</head>
<body>
<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">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="64" y2="64" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#cdf06f"/>
<stop offset="1" stop-color="#7fb524"/>
<stop offset="0" stop-color="#56c9f8"/>
<stop offset="1" stop-color="#0e86c9"/>
</linearGradient>
</defs>
<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"/>
<path d="M23 18v26M31 18v26M39 18v26"/>
<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 { useQuery } from '@tanstack/react-query';
import { Compass, ShieldCheck } from 'lucide-react';
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 {
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 { useDemoMode } from '@/lib/demo-mode';
import { getCurrentUserProfile } from '@/lib/api-client';
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() {
const location = useLocation();
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">
<ShieldCheck size={32} className="text-[var(--error)]" />
<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)]">
This area is restricted to the platform administrator.
</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">
<Compass size={32} className="text-[var(--text-tertiary)]" />
<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)]">
The page you are looking for does not exist or was moved.
</p>
@@ -135,43 +146,44 @@ export default function App() {
return (
<ErrorBoundary>
<Routes>
<Route path="/" element={<LandingPage />} />
<Route path="/auth/sign-in" element={<SignInPage />} />
<Route path="/auth/sign-up" element={<SignUpPage />} />
<Route path="/auth/accept-invite" element={<AcceptInvitePage />} />
<Route path="/setup" element={<SetupWizardPage />} />
<Route path="/landing" element={<Page><LandingPage /></Page>} />
<Route path="/auth/sign-in" element={<Page><SignInPage /></Page>} />
<Route path="/auth/sign-up" element={<Page><SignUpPage /></Page>} />
<Route path="/auth/accept-invite" element={<Page><AcceptInvitePage /></Page>} />
<Route path="/setup" element={<Page><SetupWizardPage /></Page>} />
{/* Public browsing — approved projects, templates, docs. */}
{/* Project workspace renders outside the shell — fullscreen canvas, no nav chrome */}
<Route path="/projects/:projectId" element={<ProjectWorkspacePage />} />
<Route path="/projects/:projectId/services/:serviceId" element={<ServiceDetailPage />} />
<Route path="/projects/:projectId" element={<Page><ProjectWorkspacePage /></Page>} />
<Route path="/projects/:projectId/services/:serviceId" element={<Page><ServiceDetailPage /></Page>} />
<Route element={<PlatformShell />}>
<Route path="/projects" element={<ProjectsPage />} />
<Route path="/templates" element={<TemplatesPage />} />
<Route path="/databases" element={<DatabasesPage />} />
<Route path="/ha" element={<HighAvailabilityPage />} />
<Route path="/operations" element={<OperationsPage />} />
<Route path="/activity" element={<ActivityPage />} />
<Route path="/security" element={<SecurityPage />} />
<Route path="/usage" element={<UsagePage />} />
<Route path="/docs" element={<DocsPage />} />
<Route path="/" element={<Page><DashboardPage /></Page>} />
<Route path="/projects" element={<Page><ProjectsPage /></Page>} />
<Route path="/templates" element={<Page><TemplatesPage /></Page>} />
<Route path="/databases" element={<Page><DatabasesPage /></Page>} />
<Route path="/ha" element={<Page><HighAvailabilityPage /></Page>} />
<Route path="/operations" element={<Page><OperationsPage /></Page>} />
<Route path="/activity" element={<Page><ActivityPage /></Page>} />
<Route path="/security" element={<Page><SecurityPage /></Page>} />
<Route path="/usage" element={<Page><UsagePage /></Page>} />
<Route path="/docs" element={<Page><DocsPage /></Page>} />
{/* Authenticated areas */}
<Route element={<AuthRequired />}>
<Route path="/builds" element={<BuildsPage />} />
<Route path="/people" element={<PeoplePage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/git/github-app/callback" element={<GitHubAppCallbackPage />} />
<Route path="/settings/audit-logs" element={<AuditLogsPage />} />
<Route path="/settings/webhooks" element={<WebhooksPage />} />
<Route path="/showcase" element={<ComponentShowcase />} />
<Route path="/builds" element={<Page><BuildsPage /></Page>} />
<Route path="/people" element={<Page><PeoplePage /></Page>} />
<Route path="/settings" element={<Page><SettingsPage /></Page>} />
<Route path="/git/github-app/callback" element={<Page><GitHubAppCallbackPage /></Page>} />
<Route path="/settings/audit-logs" element={<Page><AuditLogsPage /></Page>} />
<Route path="/settings/webhooks" element={<Page><WebhooksPage /></Page>} />
<Route path="/showcase" element={<Page><ComponentShowcase /></Page>} />
</Route>
{/* Platform owner only */}
<Route element={<AdminRequired />}>
<Route path="/admin" element={<AdminPage />} />
<Route path="/nodes/:id" element={<NodeDetailPage />} />
<Route path="/admin" element={<Page><AdminPage /></Page>} />
<Route path="/nodes/:id" element={<Page><NodeDetailPage /></Page>} />
</Route>
<Route path="*" element={<NotFoundPage />} />
@@ -179,7 +191,7 @@ export default function App() {
{/* Full-bleed design demo — renders its own chrome, no app shell */}
<Route element={<AuthRequired />}>
<Route path="/metrics-demo" element={<ServiceMetricsDashboard />} />
<Route path="/metrics-demo" element={<Page><ServiceMetricsDashboard /></Page>} />
</Route>
</Routes>
</ErrorBoundary>
+127 -105
View File
@@ -9,11 +9,13 @@ import {
ChartBar,
CheckCircle2,
ChevronsLeft,
ChevronsUpDown,
Clock,
Container,
Database,
FileText,
FolderKanban,
LayoutDashboard,
LayoutTemplate,
LogIn,
LogOut,
@@ -41,22 +43,26 @@ import {
markAllNotificationsRead,
markNotificationRead,
pullUpgradeImage,
listProjects,
} from '@/lib/api-client';
import { signOutAuthSession } from '@/lib/auth-client';
import { isDemoSearch } from '@/lib/demo-mode';
import { demoProjects } from '@/lib/demo-data';
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 NavSection = { title: string; items: NavItem[] };
const navSections: NavSection[] = [
{
title: 'Build',
title: 'Platform',
items: [
{ label: 'Dashboard', href: '/', icon: LayoutDashboard, hint: 'Fleet overview' },
{ label: 'Projects', href: '/projects', icon: FolderKanban, hint: 'Services & deployments' },
{ label: 'Templates', href: '/templates', icon: LayoutTemplate, hint: 'Deployable presets' },
{ 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: [
{ label: 'Operations', href: '/operations', icon: Activity, hint: 'Live jobs & failures' },
{ 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: 'Security', href: '/security', icon: Shield, hint: 'Scans & compliance' },
{ label: 'Usage', href: '/usage', icon: ChartBar, hint: 'Resource consumption' },
@@ -164,6 +169,18 @@ export function PlatformShell() {
const [theme, setTheme] = useState<ThemeMode>(() => getInitialTheme());
const [sidebarExpanded, setSidebarExpanded] = useState<boolean>(() => getInitialSidebar());
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 signedIn = isDemoMode || Boolean(sessionQuery.data);
@@ -234,8 +251,19 @@ export function PlatformShell() {
}
};
const isActiveRoute = (itemHref: string) =>
location.pathname === itemHref || location.pathname.startsWith(`${itemHref}/`);
// Longest matching href wins so /settings/audit-logs lights only
// "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 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)
.toUpperCase() || 'C';
const avatar = (
<div
className="flex h-[30px] w-[30px] shrink-0 items-center justify-center rounded-full text-[11px] font-bold"
style={{ background: 'linear-gradient(135deg, var(--accent-primary), var(--accent-secondary))', color: 'var(--accent-on)' }}
>
{initials}
</div>
);
const paletteNavigate = (target: string) => {
navigate(href(target));
};
// Project deep-links for the palette — only fetched once it opens.
const paletteProjectsQuery = useQuery({
queryKey: ['palette-projects'],
queryFn: () => listProjects(),
enabled: paletteOpen && !isDemoMode,
staleTime: 60_000,
});
const paletteProjects = isDemoMode
? demoProjects
: paletteProjectsQuery.data ?? [];
if (!isDemoMode && signedIn && setupQuery.data?.needs_setup) {
return <Navigate to="/setup" replace />;
@@ -261,40 +295,46 @@ export function PlatformShell() {
return (
<div className="app-shell min-h-screen">
<div className="ambient-glow" />
<div className="relative flex min-h-screen">
{/* ── Sidebar — fixed rail per design/23-sentry ── */}
<aside
className="hidden h-screen shrink-0 flex-col border-r py-4 transition-[width] duration-200 md:flex"
style={{
width: sidebarExpanded ? '218px' : '62px',
background: 'var(--bg-base)',
borderRightColor: 'var(--border-subtle)',
}}
className="shell-side hidden h-screen shrink-0 flex-col md:flex"
style={{ width: sidebarExpanded ? '252px' : '64px' }}
>
<div className={`flex items-center ${sidebarExpanded ? 'justify-between px-3' : 'flex-col items-center gap-2'}`}>
<NavLink to={href('/projects')} title="Containr">
<img src="/containr.svg" alt="Containr" className="h-[38px] w-[38px] rounded-xl" />
<div
className={`flex items-center ${
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>
<button
type="button"
onClick={() => setSidebarExpanded((v) => !v)}
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>
</div>
<nav className="mt-3 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
{visibleNavSections.map((section) => (
<div key={section.title}>
<nav className={`flex flex-1 flex-col overflow-y-auto ${sidebarExpanded ? 'px-3' : 'gap-4 px-2'}`}>
{visibleNavSections.map((section, si) => (
<div key={section.title} className={si > 0 && sidebarExpanded ? 'mt-[26px]' : ''}>
{sidebarExpanded ? (
<p className="mb-1 px-2 text-[10px] font-semibold uppercase tracking-[0.14em] text-[var(--text-tertiary)]">
{section.title}
</p>
<p className="shell-nav-group">{section.title}</p>
) : 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) => {
const Icon = item.icon;
const isActive = isActiveRoute(item.href);
@@ -303,27 +343,12 @@ export function PlatformShell() {
key={item.href}
to={href(item.href)}
title={sidebarExpanded ? item.hint : `${item.label} — ${item.hint}`}
className={`relative flex items-center rounded-[11px] transition-colors ${
sidebarExpanded ? 'gap-2.5 px-2 py-2' : 'h-10 w-10 justify-center'
} ${
isActive
? 'bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]'
: 'text-[var(--text-tertiary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-secondary)]'
className={`shell-ni ${isActive ? 'on' : ''} ${
sidebarExpanded ? '' : 'h-10 w-10 justify-center !px-0'
}`}
>
{isActive ? (
<span
className="absolute -left-2 h-4 w-[2.5px] rounded-full"
style={{ background: 'var(--accent-primary)' }}
/>
) : null}
<Icon size={17} className="shrink-0" />
{sidebarExpanded ? (
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] font-medium leading-tight">{item.label}</span>
<span className="block truncate text-[10px] text-[var(--text-tertiary)]">{item.hint}</span>
</span>
) : null}
<Icon size={16} className="shrink-0" />
{sidebarExpanded ? <span className="truncate">{item.label}</span> : null}
</NavLink>
);
})}
@@ -332,23 +357,26 @@ export function PlatformShell() {
))}
</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 ? (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button
type="button"
title={sidebarExpanded ? undefined : userEmail}
className={`flex items-center gap-2.5 rounded-[11px] text-left transition-colors hover:bg-[var(--surface-muted)] ${
sidebarExpanded ? 'w-full px-2 py-2' : 'h-10 w-10 justify-center'
className={`shell-user ${
sidebarExpanded ? 'w-full' : 'h-10 w-10 justify-center border-transparent bg-transparent'
}`}
>
{avatar}
<span className="shell-avatar">{initials}</span>
{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-[13px] font-medium text-[var(--text-primary)]">{userName}</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}
</button>
</DropdownMenu.Trigger>
@@ -427,12 +455,12 @@ export function PlatformShell() {
<NavLink
to="/auth/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)] ${
sidebarExpanded ? 'w-full px-2 py-2' : 'h-10 w-10 justify-center'
className={`shell-ni ${
sidebarExpanded ? 'w-full' : 'h-10 w-10 justify-center !px-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>
)}
</div>
@@ -440,51 +468,31 @@ export function PlatformShell() {
<div className="relative z-10 flex min-h-screen min-w-0 flex-1 flex-col overflow-hidden">
<BannerBar />
<header
className="hidden h-[46px] shrink-0 items-center border-b px-5 md:flex"
style={{
background: 'var(--bg-base)',
borderBottomColor: 'var(--border-subtle)',
fontFamily: "'JetBrains Mono', monospace",
fontSize: '11px',
}}
>
<NavLink to={href('/projects')} className="mr-5 text-sm font-extrabold tracking-tight text-[var(--text-primary)]" style={{ fontFamily: 'Inter, sans-serif' }}>
<BrandWordmark />
</NavLink>
<div className="flex items-center gap-5">
<span className="v-tick"><i className="sig" /><b>{isDemoMode ? 'demo' : 'nominal'}</b></span>
<span className="v-tick"><i className="sig" style={{ background: 'var(--text-tertiary)' }} />node <b>local</b></span>
</div>
<div className="search-box ml-6 max-w-[300px]" style={{ height: 30 }}>
<Search size={13} />
<input type="text" placeholder="search…" className="v-mono" style={{ fontSize: 11.5 }} />
</div>
<div className="ml-auto flex items-center gap-4">
<span className="v-env">{isDemoMode ? 'env:demo' : 'env:production'}</span>
{/* ── Topbar — search left, utility icons right ── */}
<header className="shell-top hidden h-[56px] shrink-0 items-center gap-3.5 md:flex">
<button
type="button"
onClick={() => setTheme((current) => (current === 'dark' ? 'light' : 'dark'))}
className="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)' }}
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
onClick={() => setPaletteOpen(true)}
className="shell-search"
>
{theme === 'dark' ? <Sun size={15} /> : <Moon size={15} />}
<Search size={13} className="shrink-0" />
Search anything
<kbd>⌘ K</kbd>
</button>
<span className="v-env">{isDemoMode ? 'env:demo' : `env:${import.meta.env.MODE}`}</span>
<div className="flex-1" />
{isAdmin && !isDemoMode ? (
<button
type="button"
onClick={() => pullMutation.mutate()}
disabled={pullMutation.isPending || !upgradeQuery.data?.imageRef}
className="inline-flex h-8 items-center gap-2 rounded-[var(--radius-md)] px-3 text-xs font-semibold text-[var(--accent-on)] disabled:cursor-not-allowed disabled:opacity-50"
style={{ background: 'var(--accent-primary)' }}
className="s-btn-accent"
title={upgradeQuery.data?.message || 'Pull latest configured image'}
>
{pullMutation.isPending ? <RefreshCw size={14} className="animate-spin" /> : <UploadCloud size={14} />}
{pullMutation.isPending ? <RefreshCw size={13} className="animate-spin" /> : <UploadCloud size={13} />}
Upgrade
</button>
) : null}
@@ -494,16 +502,11 @@ export function PlatformShell() {
<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)' }}
className="shell-tic relative"
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}
<Bell size={16} />
{unreadCount > 0 ? <span className="shell-tic-dot" /> : null}
</button>
{notificationsOpen ? (
@@ -566,13 +569,21 @@ export function PlatformShell() {
) : null}
</div>
) : 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 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">
<NavLink to={href('/projects')} 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)]">
<NavLink to={href('/')} className="flex items-center gap-3">
<div className="shell-mark flex h-9 w-9 items-center justify-center">
<Container size={16} />
</div>
<span className="font-headline font-semibold text-[var(--text-primary)]">Containr</span>
@@ -613,6 +624,17 @@ export function PlatformShell() {
</main>
</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>
);
}
@@ -6,6 +6,7 @@ import { type FilterConfig } from '@/lib/dynamic-filter';
import { useDynamicFilter, useFilterState } from '@/lib/use-dynamic-filter';
import { formatRelative } from '@/lib/time';
import { DemoRestricted, FilterBar } from '@/shared/components';
import { QuietBtn, SCard, SPageHead, SPill } from '@/shared/components/sentry';
import {
Activity, AlertCircle, CheckCircle2, Database, GitBranch, Info, Key,
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)]' },
};
const SEV_TONE: Record<string, 'err' | 'warn' | 'ok' | 'info'> = {
error: 'err', warning: 'warn', success: 'ok', info: 'info',
};
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',
options: [
@@ -58,46 +63,33 @@ export function ActivityPage() {
return (
<div className="min-h-screen">
<div className="border-b border-[var(--border-subtle)]">
<div className="w-full px-8 py-5">
<h1 className="v-title">Activity<span className="v-cursor">_</span></h1>
<p className="v-mono mt-1.5 text-[11px] text-[var(--text-tertiary)]">
platform events across your visible projects
</p>
</div>
<div className="w-full px-4 pt-6 sm:px-8">
<SPageHead
title="Activity"
titleAccent="_"
sub="Platform events across your visible projects"
actions={query.isFetching ? <Loader2 size={14} className="animate-spin text-[var(--text-muted)]" /> : undefined}
/>
</div>
<div className="px-8 py-6 space-y-4">
<div className="flex items-start gap-3">
<div className="flex-1">
<div className="px-4 sm:px-8 space-y-4">
<SCard icon={<Activity />} title="Event Feed" accent={`${entries.length} events`} pad={false}>
<div className="px-4 pt-1 pb-3">
<FilterBar {...filter} />
</div>
{query.isFetching && <Loader2 size={14} className="animate-spin text-[var(--text-muted)] mt-3" />}
</div>
<div className="panel p-0 divide-y divide-[var(--border-subtle)]">
<div className="divide-y divide-[var(--border-subtle)] border-t border-[var(--border-subtle)]">
{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="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 className="flex items-center gap-2.5">
<QuietBtn disabled={page <= 1} onClick={() => setPage(page - 1)}>← newer</QuietBtn>
<QuietBtn disabled={!query.data?.has_more} onClick={() => setPage(page + 1)}>older →</QuietBtn>
<span className="v-mono text-[10.5px] text-[var(--text-muted)]">page {page}</span>
</div>
</div>
</div>
@@ -108,14 +100,14 @@ function Entry({ entry }: { entry: ActivityEntry }) {
const Cat = CATEGORY_ICONS[entry.category] ?? Activity;
const sev = SEVERITY_STYLE[entry.severity] ?? SEVERITY_STYLE.info;
return (
<div className="flex items-center gap-3 px-5 py-3">
<div className={`w-8 h-8 rounded-md bg-[var(--bg-elevated)] flex items-center justify-center ${sev.cls}`}>
<Cat size={15} />
<div className="flex items-center gap-3 px-4 py-3 hover:bg-[var(--tint-03)] transition-colors">
<div className={`s-ibox ${sev.cls}`} style={{ width: 30, height: 30 }}>
<Cat size={14} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-[13px] 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>
<span className="text-[13px] font-medium text-[var(--text-primary)] truncate">{entry.label || entry.action}</span>
<SPill tone={SEV_TONE[entry.severity] ?? 'info'}>{entry.severity}</SPill>
</div>
<div className="v-mono text-[10px] text-[var(--text-muted)] mt-0.5">
{entry.category} · {entry.action}{entry.user_email ? ` · ${entry.user_email}` : ''}
@@ -38,6 +38,7 @@ import {
import { useAuthSession } from '@/lib/use-auth-session';
import { useDemoMode } from '@/lib/demo-mode';
import { DemoRestricted, LoadingState, useToast } from '@/shared/components';
import { GhostBtn, QuietBtn, SPageHead, SPill } from '@/shared/components/sentry';
const statLabels: Record<string, string> = {
users: 'Users',
@@ -199,7 +200,7 @@ export function AdminPage() {
return (
<div className="mx-auto max-w-xl px-5 py-24 text-center">
<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)]">
This area is restricted to platform administrators.
</p>
@@ -216,74 +217,74 @@ export function AdminPage() {
const invites = invitesQuery.data ?? [];
return (
<div className="mx-auto max-w-6xl px-5 py-8">
<div className="mb-8">
<h1 className="text-[24px] font-bold tracking-tight text-[var(--text-primary)]">Admin console</h1>
<p className="mt-1 text-sm text-[var(--text-secondary)]">
Platform overview, project approvals, and user management.
<div className="w-full px-4 py-6 sm:px-8">
<SPageHead
title="Admin Console"
titleAccent="_"
sub="Platform overview, project approvals, and user management."
/>
<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]) => (
<div key={key} className="s-stat">
<div className="px-4 pb-1 pt-4">
<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)]">
{stats[key] ?? 0}
</p>
</div>
<div className="grid grid-cols-4 gap-3 max-lg:grid-cols-3 max-sm:grid-cols-2">
{Object.entries(statLabels).map(([key, label]) => (
<div
key={key}
className="rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)] px-4 py-3.5"
>
<p className="text-[20px] font-bold tabular-nums text-[var(--text-primary)]">{stats[key] ?? 0}</p>
<p className="mt-0.5 text-[10.5px] uppercase tracking-wider text-[var(--text-tertiary)]">{label}</p>
<div className="s-stat-foot mt-3">
<span>total</span>
</div>
</div>
))}
</div>
<section className="mt-8">
<div className="mb-3 flex items-center gap-2">
<Clock size={15} className="text-[var(--warning)]" />
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Pending approvals</h2>
{pending.length > 0 ? (
<span className="rounded-full bg-[var(--warning-soft)] px-2 py-0.5 text-[10.5px] font-semibold text-[var(--warning)]">
{pending.length}
<section className="s-card !p-0 mt-6 overflow-hidden">
<div className="s-cardhead border-b border-[var(--border-subtle)]">
<span className="s-ibox"><Clock /></span>
<h2 className="s-t">Pending Approvals</h2>
<span className="s-trail">
{pending.length > 0 ? <SPill tone="warn">{pending.length}</SPill> : null}
</span>
) : null}
</div>
{pending.length > 0 ? (
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)]">
<div>
{pending.map((project) => (
<div
key={project.id}
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">
<Link to={`/projects/${project.id}`} className="text-[13.5px] font-medium text-[var(--text-primary)] hover:underline">
{project.name}
</Link>
<p className="truncate text-[11.5px] text-[var(--text-tertiary)]">{project.description || 'No description'}</p>
</div>
<button
type="button"
<GhostBtn
onClick={() => approvalMutation.mutate({ id: project.id, approved: true })}
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
</button>
</GhostBtn>
</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.
</div>
</p>
)}
</section>
<section className="mt-8">
<div className="mb-3 flex items-center gap-2">
<FolderKanban size={15} className="text-[var(--accent-primary)]" />
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">All projects</h2>
<section className="s-card !p-0 mt-6 overflow-hidden">
<div className="s-cardhead border-b border-[var(--border-subtle)]">
<span className="s-ibox"><FolderKanban /></span>
<h2 className="s-t">All Projects</h2>
<span className="s-trail"><span className="s-chip" style={{ cursor: 'default' }}>{allProjects.length}</span></span>
</div>
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)]">
<div>
{allProjects.map((project) => (
<div
key={project.id}
@@ -297,22 +298,16 @@ export function AdminPage() {
{project.stats?.service_count ?? 0} services · {project.stats?.deployment_count ?? 0} deploys
</p>
</div>
<span
className={`rounded-full px-2.5 py-0.5 text-[10.5px] font-semibold ${
project.isApproved
? 'bg-[var(--success-soft)] text-[var(--success)]'
: 'bg-[var(--warning-soft)] text-[var(--warning)]'
}`}
>
<SPill tone={project.isApproved ? 'ok' : 'warn'}>
{project.isApproved ? 'public' : 'pending'}
</span>
</SPill>
<button
type="button"
onClick={() => {
setEditingProject(project);
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"
>
<Pencil size={12} />
@@ -325,19 +320,17 @@ export function AdminPage() {
}
}}
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"
>
<Trash2 size={12} />
</button>
<button
type="button"
<QuietBtn
onClick={() => approvalMutation.mutate({ id: project.id, approved: !project.isApproved })}
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'}
</button>
</QuietBtn>
</div>
))}
{allProjects.length === 0 ? (
@@ -346,12 +339,13 @@ export function AdminPage() {
</div>
</section>
<section className="mt-8">
<div className="mb-3 flex items-center gap-2">
<Megaphone size={15} className="text-[var(--accent-primary)]" />
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Announcements</h2>
<section className="s-card !p-0 mt-6 overflow-hidden">
<div className="s-cardhead border-b border-[var(--border-subtle)]">
<span className="s-ibox"><Megaphone /></span>
<h2 className="s-t">Announcements</h2>
<span className="s-trail"><span className="s-chip" style={{ cursor: 'default' }}>{banners.length}</span></span>
</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">
<input
value={bannerForm.title}
@@ -375,14 +369,12 @@ export function AdminPage() {
<option value="warning">warning</option>
<option value="error">error</option>
</select>
<button
type="button"
<GhostBtn
onClick={() => bannerCreateMutation.mutate()}
disabled={bannerCreateMutation.isPending || bannerForm.title.trim().length === 0}
className="rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-[12px] font-semibold text-[var(--accent-on)] disabled:opacity-50"
>
{bannerCreateMutation.isPending ? 'Publishing…' : 'Publish'}
</button>
</GhostBtn>
</div>
{banners.map((banner) => (
<div
@@ -396,14 +388,12 @@ export function AdminPage() {
{banner.body ? ` · ${banner.body}` : ''}
</p>
</div>
<button
type="button"
<QuietBtn
onClick={() => bannerToggleMutation.mutate({ id: banner.id, active: !banner.active })}
disabled={bannerToggleMutation.isPending}
className="rounded-[var(--radius-md)] border border-[var(--border-default)] px-3 py-1.5 text-[12px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:opacity-50"
>
{banner.active ? 'Deactivate' : 'Activate'}
</button>
</QuietBtn>
<button
type="button"
onClick={() => {
@@ -412,7 +402,7 @@ export function AdminPage() {
}
}}
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"
>
<Trash2 size={12} />
@@ -425,12 +415,13 @@ export function AdminPage() {
</div>
</section>
<section className="mt-8">
<div className="mb-3 flex items-center gap-2">
<UserPlus size={15} className="text-[var(--accent-primary)]" />
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Team invites</h2>
<section className="s-card !p-0 mt-6 overflow-hidden">
<div className="s-cardhead border-b border-[var(--border-subtle)]">
<span className="s-ibox"><UserPlus /></span>
<h2 className="s-t">Team Invites</h2>
<span className="s-trail"><span className="s-chip" style={{ cursor: 'default' }}>{invites.length}</span></span>
</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">
<input
value={inviteEmail}
@@ -439,35 +430,25 @@ export function AdminPage() {
type="email"
className="h-9 min-w-64 flex-1 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm focus:border-[var(--accent-primary)]"
/>
<button
type="button"
<GhostBtn
onClick={() => inviteCreateMutation.mutate()}
disabled={inviteCreateMutation.isPending}
className="rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-[12px] font-semibold text-[var(--accent-on)] disabled:opacity-50"
>
{inviteCreateMutation.isPending ? 'Creating…' : 'Create invite link'}
</button>
</GhostBtn>
</div>
{inviteLink ? (
<div className="flex items-center gap-2 border-b border-[var(--border-subtle)] bg-[var(--accent-primary-soft)] px-4 py-2.5">
<code className="min-w-0 flex-1 truncate text-[12px] text-[var(--accent-primary)]">{inviteLink}</code>
<button
type="button"
<code className="v-mono min-w-0 flex-1 truncate text-[12px] text-[var(--accent-primary)]">{inviteLink}</code>
<QuietBtn
onClick={() => {
navigator.clipboard?.writeText(inviteLink);
showToast('success', 'Link copied');
}}
className="inline-flex items-center gap-1 rounded-[var(--radius-md)] border border-[var(--border-default)] px-2.5 py-1 text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
>
<Copy size={11} /> Copy
</button>
<button
type="button"
onClick={() => setInviteLink(null)}
className="text-[11px] text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]"
>
Dismiss
</button>
</QuietBtn>
<QuietBtn onClick={() => setInviteLink(null)}>Dismiss</QuietBtn>
</div>
) : null}
{invites.map((invite) => {
@@ -486,26 +467,17 @@ export function AdminPage() {
expires {new Date(invite.expires_at).toLocaleString()}
</p>
</div>
<span
className={`rounded-full px-2.5 py-0.5 text-[10.5px] font-semibold ${
used
? 'bg-[var(--surface-muted)] text-[var(--text-tertiary)]'
: expired
? 'bg-[var(--error-soft)] text-[var(--error)]'
: 'bg-[var(--success-soft)] text-[var(--success)]'
}`}
>
<SPill tone={used ? 'off' : expired ? 'err' : 'ok'}>
{used ? 'used' : expired ? 'expired' : 'open'}
</span>
</SPill>
{!used ? (
<button
type="button"
<QuietBtn
onClick={() => inviteRevokeMutation.mutate(invite.id)}
disabled={inviteRevokeMutation.isPending}
className="rounded-[var(--radius-md)] border border-[var(--error)] px-2.5 py-1.5 text-[12px] font-medium text-[var(--error)] hover:bg-[var(--error-soft)] disabled:opacity-50"
className="hover:!text-[var(--error)] hover:!border-[var(--error)]"
>
Revoke
</button>
</QuietBtn>
) : null}
</div>
);
@@ -516,12 +488,13 @@ export function AdminPage() {
</div>
</section>
<section className="mt-8 pb-10">
<div className="mb-3 flex items-center gap-2">
<Users size={15} className="text-[var(--accent-tertiary)]" />
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Users</h2>
<section className="s-card !p-0 mt-6 mb-10 overflow-hidden">
<div className="s-cardhead border-b border-[var(--border-subtle)]">
<span className="s-ibox"><Users /></span>
<h2 className="s-t">Users</h2>
<span className="s-trail"><span className="s-chip" style={{ cursor: 'default' }}>{users.length}</span></span>
</div>
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)]">
<div>
{users.map((user) => (
<UserRow
key={user.id}
@@ -540,30 +513,29 @@ export function AdminPage() {
{impersonation ? (
<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="border-b border-[var(--border-subtle)] px-5 py-4">
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">
Impersonating {impersonation.user.email}
</h2>
<p className="text-xs text-[var(--text-tertiary)]">
Token valid until {new Date(impersonation.expires_at).toLocaleString()} — audit-logged.
</p>
<div className="w-full max-w-lg s-card !p-0 overflow-hidden">
<div className="s-cardhead border-b border-[var(--border-subtle)]">
<span className="s-ibox"><ShieldCheck /></span>
<h2 className="s-t">Impersonating {impersonation.user.email}</h2>
<span className="s-trail">
<span className="v-mono text-[10.5px] text-[var(--text-muted)]">
expires {new Date(impersonation.expires_at).toLocaleString()}
</span>
</span>
</div>
<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">
<code className="min-w-0 flex-1 truncate text-[12px] text-[var(--text-primary)]">
<div className="s-inset flex items-center gap-2 px-3 py-2.5">
<code className="v-mono min-w-0 flex-1 truncate text-[12px] text-[var(--text-primary)]">
{impersonation.token}
</code>
<button
type="button"
<QuietBtn
onClick={() => {
navigator.clipboard?.writeText(impersonation.token);
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
</button>
</QuietBtn>
</div>
<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;
@@ -572,13 +544,7 @@ export function AdminPage() {
</p>
</div>
<div className="flex items-center justify-end gap-2 border-t border-[var(--border-subtle)] px-5 py-3.5">
<button
type="button"
onClick={() => setImpersonation(null)}
className="rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-xs font-semibold text-[var(--accent-on)]"
>
Done
</button>
<GhostBtn onClick={() => setImpersonation(null)}>Done</GhostBtn>
</div>
</div>
</div>
@@ -586,10 +552,13 @@ export function AdminPage() {
{editingProject ? (
<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="border-b border-[var(--border-subtle)] px-5 py-4">
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Edit project</h2>
<p className="text-xs text-[var(--text-tertiary)]">Admin edit — changes apply immediately.</p>
<div className="w-full max-w-md s-card !p-0 overflow-hidden">
<div className="s-cardhead border-b border-[var(--border-subtle)]">
<span className="s-ibox"><Pencil /></span>
<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 className="space-y-4 px-5 py-4">
<div>
@@ -611,21 +580,13 @@ export function AdminPage() {
</div>
</div>
<div className="flex items-center justify-end gap-2 border-t border-[var(--border-subtle)] px-5 py-3.5">
<button
type="button"
onClick={() => setEditingProject(null)}
className="rounded-[var(--radius-md)] px-3.5 py-2 text-xs font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-muted)]"
>
Cancel
</button>
<button
type="button"
<QuietBtn onClick={() => setEditingProject(null)}>Cancel</QuietBtn>
<GhostBtn
onClick={() => editMutation.mutate()}
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'}
</button>
</GhostBtn>
</div>
</div>
</div>
@@ -660,32 +621,22 @@ function UserRow({
<p className="truncate text-[11.5px] text-[var(--text-tertiary)]">{user.email}</p>
</div>
{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)]">
<ShieldCheck size={11} /> admin
</span>
<SPill tone="info"><ShieldCheck size={10} /> admin</SPill>
) : null}
{onImpersonate ? (
<button
type="button"
onClick={onImpersonate}
className="rounded-[var(--radius-md)] border border-[var(--border-default)] px-3 py-1.5 text-[12px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
>
Impersonate
</button>
<QuietBtn onClick={onImpersonate}>Impersonate</QuietBtn>
) : null}
<button
type="button"
<QuietBtn
onClick={() => onToggle(!user.is_admin)}
disabled={pending || isSelf}
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 ? (
<span className="inline-flex items-center gap-1"><XCircle size={12} /> Revoke</span>
) : (
'Make admin'
)}
</button>
</QuietBtn>
</div>
);
}
@@ -6,7 +6,9 @@ import { type FilterConfig } from '@/lib/dynamic-filter';
import { useDynamicFilter } from '@/lib/use-dynamic-filter';
import { formatDate, formatRelative } from '@/lib/time';
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 type { AuditLogEntity } from '@/lib/api-client';
const PAGE_SIZE = 50;
@@ -72,109 +74,101 @@ export function AuditLogsPage() {
const hasNext = logs.length === PAGE_SIZE;
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 (
<div className="min-h-screen">
<div className="border-b border-[var(--border-subtle)]">
<div className="w-full px-8 py-5">
<h1 className="v-title">
Audit Logs<span className="v-cursor">_</span>
</h1>
<p className="v-mono mt-1.5 text-[11px] text-[var(--text-tertiary)]">
Every authenticated action recorded by the platform
</p>
</div>
<div className="w-full px-4 pt-6 sm:px-8">
<SPageHead
title="Audit Logs"
titleAccent="_"
sub="Every authenticated action recorded by the platform"
/>
</div>
<div className="w-full px-8 py-6">
<section className="panel p-6">
<div className="w-full px-4 sm:px-8">
<SCard icon={<ScrollText />} title="Event Log" accent={`page ${page}`} pad={false}>
<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 className="mt-4 flex items-center justify-between">
<p className="text-xs text-[var(--text-muted)]">
<div className="border-t border-[var(--border-subtle)] px-4 overflow-x-auto">
{logsQuery.isLoading ? (
<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` : ''}
</p>
<div className="flex items-center gap-2">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
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"
>
<ChevronLeft size={12} />
Prev
</button>
<button
onClick={() => setPage((p) => p + 1)}
disabled={!hasNext}
className="flex items-center gap-1 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] disabled:opacity-40 transition-colors"
>
Next
<ChevronRight size={12} />
</button>
<QuietBtn onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page <= 1}>
<ChevronLeft size={12} /> Prev
</QuietBtn>
<QuietBtn onClick={() => setPage((p) => p + 1)} disabled={!hasNext}>
Next <ChevronRight size={12} />
</QuietBtn>
</div>
</div>
</section>
</SCard>
</div>
</div>
);
+27 -46
View File
@@ -13,15 +13,16 @@ import {
} from '@/lib/auth-client';
import { acceptInvite, getInvite } from '@/lib/api-client';
import { useAuthSession } from '@/lib/use-auth-session';
import { GhostBtn, QuietBtn } from '@/shared/components/sentry';
function sanitizeRedirect(raw: string | null): string {
if (!raw) {
return '/projects';
return '/';
}
const value = raw.trim();
if (!value.startsWith('/') || value.startsWith('//')) {
return '/projects';
return '/';
}
return value;
@@ -54,9 +55,11 @@ function AuthCard({
}) {
return (
<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">
<h2 className="font-headline text-xl font-semibold text-[var(--text-primary)]">{title}</h2>
<p className="mb-6 mt-1.5 text-sm text-[var(--text-secondary)]">{subtitle}</p>
<div className="s-card shadow-2xl shadow-black/50">
<div className="s-cardhead mb-1">
<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}
</div>
</div>
@@ -67,19 +70,14 @@ function AuthLayout({ children }: { children: React.ReactNode }) {
return (
<div className="relative min-h-screen overflow-hidden bg-[var(--bg-void)]">
<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="mb-8 flex flex-col items-center">
<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-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>
{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
</p>
</div>
@@ -89,8 +87,8 @@ function AuthLayout({ children }: { children: React.ReactNode }) {
function AuthErrorNotice({ message }: { message: string }) {
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)]">
<AlertCircle size={16} className="mt-0.5 shrink-0" />
<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={15} className="mt-0.5 shrink-0" />
<span>{message}</span>
</div>
);
@@ -98,7 +96,7 @@ function AuthErrorNotice({ message }: { message: string }) {
function AuthInfoNotice({ message }: { message: string }) {
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}
</div>
);
@@ -206,15 +204,10 @@ export function SignInPage() {
/>
</label>
<button
type="submit"
disabled={isSubmitting || sessionQuery.isPending}
className="mt-1 inline-flex h-10 w-full items-center justify-center gap-2 rounded-[var(--radius-md)] text-sm font-semibold text-[var(--accent-on)] shadow-lg transition-all disabled:cursor-not-allowed disabled:opacity-60"
style={{ background: 'var(--accent-primary)' }}
>
<GhostBtn type="submit" disabled={isSubmitting || sessionQuery.isPending} className="!h-10 w-full justify-center">
{isSubmitting ? <Loader2 size={15} className="animate-spin" /> : <Mail size={15} />}
Continue
</button>
</GhostBtn>
</form>
{providers.length > 0 ? (
@@ -227,24 +220,22 @@ export function SignInPage() {
<div className="grid gap-2 sm:grid-cols-2">
{providers.includes('github') ? (
<button
type="button"
<QuietBtn
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
</button>
</QuietBtn>
) : null}
{providers.includes('google') ? (
<button
type="button"
<QuietBtn
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 />
Google
</button>
</QuietBtn>
) : null}
</div>
</>
@@ -280,7 +271,7 @@ export function AcceptInvitePage() {
const [isSubmitting, setIsSubmitting] = useState(false);
if (sessionQuery.data) {
return <Navigate to="/projects" replace />;
return <Navigate to="/" replace />;
}
const boundEmail = inviteQuery.data?.email ?? undefined;
@@ -297,7 +288,7 @@ export function AcceptInvitePage() {
// account, sign-in creates the browser session.
await signInWithEmail(effectiveEmail.trim(), password);
await queryClient.invalidateQueries({ queryKey: ['auth-session'] });
navigate('/projects', { replace: true });
navigate('/', { replace: true });
} catch (exception) {
setError(exception instanceof Error ? exception.message : 'Failed to accept invite');
} finally {
@@ -369,15 +360,10 @@ export function AcceptInvitePage() {
/>
</label>
<button
type="submit"
disabled={isSubmitting}
className="mt-1 inline-flex h-10 w-full items-center justify-center gap-2 rounded-[var(--radius-md)] text-sm font-semibold text-[var(--accent-on)] shadow-lg transition-all disabled:cursor-not-allowed disabled:opacity-60"
style={{ background: 'var(--accent-primary)' }}
>
<GhostBtn type="submit" disabled={isSubmitting} className="!h-10 w-full justify-center">
{isSubmitting ? <Loader2 size={15} className="animate-spin" /> : <User2 size={15} />}
Accept invite
</button>
</GhostBtn>
</form>
) : null}
@@ -487,15 +473,10 @@ export function SignUpPage() {
/>
</label>
<button
type="submit"
disabled={isSubmitting || sessionQuery.isPending}
className="mt-1 inline-flex h-10 w-full items-center justify-center gap-2 rounded-[var(--radius-md)] text-sm font-semibold text-[var(--accent-on)] shadow-lg transition-all disabled:cursor-not-allowed disabled:opacity-60"
style={{ background: 'var(--accent-primary)' }}
>
<GhostBtn type="submit" disabled={isSubmitting || sessionQuery.isPending} className="!h-10 w-full justify-center">
{isSubmitting ? <Loader2 size={15} className="animate-spin" /> : <User2 size={15} />}
Create Account
</button>
</GhostBtn>
</form>
<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 { demoBuilds } from '@/lib/demo-data';
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 {
Check,
X,
@@ -24,8 +36,6 @@ import {
Box,
Sparkles,
Filter,
Circle,
HardDrive,
} from 'lucide-react';
const statusOptions: Array<{ value: '' | BuildStatus; label: string }> = [
@@ -50,26 +60,11 @@ const BUILD_SCHEMA: FilterConfig<BuildEntity>[] = [
},
];
function StatusBadge({ status }: { status: BuildStatus }) {
const config = {
success: { color: 'var(--success)', bg: 'var(--success-soft)', Icon: Check, animate: false },
failed: { color: 'var(--error)', bg: 'var(--error-soft)', Icon: X, animate: false },
running: { color: 'var(--warning)', bg: 'var(--warning-soft)', Icon: Loader2, animate: true },
pending: { color: 'var(--text-tertiary)', bg: 'var(--surface-muted)', Icon: Clock, animate: false },
cancelled: { color: 'var(--text-tertiary)', bg: 'var(--surface-muted)', Icon: X, animate: false },
}[status] || { color: 'var(--text-tertiary)', bg: 'var(--surface-muted)', Icon: Circle, animate: false };
const { Icon } = config;
return (
<div
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium"
style={{ background: config.bg, color: config.color }}
>
<Icon size={12} className={config.animate ? 'animate-spin' : ''} />
{status}
</div>
);
function BuildIcon({ status }: { status: BuildStatus }) {
if (status === 'success') return <Check size={14} className="text-[var(--success)]" />;
if (status === 'failed') return <X size={14} className="text-[var(--error)]" />;
if (status === 'running') return <Loader2 size={14} className="text-[var(--warning)] animate-spin" />;
return <Clock size={14} className="text-[var(--text-tertiary)]" />;
}
function bytesToHumanReadable(bytes: number): string {
@@ -149,109 +144,113 @@ export function BuildsPage() {
return { running, success, failed, total: builds.length };
}, [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 (
<div className="min-h-screen">
{/* Header */}
<div className="border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/50 backdrop-blur-sm">
<div className="w-full px-8 py-4">
<div className="flex items-center justify-between gap-4">
<div>
<h1 className="v-title">Build Pipeline<span className="v-cursor">_</span></h1>
<p className="text-sm text-[var(--text-secondary)]">Monitor build progress and manage jobs</p>
</div>
<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)]'}`} />
<div className="w-full px-4 pt-6 sm:px-8">
<SPageHead
title="Build Pipeline"
titleAccent="_"
sub="Monitor build progress and manage jobs"
actions={
<>
<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)]'}`} />
<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>
</div>
<button
onClick={() => queryClient.invalidateQueries({ queryKey: ['builds-page'] })}
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm font-medium hover:border-[var(--border-default)] transition-colors"
>
<IconBtn onClick={() => queryClient.invalidateQueries({ queryKey: ['builds-page'] })} title="Refresh builds">
<RefreshCw size={14} />
Refresh
</button>
</div>
</div>
</div>
</IconBtn>
</>
}
/>
</div>
{/* Demo Mode Banner */}
{isDemoMode && (
<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="flex items-center gap-2 text-sm text-[var(--warning)]">
<Sparkles size={16} />
<span>Demo mode active — using sample data</span>
</div>
<div className="s-inset flex items-center gap-2 text-xs text-[var(--warning)]">
<Sparkles size={13} />
Demo mode active — using sample data
</div>
</div>
)}
{/* Stats Overview */}
<div className="w-full px-8 py-6">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<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(--accent-primary-soft)] flex items-center justify-center">
<Box size={16} className="text-[var(--accent-primary)]" />
</div>
</div>
<p className="text-2xl font-semibold text-[var(--text-primary)]">{stats.total}</p>
<p className="text-xs text-[var(--text-muted)] mt-1">Total Builds</p>
</div>
<div className="panel p-4">
<div className="flex items-center justify-between mb-3">
<div className="w-8 h-8 rounded-[var(--radius-sm)] bg-[var(--warning-soft)] flex items-center justify-center">
<Loader2 size={16} className="text-[var(--warning)]" />
</div>
</div>
<p className="text-2xl font-semibold text-[var(--warning)]">{stats.running}</p>
<p className="text-xs text-[var(--text-muted)] mt-1">In Progress</p>
</div>
<div className="panel p-4">
<div className="flex items-center justify-between mb-3">
<div className="w-8 h-8 rounded-[var(--radius-sm)] bg-[var(--success-soft)] flex items-center justify-center">
<Check size={16} className="text-[var(--success)]" />
</div>
</div>
<p className="text-2xl font-semibold text-[var(--success)]">{stats.success}</p>
<p className="text-xs text-[var(--text-muted)] mt-1">Successful</p>
</div>
<div className="panel p-4">
<div className="flex items-center justify-between mb-3">
<div className="w-8 h-8 rounded-[var(--radius-sm)] bg-[var(--error-soft)] flex items-center justify-center">
<X size={16} className="text-[var(--error)]" />
</div>
</div>
<p className="text-2xl font-semibold text-[var(--error)]">{stats.failed}</p>
<p className="text-xs text-[var(--text-muted)] mt-1">Failed</p>
</div>
</div>
<div className="w-full px-4 py-6 sm:px-8 space-y-4">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3.5">
<SStat icon={<Box />} label="Total Builds" value={stats.total} foot="all jobs" />
<SStat icon={<Loader2 />} label="In Progress" value={stats.running} foot="building now" />
<SStat icon={<Check />} label="Successful" value={stats.success} foot="completed ok" />
<SStat icon={<X />} label="Failed" value={stats.failed} foot="need attention" />
</div>
{/* Filters */}
<div className="w-full px-8">
<div className="panel p-4">
<div className="flex items-center gap-2">
<Filter size={16} className="text-[var(--text-tertiary)]" />
<span className="text-sm font-medium text-[var(--text-secondary)]">Filters</span>
</div>
<div className="flex flex-wrap items-end gap-3 mt-3">
<div className="flex-1">
<SCard icon={<Filter />} title="Filters"
trail={<span className="v-mono text-[10.5px] text-[var(--text-muted)]">{builds.length} jobs shown</span>}
>
<div className="flex flex-wrap items-end gap-3">
<div className="flex-1 min-w-[280px]">
<FilterBar {...filter} />
</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
</label>
<select
value={String(limit)}
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="50">50</option>
@@ -259,127 +258,41 @@ export function BuildsPage() {
</select>
</div>
</div>
</div>
</div>
</SCard>
{/* Build Table */}
<div className="w-full px-8 py-6">
<div className="panel overflow-hidden">
<SCard icon={<Box />} title="Build Queue" accent={`${builds.length} jobs`} pad={false}
trail={
<IconBtn onClick={() => queryClient.invalidateQueries({ queryKey: ['builds-page'] })} title="Refresh">
<RefreshCw size={13} />
</IconBtn>
}
>
{!isDemoMode && buildsQuery.isLoading ? (
<div className="p-12 text-center">
<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>
</div>
) : null}
{!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>
) : !isDemoMode && buildsQuery.isError ? (
<div className="p-10 text-center">
<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>
</div>
) : null}
{builds.length === 0 && !buildsQuery.isLoading ? (
) : builds.length === 0 ? (
<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">
<Box size={24} className="text-[var(--text-tertiary)]" />
</div>
<div className="s-ibox mx-auto mb-3 !w-10 !h-10"><Box /></div>
<p className="text-sm text-[var(--text-muted)]">No builds match current filters</p>
</div>
) : null}
{builds.length > 0 ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 p-4">
{builds.map((build) => (
<div
key={build.id}
className="panel p-4 group hover:border-[var(--accent-primary)]/30 transition-all duration-300 card-lift cursor-pointer"
onClick={() => setSelectedBuild(build)}
>
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-xl flex items-center justify-center ${
build.status === 'success'
? 'bg-[var(--success-soft)]'
: build.status === 'failed'
? 'bg-[var(--error-soft)]'
: build.status === 'running'
? 'bg-[var(--warning-soft)]'
: 'bg-[var(--surface-muted)]'
}`}>
{build.status === 'success' && <Check size={18} className="text-[var(--success)]" />}
{build.status === 'failed' && <X size={18} className="text-[var(--error)]" />}
{build.status === 'running' && <Loader2 size={18} className="text-[var(--warning)] animate-spin" />}
{build.status === 'pending' && <Clock size={18} className="text-[var(--text-tertiary)]" />}
{build.status === 'cancelled' && <X size={18} className="text-[var(--text-tertiary)]" />}
</div>
<div>
<p className="mono text-sm font-medium text-[var(--text-primary)]">{build.id}</p>
<div className="flex items-center gap-2 mt-1">
<StatusBadge status={build.status} />
<span className="text-xs text-[var(--text-tertiary)]">
{build.startedAt ? formatRelative(build.startedAt) : '—'}
</span>
</div>
</div>
</div>
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
<button className="p-2 rounded-lg hover:bg-[var(--surface-muted)] text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-all">
<FileText size={14} />
</button>
</div>
</div>
<div className="space-y-3">
{/* Image info */}
<div className="flex items-center justify-between text-xs">
<span className="text-[var(--text-tertiary)]">Image</span>
<span className="mono text-[var(--text-secondary)]">
{build.imageName || '—'}:{build.imageTag || 'latest'}
</span>
</div>
{/* Progress bar */}
{(build.status === 'running' || build.status === 'pending') && (
<div className="space-y-1.5">
<div className="flex items-center justify-between text-xs">
<span className="text-[var(--text-tertiary)]">Progress</span>
<span className="text-[var(--text-secondary)] font-medium">{build.progress}%</span>
</div>
<div className="h-2 rounded-full bg-[var(--surface-muted)] overflow-hidden">
<div
className="h-full rounded-full transition-all duration-300"
style={{ width: `${Math.max(0, Math.min(100, build.progress))}%`, background: 'var(--accent-primary)' }}
) : (
<div className="px-4">
<STable
cols={buildCols}
rows={builds}
rowKey={(b) => b.id}
onRowClick={(b) => setSelectedBuild(b)}
/>
</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>
) : null}
</div>
</SCard>
</div>
{/* Error Toast */}
@@ -393,27 +306,21 @@ export function BuildsPage() {
{selectedBuild && (
<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="relative w-full max-w-3xl panel p-6">
<div className="flex items-center justify-between mb-4">
<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">
<FileText size={20} className="text-[var(--accent-primary)]" />
</div>
<div className="relative w-full max-w-3xl s-card">
<div className="s-cardhead">
<span className="s-ibox"><FileText /></span>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Build Logs</h2>
<p className="mono text-xs text-[var(--text-tertiary)]">{selectedBuild.id}</p>
<div className="s-t">Build Logs</div>
<div className="v-mono mt-0.5 text-[11px] text-[var(--text-tertiary)]">{selectedBuild.id}</div>
</div>
<div className="s-trail">
<QuietBtn onClick={() => setSelectedBuild(null)}>Close</QuietBtn>
</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 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">
<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
? selectedBuild.log || '[demo] No logs available.'
: logsQuery.isLoading
@@ -425,6 +332,7 @@ export function BuildsPage() {
</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 {
const text = markdown
.replace(/^#[^\n]*\n/, ' ')
.replace(/```[\s\S]*?```/g, ' ')
.replace(/[#>*`\-[\]()]/g, ' ')
.replace(/\s+/g, ' ')
@@ -119,7 +120,7 @@ export function DocsBrowser() {
<div className="min-w-0 flex-1 overflow-y-auto max-md:hidden">
{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)]">
{selected.section} / {selected.path}
</p>
+154 -86
View File
@@ -1,3 +1,4 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import {
@@ -8,16 +9,18 @@ import {
GitBranch,
LayoutTemplate,
Rocket,
Server,
ShieldCheck,
Terminal,
Zap,
} 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 { useAuthSession } from '@/lib/use-auth-session';
import { useDemoMode } from '@/lib/demo-mode';
import { useBranding } from '@/lib/use-branding';
import { BrandWordmark } from '@/shared/components';
import { demoProjects, demoTemplates } from '@/lib/demo-data';
import { BrandWordmark, MiniBars, SPill, Ticks } from '@/shared/components';
import { demoHostMonitoring, demoProjects, demoTemplates } from '@/lib/demo-data';
const features = [
{ 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.' },
];
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() {
const [now] = useState(() => Date.now());
const isDemoMode = useDemoMode();
const { logoUrl, productName } = useBranding();
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 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 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 (
<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">
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-5">
<Link to="/" className="flex items-center gap-2.5">
<img src={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 />
</span>
</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="#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>
<Link
to={`/projects${demo}`}
className="rounded-[var(--radius-md)] px-3 py-1.5 font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)]"
>
<Link to={`/projects${demo}`} className="rounded-[var(--radius-md)] px-3 py-1.5 font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)]">
Browse
</Link>
{signedIn ? (
<Link
to="/projects"
className="ml-1 inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-3.5 py-1.5 font-semibold text-[var(--accent-on)]"
>
{signedIn || isDemoMode ? (
<Link to={`/${demo}`} className="ml-1 s-btn-accent">
Console <ArrowRight size={13} />
</Link>
) : (
<Link
to={isDemoMode ? `/projects${demo}` : '/auth/sign-in'}
className="ml-1 inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-3.5 py-1.5 font-semibold text-[var(--accent-on)]"
>
{isDemoMode ? 'Live demo' : 'Sign in'} <ArrowRight size={13} />
<Link to="/auth/sign-in" className="ml-1 s-btn-accent">
Sign in <ArrowRight size={13} />
</Link>
)}
</nav>
</div>
</header>
<main className="relative">
<section className="mx-auto max-w-6xl px-5 pb-16 pt-20 text-center">
<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)]">
<Container size={12} className="text-[var(--accent-primary)]" />
<main>
{/* ── Hero + live instance window ── */}
<section className="border-b border-[var(--border-subtle)]">
<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">
<div>
<p className="s-chip mb-6 inline-flex">
<Container size={11} />
self-hosted PaaS
</p>
<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]">
<h1 className="font-headline text-[44px] font-bold leading-[1.06] tracking-[-0.03em] text-[var(--text-primary)] max-sm:text-[32px]">
Deploy containers like it's{' '}
<span style={{ color: 'var(--accent-primary)' }}>your own cloud</span>
<span className="text-[var(--accent-primary)]">your own cloud</span>
</h1>
<p className="mx-auto mt-5 max-w-xl text-[15px] leading-relaxed text-[var(--text-secondary)]">
<p className="mt-5 max-w-lg text-[14px] leading-relaxed text-[var(--text-secondary)]">
Containr turns any Docker host into a full deployment platform — git-push builds,
managed databases, preview environments, HA failover, and a searchable knowledge base.
</p>
<div className="mt-8 flex items-center justify-center gap-3">
<Link
to={`/projects${demo}`}
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]"
>
{isDemoMode ? 'Explore the live demo' : 'Browse projects'} <ArrowRight size={15} />
<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' : 'Open the console'} <ArrowRight size={15} />
</Link>
<a
href="#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 href="#docs" className="s-btn-quiet !px-5 !h-10 text-sm">
<BookOpen size={15} /> Read the docs
</a>
</div>
<div className="v-mono mt-8 flex items-center gap-5 text-[11px] text-[var(--text-tertiary)]">
<span className="flex items-center gap-2">
<i className="inline-block h-1.5 w-1.5 rounded-full bg-[var(--success)]" />
{projects.length} public projects
</span>
<span>{projects.reduce((s, p) => s + (p.stats?.running_services ?? 0), 0)} services online</span>
<span className="max-sm:hidden">{projects.reduce((s, p) => s + (p.stats?.deployment_count ?? 0), 0)} deployments</span>
</div>
</div>
{projects.length > 0 ? (
<div className="mx-auto mt-12 grid max-w-3xl grid-cols-3 gap-3 text-left max-sm:grid-cols-1">
<Stat label="Public projects" value={projects.length} />
<Stat
label="Running services"
value={projects.reduce((sum, p) => sum + (p.stats?.running_services ?? 0), 0)}
/>
<Stat
label="Deployments"
value={projects.reduce((sum, p) => sum + (p.stats?.deployment_count ?? 0), 0)}
/>
{/* 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>
) : null}
</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">
<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">
<div>
<p className="v-mono mb-2 flex items-center gap-2 text-[10.5px] uppercase tracking-[0.1em] text-[var(--text-tertiary)]">
<i className="inline-block h-[5px] w-[5px] rounded-[1px] bg-[var(--accent-primary)]" />
platform
</p>
<h2 className="font-headline text-[26px] font-bold tracking-tight text-[var(--text-primary)]">
Everything a deployment platform needs
</h2>
<p className="mx-auto mt-2 max-w-lg text-center text-sm text-[var(--text-secondary)]">
<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 className="mt-10 grid grid-cols-3 gap-4 max-md:grid-cols-2 max-sm:grid-cols-1">
</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) => (
<div
key={feature.title}
className="rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)] p-5 transition-colors hover:border-[var(--border-default)]"
>
<feature.icon size={18} className="text-[var(--accent-primary)]" />
<h3 className="mt-3 text-[14.5px] font-semibold text-[var(--text-primary)]">{feature.title}</h3>
<p className="mt-1.5 text-[12.5px] leading-relaxed text-[var(--text-tertiary)]">{feature.text}</p>
<div key={feature.title} className="s-card">
<div className="s-cardhead !mb-0">
<span className="s-ibox"><feature.icon size={13.5} /></span>
<h3 className="s-t">{feature.title}</h3>
</div>
<p className="mt-2 text-[12.5px] leading-relaxed text-[var(--text-tertiary)]">{feature.text}</p>
</div>
))}
</div>
</div>
</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="flex items-end justify-between">
<div className="flex items-end justify-between gap-6">
<div>
<h2 className="text-[26px] font-bold tracking-tight text-[var(--text-primary)]">Template catalog</h2>
<p className="mt-1 text-sm text-[var(--text-secondary)]">
<p className="v-mono mb-2 flex items-center gap-2 text-[10.5px] uppercase tracking-[0.1em] text-[var(--text-tertiary)]">
<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.
</p>
</div>
<Link
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>
</div>
<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
key={template.id}
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">
{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)]">
<LayoutTemplate size={14} />
</span>
<span className="s-ibox !h-7 !w-7"><LayoutTemplate size={13.5} /></span>
)}
<span className="truncate text-[13px] font-semibold text-[var(--text-primary)]">{template.name}</span>
</div>
<p className="mt-2 line-clamp-2 text-[11.5px] leading-relaxed text-[var(--text-tertiary)]">
{template.description}
</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'}
</span>
</Link>
@@ -199,10 +271,15 @@ export function LandingPage() {
</div>
</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">
<h2 className="text-[26px] font-bold tracking-tight text-[var(--text-primary)]">Documentation</h2>
<p className="mt-1 text-sm text-[var(--text-secondary)]">
<p className="v-mono mb-2 flex items-center gap-2 text-[10.5px] uppercase tracking-[0.1em] text-[var(--text-tertiary)]">
<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.
</p>
<div className="mt-8">
@@ -213,10 +290,10 @@ export function LandingPage() {
</main>
<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">
<img src="/containr.svg" alt="" className="h-4 w-4 rounded" />
Containr — self-hosted container platform
containr — self-hosted container platform
</span>
<div className="flex items-center gap-4">
<Link to={`/projects${demo}`} className="hover:text-[var(--text-secondary)]">Projects</Link>
@@ -228,12 +305,3 @@ export function LandingPage() {
</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 { formatBytes, formatRelative } from '@/lib/time';
import { DemoRestricted } from '@/shared/components';
import { GhostBtn, QuietBtn, SPill } from '@/shared/components/sentry';
import {
ArrowLeft, CheckCircle2, ChevronRight, Loader2, Pencil, XCircle,
Activity, Cpu, ListOrdered, Server,
} from 'lucide-react';
const TIME_RANGES = [
@@ -129,8 +131,7 @@ export function NodeDetailPage() {
return (
<div className="min-h-screen">
<div className="border-b border-[var(--border-subtle)]">
<div className="w-full px-8 py-5">
<div className="w-full px-4 pt-6 sm:px-8">
<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">
<ArrowLeft size={12} /> usage
@@ -140,7 +141,7 @@ export function NodeDetailPage() {
</div>
<div className="flex items-center justify-between gap-4 flex-wrap">
<div>
<h1 className="v-title">
<h1 className="v-title font-headline">
{rename === null ? agent.name : rename}
{isAdmin && rename === null && (
<button
@@ -163,7 +164,7 @@ export function NodeDetailPage() {
if (e.key === 'Escape') setRename(null);
}}
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={() => setRename(null)} className="v-mono text-[11px] text-[var(--text-muted)] hover:underline">cancel</button>
@@ -209,7 +210,7 @@ export function NodeDetailPage() {
}}
autoFocus
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
onClick={() => updateMutation.mutate({ tags: tagDraft.split(',').map((t) => t.trim()).filter(Boolean) })}
@@ -247,7 +248,7 @@ export function NodeDetailPage() {
}}
autoFocus
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
onClick={() => updateMutation.mutate({ default_domain: domainDraft.trim().toLowerCase() })}
@@ -258,17 +259,9 @@ export function NodeDetailPage() {
)}
</p>
</div>
<div className="flex items-center gap-3">
<span className={`v-mono text-[11px] px-2 py-1 rounded-md border ${online
? 'border-[var(--success)]/40 text-[var(--success)]'
: 'border-[var(--error)]/40 text-[var(--error)]'}`}>
{agent.status}
</span>
{!agent.schedulable && (
<span className="v-mono text-[11px] px-2 py-1 rounded-md border border-[var(--warning)]/40 text-[var(--warning)]">
cordoned
</span>
)}
<div className="flex items-center gap-2 flex-wrap">
<SPill tone={online ? 'ok' : 'err'}>{agent.status}</SPill>
{!agent.schedulable && <SPill tone="warn">cordoned</SPill>}
{isAdmin && (
<>
<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
</label>
<button
<QuietBtn
onClick={() => cordonMutation.mutate(agent.schedulable)}
disabled={cordonMutation.isPending}
title={agent.schedulable
? 'Block new placements — running replicas stay'
: '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'}
</button>
<button
</QuietBtn>
<QuietBtn
onClick={() => setConfirmDrain(true)}
disabled={drainMutation.isPending || !online}
title={online
? 'Cordon + remove all service containers + unpin services (they redeploy locally)'
: '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
</button>
<button
</QuietBtn>
<GhostBtn
onClick={() => upgradeMutation.mutate()}
disabled={upgradeMutation.isPending || !online}
title={online
? `Upgrade agent to the server's bundled binary (server ${upgradeMutation.data?.version ?? 'build'}) — the agent restarts itself`
: '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'}
</button>
<button
</GhostBtn>
<QuietBtn
onClick={() => setConfirmVolumes(true)}
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
</button>
<button
</QuietBtn>
<QuietBtn
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
</button>
</QuietBtn>
</>
)}
</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">
<Stat label="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="load 1m" value={latest ? latest.system_load.load_1m.toFixed(2) : '—'} sub={latest ? `5m ${latest.system_load.load_5m.toFixed(2)} · 15m ${latest.system_load.load_15m.toFixed(2)}` : ''} />
<Stat label="containers" value={latest ? String(latest.container_count) : String(containers.length)} sub="reported by agent" />
<Stat label="cpu" icon={<Cpu />} value={latest ? `${latest.cpu.usage_percent.toFixed(0)}%` : '—'} sub={latest ? `${latest.cpu.cores} cores` : ''} />
<Stat label="memory" icon={<Server />} value={latest ? `${latest.memory.usage_percent.toFixed(0)}%` : '—'} sub={latest ? `${formatBytes(latest.memory.available)} free of ${formatBytes(latest.memory.limit)}` : ''} />
<Stat label="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" icon={<ListOrdered />} value={latest ? String(latest.container_count) : String(containers.length)} sub="reported by agent" />
</div>
<section className="panel p-5">
<div className="flex items-center justify-between mb-4">
<h2 className="v-mono text-[11px] uppercase tracking-wider text-[var(--text-tertiary)]">telemetry</h2>
<div className="flex gap-1">
<section className="s-card !p-0">
<div className="s-cardhead border-b border-[var(--border-subtle)]">
<span className="s-ibox"><Activity /></span>
<h2 className="s-t">Telemetry</h2>
<span className="s-trail">
<span className="flex gap-1">
{TIME_RANGES.map((r) => (
<button
key={r.value}
onClick={() => setTimeRange(r.value)}
className={`v-mono text-[11px] px-2.5 py-1 rounded-md border ${timeRange === r.value
? 'border-[var(--accent-primary)]/50 text-[var(--accent-primary)]'
: 'border-[var(--border-subtle)] text-[var(--text-muted)] hover:text-[var(--text-primary)]'}`}
className={`s-chip v-mono ${timeRange === r.value ? 'on' : ''}`}
>
{r.label}
</button>
))}
</span>
</span>
</div>
</div>
<div className="p-4">
{metricsQuery.isLoading ? (
<p className="text-xs text-[var(--text-muted)]">Loading metrics…</p>
) : metrics.length < 2 ? (
@@ -365,12 +356,17 @@ export function NodeDetailPage() {
<Sparkline label="memory %" points={metrics} value={(m) => m.memory.usage_percent} />
</div>
)}
</div>
</section>
<div className="grid gap-6 lg:grid-cols-2">
<section className="panel 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="divide-y divide-[var(--border-subtle)] border-t border-[var(--border-subtle)]">
<section className="s-card !p-0">
<div className="s-cardhead border-b 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 && (
<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 v-mono text-[11px] text-[var(--text-tertiary)]">{c.image}</p>
</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>
</section>
<section className="panel 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="divide-y divide-[var(--border-subtle)] border-t border-[var(--border-subtle)]">
<section className="s-card !p-0">
<div className="s-cardhead border-b 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 && (
<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] !== '');
}
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 (
<div className="panel p-4">
<p className="v-mono text-[10px] uppercase tracking-wider text-[var(--text-tertiary)]">{label}</p>
<p className="mt-1 text-xl font-semibold text-[var(--text-primary)]">{value}</p>
{sub && <p className="mt-0.5 v-mono text-[11px] text-[var(--text-muted)]">{sub}</p>}
<div className="s-stat">
<div className="px-4 pb-1 pt-4">
<div className="flex items-center gap-2.5">
<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>
);
}
@@ -510,24 +522,20 @@ function ConfirmModal({ title, body, confirmLabel, danger, extra, onCancel, onCo
pending: boolean;
}) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onCancel}>
<div className="panel w-full max-w-sm p-5" onClick={(e) => e.stopPropagation()}>
<h3 className="text-sm font-semibold text-[var(--text-primary)]">{title}</h3>
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm" onClick={onCancel}>
<div className="s-card w-full max-w-sm" onClick={(e) => e.stopPropagation()}>
<h3 className="s-t">{title}</h3>
<p className="mt-2 text-xs text-[var(--text-secondary)]">{body}</p>
{extra && <div className="mt-3">{extra}</div>}
<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)]">
cancel
</button>
<button
<QuietBtn onClick={onCancel}>cancel</QuietBtn>
<GhostBtn
onClick={onConfirm}
disabled={pending}
className={`v-mono text-[11px] px-3 py-1.5 rounded-md border disabled:opacity-50 ${danger
? 'border-[var(--error)]/50 text-[var(--error)] hover:bg-[var(--error)]/10'
: 'border-[var(--accent-primary)]/50 text-[var(--accent-primary)] hover:bg-[var(--accent-primary)]/10'}`}
className={danger ? '!text-[var(--error)] !border-[var(--error)]/40 !bg-[var(--error)]/10' : undefined}
>
{pending ? 'working…' : confirmLabel}
</button>
</GhostBtn>
</div>
</div>
</div>
@@ -4,28 +4,10 @@ import { getOperations, type OperationsView } from '@/lib/api-client';
import { useDemoMode } from '@/lib/demo-mode';
import { formatRelative } from '@/lib/time';
import { DemoRestricted } from '@/shared/components';
import { 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';
function statusClass(status: string): string {
switch (status) {
case 'deployed':
case 'completed':
case 'success':
return 'text-[var(--success)]';
case 'failed':
case 'cancelled':
case 'rolled_back':
return 'text-[var(--error)]';
case 'building':
case 'deploying':
case 'in_progress':
case 'running':
return 'text-[var(--accent-primary)]';
default:
return 'text-[var(--warning)]';
}
}
function Section({ title, icon: Icon, count, children }: {
title: string;
icon: typeof Activity;
@@ -33,13 +15,15 @@ function Section({ title, icon: Icon, count, children }: {
children: React.ReactNode;
}) {
return (
<section className="panel p-5">
<div className="flex items-center gap-2 mb-4">
<Icon size={16} className="text-[var(--accent-primary)]" />
<h2 className="text-sm font-semibold text-[var(--text-primary)]">{title}</h2>
<span className="text-xs text-[var(--text-muted)] mono">({count})</span>
<section className="s-card overflow-hidden">
<div className="s-cardhead border-b border-[var(--border-subtle)]">
<span className="s-ibox"><Icon /></span>
<h2 className="s-t">{title}</h2>
<span className="s-trail">
<span className="s-chip" style={{ cursor: 'default' }}>{count}</span>
</span>
</div>
{children}
<div className="p-3">{children}</div>
</section>
);
}
@@ -74,13 +58,8 @@ export function OperationsPage() {
return (
<div className="min-h-screen">
<div className="border-b border-[var(--border-subtle)]">
<div className="w-full px-8 py-5">
<h1 className="v-title">Operations<span className="v-cursor">_</span></h1>
<p className="v-mono mt-1.5 text-[11px] text-[var(--text-tertiary)]">
Live view of deploys, queues, cron runs, and backups
</p>
</div>
<div className="w-full px-4 pt-6 sm:px-8">
<SPageHead title="Operations" titleAccent="_" sub="Live view of deploys, queues, cron runs, and backups" />
</div>
{opsQuery.isLoading ? (
@@ -88,24 +67,12 @@ export function OperationsPage() {
<Loader2 size={20} className="animate-spin text-[var(--text-muted)]" />
</div>
) : (
<div className="w-full px-8 py-6 space-y-6">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="panel-soft p-4">
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">In flight</p>
<p className="mt-1 text-2xl font-semibold text-[var(--accent-primary)]">{active.length}</p>
</div>
<div className="panel-soft p-4">
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Queued</p>
<p className="mt-1 text-2xl font-semibold text-[var(--warning)]">{queuedTotal}</p>
</div>
<div className="panel-soft p-4">
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Failed 24h</p>
<p className="mt-1 text-2xl font-semibold text-[var(--error)]">{failed.length}</p>
</div>
<div className="panel-soft p-4">
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Backups 24h</p>
<p className="mt-1 text-2xl font-semibold text-[var(--text-secondary)]">{backups.length}</p>
</div>
<div className="w-full px-4 py-6 sm:px-8 space-y-6">
<div className="grid grid-cols-2 gap-3.5 md:grid-cols-4">
<SStat icon={<Activity />} label="In Flight" value={active.length} foot="deploying now" />
<SStat icon={<Clock />} label="Queued" value={queuedTotal} foot="waiting in queue" />
<SStat icon={<AlertTriangle />} label="Failed 24h" value={failed.length} foot="need attention" />
<SStat icon={<Database />} label="Backups 24h" value={backups.length} foot="completed runs" />
</div>
<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-xs text-[var(--text-muted)]"> · {d.project_name}</span>
</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>
</button>
))}
@@ -152,7 +119,7 @@ export function OperationsPage() {
<p className="text-[11px] text-[var(--error)]/80 truncate">{d.error}</p>
)}
</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>
</button>
))}
@@ -167,9 +134,9 @@ export function OperationsPage() {
<div className="space-y-1.5">
{cron.map((r) => (
<div key={r.id} className="flex items-center gap-3 px-3 py-1.5 text-xs">
<span className={`mono ${statusClass(r.status)}`}>{r.status}</span>
<SPill tone={statusTone(r.status)}>{r.status}</SPill>
<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>}
<span className="ml-auto text-[var(--text-muted)]">{formatRelative(r.started_at)}</span>
</div>
@@ -185,7 +152,7 @@ export function OperationsPage() {
<div className="space-y-1.5">
{backups.map((b) => (
<div key={b.id} className="flex items-center gap-3 px-3 py-1.5 text-xs">
<span className={`mono ${statusClass(b.status)}`}>{b.status}</span>
<SPill tone={statusTone(b.status)}>{b.status}</SPill>
<span className="text-[var(--text-primary)]">{b.database_name}</span>
<span className="text-[var(--text-muted)]">{b.size}</span>
<span className="ml-auto text-[var(--text-muted)]">{formatRelative(b.created_at)}</span>
@@ -198,14 +165,15 @@ export function OperationsPage() {
{queue.length > 0 && (
<Section title="Deploy queue depth" icon={Clock} count={queue.length}>
<div className="space-y-1.5">
<div className="space-y-2">
{queue.map((q) => (
<div key={q.service_id} className="flex items-center gap-3 px-3 py-1.5 text-xs mono">
<span className="text-[var(--text-secondary)]">{q.service_id.slice(0, 8)}</span>
<span className={q.running ? 'text-[var(--accent-primary)]' : 'text-[var(--text-muted)]'}>
{q.running ? 'running' : 'idle'}
<div key={q.service_id} className="flex items-center gap-3 px-3 py-1.5 text-xs">
<span className="v-mono text-[var(--text-secondary)]">{q.service_id.slice(0, 8)}</span>
<SPill tone={q.running ? 'info' : 'off'}>{q.running ? 'running' : 'idle'}</SPill>
<span className="flex-1 max-w-[220px]">
<Ticks pct={Math.min(100, q.queued * 25)} count={32} warn={!q.running} />
</span>
<span className="text-[var(--warning)]">{q.queued} queued</span>
<span className="v-mono text-[var(--warning)]">{q.queued} queued</span>
</div>
))}
</div>
@@ -13,10 +13,31 @@ import {
Search,
FolderOpen,
X,
Cpu,
MemoryStick,
HardDrive,
Rocket,
RefreshCw,
Server,
} from 'lucide-react';
import { useAuthSession } from '@/lib/use-auth-session';
import { useDemoMode } from '@/lib/demo-mode';
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 = [
{ 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' },
];
type DeployRow = { id: string; name: string; project?: string; status: string; when: string };
function getHealthStatus(stats: ProjectStats): 'healthy' | 'idle' | 'degraded' | 'critical' {
if (stats.service_count === 0) return 'idle';
if (stats.running_services === stats.service_count) return 'healthy';
@@ -32,19 +55,6 @@ function getHealthStatus(stats: ProjectStats): 'healthy' | 'idle' | 'degraded' |
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 {
if (!date) return '—';
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`;
}
/* Mini service topology preview inside each project card. */
function MiniCanvas({ project }: { project: ProjectEntity }) {
const count = project.stats.service_count;
const running = project.stats.running_services;
@@ -84,40 +95,61 @@ function MiniCanvas({ project }: { project: ProjectEntity }) {
function ProjectCard({ project, href }: { project: ProjectEntity; href: string }) {
const navigate = useNavigate();
const health = getHealthStatus(project.stats);
const badge = healthBadge(health);
const envOk = health === 'healthy';
const envLabel = envOk ? 'production' : health === 'idle' ? 'idle' : health === 'critical' ? 'down' : 'degraded';
const envLabel =
health === 'healthy' ? 'production' : health === 'idle' ? 'idle' : health === 'critical' ? 'down' : 'degraded';
return (
<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)}
>
<div className="mb-3 flex items-center gap-2">
<h3 className="truncate text-[14.5px] font-bold tracking-tight text-[var(--text-primary)]">
{project.name}
</h3>
<span className={`v-st ml-auto ${badge.cls}`}>{badge.label}</span>
<div className="s-cardhead">
<IboxStub />
<h3 className="s-t truncate">{project.name}</h3>
<span className="s-trail">
<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 className="px-4">
<MiniCanvas project={project} />
<div className="v-mono mt-3 flex items-center gap-2 text-[10.5px] text-[var(--text-tertiary)]">
</div>
<div className="s-stat-foot mt-3.5">
<span className="flex items-center gap-2">
<i
className="inline-block h-1.5 w-1.5 rounded-full"
style={{ background: envOk ? 'var(--success)' : health === 'idle' ? 'var(--text-tertiary)' : health === 'critical' ? 'var(--error)' : 'var(--warning)' }}
style={{
background:
health === 'healthy'
? 'var(--success)'
: health === 'idle'
? 'var(--text-tertiary)'
: health === 'critical'
? 'var(--error)'
: 'var(--warning)',
}}
/>
<span className="text-[var(--text-secondary)]">
{envLabel}
{envLabel} · {project.stats.running_services}/{project.stats.service_count} online
</span>
<span>·</span>
<span>
{project.stats.running_services}/{project.stats.service_count} online
</span>
<span className="ml-auto">{formatRelative(project.updatedAt)}</span>
<span>{formatRelative(project.updatedAt)}</span>
</div>
</article>
);
}
function IboxStub() {
return (
<span className="s-ibox">
<Server size={13.5} />
</span>
);
}
export function ProjectsPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
@@ -181,7 +213,7 @@ export function ProjectsPage() {
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 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
? Math.min(100, Math.round((host.load.load1m / host.cpu.cores) * 100))
: null;
@@ -190,9 +222,9 @@ export function ProjectsPage() {
const fmtGB = (v: number) => `${(v / (1024 * 1024 * 1024)).toFixed(0)}G`;
const deployments = isDemoMode ? [] : deploysQuery.data ?? [];
const deployCount = deployments.length;
const feed = isDemoMode
const feed: DeployRow[] = isDemoMode
? demoDeploys
: deployments.slice(0, 5).map((d) => ({
: deployments.slice(0, 6).map((d) => ({
id: d.id,
name: d.serviceName || d.imageName || d.serviceId || d.id,
project: d.projectName,
@@ -200,120 +232,188 @@ export function ProjectsPage() {
when: formatRelative(d.startedAt ?? d.completedAt ?? d.createdAt),
}));
const statusClass = (status: string) =>
status === 'DEPLOYED' || status === 'SUCCESS' || status === 'SUCCEEDED'
? 'v-st-ok'
: status === 'BUILDING' || status === 'RUNNING' || status === 'QUEUED'
? 'v-st-run'
: status === 'FAILED' || status === 'CANCELLED'
? 'v-st-fail'
: 'v-st-off';
const statDefs = [
{
icon: <Cpu size={13.5} />,
label: `${hostLabel} CPU`,
value: cpuPct ?? '—',
unit: cpuPct !== null ? '%' : undefined,
ticks: cpuPct,
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 (
<div className="min-h-screen">
<div className="w-full px-8 py-6">
{/* Page head */}
<div className="mb-6 flex items-center justify-between gap-5">
<div>
<h1 className="v-title">Projects<span className="v-cursor">_</span></h1>
<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 ·{' '}
<b className="text-[var(--text-secondary)]">{String(totalServices).padStart(2, '0')}</b> services ·{' '}
<b className="text-[var(--text-secondary)]">{runningServices}</b> online
</p>
</div>
<div className="flex items-center gap-2.5">
<div className="search-box" style={{ width: 'min(240px, 42vw)' }}>
<div className="w-full px-4 py-6 sm:px-7">
<SPageHead
title="Projects"
titleAccent="_"
sub={
<>
{String(projects.length).padStart(2, '0')} projects · {String(totalServices).padStart(2, '0')} services ·{' '}
</>
}
subAccent={`${runningServices} online`}
actions={
<>
<div className="search-box" style={{ width: 'min(230px, 40vw)' }}>
<Search size={13} />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="search projects…"
className="v-mono"
style={{ fontSize: 11.5 }}
placeholder="Search projects"
/>
</div>
<IconBtn title="Refresh" onClick={() => { projectsQuery.refetch(); deploysQuery.refetch(); hostQuery.refetch(); }}>
<RefreshCw size={14} />
</IconBtn>
{signedIn && (
<button onClick={openCreate} className="v-btn">
+ new project
</button>
<GhostBtn onClick={openCreate}>+ New Project</GhostBtn>
)}
</div>
</div>
</>
}
/>
{/* Host stat row */}
<div className="mb-7 grid grid-cols-2 gap-3 lg:grid-cols-4">
<div className="v-stat">
<div className="v-k"><span>{hostLabel}.CPU</span><span className="v-live" /></div>
<div className="v-v">{cpuPct ?? '—'}<span>%</span></div>
{isDemoMode ? (
<div className="v-spark">
{[28, 44, 36, 58, 42, 66, 50, 34, 56, 40, 30, 20].map((h, i) => (
<i key={i} style={{ height: `${h}%` }} />
{/* 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>
) : null}
<div className="v-d">
{isDemoMode || host ? (
<><b style={{ color: 'var(--success)' }}>healthy</b> · {host?.cpu.cores ?? 16} cores</>
) : 'no telemetry'}
</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>
{anomalies.length > 0 && (
<div className="mb-4">
<SAnomaly
icon={
<svg width="12.5" height="12.5" viewBox="0 0 16 16" fill="none" stroke="var(--warning)" strokeWidth="1.5">
<path d="M8 2.4 2.2 13h11.6z" />
<path d="M8 6.6v3M8 11.4v.3" />
</svg>
}
title={<><b className="text-[var(--text-primary)] font-semibold">{anomalies.length}</b> resource anomaly{anomalies.length > 1 ? 's' : ''} detected</>}
items={anomalies}
/>
</div>
)}
{/* 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 && (
<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) => (
<div key={i} className="panel h-[210px] animate-pulse" />
<div key={i} className="s-card h-[210px] animate-pulse" />
))}
</div>
)}
{!isDemoMode && projectsQuery.isError && (
<div className="panel 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)]">
<X size={24} className="text-[var(--error)]" />
<div className="s-card p-8 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-[var(--error-soft)]">
<X size={22} className="text-[var(--error)]" />
</div>
<p className="text-lg font-medium 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="text-[15px] font-semibold text-[var(--text-primary)]">Failed to load projects</p>
<p className="mt-1.5 text-[12px] text-[var(--text-tertiary)]">Check API connectivity and try again</p>
</div>
)}
{filteredProjects.length === 0 && !projectsQuery.isLoading && !projectsQuery.isError ? (
<div className="panel 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)]">
<FolderOpen size={28} className="text-[var(--accent-primary)]" />
<div className="s-card p-12 text-center">
<div className="mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-xl border border-[var(--border-default)] bg-[var(--bg-overlay)]">
<FolderOpen size={24} className="text-[var(--accent-primary)]" />
</div>
<p className="text-xl font-semibold text-[var(--text-primary)]">No projects yet</p>
<p className="mx-auto mt-2 max-w-md text-[var(--text-secondary)]">
<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-[12.5px] text-[var(--text-secondary)]">
{signedIn
? '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.'}
</p>
<button onClick={openCreate} className="v-btn mt-6">
{signedIn ? '+ create project' : 'sign in to create'}
</button>
<GhostBtn onClick={openCreate} className="mt-6">
{signedIn ? '+ New Project' : 'Sign in to create'}
</GhostBtn>
</div>
) : null}
@@ -326,24 +426,23 @@ export function ProjectsPage() {
)}
{/* Deploy feed */}
<p className="v-sect mt-7">RECENT DEPLOYMENTS <span className="v-sect-r">latest</span></p>
<div className="v-tbl">
<table>
<thead>
<tr><th>SERVICE</th><th>STATUS</th><th style={{ textAlign: 'right' }}>WHEN</th></tr>
</thead>
<tbody>
{feed.length === 0 ? (
<tr><td colSpan={3} className="text-center" style={{ color: 'var(--text-tertiary)' }}>no deployments yet</td></tr>
) : feed.map((d) => (
<tr key={d.id}>
<td className="v-lead">{d.name}{d.project ? <span className="v-mono ml-2 text-[10.5px] font-normal text-[var(--text-tertiary)]">{d.project}</span> : null}</td>
<td><span className={`v-st ${statusClass(d.status)}`}>{d.status}</span></td>
<td style={{ textAlign: 'right' }}>{d.when}</td>
</tr>
))}
</tbody>
</table>
<div className="mt-6">
<SCard
icon={<Rocket size={13.5} />}
title="Recent Deployments"
accent={feed.length > 0 ? `(${feed.length})` : undefined}
trail={
<IconBtn title="Refresh" onClick={() => deploysQuery.refetch()}>
<RefreshCw size={13} />
</IconBtn>
}
pad={false}
>
<STable cols={deployCols} rows={feed} rowKey={(r) => r.id} />
{feed.length === 0 && (
<p className="px-7 pb-5 text-[12px] text-[var(--text-tertiary)]">No deployments yet.</p>
)}
</SCard>
</div>
</div>
@@ -351,58 +450,55 @@ export function ProjectsPage() {
{isCreateOpen && (
<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="relative w-full max-w-lg panel p-6">
<h2 className="text-xl font-semibold text-[var(--text-primary)]">Create new project</h2>
<p className="mt-1 text-sm text-[var(--text-secondary)]">
<div className="s-card relative w-full max-w-lg p-6">
<h2 className="font-headline text-[19px] font-bold tracking-tight text-[var(--text-primary)]">New project</h2>
<p className="mt-1 text-[12.5px] text-[var(--text-secondary)]">
Projects organize your services and provide a visual canvas for topology management.
</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.
</p>
</div>
<div className="mt-6 space-y-4">
<div className="mt-5 space-y-4">
<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
</label>
<input
value={form.name}
onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))}
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>
<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>
</label>
<textarea
value={form.description}
onChange={(e) => setForm((p) => ({ ...p, description: e.target.value }))}
placeholder="Describe your project..."
placeholder="Describe your project…"
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>
{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}
</div>
)}
<div className="mt-6 flex justify-end gap-3">
<button onClick={() => setCreateOpen(false)} className="v-btn-ghost">
Cancel
</button>
<button
<div className="mt-6 flex justify-end gap-2.5">
<QuietBtn onClick={() => setCreateOpen(false)}>Cancel</QuietBtn>
<GhostBtn
onClick={() => createProjectMutation.mutate()}
disabled={!form.name.trim() || createProjectMutation.isPending}
className="v-btn"
>
{createProjectMutation.isPending ? 'Creating...' : 'Create Project'}
</button>
{createProjectMutation.isPending ? 'Creating…' : 'Create Project'}
</GhostBtn>
</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 { Link, useNavigate, useSearchParams } from 'react-router-dom';
import {
@@ -104,6 +104,7 @@ import { formatRelative } from '@/lib/time';
import { getAuthBaseUrl, signOutAuthSession } from '@/lib/auth-client';
import { useBuildUpdates } from '@/lib/use-build-updates';
import { EnhancedMetricCard, LineAreaChart, DonutChart, SegmentedBar, BarChart, DemoRestricted } from '@/shared/components';
import { SPageHead } from '@/shared/components/sentry';
import {
Clock,
Activity,
@@ -148,13 +149,10 @@ import {
Send,
} from 'lucide-react';
function SecondaryPageHeader({ title, description }: { title: string; description: string }) {
function SecondaryPageHeader({ title, description, actions }: { title: string; description: string; actions?: ReactNode }) {
return (
<div className="border-b border-[var(--border-subtle)]">
<div className="w-full px-8 py-5">
<h1 className="v-title">{title}<span className="v-cursor">_</span></h1>
<p className="v-mono mt-1.5 text-[11px] text-[var(--text-tertiary)]">{description}</p>
</div>
<div className="w-full px-4 pt-6 sm:px-8">
<SPageHead title={title} titleAccent="_" sub={description} actions={actions} />
</div>
);
}
@@ -180,15 +178,17 @@ function StatCard({
};
return (
<div className="panel p-5">
<div className="flex items-start justify-between mb-3">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--surface-muted)] flex items-center justify-center">
<Icon size={18} className="text-[var(--text-tertiary)]" />
<div className="s-stat">
<div className="px-4 pb-1 pt-4">
<div className="flex items-center gap-2.5">
<span className="s-ibox"><Icon /></span>
<span className="text-[11.5px] font-medium text-[var(--text-secondary)]">{title}</span>
</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>
<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>
);
}
@@ -436,7 +436,7 @@ export function UsagePage() {
title="Usage"
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={`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)]'}`}>
@@ -450,14 +450,14 @@ export function UsagePage() {
<p className="mt-3 text-sm text-[var(--text-muted)]">Loading usage data...</p>
</div>
) : !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">
<AlertCircle size={24} className="text-[var(--error)]" />
</div>
<p className="text-sm text-[var(--error)]">Failed to load usage data</p>
</div>
) : !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>
</div>
) : (
@@ -490,14 +490,12 @@ export function UsagePage() {
/>
</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="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)]">
<Server size={18} className="text-[var(--accent-primary)]" />
</div>
<span className="s-ibox !w-9 !h-9"><Server /></span>
<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)]">
{host
? `${host.hostname} · ${host.os}/${host.architecture} · uptime ${formatUptime(host.uptimeSeconds)}${host.dockerAvailable ? '' : ' · Docker unavailable'}`
@@ -578,13 +576,11 @@ export function UsagePage() {
)}
</section>
<section className="panel p-6">
<section className="s-card p-5">
<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)]">
<Radio size={18} className="text-[var(--text-tertiary)]" />
</div>
<span className="s-ibox !w-9 !h-9"><Radio /></span>
<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>
</div>
</div>
@@ -678,8 +674,8 @@ export function UsagePage() {
<button
onClick={() => createTokenMutation.mutate()}
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"
style={{ background: 'var(--accent-primary)' }}
className="s-btn-accent h-8 disabled:opacity-50"
>
{createTokenMutation.isPending ? 'Issuing…' : 'Issue token'}
</button>
@@ -688,7 +684,7 @@ export function UsagePage() {
{issuedToken && (
<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">
<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
onClick={() => void navigator.clipboard.writeText(issuedToken.token ?? '')}
className="flex items-center gap-1 text-xs text-[var(--accent-primary)] hover:underline shrink-0"
@@ -706,11 +702,11 @@ export function UsagePage() {
</p>
)}
<pre className="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">{installCommand}</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)]">
<p>Agent endpoint: <span className="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>Agent endpoint: <span className="v-mono text-[var(--text-primary)]">{agentEndpoint}</span></p>
<p>Use an issued token above, or set <span className="v-mono text-[var(--text-primary)]">CONTAINR_AGENT_AUTH_TOKENS</span> on the backend.</p>
<p>Agent sends host resources, polls pending Docker commands, and reports command results.</p>
</div>
@@ -844,7 +840,7 @@ export function PeoplePage() {
title="People"
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 ? (
<div className="py-16 text-center">
<Loader2 size={24} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
@@ -876,19 +872,17 @@ export function PeoplePage() {
/>
</div>
<section className="panel p-6">
<section className="s-card p-5">
<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)]">
<UserPlus size={18} className="text-[var(--accent-primary)]" />
</div>
<span className="s-ibox !w-9 !h-9"><UserPlus /></span>
<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>
</div>
</div>
<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) => {
event.preventDefault();
createUserMutation.mutate();
@@ -921,8 +915,8 @@ export function PeoplePage() {
<button
type="submit"
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"
style={{ background: 'var(--accent-primary)' }}
className="s-btn-accent h-10 justify-center disabled:opacity-60"
>
{createUserMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : <UserPlus size={14} />}
Create
@@ -1057,14 +1051,12 @@ function GitProvidersSection() {
const selectedType = gitProviderTypes.find((t) => t.value === form.name)!;
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 gap-3">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<GitBranch size={18} className="text-[var(--accent-primary)]" />
</div>
<span className="s-ibox !w-9 !h-9"><GitBranch /></span>
<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>
</div>
</div>
@@ -1073,8 +1065,8 @@ function GitProvidersSection() {
<button
onClick={() => provisionMutation.mutate()}
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"
style={{ background: 'var(--accent-primary)' }}
className="s-btn-accent h-9 disabled:opacity-50"
>
<GitBranch size={14} />
{provisionMutation.isPending ? 'Opening GitHub…' : 'Set up GitHub App'}
@@ -1090,8 +1082,8 @@ function GitProvidersSection() {
)}
<button
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"
style={{ background: 'var(--accent-primary)' }}
className="s-btn-accent h-9"
>
<Link2 size={14} />
{formOpen ? 'Close' : 'Connect'}
@@ -1140,12 +1132,10 @@ function GitProvidersSection() {
{providers.map((provider) => (
<div
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="w-8 h-8 rounded-[var(--radius-sm)] bg-[var(--surface-card)] flex items-center justify-center">
<GitBranch size={14} className="text-[var(--text-tertiary)]" />
</div>
<span className="s-ibox"><GitBranch /></span>
<div>
<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>
@@ -1201,7 +1191,7 @@ function GitProvidersSection() {
type="password"
value={form.accessToken}
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="••••••••••••••••"
/>
<p className="text-xs text-[var(--text-muted)] mt-1.5">{selectedType.tokenHint}</p>
@@ -1214,7 +1204,7 @@ function GitProvidersSection() {
<input
value={form.apiUrl}
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"
/>
</div>
@@ -1228,8 +1218,8 @@ function GitProvidersSection() {
<button
onClick={() => createMutation.mutate()}
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"
style={{ background: 'var(--accent-primary)' }}
className="s-btn-accent h-9 disabled:opacity-50 disabled:cursor-not-allowed"
>
{createMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />}
Connect Provider
@@ -1274,7 +1264,7 @@ export function GitHubAppCallbackPage() {
{!code ? (
<>
<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)]">
GitHub did not return an app code. Start the setup again from Git providers.
</p>
@@ -1282,14 +1272,14 @@ export function GitHubAppCallbackPage() {
) : convertMutation.isError ? (
<>
<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)]">
{convertMutation.error instanceof Error ? convertMutation.error.message : 'Could not provision the GitHub App'}
</p>
<button
type="button"
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
</button>
@@ -1297,7 +1287,7 @@ export function GitHubAppCallbackPage() {
) : (
<>
<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>
</>
)}
@@ -1353,13 +1343,11 @@ function PlatformSettingsSection() {
};
return (
<section className="panel p-6">
<section className="s-card p-5">
<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">
<Cloud size={18} className="text-[var(--accent-primary)]" />
</div>
<span className="s-ibox !w-9 !h-9"><Cloud /></span>
<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>
</div>
</div>
@@ -1412,8 +1400,8 @@ function PlatformSettingsSection() {
<button
onClick={saveToken}
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"
style={{ background: 'var(--accent-primary)' }}
className="s-btn-accent h-9 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Save
</button>
@@ -1475,8 +1463,8 @@ function PlatformSettingsSection() {
saveMutation.mutate({ branding: b });
}}
disabled={saveMutation.isPending || Object.keys(brandDraft).length === 0}
className="h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
style={{ background: 'var(--accent-primary)' }}
className="s-btn-accent h-9 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Save branding
</button>
@@ -1548,13 +1536,11 @@ function UserTokensSection({ isAdmin }: { isAdmin: boolean }) {
];
return (
<section className="panel p-6">
<section className="s-card p-5">
<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">
<Key size={18} className="text-[var(--accent-primary)]" />
</div>
<span className="s-ibox !w-9 !h-9"><Key /></span>
<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>
</div>
</div>
@@ -1588,8 +1574,8 @@ function UserTokensSection({ isAdmin }: { isAdmin: boolean }) {
<button
onClick={() => createMutation.mutate()}
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"
style={{ background: 'var(--accent-primary)' }}
className="s-btn-accent h-9 disabled:opacity-50 disabled:cursor-not-allowed"
>
{createMutation.isPending ? 'Creating…' : 'Create token'}
</button>
@@ -1598,7 +1584,7 @@ function UserTokensSection({ isAdmin }: { isAdmin: boolean }) {
{issuedToken ? (
<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">
<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
onClick={() => void navigator.clipboard.writeText(issuedToken.token ?? '')}
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>
</div>
<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>
</div>
) : 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 className="min-w-0">
<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 text-[var(--text-muted)]">
{token.last_used_at ? `used ${formatRelative(token.last_used_at)}` : 'unused'}
@@ -1692,13 +1678,11 @@ function RegistriesSection() {
const registries = registriesQuery.data ?? [];
return (
<section className="panel p-6">
<section className="s-card p-5">
<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">
<Archive size={18} className="text-[var(--accent-primary)]" />
</div>
<span className="s-ibox !w-9 !h-9"><Archive /></span>
<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>
</div>
</div>
@@ -1733,8 +1717,8 @@ function RegistriesSection() {
<button
onClick={() => createMutation.mutate()}
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"
style={{ background: 'var(--accent-primary)' }}
className="s-btn-accent h-9 disabled:opacity-50 disabled:cursor-not-allowed"
>
{createMutation.isPending ? 'Adding…' : 'Add registry'}
</button>
@@ -1760,7 +1744,7 @@ function RegistriesSection() {
<div key={r.id} className="flex items-center justify-between gap-2 text-xs">
<div className="min-w-0">
<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}
<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'}
@@ -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';
return (
<section className="panel p-6">
<section className="s-card p-5">
<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">
<HardDrive size={18} className="text-[var(--accent-primary)]" />
</div>
<span className="s-ibox !w-9 !h-9"><HardDrive /></span>
<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>
</div>
</div>
@@ -1853,8 +1835,8 @@ function BackupTargetsSection() {
<button
onClick={() => createMutation.mutate()}
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"
style={{ background: 'var(--accent-primary)' }}
className="s-btn-accent h-9 disabled:opacity-50 disabled:cursor-not-allowed"
>
{createMutation.isPending ? 'Checking…' : 'Add target'}
</button>
@@ -1880,7 +1862,7 @@ function BackupTargetsSection() {
<div key={t.id} className="flex items-center justify-between gap-2 text-xs">
<div className="min-w-0">
<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)]">
{t.has_credentials ? 'keys set' : 'anonymous'}{t.use_tls ? '' : ' · no TLS'}
</span>
@@ -1961,13 +1943,11 @@ function NotificationChannelsSection() {
const channels = channelsQuery.data ?? [];
return (
<section className="panel p-6">
<section className="s-card p-5">
<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">
<Bell size={18} className="text-[var(--accent-primary)]" />
</div>
<span className="s-ibox !w-9 !h-9"><Bell /></span>
<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>
</div>
</div>
@@ -1998,8 +1978,8 @@ function NotificationChannelsSection() {
<button
onClick={() => createMutation.mutate()}
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"
style={{ background: 'var(--accent-primary)' }}
className="s-btn-accent h-9 disabled:opacity-50 disabled:cursor-not-allowed"
>
{createMutation.isPending ? 'Adding…' : 'Add channel'}
</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">
{ch.kind}
</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' ? (
<span className="ml-2 text-[var(--success)]">delivered</span>
) : testResult[ch.id] ? (
@@ -2145,16 +2125,14 @@ export function SettingsPage() {
title="Settings"
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">
{/* Profile Section */}
<section className="panel p-6">
<section className="s-card p-5">
<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">
<User size={18} className="text-[var(--accent-primary)]" />
</div>
<span className="s-ibox !w-9 !h-9"><User /></span>
<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>
</div>
</div>
@@ -2196,8 +2174,8 @@ export function SettingsPage() {
<button
onClick={() => updateProfileMutation.mutate()}
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"
style={{ background: 'var(--accent-primary)' }}
className="s-btn-accent h-9 disabled:opacity-50 disabled:cursor-not-allowed"
>
{updateProfileMutation.isPending ? (
<>
@@ -2248,13 +2226,11 @@ export function SettingsPage() {
<GitProvidersSection />
{/* 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="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<ScrollText size={18} className="text-[var(--accent-primary)]" />
</div>
<span className="s-ibox !w-9 !h-9"><ScrollText /></span>
<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>
</div>
</div>
@@ -2271,13 +2247,11 @@ export function SettingsPage() {
</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="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--surface-muted)] flex items-center justify-center">
<Settings size={18} className="text-[var(--text-tertiary)]" />
</div>
<span className="s-ibox !w-9 !h-9"><Settings /></span>
<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>
</div>
</div>
@@ -2288,7 +2262,7 @@ export function SettingsPage() {
<Key size={14} className="text-[var(--text-tertiary)]" />
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">API Base</span>
</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 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)]" />
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Auth Base</span>
</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 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>
<ul className="space-y-1">
{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}
</li>
))}
@@ -2376,7 +2350,7 @@ export function DocsPage() {
title="Docs"
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 />
</div>
</div>
@@ -2513,7 +2487,7 @@ export function DatabasesPage() {
title="Databases"
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">
<p className="text-xs text-[var(--text-tertiary)]">
{databases.length} {databases.length === 1 ? 'database' : 'databases'}
@@ -2522,7 +2496,7 @@ export function DatabasesPage() {
<button
type="button"
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
</button>
@@ -2540,7 +2514,7 @@ export function DatabasesPage() {
<button
type="button"
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
</button>
@@ -2574,7 +2548,7 @@ export function DatabasesPage() {
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<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' && (
<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>
<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">
<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 || '—'}
</code>
{db.connection_url && (
@@ -2617,7 +2591,7 @@ export function DatabasesPage() {
<button
onClick={() => testByIdMutation.mutate(db.id ?? '')}
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
</button>
@@ -2634,21 +2608,21 @@ export function DatabasesPage() {
<button
onClick={() => actionMutation.mutate({ id: db.id ?? '', action: 'start' })}
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
</button>
<button
onClick={() => actionMutation.mutate({ id: db.id ?? '', action: 'stop' })}
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
</button>
<button
onClick={() => actionMutation.mutate({ id: db.id ?? '', action: 'restart' })}
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
</button>
@@ -2665,7 +2639,7 @@ export function DatabasesPage() {
onClick={() => setBindDb(bindDb?.id === db.id ? null : db)}
disabled={!db.connection_url}
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
</button>
@@ -2697,7 +2671,7 @@ export function DatabasesPage() {
] 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">
<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>
@@ -2743,8 +2717,8 @@ export function DatabasesPage() {
<button
onClick={() => backupMutation.mutate(db.id ?? '')}
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"
style={{ background: 'var(--accent-primary)' }}
className="s-btn-accent disabled:opacity-40"
>
<Archive size={11} />
{backupMutation.isPending ? 'Starting…' : 'New backup'}
@@ -2764,7 +2738,7 @@ export function DatabasesPage() {
{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">
<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>
{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' && (
@@ -2994,7 +2968,7 @@ export function DatabasesPage() {
(createMode === 'managed' ? createMutation.isPending : registerExternalMutation.isPending) ||
(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' ? 'Create database' : 'Register database'}
@@ -3041,10 +3015,10 @@ function BindDatabasePanel({
const services = servicesQuery.data ?? [];
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">
<p className="text-xs font-medium text-[var(--text-primary)]">Bind to service</p>
<button onClick={onClose} className="text-xs text-[var(--text-muted)] hover:text-[var(--text-primary)]">Close</button>
<p className="s-t">Bind to service</p>
<button onClick={onClose} className="v-mono text-[11px] text-[var(--text-muted)] hover:text-[var(--text-primary)]">close</button>
</div>
<p className="text-xs text-[var(--text-muted)]">
Injects this database's connection URL as an environment variable on the selected service.
@@ -3082,13 +3056,13 @@ function BindDatabasePanel({
<input
value={varKey}
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>
<button
onClick={() => bindMutation.mutate()}
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
</button>
@@ -3129,7 +3103,7 @@ function BackupScheduleRow({
value={value}
onChange={(e) => setValue(e.target.value)}
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
onClick={() => mutation.mutate(value.trim())}
@@ -3251,7 +3225,7 @@ export function HighAvailabilityPage() {
title="High availability"
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)]">
<ShieldCheck size={13} className="text-[var(--accent-primary)]" />
<span>HA state feeds the platform security posture.</span>
@@ -3297,17 +3271,17 @@ export function HighAvailabilityPage() {
</div>
{isAdmin ? (
<div className="panel p-6">
<div className="s-card p-5">
<div className="flex items-center gap-3 mb-4">
<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 className="flex flex-wrap items-end gap-3">
<button
type="button"
disabled={toggleMutation.isPending || statusQuery.isLoading}
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'}
</button>
@@ -3338,10 +3312,10 @@ export function HighAvailabilityPage() {
</div>
) : null}
<div className="panel p-6">
<div className="s-card p-5">
<div className="flex items-center gap-3 mb-4">
<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>
{!isDemoMode && alertsQuery.isLoading ? (
<p className="text-sm text-[var(--text-secondary)]">Loading…</p>
@@ -3365,7 +3339,7 @@ export function HighAvailabilityPage() {
type="button"
disabled={resolveMutation.isPending}
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
</button>
@@ -3376,17 +3350,17 @@ export function HighAvailabilityPage() {
)}
</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 gap-3">
<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>
{policyForm === null && isAdmin && (
<button
type="button"
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
</button>
@@ -3480,14 +3454,14 @@ export function HighAvailabilityPage() {
type="button"
disabled={savePolicyMutation.isPending || !policyForm.serviceId}
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'}
</button>
<button
type="button"
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
</button>
@@ -3526,7 +3500,7 @@ export function HighAvailabilityPage() {
enabled: policy.enabled ?? 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
</button>
@@ -3553,10 +3527,10 @@ export function HighAvailabilityPage() {
)}
</div>
<div className="panel p-6">
<div className="s-card p-5">
<div className="flex items-center gap-3 mb-4">
<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>
{!isDemoMode && healthQuery.isLoading ? (
<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 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({
queryKey: ['services', projectId],
queryFn: () => listServicesByProject(projectId),
enabled: !isDemoMode && Boolean(projectId),
queryKey: ['services', effectiveProjectId],
queryFn: () => listServicesByProject(effectiveProjectId),
enabled: !isDemoMode && Boolean(effectiveProjectId),
});
const services = isDemoMode ? getDemoServicesByProject(projectId) : servicesQuery.data ?? [];
const services = isDemoMode ? getDemoServicesByProject(effectiveProjectId) : servicesQuery.data ?? [];
const metricsQuery = useQuery({
queryKey: ['security-metrics', projectId],
queryFn: () => getSecurityMetrics(projectId),
enabled: !isDemoMode && Boolean(projectId),
queryKey: ['security-metrics', effectiveProjectId],
queryFn: () => getSecurityMetrics(effectiveProjectId),
enabled: !isDemoMode && Boolean(effectiveProjectId),
refetchInterval: 15_000,
});
const historyQuery = useQuery({
queryKey: ['security-history', projectId],
queryFn: () => getSecurityHistory(projectId),
enabled: !isDemoMode && Boolean(projectId),
queryKey: ['security-history', effectiveProjectId],
queryFn: () => getSecurityHistory(effectiveProjectId),
enabled: !isDemoMode && Boolean(effectiveProjectId),
refetchInterval: 15_000,
});
const vulnsQuery = useQuery({
queryKey: ['security-vulns', projectId],
queryFn: () => listVulnerabilities(projectId),
enabled: !isDemoMode && Boolean(projectId),
queryKey: ['security-vulns', effectiveProjectId],
queryFn: () => listVulnerabilities(effectiveProjectId),
enabled: !isDemoMode && Boolean(effectiveProjectId),
});
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ['security-metrics', projectId] });
queryClient.invalidateQueries({ queryKey: ['security-history', projectId] });
queryClient.invalidateQueries({ queryKey: ['security-vulns', projectId] });
queryClient.invalidateQueries({ queryKey: ['security-metrics', effectiveProjectId] });
queryClient.invalidateQueries({ queryKey: ['security-history', effectiveProjectId] });
queryClient.invalidateQueries({ queryKey: ['security-vulns', effectiveProjectId] });
};
const scanMutation = useMutation({
mutationFn: () => startSecurityScan({
project_id: projectId,
project_id: effectiveProjectId,
service_id: scanServiceId || undefined,
scan_type: scanType as 'dependency' | 'configuration' | 'comprehensive',
}),
@@ -3661,10 +3639,10 @@ export function SecurityPage() {
const metrics = isDemoMode ? demoSecurityMetrics : metricsQuery.data;
const scanHistory = isDemoMode
? demoSecurityScans.filter((s) => s.project_id === projectId)
? demoSecurityScans.filter((s) => s.project_id === effectiveProjectId)
: historyQuery.data ?? [];
const vulns = (isDemoMode
? demoVulnerabilities.filter((v) => v.project_id === projectId)
? demoVulnerabilities.filter((v) => v.project_id === effectiveProjectId)
: vulnsQuery.data ?? []
).filter((v) => v.status !== 'resolved');
const pageError =
@@ -3677,7 +3655,7 @@ export function SecurityPage() {
title="Security"
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)]">
<HeartPulse size={13} className="text-[var(--accent-primary)]" />
<span>Platform availability and failover live under High availability.</span>
@@ -3695,7 +3673,7 @@ export function SecurityPage() {
<div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Project</label>
<select
value={projectId}
value={effectiveProjectId}
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"
>
@@ -3705,7 +3683,7 @@ export function SecurityPage() {
))}
</select>
</div>
{projectId && (
{effectiveProjectId && (
<>
<div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Scan type</label>
@@ -3737,7 +3715,7 @@ export function SecurityPage() {
type="button"
disabled={scanMutation.isPending}
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'}
</button>
@@ -3748,7 +3726,7 @@ export function SecurityPage() {
)}
</div>
{projectId && metrics && (
{effectiveProjectId && metrics && (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
title="Security score"
@@ -3779,11 +3757,11 @@ export function SecurityPage() {
</div>
)}
{projectId && (
<div className="panel p-6">
{effectiveProjectId && (
<div className="s-card p-5">
<div className="flex items-center gap-3 mb-4">
<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>
{!isDemoMode && vulnsQuery.isLoading ? (
<p className="text-sm text-[var(--text-secondary)]">Loading…</p>
@@ -3822,7 +3800,7 @@ export function SecurityPage() {
type="button"
disabled={vulnMutation.isPending}
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
</button>
@@ -3835,11 +3813,11 @@ export function SecurityPage() {
</div>
)}
{projectId && (
<div className="panel p-6">
{effectiveProjectId && (
<div className="s-card p-5">
<div className="flex items-center gap-3 mb-4">
<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>
{!isDemoMode && historyQuery.isLoading ? (
<p className="text-sm text-[var(--text-secondary)]">Loading…</p>
@@ -34,8 +34,7 @@ export function ComponentShowcase() {
};
return (
<div className="min-h-screen p-8" style={{ background: '#16171c' }}>
<div className="ambient-glow" />
<div className="min-h-screen p-8" style={{ background: 'var(--bg-void)' }}>
<div className="w-full relative z-10">
{/* Header */}
@@ -91,25 +90,25 @@ export function ComponentShowcase() {
Line Charts
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="panel p-6">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4">
<div className="s-card p-6">
<h3 className="s-t mb-4">
Basic Line Chart (CPU Style)
</h3>
<LineChart
data={sampleLineData}
color="#b4e34a"
color="#38bdf8"
height={120}
showDots={true}
smooth={true}
/>
</div>
<div className="panel p-6">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4">
<div className="s-card p-6">
<h3 className="s-t mb-4">
Line Chart (No Dots)
</h3>
<LineChart
data={sampleLineData}
color="#7ab8ff"
color="var(--accent-primary)"
height={120}
showDots={false}
smooth={true}
@@ -123,8 +122,8 @@ export function ComponentShowcase() {
Area Charts
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="panel p-6">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4">
<div className="s-card p-6">
<h3 className="s-t mb-4">
Area Chart (Active Users Style)
</h3>
<LineAreaChart
@@ -134,8 +133,8 @@ export function ComponentShowcase() {
height={150}
/>
</div>
<div className="panel p-6">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4">
<div className="s-card p-6">
<h3 className="s-t mb-4">
Area Chart (Success Color)
</h3>
<LineAreaChart
@@ -153,20 +152,20 @@ export function ComponentShowcase() {
Multi-Line & Bar Charts
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="panel p-6">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4">
<div className="s-card p-6">
<h3 className="s-t mb-4">
Multi-Line Chart (Performance Style)
</h3>
<MultiLineChart
datasets={[
{ data: sampleLineData.map(v => v + 10), color: '#7ab8ff', fillOpacity: 0.15 },
{ data: sampleLineData, color: '#f2c94c', fillOpacity: 0.15 }
{ data: sampleLineData.map(v => v + 10), color: 'var(--accent-primary)', fillOpacity: 0.15 },
{ data: sampleLineData, color: 'var(--warning)', fillOpacity: 0.15 }
]}
height={120}
/>
</div>
<div className="panel p-6">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4">
<div className="s-card p-6">
<h3 className="s-t mb-4">
Bar Chart (Timeline Style)
</h3>
<BarChart
@@ -184,23 +183,23 @@ export function ComponentShowcase() {
Donut Charts
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="panel p-6 flex flex-col items-center">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4">
<div className="s-card p-6 flex flex-col items-center">
<h3 className="s-t mb-4">
RAM Usage (65%)
</h3>
<DonutChart percentage={65} color="#f2c94c" size={160} />
<DonutChart percentage={65} color="var(--warning)" size={160} />
</div>
<div className="panel p-6 flex flex-col items-center">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4">
<div className="s-card p-6 flex flex-col items-center">
<h3 className="s-t mb-4">
Disk Usage (82%)
</h3>
<DonutChart percentage={82} color="#b4e34a" size={160} />
<DonutChart percentage={82} color="#38bdf8" size={160} />
</div>
<div className="panel p-6 flex flex-col items-center">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4">
<div className="s-card p-6 flex flex-col items-center">
<h3 className="s-t mb-4">
Network (45%)
</h3>
<DonutChart percentage={45} color="#7ab8ff" size={160} />
<DonutChart percentage={45} color="var(--accent-primary)" size={160} />
</div>
</div>
</section>
@@ -222,7 +221,7 @@ export function ComponentShowcase() {
status="good"
statusText="Good"
icon={<Cpu size={16} />}
chart={<LineChart data={sampleLineData} color="#b4e34a" height={76} />}
chart={<LineChart data={sampleLineData} color="#38bdf8" height={76} />}
onClick={() => handleCardClick('CPU Usage')}
animationDelay={0.04}
/>
@@ -233,7 +232,7 @@ export function ComponentShowcase() {
status="average"
statusText="Average"
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')}
animationDelay={0.09}
/>
@@ -276,7 +275,7 @@ export function ComponentShowcase() {
value="12.5K"
subtitle="Queries per minute"
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={
<div className="flex items-center gap-3 text-xs">
<div className="flex items-center gap-1.5">
@@ -313,8 +312,8 @@ export function ComponentShowcase() {
upSpeed={10.4}
downSpeed={5.2}
datasets={[
{ data: sampleLineData.map(v => v + 20), color: '#7ab8ff', fillOpacity: 0.15 },
{ data: sampleLineData.map(v => v + 10), color: '#f2c94c', fillOpacity: 0.15 }
{ data: sampleLineData.map(v => v + 20), color: 'var(--accent-primary)', fillOpacity: 0.15 },
{ data: sampleLineData.map(v => v + 10), color: 'var(--warning)', fillOpacity: 0.15 }
]}
onClick={() => handleCardClick('Performance')}
animationDelay={0.09}
@@ -331,7 +330,7 @@ export function ComponentShowcase() {
<h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4">
Badges & Indicators
</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">
<span className="badge-active">
<span className="live-dot" />
@@ -358,7 +357,7 @@ export function ComponentShowcase() {
<h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4">
Buttons
</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">
<button className="btn-stop">
<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">
Pills & Tabs
</h2>
<div className="panel p-6 space-y-6">
<div className="s-card p-6 space-y-6">
<div>
<p className="text-sm text-[var(--text-secondary)] mb-3">Pill Group</p>
<div className="pill-group">
@@ -420,9 +419,9 @@ export function ComponentShowcase() {
<h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4">
Search & Input
</h2>
<div className="panel p-6 space-y-4">
<div className="s-card p-6 space-y-4">
<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"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
@@ -435,7 +434,7 @@ export function ComponentShowcase() {
<h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4">
Toast Notifications
</h2>
<div className="panel p-6">
<div className="s-card p-6">
<div className="flex gap-3 flex-wrap">
<button
onClick={() => showToast('Operation completed successfully!', 'success')}
File diff suppressed because it is too large. Load diff
@@ -78,8 +78,8 @@ export function ServiceMetricsDashboard() {
userData,
perf: perfPercent,
perfData: [
{ data: perfData1, color: '#7ab8ff', fillOpacity: 0.15 },
{ data: perfData2, color: '#f2c94c', fillOpacity: 0.15 }
{ data: perfData1, color: 'var(--accent-primary)', fillOpacity: 0.15 },
{ data: perfData2, color: 'var(--warning)', fillOpacity: 0.15 }
],
upSpeed: seededRandom(`${serviceId}:upspeed`, 8, 13).toFixed(1),
downSpeed: seededRandom(`${serviceId}:downspeed`, 4, 7).toFixed(1),
@@ -107,9 +107,8 @@ export function ServiceMetricsDashboard() {
};
return (
<div className="min-h-screen" style={{ background: '#16171c' }}>
<div className="min-h-screen" style={{ background: 'var(--bg-void)' }}>
{/* Ambient glow */}
<div className="ambient-glow" />
<div className="flex min-h-screen relative">
{/* Sidebar - self.html exact match */}
@@ -117,8 +116,8 @@ export function ServiceMetricsDashboard() {
className="hidden md:flex shrink-0 flex-col items-center border-r"
style={{
width: '64px',
background: '#111217',
borderRight: '1px solid rgba(255,255,255,0.07)',
background: 'var(--bg-surface)',
borderRight: '1px solid var(--border-subtle)',
padding: '16px 0',
gap: '5px'
}}
@@ -169,10 +168,10 @@ export function ServiceMetricsDashboard() {
style={{
width: '34px',
height: '34px',
background: '#22233a',
background: 'var(--accent-primary-soft)',
fontSize: '11px',
fontWeight: 700,
color: '#9295a4',
color: 'var(--text-tertiary)',
marginTop: '4px',
letterSpacing: '-0.3px'
}}
@@ -188,8 +187,8 @@ export function ServiceMetricsDashboard() {
className="shrink-0 flex items-center"
style={{
height: '52px',
background: '#111217',
borderBottom: '1px solid rgba(255,255,255,0.07)',
background: 'var(--bg-surface)',
borderBottom: '1px solid var(--border-subtle)',
padding: '0 22px',
gap: '14px'
}}
@@ -201,22 +200,22 @@ export function ServiceMetricsDashboard() {
<div className="ml-auto flex items-center" style={{ gap: '8px' }}>
<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={{
height: '32px',
padding: '0 14px',
border: '1px solid rgba(255,255,255,0.1)',
border: '1px solid var(--border-default)',
fontSize: '13px'
}}
>
Support
</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={{
height: '32px',
padding: '0 14px',
background: 'rgba(255,255,255,0.08)',
background: 'var(--bg-overlay)',
fontSize: '13px',
gap: '6px'
}}
@@ -229,10 +228,10 @@ export function ServiceMetricsDashboard() {
style={{
width: '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>
</div>
</header>
@@ -245,22 +244,22 @@ export function ServiceMetricsDashboard() {
style={{
gap: '6px',
padding: '14px 0 10px',
color: '#6b6e7d',
color: 'var(--text-tertiary)',
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"/>
</svg>
<button
onClick={() => navigate(-1)}
style={{ color: '#6b6e7d' }}
className="hover:text-[#9295a4] transition-colors"
style={{ color: 'var(--text-tertiary)' }}
className="hover:text-[var(--text-tertiary)] transition-colors"
>
Servers
</button>
<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>
{/* Project Header */}
@@ -280,7 +279,7 @@ export function ServiceMetricsDashboard() {
</div>
<div>
<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
</span>
<span className={`badge-${isSystemRunning ? 'active' : 'stopped'}`}>
@@ -293,8 +292,8 @@ export function ServiceMetricsDashboard() {
href="https://nufest-dth.app"
target="_blank"
rel="noopener noreferrer"
className="flex items-center hover:text-[#9295a4] transition-colors"
style={{ color: '#6b6e7d', fontSize: '12.5px', textDecoration: 'none', gap: '4px' }}
className="flex items-center hover:text-[var(--text-tertiary)] transition-colors"
style={{ color: 'var(--text-tertiary)', fontSize: '12.5px', textDecoration: 'none', gap: '4px' }}
>
https://nufest-dth.app
<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>
</a>
<button
className="flex items-center hover:text-[#9295a4] transition-colors"
style={{ color: '#6b6e7d', fontSize: '12.5px', gap: '4px' }}
className="flex items-center hover:text-[var(--text-tertiary)] transition-colors"
style={{ color: 'var(--text-tertiary)', fontSize: '12.5px', gap: '4px' }}
>
Project Information
<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
className="flex"
style={{
borderBottom: '1px solid rgba(255,255,255,0.07)',
borderBottom: '1px solid var(--border-subtle)',
marginBottom: '18px'
}}
>
@@ -365,16 +364,16 @@ export function ServiceMetricsDashboard() {
{/* Metrics Header */}
<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' }}>
<button
className="rounded-[9px] border flex items-center font-medium cursor-pointer"
style={{
height: '32px',
padding: '0 12px',
border: '1px solid rgba(255,255,255,0.09)',
background: 'rgba(255,255,255,0.04)',
color: '#9295a4',
border: '1px solid var(--border-default)',
background: 'var(--bg-elevated)',
color: 'var(--text-tertiary)',
fontSize: '12.5px',
gap: '6px'
}}
@@ -408,7 +407,7 @@ export function ServiceMetricsDashboard() {
status="good"
statusText="Good"
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}
/>
@@ -418,23 +417,23 @@ export function ServiceMetricsDashboard() {
<div className="card-icon">
<MemoryStick size={16} />
</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 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}%
</div>
<div style={{ fontSize: 12, color: '#6b6e7d', marginTop: 4 }}>
<span style={{ color: '#f2c94c', fontWeight: 700 }}>Average</span> Daily usage
<div style={{ fontSize: 12, color: 'var(--text-tertiary)', marginTop: 4 }}>
<span style={{ color: 'var(--warning)', fontWeight: 700 }}>Average</span> Daily usage
</div>
<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={{ fontSize: 10.5, color: '#6b6e7d', marginBottom: 1 }}>Used</div>
<div style={{ fontSize: 12.5, fontWeight: 700, color: '#e8e9f0' }}>{metrics.ramUsedGB} GB / 8GB</div>
<div style={{ fontSize: 10.5, color: 'var(--text-tertiary)', marginBottom: 1 }}>Used</div>
<div style={{ fontSize: 12.5, fontWeight: 700, color: 'var(--text-primary)' }}>{metrics.ramUsedGB} GB / 8GB</div>
</div>
</div>
<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">
<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" />
@@ -9,6 +9,7 @@ import {
getSetupStatus,
updatePlatformSettings,
} from '@/lib/api-client';
import { GhostBtn, QuietBtn } from '@/shared/components/sentry';
// Marker the GitHub App callback reads so the wizard flow resumes here
// instead of the settings page.
@@ -39,7 +40,7 @@ export function SetupWizardPage() {
mutationFn: completeSetup,
onSuccess: () => {
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.
if (status && !status.needs_setup && !override) {
return <Navigate to="/projects" replace />;
return <Navigate to="/" replace />;
}
const steps: { key: Step; label: string }[] = [
@@ -92,9 +93,12 @@ export function SetupWizardPage() {
</div>
{step === 'account' ? (
<section className="panel p-6">
<h1 className="text-xl font-bold text-[var(--text-primary)]">Welcome to Containr</h1>
<p className="mt-2 text-sm text-[var(--text-secondary)]">
<section className="s-card">
<div className="s-cardhead mb-1">
<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
right after, so this account owns the instance.
</p>
@@ -105,8 +109,7 @@ export function SetupWizardPage() {
</p>
<Link
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)]"
style={{ background: 'var(--accent-primary)' }}
className="mt-4 s-btn-accent inline-flex items-center gap-2"
>
Sign in <ChevronRight size={14} />
</Link>
@@ -114,8 +117,7 @@ export function SetupWizardPage() {
) : !status?.has_users ? (
<Link
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)]"
style={{ background: 'var(--accent-primary)' }}
className="mt-4 s-btn-accent inline-flex items-center gap-2"
>
Create admin account <ChevronRight size={14} />
</Link>
@@ -127,9 +129,9 @@ export function SetupWizardPage() {
{step === 'tunnel' ? <TunnelStep onNext={() => setOverride('done')} /> : null}
{step === 'done' ? (
<section className="panel p-6 text-center">
<Rocket size={28} className="mx-auto text-[var(--accent-primary)]" />
<h1 className="mt-4 text-xl font-bold text-[var(--text-primary)]">All set</h1>
<section className="s-card text-center">
<div className="s-ibox mx-auto !h-10 !w-10"><ShieldCheck /></div>
<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)]">
Deploy your first service from a git repo, an image, or a template — the dashboard is
ready.
@@ -141,12 +143,10 @@ export function SetupWizardPage() {
: 'Failed to save setup state'}
</p>
) : null}
<button
type="button"
<GhostBtn
onClick={() => completeMutation.mutate()}
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"
style={{ background: 'var(--accent-primary)' }}
className="mt-5 !h-10 !px-6"
>
{completeMutation.isPending ? (
<Loader2 size={14} className="animate-spin" />
@@ -154,7 +154,7 @@ export function SetupWizardPage() {
<ShieldCheck size={15} />
)}
Enter Containr
</button>
</GhostBtn>
</section>
) : null}
</div>
@@ -185,12 +185,12 @@ function GitHubStep({ onNext }: { onNext: () => void }) {
});
return (
<section className="panel p-6">
<div className="flex items-center gap-3 mb-3">
<Github size={20} className="text-[var(--text-primary)]" />
<h1 className="text-xl font-bold text-[var(--text-primary)]">Connect a git provider</h1>
<section className="s-card">
<div className="s-cardhead mb-1">
<span className="s-ibox"><Github /></span>
<h1 className="s-t">Connect a Git Provider</h1>
</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
environments automatically. Self-hosted GitLab/Gitea can be added later in Settings.
</p>
@@ -201,24 +201,15 @@ function GitHubStep({ onNext }: { onNext: () => void }) {
: 'Could not start GitHub App provisioning'}
</p>
) : null}
<div className="mt-5 flex items-center gap-3">
<button
type="button"
<div className="mt-5 flex items-center gap-2">
<GhostBtn
onClick={() => provisionMutation.mutate()}
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
</button>
<button
type="button"
onClick={onNext}
className="h-9 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
>
Skip for now
</button>
</GhostBtn>
<QuietBtn onClick={onNext}>Skip for now</QuietBtn>
</div>
</section>
);
@@ -237,9 +228,12 @@ function TunnelStep({ onNext }: { onNext: () => void }) {
});
return (
<section className="panel p-6">
<h1 className="text-xl font-bold text-[var(--text-primary)]">Public access (optional)</h1>
<p className="mt-2 text-sm text-[var(--text-secondary)]">
<section className="s-card">
<div className="s-cardhead mb-1">
<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,
Containr stays reachable on this network — you can add it later in Settings.
</p>
@@ -248,31 +242,22 @@ function TunnelStep({ onNext }: { onNext: () => void }) {
value={token}
onChange={(e) => setToken(e.target.value)}
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 ? (
<p className="mt-3 text-xs text-[var(--error)]">
{saveMutation.error instanceof Error ? saveMutation.error.message : 'Failed to save token'}
</p>
) : null}
<div className="mt-5 flex items-center gap-3">
<button
type="button"
<div className="mt-5 flex items-center gap-2">
<GhostBtn
onClick={() => (token.trim() ? saveMutation.mutate() : onNext())}
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'}
</button>
<button
type="button"
onClick={onNext}
className="h-9 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
>
Skip
</button>
</GhostBtn>
<QuietBtn onClick={onNext}>Skip</QuietBtn>
</div>
</section>
);
@@ -17,6 +17,7 @@ import {
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { Database, GitBranch, Package, Timer, Trash2, X } from 'lucide-react';
import { GhostBtn, QuietBtn } from '@/shared/components/sentry';
import {
canvasFromConfig,
configFromCanvas,
@@ -57,7 +58,7 @@ function ServiceBuilderNodeView({ data, selected }: NodeProps<ServiceBuilderNode
</div>
<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="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 || '—'}
</span>
</div>
@@ -120,8 +121,8 @@ function BuilderInner({
id: `${dep}->${svc.key}`,
source: dep,
target: svc.key,
markerEnd: { type: MarkerType.ArrowClosed, width: 14, height: 14, color: 'rgba(180,227,74,0.55)' },
style: { stroke: 'rgba(180,227,74,0.42)', strokeWidth: 1.6 },
markerEnd: { type: MarkerType.ArrowClosed, width: 14, height: 14, color: 'rgba(56, 189, 248,0.55)' },
style: { stroke: 'rgba(56, 189, 248,0.42)', strokeWidth: 1.6 },
className: 'edge-premium',
})),
),
@@ -239,7 +240,7 @@ function BuilderInner({
key={t}
type="button"
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} />
{meta.label}
@@ -263,7 +264,7 @@ function BuilderInner({
deleteKeyCode={['Backspace', 'Delete']}
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>
<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
@@ -295,21 +296,16 @@ function BuilderInner({
</div>
) : null}
<div className="flex gap-2">
<button
type="button"
onClick={onCancel}
className="flex-1 rounded-[var(--radius-md)] px-3 py-2 text-xs font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-muted)]"
>
<QuietBtn onClick={onCancel} className="flex-1 justify-center">
Back to JSON
</button>
<button
type="button"
</QuietBtn>
<GhostBtn
disabled={errors.length > 0 || services.size === 0}
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
</button>
</GhostBtn>
</div>
</div>
</div>
@@ -409,7 +405,7 @@ function Inspector({
<label className={`${LABEL_CLS} mt-3`}>Environment (KEY=value, one per line)</label>
<textarea
className={`${FIELD_CLS} mono min-h-[70px] resize-y`}
className={`${FIELD_CLS} v-mono min-h-[70px] resize-y`}
defaultValue={envText}
spellCheck={false}
onBlur={(e) => {
@@ -425,7 +421,7 @@ function Inspector({
<label className={`${LABEL_CLS} mt-3`}>Volumes (source:target[:ro], one per line)</label>
<textarea
className={`${FIELD_CLS} mono min-h-[50px] resize-y`}
className={`${FIELD_CLS} v-mono min-h-[50px] resize-y`}
defaultValue={volText}
spellCheck={false}
onBlur={(e) => {
@@ -18,6 +18,7 @@ import {
import { useAuthSession } from '@/lib/use-auth-session';
import { useDemoMode } from '@/lib/demo-mode';
import { demoProjects, demoTemplates, demoTemplateDetails } from '@/lib/demo-data';
import { GhostBtn, QuietBtn, SPageHead } from '@/shared/components/sentry';
import {
Search,
Filter,
@@ -489,67 +490,51 @@ export function TemplatesPage() {
return (
<div className="min-h-screen">
{/* Header */}
<div className="border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/50 backdrop-blur-sm">
<div className="w-full px-8 py-4">
<div className="flex items-center justify-between gap-4">
<div>
<h1 className="v-title">Template Catalog<span className="v-cursor">_</span></h1>
<p className="text-sm text-[var(--text-secondary)]">Deploy services from pre-configured templates</p>
</div>
<div className="flex items-center gap-3 text-sm text-[var(--text-tertiary)]">
<Layers size={16} />
<span>{filteredTemplates.length} templates</span>
{signedIn && !isDemoMode ? (
<div className="w-full px-4 pt-6 sm:px-8">
<SPageHead
title="Template Catalog"
titleAccent="_"
sub={`${filteredTemplates.length} templates · deploy services from pre-configured stacks`}
actions={signedIn && !isDemoMode ? (
<>
<button
type="button"
<QuietBtn
onClick={() => {
setComposeYaml('');
setComposeWarnings([]);
setComposeError(null);
setComposeOpen(true);
}}
className="inline-flex h-9 items-center gap-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-3.5 text-[12.5px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
>
<FileCode size={14} /> Import Compose
</button>
<button
type="button"
onClick={openCreateEditor}
className="inline-flex h-9 items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-3.5 text-[12.5px] font-semibold text-[var(--accent-on)]"
>
<Plus size={14} /> New template
</button>
<FileCode size={13} /> Import Compose
</QuietBtn>
<GhostBtn onClick={openCreateEditor}>
<Plus size={13} /> New template
</GhostBtn>
</>
) : null}
</div>
</div>
</div>
) : undefined}
/>
</div>
{/* Demo Mode Banner */}
{isDemoMode && (
<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="flex items-center gap-2 text-sm text-[var(--warning)]">
<Sparkles size={16} />
<span>Demo mode active — using sample data</span>
</div>
<div className="s-inset flex items-center gap-2 text-xs text-[var(--warning)]">
<Sparkles size={13} />
Demo mode active — using sample data
</div>
</div>
)}
{/* 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">
{/* Template List */}
<section className="panel overflow-hidden">
<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>
<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="space-y-3">
<div>
<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) => {
const selected = template.id === selectedTemplateId;
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 (
<button
key={template.id}
onClick={() => selectTemplate(template.id)}
className={`w-full p-4 rounded-[var(--radius-lg)] border text-left transition-all duration-300 group ${
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'
}`}
>
<div className="flex items-start gap-3">
<div
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={{
background: selected ? categoryColor : `${categoryColor}20`,
@@ -691,12 +676,10 @@ export function TemplatesPage() {
</section>
{/* Template Detail */}
<section className="panel p-6">
<section className="s-card p-5">
{!selectedTemplateId ? (
<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">
<Layers size={28} className="text-[var(--text-tertiary)]" />
</div>
<div className="s-ibox mx-auto mb-4 !w-14 !h-14"><Layers size={22} /></div>
<p className="text-sm text-[var(--text-muted)]">Select a template to view details and deploy</p>
</div>
) : null}
@@ -722,15 +705,15 @@ export function TemplatesPage() {
{/* Header */}
<div className="flex items-start justify-between gap-4 mb-6">
<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);
return <Icon size={24} className="text-[var(--accent-primary)]" />;
return <Icon size={24} />;
})()}
</div>
<div>
<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 && (
<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)]" />
@@ -738,7 +721,7 @@ export function TemplatesPage() {
</span>
)}
{!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
</span>
)}
@@ -747,10 +730,10 @@ export function TemplatesPage() {
</div>
</div>
<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)}
</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'}
</span>
{canManageTemplate(selectedDetail.template) ? (
@@ -777,25 +760,25 @@ export function TemplatesPage() {
{/* Config Grid */}
<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="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 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="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 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="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 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="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 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-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.
@@ -808,7 +791,7 @@ export function TemplatesPage() {
</div>
{isGraphTemplate ? (
<div className="panel-soft p-4 mb-6">
<div className="s-inset mb-6">
<div className="flex items-center gap-2 mb-3">
<Network size={14} className="text-[var(--accent-primary)]" />
<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" />
)}
<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 ? (
<span className="text-[10px] text-[var(--text-tertiary)]">
after {member.dependsOn.join(', ')}
</span>
) : null}
</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 || '—'}
</span>
</div>
@@ -845,26 +828,28 @@ export function TemplatesPage() {
{/* Deploy Section */}
<div className="border-t border-[var(--border-subtle)] pt-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--success-soft)] flex items-center justify-center">
<Play size={18} className="text-[var(--success)]" />
</div>
<div className="s-cardhead !mb-4">
<span className="s-ibox"><Play /></span>
<div>
<h3 className="text-lg font-semibold text-[var(--text-primary)]">Deploy from Template</h3>
<p className="text-sm text-[var(--text-secondary)]">
<span className="s-t">Deploy from Template</span>
<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'}
</p>
</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">
<p className="text-sm text-[var(--text-secondary)]">
Sign in to deploy this template into one of your projects.
</p>
<Link
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} />
</Link>
@@ -970,7 +955,7 @@ export function TemplatesPage() {
<span>
{lastDeployment.created
? `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>
<Link
to={lastDeployment.created
@@ -986,7 +971,7 @@ export function TemplatesPage() {
{lastDeployment.created.map((m) => (
<div key={m.id} className="flex items-center gap-2 text-xs">
{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>
</div>
))}
@@ -999,8 +984,8 @@ export function TemplatesPage() {
<button
onClick={() => deployMutation.mutate()}
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"
style={{ background: 'var(--accent-primary)' }}
className="s-btn-accent h-11 disabled:opacity-50 disabled:cursor-not-allowed"
>
{deployMutation.isPending ? (
<>
@@ -1133,7 +1118,7 @@ export function TemplatesPage() {
value={editorJson}
onChange={(e) => setEditorJson(e.target.value)}
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 ? (
@@ -1171,7 +1156,7 @@ export function TemplatesPage() {
type="button"
onClick={submitEditor}
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}
{editingTemplate ? 'Save changes' : 'Create template'}
@@ -1223,7 +1208,7 @@ export function TemplatesPage() {
onChange={(e) => setComposeYaml(e.target.value)}
spellCheck={false}
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">
@@ -1234,7 +1219,7 @@ export function TemplatesPage() {
onChange={(e) => setGitRepo(e.target.value)}
spellCheck={false}
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 className="text-xs text-[var(--text-tertiary)]">
@@ -1244,7 +1229,7 @@ export function TemplatesPage() {
onChange={(e) => setGitPath(e.target.value)}
spellCheck={false}
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 className="text-xs text-[var(--text-tertiary)]">
@@ -1254,7 +1239,7 @@ export function TemplatesPage() {
onChange={(e) => setGitRef(e.target.value)}
spellCheck={false}
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>
<p className="text-[11px] text-[var(--text-tertiary)]">
@@ -1279,7 +1264,7 @@ export function TemplatesPage() {
type="button"
onClick={() => composeImportMutation.mutate()}
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} />}
Convert to template
@@ -8,11 +8,12 @@ import {
testWebhook,
updateWebhook,
type OutboundWebhook,
type WebhookDelivery,
} from '@/lib/api-client';
import { useDemoMode } from '@/lib/demo-mode';
import { formatRelative } from '@/lib/time';
import { DemoRestricted, useToast } from '@/shared/components';
import { GhostBtn, QuietBtn, SPageHead, SPill } from '@/shared/components/sentry';
import { statusTone } from '@/shared/components/sentry-utils';
import {
Webhook,
Plus,
@@ -30,17 +31,6 @@ const inputClass =
const eventSuggestions = ['*', 'service.*', 'project.*', 'database.*', 'deployment.*', 'template.*'];
function statusClass(status: WebhookDelivery['status']): string {
switch (status) {
case 'success':
return 'text-[var(--success)]';
case 'failed':
return 'text-[var(--error)]';
default:
return 'text-[var(--warning)]';
}
}
function WebhookRow({
webhook,
onDelete,
@@ -63,59 +53,50 @@ function WebhookRow({
});
return (
<div className="panel p-4">
<div className="flex items-center gap-3">
<button onClick={() => setExpanded((v) => !v)} className="text-[var(--text-tertiary)]">
{expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</button>
<div className="s-card !p-0">
<div className="flex items-center gap-3 px-4 py-3">
<span className="s-ibox shrink-0"><Webhook /></span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-[var(--text-primary)]">{webhook.name}</span>
{!webhook.enabled && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--surface-muted)] text-[var(--text-muted)]">
disabled
</span>
)}
{!webhook.enabled && <SPill tone="off">disabled</SPill>}
</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 className="hidden md:flex gap-1">
<div className="hidden md:flex gap-1.5">
{webhook.events.map((ev) => (
<span
key={ev}
className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--accent-primary-soft)] text-[var(--accent-primary)] mono"
>
{ev}
</span>
<span key={ev} className="s-chip">{ev}</span>
))}
</div>
<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
onClick={onTest}
disabled={testing || !webhook.enabled}
className="p-2 rounded-[var(--radius-md)] text-[var(--text-tertiary)] hover:text-[var(--accent-primary)] disabled:opacity-40 transition-colors"
className="s-icon-btn disabled:opacity-40"
title="Send test ping"
>
{testing ? <Loader2 size={15} className="animate-spin" /> : <Send size={15} />}
</button>
<button
onClick={onToggle}
className="p-2 rounded-[var(--radius-md)] text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-colors text-xs"
>
{webhook.enabled ? 'Disable' : 'Enable'}
{testing ? <Loader2 size={13} className="animate-spin" /> : <Send size={13} />}
</button>
<QuietBtn onClick={onToggle}>{webhook.enabled ? 'Disable' : 'Enable'}</QuietBtn>
<button
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"
>
<Trash2 size={15} />
<Trash2 size={13} />
</button>
</div>
</div>
{expanded && (
<div className="mt-3 border-t border-[var(--border-subtle)] pt-3">
<p className="text-[11px] uppercase tracking-wider text-[var(--text-muted)] mb-2">
<div className="border-t border-[var(--border-subtle)] bg-[var(--surface-muted)]/40 px-4 py-3">
<p className="v-mono text-[10px] uppercase tracking-[0.12em] text-[var(--text-muted)] mb-2">
Recent deliveries
</p>
{deliveriesQuery.isLoading ? (
@@ -123,10 +104,10 @@ function WebhookRow({
) : (deliveriesQuery.data ?? []).length === 0 ? (
<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) => (
<div key={d.id} className="flex items-center gap-3 text-xs mono">
<span className={statusClass(d.status)}>{d.status}</span>
<div key={d.id} className="flex items-center gap-3 text-[11px] v-mono">
<SPill tone={statusTone(d.status)}>{d.status}</SPill>
<span className="text-[var(--text-secondary)]">{d.event}</span>
{d.response_status != null && (
<span className="text-[var(--text-muted)]">HTTP {d.response_status}</span>
@@ -207,46 +188,41 @@ export function WebhooksPage() {
return (
<div className="min-h-screen">
<div className="border-b border-[var(--border-subtle)]">
<div className="w-full px-8 py-5 flex items-center justify-between">
<div>
<h1 className="v-title">Webhooks<span className="v-cursor">_</span></h1>
<p className="v-mono mt-1.5 text-[11px] text-[var(--text-tertiary)]">
Signed HTTP delivery for every platform event — deploys, restarts, deletes
</p>
</div>
<button
onClick={() => setCreating(true)}
className="flex items-center gap-1.5 h-9 px-4 rounded-[var(--radius-md)] text-sm font-semibold text-[var(--accent-on)]"
style={{ background: 'var(--accent-primary)' }}
>
<Plus size={15} />
New webhook
</button>
</div>
<div className="w-full px-4 pt-6 sm:px-8">
<SPageHead
title="Webhooks"
titleAccent="_"
sub="Signed HTTP delivery for every platform event — deploys, restarts, deletes"
actions={
<GhostBtn onClick={() => setCreating(true)}>
<Plus size={13} /> New webhook
</GhostBtn>
}
/>
</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 && (
<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">
<p className="text-sm font-medium text-[var(--text-primary)]">Signing secret — shown once</p>
<p className="text-xs text-[var(--text-muted)]">
Verify deliveries with <span className="mono">X-Containr-Signature</span>
<p className="v-mono text-[11px] text-[var(--text-muted)]">
Verify deliveries with <span className="v-mono">X-Containr-Signature</span>
</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>
<button
onClick={() => {
navigator.clipboard?.writeText(secret);
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 onClick={() => setSecret(null)} className="p-2 text-[var(--text-tertiary)]">
<X size={15} />
<button onClick={() => setSecret(null)} className="s-icon-btn">
<X size={13} />
</button>
</div>
)}
@@ -254,12 +230,12 @@ export function WebhooksPage() {
{webhooksQuery.isLoading ? (
<Loader2 size={18} className="animate-spin text-[var(--text-muted)]" />
) : (webhooksQuery.data ?? []).length === 0 ? (
<div className="panel p-8 text-center">
<Webhook size={28} className="mx-auto text-[var(--text-muted)] mb-3" />
<div className="s-card py-10 text-center">
<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-xs text-[var(--text-muted)] mt-1">
Subscribe an HTTP endpoint to platform events like <span className="mono">service.*</span> or{' '}
<span className="mono">deployment.fail</span>.
<p className="v-mono text-[11px] text-[var(--text-muted)] mt-1">
Subscribe an HTTP endpoint to platform events like <span className="v-mono">service.*</span> or{' '}
<span className="v-mono">deployment.fail</span>.
</p>
</div>
) : (
@@ -283,8 +259,11 @@ export function WebhooksPage() {
{creating && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-[var(--bg-void)]/80 backdrop-blur-sm" onClick={() => setCreating(false)} />
<div className="relative w-full max-w-md panel p-6">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">New webhook</h3>
<div className="relative w-full max-w-md s-card">
<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>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
@@ -304,7 +283,7 @@ export function WebhooksPage() {
<input
value={form.url}
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"
/>
</div>
@@ -317,30 +296,21 @@ export function WebhooksPage() {
<button
key={ev}
onClick={() => toggleEvent(ev)}
className={`px-2.5 py-1 rounded-[var(--radius-md)] border text-xs mono transition-colors ${
form.events.includes(ev)
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]'
: 'border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-tertiary)]'
}`}
className={`s-chip v-mono ${form.events.includes(ev) ? 'on' : ''}`}
>
{ev}
</button>
))}
</div>
<p className="mt-2 text-[11px] text-[var(--text-muted)]">
Events are <span className="mono">resource.action</span> — wildcards like{' '}
<span className="mono">service.*</span> match the whole resource.
<p className="mt-2 v-mono text-[10.5px] text-[var(--text-muted)]">
Events are <span className="v-mono">resource.action</span> — wildcards like{' '}
<span className="v-mono">service.*</span> match the whole resource.
</p>
</div>
</div>
<div className="mt-6 flex justify-end gap-2">
<button
onClick={() => setCreating(false)}
className="h-10 px-4 rounded-[var(--radius-md)] text-sm text-[var(--text-secondary)]"
>
Cancel
</button>
<button
<QuietBtn onClick={() => setCreating(false)}>Cancel</QuietBtn>
<GhostBtn
onClick={() => createMutation.mutate()}
disabled={
createMutation.isPending ||
@@ -348,12 +318,10 @@ export function WebhooksPage() {
!form.url.trim() ||
form.events.length === 0
}
className="h-10 px-5 rounded-[var(--radius-md)] text-sm font-semibold text-[var(--accent-on)] disabled:opacity-50 flex items-center gap-2"
style={{ background: 'var(--accent-primary)' }}
>
{createMutation.isPending && <Loader2 size={14} className="animate-spin" />}
{createMutation.isPending && <Loader2 size={13} className="animate-spin" />}
Create
</button>
</GhostBtn>
</div>
</div>
</div>
@@ -167,12 +167,12 @@ function toFlowEdges(
},
markerEnd: {
type: MarkerType.ArrowClosed,
color: 'rgba(180, 227, 74, 0.55)',
color: 'color-mix(in srgb, var(--accent-primary) 55%, transparent)',
width: 13,
height: 13,
},
style: {
stroke: 'rgba(180, 227, 74, 0.42)',
stroke: 'color-mix(in srgb, var(--accent-primary) 42%, transparent)',
strokeWidth: 1.6,
},
className: 'edge-premium',
@@ -198,7 +198,7 @@ function ZoomControls({ onTidy }: { onTidy: () => void }) {
<button
type="button"
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)"
>
{Math.round(zoom * 100)}%
@@ -873,7 +873,7 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
const minimapNodeColor = useCallback(
(node: CanvasNode) => {
if (node.type === 'groupNode') {
return 'rgba(255,255,255,0.08)';
return 'var(--tint-08)';
}
return serviceAccent((node.data as ServiceNodeData).service);
},
@@ -922,14 +922,14 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
selectionKeyCode="Shift"
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
position="bottom-right"
pannable
zoomable
nodeColor={minimapNodeColor}
bgColor="rgba(14, 15, 18, 0.92)"
maskColor="rgba(12, 13, 15, 0.72)"
bgColor="var(--surface-glass)"
maskColor="var(--surface-glass)"
className="canvas-minimap hidden md:block"
/>
</ReactFlow>
@@ -937,21 +937,19 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
{/* Floating: env filter + group (top-left) */}
<div className="absolute left-4 top-4 flex items-center gap-2">
{environments.length > 1 && (
<div className="canvas-pill flex items-center gap-0.5 p-1">
<div className="canvas-pill !rounded-[9px] p-1">
<span className="s-seg">
{['all', ...environments].map((env) => (
<button
key={env}
type="button"
onClick={() => setEnvFilter(env)}
className={`px-2.5 py-1 rounded-full text-[11px] font-medium capitalize transition-all ${
envFilter === env
? 'bg-[var(--surface-card)] text-[var(--text-primary)] shadow-sm'
: 'text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]'
}`}
className={`!w-auto !h-auto px-2.5 py-1 capitalize v-mono text-[10.5px] ${envFilter === env ? 'on' : ''}`}
>
{env}
</button>
))}
</span>
</div>
)}
{!readOnly && (
@@ -1018,12 +1016,12 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
{/* Empty state */}
{services.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="text-center pointer-events-auto">
<div className="w-14 h-14 mx-auto mb-4 rounded-2xl bg-[var(--surface-card)] border border-[var(--border-subtle)] flex items-center justify-center">
<Rocket size={24} className="text-[var(--accent-primary)]" />
<div className="text-center pointer-events-auto s-card !py-8 !px-10">
<div className="s-ibox mx-auto mb-4 !h-11 !w-11">
<Rocket />
</div>
<h3 className="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">
<h3 className="font-headline text-base font-semibold text-[var(--text-primary)]">Deploy your first service</h3>
<p className="v-mono text-[11.5px] text-[var(--text-tertiary)] mt-1.5 mb-5 max-w-xs">
Add a service from a git repo or Docker image - it runs on a private network with the rest of this
project.
</p>
@@ -1031,10 +1029,9 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
<button
type="button"
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"
style={{ background: 'var(--accent-primary)' }}
className="s-btn-accent inline-flex items-center gap-2"
>
<Plus size={15} />
<Plus size={13} />
Add Service
</button>
)}
@@ -1,6 +1,7 @@
import type { ServiceEntity } from '@/lib/api-client';
import { serviceStatusClass } from '@/lib/api-client';
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 { serviceAccent } from './service-visuals';
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 }) {
return (
<div>
<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}
</div>
{children}
@@ -163,48 +147,49 @@ export function ServiceInspector({
return (
<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="w-9 h-9 rounded-[var(--radius-md)] flex items-center justify-center flex-shrink-0"
style={{ background: `${accent}1c`, color: accent }}
<div className="s-cardhead border-b border-[var(--border-subtle)]">
<span
className="s-ibox flex-shrink-0"
style={{ background: `${accent}14`, color: accent, borderColor: `${accent}33` }}
>
<ServiceIcon service={service} size={16} />
</div>
<ServiceIcon service={service} size={15} />
</span>
<div className="min-w-0 flex-1">
<h3 className="text-sm font-semibold text-[var(--text-primary)] truncate">{service.name}</h3>
<div className="flex items-center gap-1.5 text-[11px] text-[var(--text-tertiary)]">
<span className={`status-dot ${serviceStatusClass(service.status)}`} />
<span style={{ color: statusColor(service.status) }}>{statusLabel(service.status)}</span>
<span>· {service.type}</span>
{service.environment && service.environment !== 'production' && <span>· {service.environment}</span>}
<h3 className="text-[13px] font-semibold text-[var(--text-primary)] truncate leading-tight">{service.name}</h3>
<div className="flex items-center gap-1.5 mt-1">
<SPill tone={statusTone(service.status)}>{statusLabel(service.status)}</SPill>
<span className="v-mono text-[10px] text-[var(--text-tertiary)]">
{service.type}
{service.environment && service.environment !== 'production' ? ` · ${service.environment}` : ''}
</span>
</div>
</div>
<button
type="button"
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)"
>
<X size={14} />
<X size={12} />
</button>
</div>
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-4">
{(publicUrl || service.port) && (
<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" />
{publicUrl ? (
<a
href={publicUrl}
target="_blank"
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?:\/\//, '')}
</a>
) : (
<span className="mono text-[11px] text-[var(--text-secondary)]">
<span className="v-mono text-[11px] text-[var(--text-secondary)]">
{service.name}:{service.port}
</span>
)}
@@ -218,7 +203,7 @@ export function ServiceInspector({
</button>
</div>
{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}
</p>
) : null}
@@ -266,13 +251,13 @@ export function ServiceInspector({
{variables.slice(0, 6).map((variable) => (
<div
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" />
<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}
</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}
</span>
</div>
@@ -299,7 +284,7 @@ export function ServiceInspector({
{visibleConfig.map(([label, value]) => (
<div key={label} className="flex items-baseline gap-2">
<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}
</dd>
</div>
@@ -307,7 +292,7 @@ export function ServiceInspector({
{(service.replicas ?? 0) > 0 && (
<div className="flex items-baseline gap-2">
<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>
)}
</dl>
@@ -318,7 +303,7 @@ export function ServiceInspector({
{connections.length === 0 ? (
<p className="text-[11px] text-[var(--text-muted)]">
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>
) : (
<div className="space-y-1.5">
@@ -327,13 +312,13 @@ export function ServiceInspector({
key={conn.id}
type="button"
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)]">
<Link2 size={10} className="text-[var(--accent-primary)] flex-shrink-0" />
<span className="truncate font-medium">{conn.outbound ? `→ ${conn.peer}` : `← ${conn.peer}`}</span>
</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(' · ')}
</p>
</button>
@@ -350,65 +335,58 @@ export function ServiceInspector({
</div>
<div className="border-t border-[var(--border-subtle)] px-4 py-3 space-y-2">
<button
type="button"
<GhostBtn
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"
style={{ background: 'var(--accent-primary)' }}
className="w-full justify-center !h-9"
>
<ArrowUpRight size={14} />
Open service
</button>
</GhostBtn>
<div className="grid grid-cols-3 gap-1.5">
<button
type="button"
<QuietBtn
disabled={actions.pending || readOnly}
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} />}
Deploy
</button>
</QuietBtn>
{service.status !== 'running' ? (
<button
type="button"
<QuietBtn
disabled={actions.pending || readOnly}
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} />
Start
</button>
</QuietBtn>
) : (
<button
type="button"
<QuietBtn
disabled={actions.pending || readOnly}
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} />}
Restart
</button>
</QuietBtn>
)}
<button
type="button"
<QuietBtn
disabled={actions.pending || readOnly || service.status !== 'running'}
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} />
Stop
</button>
</QuietBtn>
</div>
<button
type="button"
<QuietBtn
disabled={actions.pending || readOnly}
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} />
Delete service
</button>
</QuietBtn>
</div>
</aside>
);
@@ -1,10 +1,11 @@
import { Handle, Position, type Node, type NodeProps } from '@xyflow/react';
import type { ServiceEntity } from '@/lib/api-client';
import { serviceStatusClass } from '@/lib/api-client';
import { Box, ExternalLink, Copy, Layers, Loader2 } from 'lucide-react';
import { useState } from 'react';
import { serviceIcon, serviceAccent } from './service-visuals';
import { ServiceIcon } from './ServiceIcon';
import { SPill } from '@/shared/components/sentry';
import { statusTone } from '@/shared/components/sentry-utils';
export type ServiceNodeData = {
service: ServiceEntity;
@@ -65,10 +66,10 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
return (
<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
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] shadow-[0_0_0_1px_var(--accent-primary),0_12px_36px_rgba(0,0,0,0.5)]'
: 'border-[var(--border-subtle)] bg-[var(--surface-card)] hover:border-[var(--border-default)] hover:shadow-[0_12px_32px_rgba(0,0,0,0.45)] hover:-translate-y-px'
? '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)]'
}`}
>
<div
@@ -86,39 +87,39 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
style={{ background: stateColor }}
/>
<div className="relative px-3.5 pt-3.5 pb-3">
<div className="flex items-center gap-3 mb-2.5">
<div className="relative">
<div className="flex items-center gap-2.5 px-3.5 pt-3 pb-2.5">
<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={{
background: selected && !brand ? typeColor : `${typeColor}1c`,
background: selected && !brand ? typeColor : `${typeColor}14`,
color: selected && !brand ? 'var(--bg-void)' : typeColor,
}}
>
{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 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}
</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.environment && service.environment !== 'production' ? ` · ${service.environment}` : ''}
</p>
</div>
{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} />
{replicas}
</span>
)}
</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" />
{publicUrl ? (
<a
@@ -126,12 +127,12 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
target="_blank"
rel="noopener noreferrer"
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?:\/\//, '')}
</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}
</span>
)}
@@ -148,15 +149,10 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
</button>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<span className={`status-dot ${serviceStatusClass(service.status)}`} />
<span className="text-[10px] font-semibold tracking-[0.08em] uppercase" style={{ color: stateColor }}>
{statusLabel(service.status)}
</span>
</div>
<div className="flex items-center justify-between px-3.5 py-2 border-t border-[var(--border-subtle)]">
<SPill tone={statusTone(service.status)}>{statusLabel(service.status)}</SPill>
{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}
</span>
)}
@@ -191,25 +187,25 @@ export function GroupNode({ id, data }: NodeProps<GroupNodeType>) {
return (
<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={{
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)}
onMouseLeave={() => setIsHovered(false)}
>
<div className="relative p-4">
<div className="flex items-center gap-2.5">
<div
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors duration-200"
<span
className="s-ibox !h-7 !w-7 transition-colors duration-200"
style={{
background: isHovered ? 'var(--accent-primary-soft)' : 'var(--surface-card)',
color: isHovered ? 'var(--accent-primary)' : 'var(--text-tertiary)',
background: isHovered ? 'var(--accent-primary-soft)' : undefined,
color: isHovered ? 'var(--accent-primary)' : undefined,
}}
>
<Box size={14} />
</div>
<Box />
</span>
{editing ? (
<input
autoFocus
@@ -227,7 +223,7 @@ export function GroupNode({ id, data }: NodeProps<GroupNodeType>) {
/>
) : (
<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) => {
e.stopPropagation();
setDraft(data.title);
@@ -241,7 +237,7 @@ export function GroupNode({ id, data }: NodeProps<GroupNodeType>) {
</div>
<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 }}
>
Drag services here to group them
@@ -18,15 +18,15 @@ export function serviceTypeIcon(type: string, size = 18) {
export function serviceTypeColor(type: string): string {
switch (type) {
case 'web':
return '#7ab8ff';
return 'var(--info)';
case 'database':
return '#b4e34a';
return '#38bdf8';
case 'worker':
return '#f2c94c';
return 'var(--warning)';
case 'cron':
return '#f2994a';
return 'var(--accent-secondary)';
default:
return '#9295a4';
return 'var(--text-tertiary)';
}
}
@@ -1,306 +1,198 @@
import { useEffect, useState } from 'react';
import {
LineMetricChart,
DualLineChart,
DonutChart,
} from '@/shared/components';
interface MetricData {
cpu: number[];
ram: number;
ramUsed: string;
cache: number;
cacheBreakdown: { cache: number; nonCache: number; total: number };
users: number[];
performance: number[];
performanceAlt: number[];
upSpeed: number;
downSpeed: number;
}
import { useEffect, useRef, useState } from 'react';
import { useQueries } from '@tanstack/react-query';
import { Cpu, HardDrive, ArrowDownUp, Boxes } from 'lucide-react';
import { LineMetricChart, DonutChart } from '@/shared/components';
import { getServiceMetrics, type ServiceMetrics } from '@/lib/api-client';
import { formatBytes } from '@/lib/time';
interface MetricsDashboardProps {
projectId?: string;
isRunning?: boolean;
onStop?: () => void;
onRestart?: () => void;
services: { id: string; name: string; status: string }[];
isDemoMode?: boolean;
}
function getRandom(min: number, max: number) {
return Math.floor(Math.random() * (max - min + 1)) + min;
function healthLabel(pct: number): { text: string; color: string } {
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 {
return {
cpu: Array.from({ length: 24 }, () => getRandom(10, 45)),
ram: getRandom(50, 85),
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];
function Tile({ icon: Icon, label, children }: {
icon: React.ElementType;
label: string;
children: React.ReactNode;
}) {
return (
<div>
{/* Metrics Header - self.html exact match */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '16px' }}>
<span style={{ fontSize: '15px', fontWeight: 700, color: '#e8e9f0' }}>Metrics</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 className="s-card !p-0 overflow-hidden">
<div className="s-cardhead">
<span className="s-ibox"><Icon /></span>
<span className="s-t">{label}</span>
</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>
);
}
@@ -2,6 +2,7 @@ import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { listProjectVariables, updateProjectVariables } from '@/lib/api-client';
import { Loader2, Plus, Save, Trash2, KeyRound } from 'lucide-react';
import { QuietBtn } from '@/shared/components/sentry';
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="flex items-center justify-between mb-3">
<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)]">
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.
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setDrafts([...rows, { key: '', value: '', isSecret: false }])}
className="flex items-center gap-2 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] transition-colors"
>
<QuietBtn onClick={() => setDrafts([...rows, { key: '', value: '', isSecret: false }])}>
<Plus size={12} />
Add
</button>
</QuietBtn>
<button
onClick={() => saveMutation.mutate(rows)}
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"
style={{ background: 'var(--accent-primary)' }}
className="s-btn-accent disabled:opacity-50"
>
{saveMutation.isPending ? <Loader2 size={12} className="animate-spin" /> : <Save size={12} />}
Save
@@ -78,12 +76,12 @@ export function SharedVariablesSection({ projectId }: { projectId: string }) {
) : (
<div className="space-y-2">
{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
value={row.key}
onChange={(e) => updateRow(i, { key: e.target.value })}
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
value={row.value}
@@ -91,13 +89,13 @@ export function SharedVariablesSection({ projectId }: { projectId: string }) {
type={row.isSecret ? 'password' : 'text'}
placeholder="value"
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
type="button"
title={row.isSecret ? 'Secret — masked on save' : 'Mark as secret'}
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
? '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)]'
@@ -108,7 +106,7 @@ export function SharedVariablesSection({ projectId }: { projectId: string }) {
<button
type="button"
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} />
</button>
@@ -50,7 +50,9 @@ import {
GitBranch,
Container,
FileUp,
RefreshCw,
} from 'lucide-react';
import { GhostBtn, QuietBtn } from '@/shared/components/sentry';
type WorkspaceView = 'canvas' | 'observability' | 'logs' | 'activity' | 'settings';
@@ -146,12 +148,10 @@ function ServiceCreateDialog(props: {
return (
<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="relative w-full max-w-lg panel p-6">
<div className="flex items-center gap-3 mb-1">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<Plus size={20} className="text-[var(--accent-primary)]" />
</div>
<h3 className="text-xl font-semibold text-[var(--text-primary)]">Add Service</h3>
<div className="relative w-full max-w-lg s-card !p-6">
<div className="flex items-center gap-2.5 mb-1">
<span className="s-ibox !w-9 !h-9"><Plus /></span>
<h3 className="font-headline text-lg font-semibold text-[var(--text-primary)]">Add Service</h3>
</div>
<p className="text-sm text-[var(--text-secondary)]">
Deploy a new service to this project. Configure runtime settings after creation.
@@ -202,7 +202,7 @@ function ServiceCreateDialog(props: {
</div>
{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>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
@@ -287,12 +287,12 @@ function ServiceCreateDialog(props: {
<input
value={form.image ?? ''}
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"
/>
</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>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
Provider
@@ -326,7 +326,7 @@ function ServiceCreateDialog(props: {
<input
value={repoSearch}
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..."
/>
{repositoriesQuery.isLoading ? (
@@ -357,7 +357,7 @@ function ServiceCreateDialog(props: {
: '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>
</button>
))}
@@ -380,7 +380,7 @@ function ServiceCreateDialog(props: {
<select
value={form.git_branch ?? ''}
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>
{branches.map((branch) => (
@@ -391,7 +391,7 @@ function ServiceCreateDialog(props: {
<input
value={form.git_branch ?? ''}
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'}
/>
)}
@@ -403,7 +403,7 @@ function ServiceCreateDialog(props: {
<input
value={form.build_path ?? ''}
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)"
/>
</div>
@@ -419,7 +419,7 @@ function ServiceCreateDialog(props: {
<input
value={form.command ?? ''}
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"
/>
</div>
@@ -434,7 +434,7 @@ function ServiceCreateDialog(props: {
<div className="mt-6 flex justify-end gap-3">
<button
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
</button>
@@ -454,8 +454,8 @@ function ServiceCreateDialog(props: {
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"
style={{ background: 'var(--accent-primary)' }}
className="s-btn-accent disabled:opacity-50 disabled:cursor-not-allowed"
>
{props.loading ? 'Creating...' : isDatabase ? 'Create Database' : 'Create Service'}
</button>
@@ -689,17 +689,17 @@ export function ProjectWorkspacePage() {
if ((!isDemoMode && projectQuery.isError) || !project) {
return (
<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">
<X size={24} className="text-[var(--error)]" />
</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)]">
This project may have been deleted or you don't have access.
</p>
<button
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
</button>
@@ -724,13 +724,13 @@ export function ProjectWorkspacePage() {
<span className="hidden sm:inline">Projects</span>
</button>
<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">
{services.length} service{services.length !== 1 ? 's' : ''} · {runningServices} running
</span>
{/* 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) => {
const active = activeView === item.key;
const Icon = item.icon;
@@ -742,11 +742,7 @@ export function ProjectWorkspacePage() {
next.set('view', item.key);
return next;
})}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-all whitespace-nowrap ${
active
? 'bg-[var(--surface-card)] text-[var(--text-primary)] shadow-sm'
: 'text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]'
}`}
className={`tab ${active ? 'active' : ''} !py-0 h-full !text-[11.5px]`}
>
<Icon size={13} />
<span className="hidden md:inline">{item.label}</span>
@@ -765,29 +761,21 @@ export function ProjectWorkspacePage() {
</span>
<button
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} />
<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>
{!isDemoMode && signedIn && (
<>
<button
onClick={() => setImportOpen(true)}
className="flex items-center gap-1.5 h-8 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] hover:text-[var(--text-primary)] transition-colors"
title="Deploy a docker-compose file into this project"
>
<FileUp size={14} />
<QuietBtn onClick={() => setImportOpen(true)} title="Deploy a docker-compose file into this project">
<FileUp size={13} />
<span className="hidden sm:inline">Compose</span>
</button>
<button
onClick={openAddService}
className="flex items-center gap-1.5 h-8 px-3 rounded-[var(--radius-md)] text-[var(--accent-on)] text-xs font-semibold shadow-lg hover:shadow-xl transition-all"
style={{ background: 'var(--accent-primary)' }}
>
<Plus size={14} />
</QuietBtn>
<GhostBtn onClick={openAddService}>
<Plus size={13} />
<span className="hidden sm:inline">Add Service</span>
</button>
</GhostBtn>
</>
)}
</div>
@@ -818,67 +806,48 @@ export function ProjectWorkspacePage() {
<div className="flex-1 min-h-0 overflow-y-auto">
{isDemoMode && (
<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="flex items-center gap-2 text-sm text-[var(--warning)]">
<Sparkles size={16} />
<span>Demo mode active - using sample data for preview</span>
</div>
<div className="s-inset flex items-center gap-2 text-xs text-[var(--warning)]">
<Sparkles size={13} />
Demo mode active — using sample data for preview
</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">
{activeView === 'observability' && (
<div className="space-y-6">
{/* Health Summary */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="panel-soft p-4">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-[var(--success)] live-pulse" />
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Healthy</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3.5">
{([
{ label: 'Healthy', color: 'var(--success)', value: runningServices, pulse: true },
{ label: 'Failed', color: 'var(--error)', value: services.filter((s) => s.status === 'failed').length, pulse: false },
{ 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>
<p className="mt-2 text-3xl font-semibold text-[var(--success)]">{runningServices}</p>
<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 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>
{/* Metrics Dashboard */}
<MetricsDashboard
isRunning={runningServices > 0}
onStop={() => toast.showToast('Services stopped', 'warning')}
onRestart={() => toast.showToast('Services restarted', 'success')}
/>
<MetricsDashboard services={visibleServices} isDemoMode={isDemoMode} />
{/* Service List */}
<div className="panel p-6">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-[var(--text-secondary)]">Services</h3>
<div className="s-card">
<div className="s-cardhead">
<span className="s-ibox"><Layers /></span>
<span className="s-t">Services</span>
{!isDemoMode && environments.length > 0 && (
<div className="flex items-center gap-1.5 flex-wrap">
<div className="s-trail flex-wrap">
<button
onClick={() => setEnvFilter(null)}
className={`px-2.5 py-1 rounded-full text-xs font-medium transition-colors ${
envFilter === null
? 'bg-[var(--accent-primary)] text-white'
: 'bg-[var(--surface-muted)] text-[var(--text-secondary)] hover:bg-[var(--surface-card)]'
}`}
className={`s-chip !py-1 !px-2.5 ${envFilter === null ? '!text-[var(--accent-primary)] !border-[var(--accent-primary)]/40' : ''}`}
>
All
</button>
@@ -886,11 +855,7 @@ export function ProjectWorkspacePage() {
<button
key={env.id}
onClick={() => setEnvFilter(envFilter === env.name ? null : env.name)}
className={`px-2.5 py-1 rounded-full text-xs font-medium transition-colors ${
envFilter === env.name
? 'bg-[var(--accent-primary)] text-white'
: 'bg-[var(--surface-muted)] text-[var(--text-secondary)] hover:bg-[var(--surface-card)]'
}`}
className={`s-chip !py-1 !px-2.5 ${envFilter === env.name ? '!text-[var(--accent-primary)] !border-[var(--accent-primary)]/40' : ''}`}
>
{env.name} ({env.service_count})
</button>
@@ -899,6 +864,7 @@ export function ProjectWorkspacePage() {
</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)]">
{visibleServices.length === 0 ? (
<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"
>
<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">
<Box size={14} className="text-[var(--text-tertiary)]" />
</div>
<span className="s-ibox"><Box /></span>
<div>
<p className="text-sm font-medium text-[var(--text-primary)]">{service.name}</p>
<p className="text-xs text-[var(--text-tertiary)]">{service.type}</p>
@@ -933,31 +897,29 @@ export function ProjectWorkspacePage() {
</div>
</div>
</div>
</div>
)}
{activeView === 'logs' && (
<div className="panel p-6">
<div className="flex items-center justify-between mb-4">
<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">
<FileText size={20} className="text-[var(--accent-primary)]" />
</div>
<div className="s-card">
<div className="s-cardhead">
<span className="s-ibox"><FileText /></span>
<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 className="s-t">Logs</div>
<div className="mt-0.5 text-[11px] text-[var(--text-tertiary)]">Aggregated service output</div>
</div>
{!isDemoMode && (
<button
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"
>
<div className="s-trail">
<QuietBtn onClick={() => workspaceLogsQuery.refetch()}>
<RefreshCw size={13} />
Refresh
</button>
</QuietBtn>
</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 ? (
<p className="text-[var(--text-muted)]">No services available.</p>
) : isDemoMode ? (
@@ -999,19 +961,19 @@ export function ProjectWorkspacePage() {
)}
</div>
</div>
</div>
)}
{activeView === 'activity' && (
<div className="panel p-6">
<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">
<Clock size={20} className="text-[var(--accent-primary)]" />
</div>
<div className="s-card">
<div className="s-cardhead">
<span className="s-ibox"><Clock /></span>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Activity</h2>
<p className="text-sm text-[var(--text-secondary)]">Project timeline — deploys, config, membership</p>
<div className="s-t">Activity</div>
<div className="mt-0.5 text-[11px] text-[var(--text-tertiary)]">Project timeline — deploys, config, membership</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)]">
{activityQuery.isLoading ? (
<p className="px-4 py-6 text-xs text-[var(--text-muted)]">Loading…</p>
@@ -1035,39 +997,39 @@ export function ProjectWorkspacePage() {
)}
</div>
</div>
</div>
)}
{activeView === 'settings' && (
<div className="panel p-6">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<Settings size={20} className="text-[var(--accent-primary)]" />
</div>
<div className="s-card">
<div className="s-cardhead">
<span className="s-ibox"><Settings /></span>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Settings</h2>
<p className="text-sm text-[var(--text-secondary)]">Project configuration</p>
<div className="s-t">Settings</div>
<div className="mt-0.5 text-[11px] text-[var(--text-tertiary)]">Project configuration</div>
</div>
</div>
<div className="px-4 pb-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="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 className="panel-soft p-4">
<div className="s-inset">
<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>
</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="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>
{!isDemoMode && signedIn && <SharedVariablesSection projectId={project.id} />}
<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
onClick={() => {
clearCanvasMetadata(project.id);
@@ -1080,6 +1042,7 @@ export function ProjectWorkspacePage() {
</button>
</div>
</div>
</div>
)}
</section>
</div>
@@ -1105,12 +1068,10 @@ export function ProjectWorkspacePage() {
{importOpen && (
<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="relative w-full max-w-2xl panel p-6">
<div className="flex items-center gap-3 mb-1">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<FileUp size={20} className="text-[var(--accent-primary)]" />
</div>
<h3 className="text-xl font-semibold text-[var(--text-primary)]">Import docker-compose</h3>
<div className="relative w-full max-w-2xl s-card !p-6">
<div className="flex items-center gap-2.5 mb-1">
<span className="s-ibox !w-9 !h-9"><FileUp /></span>
<h3 className="font-headline text-lg font-semibold text-[var(--text-primary)]">Import docker-compose</h3>
</div>
<p className="text-sm text-[var(--text-secondary)]">
Paste a docker-compose.yml. Services deploy in dependency order; postgres, mysql, redis, mongo,
@@ -1120,7 +1081,7 @@ export function ProjectWorkspacePage() {
value={composeYaml}
onChange={(e) => setComposeYaml(e.target.value)}
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'}
/>
<div className="mt-5 flex justify-end gap-2">
@@ -1133,8 +1094,8 @@ export function ProjectWorkspacePage() {
<button
onClick={() => composeImportMutation.mutate(composeYaml)}
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"
style={{ background: 'var(--accent-primary)' }}
className="s-btn-accent h-10 disabled:opacity-50"
>
{composeImportMutation.isPending && <Loader2 size={14} className="animate-spin" />}
Deploy stack
@@ -1163,6 +1124,9 @@ export function ProjectWorkspacePage() {
return next;
});
}}
navTargets={viewItems
.filter((item) => signedIn || !item.authOnly)
.map((item) => ({ label: `Go to ${item.label}`, target: item.key }))}
/>
{/* 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
// 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`);
if (!res.ok) {
throw new Error(`Server returned ${res.status}`);
}
return true;
}
export type SetupStatus = {
@@ -1238,9 +1239,17 @@ export type AgentContainer = {
created_at?: string;
};
type RawAgentContainer = Omit<AgentContainer, 'status'> & {
status?: string | { state?: string; health?: string };
};
export async function listAgentContainers(id: string): Promise<AgentContainer[]> {
const payload = await requestJson<{ containers?: AgentContainer[] }>(`/agents/${id}/containers`);
return payload.containers ?? [];
const payload = await requestJson<{ containers?: RawAgentContainer[] }>(`/agents/${id}/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 = {
+5
View File
@@ -13,6 +13,11 @@ export function formatRelative(iso?: string): string {
const hour = 60 * minute;
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) {
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
// worse than none.
const NEVER_PERSIST = new Set([
'auth-session',
'shell-notifications',
'notifications',
'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 gap-3">
{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}
</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 && (
<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({
data,
color = '#b4e34a',
color = '#38bdf8',
fillColor = 'transparent',
height = 76,
showDots = true,
@@ -181,7 +181,7 @@ export function MultiLineChart({
export function LineMetricChart({
data,
color = '#b4e34a',
color = '#38bdf8',
fillOpacity = 0.15,
height = 76,
showArea = false,
@@ -204,8 +204,8 @@ export function DualLineChart({
data1,
data2,
height = 58,
color1 = '#7ab8ff',
color2 = '#f2c94c',
color1 = 'var(--accent-primary)',
color2 = 'var(--warning)',
fillOpacity1 = 0.15,
fillOpacity2 = 0.15,
}: DualLineChartProps) {
@@ -1,24 +1,25 @@
import { useEffect, useState } from 'react';
import { Command } from 'cmdk';
import {
ArrowRight,
Box,
CornerDownRight,
Database,
Github,
Image,
Clock,
Search,
Settings,
FileText,
Activity,
LayoutGrid,
} from 'lucide-react';
export interface PaletteTarget {
label: string;
target: string;
description?: string;
}
interface CommandItem {
id: string;
label: string;
description?: string;
icon: typeof Box;
shortcut?: string;
action: () => void;
category?: string;
}
@@ -27,14 +28,39 @@ interface CommandPaletteProps {
open: boolean;
onClose: () => 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({
open,
onClose,
onAddService,
onNavigate,
navTargets,
openTargets,
}: CommandPaletteProps) {
if (!open) return null;
@@ -43,6 +69,8 @@ export function CommandPalette({
onClose={onClose}
onAddService={onAddService}
onNavigate={onNavigate}
navTargets={navTargets}
openTargets={openTargets}
/>
);
}
@@ -51,6 +79,8 @@ function CommandPaletteContent({
onClose,
onAddService,
onNavigate,
navTargets,
openTargets,
}: Omit<CommandPaletteProps, 'open'>) {
const [search, setSearch] = useState('');
@@ -66,42 +96,35 @@ function CommandPaletteContent({
return () => document.removeEventListener('keydown', down);
}, [onClose]);
const commands: CommandItem[] = [
const commands: CommandItem[] = [];
// Create items only exist where onAddService is wired — elsewhere the
// entries would silently no-op.
if (onAddService) {
commands.push(
{
id: 'add-web',
label: 'Add Web Service',
description: 'Deploy a web application',
icon: Box,
shortcut: 'W',
category: 'Create',
action: () => {
onAddService?.('web');
onClose();
},
action: () => { onAddService('web'); onClose(); },
},
{
id: 'add-worker',
label: 'Add Worker Service',
description: 'Deploy a background worker',
icon: Clock,
shortcut: 'K',
category: 'Create',
action: () => {
onAddService?.('worker');
onClose();
},
action: () => { onAddService('worker'); onClose(); },
},
{
id: 'add-database',
label: 'Add Database',
description: 'Provision a PostgreSQL database',
icon: Database,
shortcut: 'D',
category: 'Create',
action: () => {
onAddService?.('database');
onClose();
},
action: () => { onAddService('database'); onClose(); },
},
{
id: 'add-cron',
@@ -109,76 +132,33 @@ function CommandPaletteContent({
description: 'Schedule a recurring task',
icon: Clock,
category: 'Create',
action: () => {
onAddService?.('cron');
onClose();
action: () => { onAddService('cron'); onClose(); },
},
},
{
id: 'connect-github',
label: 'Connect GitHub Repo',
description: 'Link a repository for auto-deploy',
icon: Github,
category: 'Connect',
action: () => {
onClose();
},
},
{
id: 'deploy-image',
label: 'Deploy Docker Image',
description: 'Deploy from a container registry',
icon: Image,
category: 'Connect',
action: () => {
onClose();
},
},
{
id: 'goto-canvas',
label: 'Go to Canvas',
icon: LayoutGrid,
shortcut: 'C',
);
}
if (onNavigate) {
(navTargets ?? APP_NAV).forEach((t, i) =>
commands.push({
id: `nav-${i}`,
label: t.label,
description: t.description,
icon: CornerDownRight,
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();
},
},
];
action: () => { onNavigate(t.target); onClose(); },
}),
);
(openTargets ?? []).forEach((t, i) =>
commands.push({
id: `open-${i}`,
label: t.label,
description: t.description,
icon: ArrowRight,
category: 'Open',
action: () => { onNavigate(t.target); onClose(); },
}),
);
}
const filteredCommands = commands.filter(
(cmd) =>
@@ -197,6 +177,10 @@ function CommandPaletteContent({
{} as Record<string, CommandItem[]>
);
const placeholder = onNavigate
? 'Search pages, projects, actions…'
: 'What would you like to create?';
return (
<div className="fixed inset-0 z-50">
<div
@@ -205,7 +189,7 @@ function CommandPaletteContent({
/>
<div className="absolute left-1/2 top-[20%] -translate-x-1/2 w-full max-w-xl">
<Command
className="panel overflow-hidden"
className="s-card !p-0 overflow-hidden shadow-2xl shadow-black/50"
loop
>
<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
value={search}
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"
/>
<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]) => (
<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}
</div>
{items.map((item) => {
@@ -238,20 +222,15 @@ function CommandPaletteContent({
key={item.id}
value={item.label}
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="text-sm font-medium">{item.label}</div>
{item.description && (
<div className="text-xs text-[var(--text-muted)]">{item.description}</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>
);
})}
@@ -8,15 +8,15 @@ import { Sparkles } from 'lucide-react';
export function DemoRestricted({ feature }: { feature: string }) {
return (
<div className="flex min-h-[60vh] items-center justify-center p-8">
<div className="panel max-w-md p-8 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-[var(--accent-primary-soft)]">
<Sparkles size={22} className="text-[var(--accent-primary)]" />
<div className="s-card max-w-md text-center">
<div className="s-ibox mx-auto mb-4 !h-10 !w-10">
<Sparkles size={18} />
</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)]">
{feature} needs a live Containr session. The demo covers sample projects, services, and platform views only.
</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
</Link>
</div>
@@ -17,8 +17,8 @@ export function DonutChart({
percentage,
size = 160,
thickness = 16,
color = '#b4e34a',
trackColor = 'rgba(255, 255, 255, 0.07)',
color = '#38bdf8',
trackColor = 'var(--tint-07)',
segments = 28,
gap = 0.048,
animated = true,
@@ -1,4 +1,5 @@
import { type ReactNode } from 'react';
import { ChevronRight, MoreHorizontal } from 'lucide-react';
interface EnhancedMetricCardProps {
title: string;
@@ -15,6 +16,42 @@ interface EnhancedMetricCardProps {
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({
title,
value,
@@ -29,75 +66,23 @@ export function EnhancedMetricCard({
animationDelay = 0,
horizontal = false,
}: EnhancedMetricCardProps) {
const statusColors: Record<string, string> = {
good: '#5ee6a0',
average: '#f2c94c',
warning: '#f2c94c',
};
// Horizontal layout for cards like Active User
if (horizontal) {
return (
<div
className={`card ${className}`}
style={{
display: 'flex',
flexDirection: 'row',
padding: 0,
overflow: 'hidden',
animationDelay: `${animationDelay}s`,
}}
>
<div style={{
flex: 1,
padding: '20px 18px 18px 20px',
display: 'flex',
flexDirection: 'column',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
<div className="card-icon">{icon}</div>
<span style={{ fontSize: 14, fontWeight: 600, color: '#e8e9f0' }}>{title}</span>
</div>
<div style={{
fontSize: 36,
fontWeight: 900,
letterSpacing: '-1.5px',
lineHeight: 1,
color: '#e8e9f0',
}}>
<div className={`s-card flex flex-row overflow-hidden ${className}`} style={{ animationDelay: `${animationDelay}s` }}>
<div className="flex flex-1 flex-col">
<CardHead icon={icon} title={title} />
<div className="px-4">
<div className="font-headline text-[32px] font-bold leading-none tracking-[-0.025em] text-[var(--text-primary)]">
{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>
{subtitle && <div className="mt-1 text-[11.5px] text-[var(--text-tertiary)]">{subtitle}</div>}
{details && <div className="mt-3">{details}</div>}
</div>
<div className="mt-4" />
<CardFoot label="Details" onClick={onClick} />
</div>
{chart && (
<div style={{
width: '50%',
padding: '16px 14px 46px 0',
display: 'flex',
alignItems: 'flex-end',
}}>
<div className="flex w-1/2 items-end py-3 pr-3.5">
{chart}
</div>
)}
@@ -105,64 +90,25 @@ export function EnhancedMetricCard({
);
}
// Standard vertical card layout - matching self.html exactly
return (
<div
className={`card ${className}`}
style={{
display: 'flex',
flexDirection: 'column',
padding: 20,
animationDelay: `${animationDelay}s`,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
<div className="card-icon">{icon}</div>
<span style={{ fontSize: 14, fontWeight: 600, color: '#e8e9f0' }}>{title}</span>
</div>
<div style={{
fontSize: 38,
fontWeight: 900,
letterSpacing: '-1.5px',
lineHeight: 1,
color: '#e8e9f0',
}}>
<div className={`s-card flex flex-col ${className}`} style={{ animationDelay: `${animationDelay}s` }}>
<CardHead icon={icon} title={title} />
<div className="px-4 pb-1">
<div className="font-headline text-[32px] font-bold leading-none tracking-[-0.025em] text-[var(--text-primary)]">
{value}
</div>
{(subtitle || statusText) && (
<div style={{ fontSize: 12, color: '#6b6e7d', marginTop: 4 }}>
<div className="mt-1.5 text-[11.5px] text-[var(--text-tertiary)]">
{statusText && status && (
<span style={{ color: statusColors[status], fontWeight: 700 }}>{statusText}</span>
<span className="font-semibold" style={{ color: statusColors[status] }}>{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>
{chart && <div className="mt-3 mb-1">{chart}</div>}
{details && <div className="mt-3">{details}</div>}
</div>
<CardFoot label="Details" onClick={onClick} />
</div>
);
}
@@ -186,119 +132,57 @@ export function CacheMetricCard({
const nonCachePercent = Math.round((nonCacheMB / totalMB) * 100);
return (
<div
className="card"
style={{
display: 'flex',
flexDirection: 'column',
padding: 20,
animationDelay: `${animationDelay}s`,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
<div className="card-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="#9295a4" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
<div className="s-card flex flex-col" style={{ animationDelay: `${animationDelay}s` }}>
<CardHead
icon={
<svg viewBox="0 0 24 24" width="13.5" height="13.5" fill="none" stroke="currentColor" 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>
</div>
<span style={{ fontSize: 14, fontWeight: 600, color: '#e8e9f0' }}>Cache</span>
</div>
<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'
}}
}
title="Cache"
/>
<div className="px-4">
<div className="font-headline text-[32px] font-bold leading-none tracking-[-0.025em] text-[var(--text-primary)]">
{totalMB} <span className="text-[12px] font-medium text-[var(--text-tertiary)]">MB</span>
</div>
<div className="mt-1.5 text-[11.5px] text-[var(--text-tertiary)]">
<span className="font-semibold text-[var(--warning)]">{Math.round(totalMB * 0.625)}MB average</span> cached images and files
</div>
{/* Stats Row */}
<div style={{
display: 'grid',
gridTemplateColumns: '1fr auto 1fr auto 1fr',
gap: 0,
alignItems: 'stretch'
}}>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 11, color: '#6b6e7d', marginBottom: 5 }}>
<div className="stat-dot" style={{ background: '#ff6b5b' }} /> Cache
</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 4 }}>
<span style={{ fontSize: 15, fontWeight: 800 }}>{cacheMB} MB</span>
<span style={{ fontSize: 11, color: '#6b6e7d' }}>{cachePercent}%</span>
</div>
</div>
<div style={{ width: 1, background: 'rgba(255,255,255,0.08)', margin: '0 16px' }} />
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 11, color: '#6b6e7d', marginBottom: 5 }}>
<div className="stat-dot" style={{ background: '#8c6ef0' }} /> Non-Cache
</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 4 }}>
<span style={{ fontSize: 15, fontWeight: 800 }}>{nonCacheMB} MB</span>
<span style={{ fontSize: 11, color: '#6b6e7d' }}>{nonCachePercent}%</span>
</div>
</div>
<div style={{ width: 1, background: 'rgba(255,255,255,0.08)', margin: '0 16px' }} />
<div>
<div style={{ fontSize: 11, color: '#6b6e7d', marginBottom: 5 }}>Total</div>
<div style={{ fontSize: 15, fontWeight: 800 }}>{totalMB * 5} GB</div>
</div>
{/* Segmented bar */}
<div className="my-4 flex h-8 items-center gap-1">
<div className="h-full rounded-l-md" style={{ width: `${cachePercent}%`, background: 'var(--error)' }} />
<div className="h-full" style={{ width: `${nonCachePercent}%`, background: 'var(--accent-tertiary)' }} />
<div className="h-full flex-1 rounded-r-md bg-[var(--tint-07)]" />
</div>
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
paddingTop: 16
}}>
<span
onClick={onClick}
style={{ fontSize: 13, color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}
>
Details
</span>
<div className="arrow-btn" onClick={onClick}>
<svg viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6" />
</svg>
{/* Stats row */}
<div className="grid grid-cols-[1fr_auto_1fr_auto_1fr] items-stretch">
{[
{ dot: 'var(--error)', label: 'Cache', value: `${cacheMB} MB`, pct: `${cachePercent}%` },
{ dot: 'var(--accent-tertiary)', label: 'Non-Cache', value: `${nonCacheMB} MB`, pct: `${nonCachePercent}%` },
{ dot: null, label: 'Total', value: `${totalMB * 5} GB`, pct: '' },
].map((s, i) => (
<div key={s.label} className="contents">
{i > 0 && <div className="mx-4 w-px bg-[var(--border-subtle)]" />}
<div>
<div className="mb-1 flex items-center gap-1.5 text-[10.5px] text-[var(--text-tertiary)]">
{s.dot && <i className="h-1.5 w-1.5 rounded-full" style={{ background: s.dot }} />}
{s.label}
</div>
<div className="flex items-baseline gap-1">
<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 className="mt-4" />
<CardFoot label="Details" onClick={onClick} />
</div>
);
}
@@ -319,86 +203,47 @@ export function PerformanceMetricCard({
animationDelay = 0,
}: PerformanceMetricCardProps) {
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 (
<div
className="card"
style={{
display: 'flex',
flexDirection: 'column',
padding: 20,
animationDelay: `${animationDelay}s`,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
<div className="card-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="#9295a4" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<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"/>
<div className="s-card flex flex-col" style={{ animationDelay: `${animationDelay}s` }}>
<CardHead
icon={
<svg viewBox="0 0 24 24" width="13.5" height="13.5" fill="none" stroke="currentColor" 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>
</div>
<span style={{ fontSize: 14, fontWeight: 600, color: '#e8e9f0' }}>Performance</span>
</div>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 16, flex: 1 }}>
<div style={{ flex: 1 }}>
<div style={{
fontSize: 36,
fontWeight: 900,
letterSpacing: '-1.5px',
lineHeight: 1,
color: '#e8e9f0',
}}>
}
title="Performance"
/>
<div className="flex flex-1 items-start gap-4 px-4">
<div className="flex-1">
<div className="font-headline text-[32px] font-bold leading-none tracking-[-0.025em] text-[var(--text-primary)]">
{percentage}%
</div>
<div style={{ fontSize: 12, color: '#6b6e7d', marginTop: 4 }}>
<span style={{ color: statusColor, fontWeight: 700 }}>{status}</span> Last scan on {new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
<div className="mt-1.5 text-[11.5px] text-[var(--text-tertiary)]">
<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 style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 8 }}>
{/* Chart placeholder - will be replaced with actual chart */}
<div style={{ width: 134, height: 58, background: 'rgba(255,255,255,0.03)', borderRadius: 8 }} />
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, alignItems: 'flex-end' }}>
<div className="speed-row" style={{ color: '#7ab8ff' }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
<line x1="12" y1="19" x2="12" y2="5"/>
<polyline points="5 12 12 19 19 12"/>
<div className="flex flex-col items-end gap-2">
<div className="h-[58px] w-[134px] rounded-lg bg-[var(--tint-03)]" />
<div className="flex flex-col items-end gap-1">
{[{ 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)]">
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
<line x1="12" y1={s.up ? 5 : 19} x2="12" y2={s.up ? 19 : 5} />
{s.up ? <polyline points="19 12 12 5 5 12" /> : <polyline points="5 12 12 19 19 12" />}
</svg>
<span>{upSpeed}</span> Mbps
<span>{s.v}</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 style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
paddingTop: 14
}}>
<span
onClick={onClick}
style={{ fontSize: 13, color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}
>
Check Speed
</span>
<div className="arrow-btn" onClick={onClick}>
<svg viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6" />
</svg>
</div>
</div>
<div className="mt-4" />
<CardFoot label="Check Speed" onClick={onClick} />
</div>
);
}
@@ -61,9 +61,9 @@ export class ErrorBoundary extends Component<Props, State> {
return (
<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">
<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
</h2>
<p className="text-sm text-[var(--text-secondary)]">
@@ -72,24 +72,18 @@ export class ErrorBoundary extends Component<Props, State> {
</div>
{import.meta.env.DEV && this.state.error && (
<div className="bg-[var(--surface-muted)] border border-[var(--border-subtle)] rounded p-3">
<p className="text-xs font-mono text-[var(--error)] break-all">
<div className="s-inset">
<p className="v-mono text-[11px] text-[var(--error)] break-all">
{this.state.error.toString()}
</p>
</div>
)}
<div className="flex gap-2">
<button
onClick={this.handleReset}
className="flex-1 px-4 py-2 bg-[var(--accent-primary-soft)] text-[var(--text-primary)] rounded hover:opacity-90 transition-opacity"
>
<button onClick={this.handleReset} className="s-btn-accent flex-1 justify-center">
Try Again
</button>
<button
onClick={() => window.location.reload()}
className="flex-1 px-4 py-2 bg-[var(--surface-muted)] text-[var(--text-primary)] border border-[var(--border-subtle)] rounded hover:bg-[var(--surface-soft)] transition-colors"
>
<button onClick={() => window.location.reload()} className="s-btn-quiet flex-1 justify-center">
Reload Page
</button>
</div>
+49 -118
View File
@@ -1,4 +1,5 @@
import { type ReactNode, useEffect, useRef, useState } from 'react';
import { ChevronRight } from 'lucide-react';
interface MetricCardProps {
title: string;
@@ -15,6 +16,12 @@ interface MetricCardProps {
horizontal?: boolean;
}
const statusColors: Record<string, string> = {
good: 'var(--success)',
average: 'var(--warning)',
warning: 'var(--warning)',
};
export function MetricCard({
title,
value,
@@ -49,140 +56,64 @@ export function MetricCard({
return () => observer.disconnect();
}, [animationDelay]);
const statusColors: Record<string, string> = {
good: '#5ee6a0',
average: '#f2c94c',
warning: '#f2c94c',
};
// Horizontal layout for cards like Active User
if (horizontal) {
return (
<div
ref={cardRef}
className={`panel ${className}`}
style={{
display: 'flex',
flexDirection: 'row',
padding: 0,
overflow: 'hidden',
const reveal = {
opacity: isVisible ? 1 : 0,
transform: isVisible ? 'translateY(0)' : 'translateY(12px)',
transition: 'all 0.5s ease',
}}
} as const;
const footer = (
<div className="s-stat-foot">
<span
onClick={onClick}
style={{ cursor: onClick ? 'pointer' : 'default' }}
>
<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>
{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>
<div style={{
fontSize: 36,
fontWeight: 900,
letterSpacing: '-1.5px',
lineHeight: 1,
color: '#e8e9f0',
}}>
);
if (horizontal) {
return (
<div ref={cardRef} className={`s-stat ${className}`} style={{ ...reveal, display: 'flex' }}>
<div className="flex-1 px-4 pt-4 pb-2 flex flex-col">
<div className="flex items-center gap-2.5">
<span className="s-ibox">{icon}</span>
<span className="text-[11.5px] font-medium text-[var(--text-secondary)]">{title}</span>
</div>
<div className="mt-4 font-headline text-[30px] font-bold leading-none tracking-[-0.025em] text-[var(--text-primary)]">
{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>
{details ? <div className="mt-3">{details}</div> : null}
<div className="mt-auto">{footer}</div>
</div>
</div>
</div>
{chart && (
<div style={{
width: '50%',
padding: '16px 14px 46px 0',
display: 'flex',
alignItems: 'flex-end',
}}>
{chart}
</div>
)}
{chart ? (
<div className="w-1/2 flex items-end pr-3 pb-10 pt-3">{chart}</div>
) : null}
</div>
);
}
// Standard vertical card layout - matching self.html exactly
return (
<div
ref={cardRef}
className={`panel ${className}`}
style={{
display: 'flex',
flexDirection: 'column',
padding: 20,
opacity: isVisible ? 1 : 0,
transform: isVisible ? 'translateY(0)' : 'translateY(12px)',
transition: 'all 0.5s ease',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
<div className="card-icon">{icon}</div>
<span style={{ fontSize: 14, fontWeight: 600, color: '#e8e9f0' }}>{title}</span>
<div ref={cardRef} className={`s-stat ${className}`} style={reveal}>
<div className="flex items-center gap-2.5 px-4 pt-4">
<span className="s-ibox">{icon}</span>
<span className="text-[11.5px] font-medium text-[var(--text-secondary)]">{title}</span>
</div>
<div style={{
fontSize: 38,
fontWeight: 900,
letterSpacing: '-1.5px',
lineHeight: 1,
color: '#e8e9f0',
}}>
<div className="px-4 mt-4 font-headline text-[32px] font-bold leading-none tracking-[-0.025em] text-[var(--text-primary)]">
{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>
{chart ? <div className="px-4 mt-3">{chart}</div> : null}
{details ? <div className="px-4 mt-3">{details}</div> : null}
<div className="mt-4">{footer}</div>
</div>
);
}
@@ -1,4 +1,5 @@
import { Check, X, Loader2, Box, HelpCircle, type LucideIcon } from 'lucide-react';
import type { PillTone } from './sentry-utils';
type ServiceStatus = 'running' | 'building' | 'failed' | 'stopped' | 'unknown';
@@ -12,43 +13,29 @@ interface StatusBadgeProps {
const statusConfig: Record<
ServiceStatus,
{ color: string; bg: string; Icon: LucideIcon; label: string; animate?: boolean; glow?: string }
{ tone: PillTone; Icon: LucideIcon; label: string; animate?: boolean }
> = {
running: {
color: 'var(--success)',
bg: 'var(--success-soft)',
Icon: Check,
label: 'Active',
animate: true,
glow: 'rgba(61, 214, 140, 0.4)',
},
building: {
color: 'var(--warning)',
bg: 'var(--warning-soft)',
Icon: Loader2,
label: 'Building',
animate: true,
glow: 'rgba(255, 112, 67, 0.4)',
},
failed: {
color: 'var(--error)',
bg: 'var(--error-soft)',
Icon: X,
label: 'Failed',
glow: 'rgba(255, 107, 91, 0.4)',
},
stopped: {
color: 'var(--text-muted)',
bg: 'var(--surface-muted)',
Icon: Box,
label: 'Stopped',
},
unknown: {
color: 'var(--text-muted)',
bg: 'var(--surface-muted)',
Icon: HelpCircle,
label: 'Unknown',
},
running: { tone: 'ok', Icon: Check, label: 'Active' },
building: { tone: 'warn', Icon: Loader2, label: 'Building', animate: true },
failed: { tone: 'err', Icon: X, label: 'Failed' },
stopped: { tone: 'off', Icon: Box, label: 'Stopped' },
unknown: { tone: 'off', Icon: HelpCircle, label: 'Unknown' },
};
const TONE_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)]',
};
const TONE_DOT: Record<PillTone, string> = {
ok: 'var(--success)',
warn: 'var(--warning)',
err: 'var(--error)',
info: 'var(--info)',
off: 'var(--text-tertiary)',
};
export function StatusBadge({
@@ -62,36 +49,26 @@ export function StatusBadge({
const { Icon } = config;
const sizeClasses = {
sm: 'px-2 py-0.5 text-[10px] gap-1.5',
md: 'px-2.5 py-1 text-xs gap-1.5',
lg: 'px-3 py-1.5 text-sm gap-2',
sm: 'px-2 py-[2px] text-[10px] gap-1.5',
md: 'px-2 py-[3.5px] text-[10.5px] gap-1.5',
lg: 'px-2.5 py-1 text-[11.5px] gap-1.5',
};
const iconSizes = {
sm: 10,
md: 12,
lg: 14,
};
const iconSizes = { sm: 10, md: 11, lg: 12 };
return (
<div
className={`inline-flex items-center rounded-full font-bold uppercase tracking-wider ring-1 ring-inset transition-all duration-200 ${sizeClasses[size]} ${className}`}
style={{
background: config.bg,
color: config.color,
borderColor: `${config.color}20`,
boxShadow: status === 'running' && pulse ? `0 0 12px ${config.glow}` : 'none',
}}
className={`inline-flex items-center rounded-[5px] border font-medium transition-colors ${sizeClasses[size]} ${TONE_CLS[config.tone]} ${className}`}
>
{status === 'running' && pulse && (
<span
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
size={iconSizes[size]}
className={config.animate && status === 'building' ? 'animate-spin' : ''}
className={config.animate ? 'animate-spin' : ''}
/>
{showLabel && <span>{config.label}</span>}
</div>
@@ -106,27 +83,19 @@ interface LiveIndicatorProps {
export function LiveIndicator({ isLive, label, size = 'md' }: LiveIndicatorProps) {
const sizeClasses = {
sm: 'px-2 py-0.5 text-[10px] gap-1.5',
md: 'px-2.5 py-1 text-xs gap-2',
lg: 'px-3 py-1.5 text-sm gap-2',
sm: 'px-2 py-[2px] text-[10px] gap-1.5',
md: 'px-2 py-[3.5px] text-[10.5px] gap-1.5',
lg: 'px-2.5 py-1 text-[11.5px] gap-1.5',
};
const tone: PillTone = isLive ? 'ok' : 'off';
return (
<div
className={`inline-flex items-center rounded-full font-bold uppercase tracking-wider ring-1 ring-inset transition-all duration-300 ${sizeClasses[size]}`}
style={{
background: isLive ? 'var(--success-soft)' : 'var(--surface-muted)',
color: isLive ? 'var(--success)' : 'var(--text-muted)',
borderColor: isLive ? 'rgba(61, 214, 140, 0.2)' : 'rgba(255, 255, 255, 0.05)',
boxShadow: isLive ? '0 0 12px rgba(61, 214, 140, 0.3)' : 'none',
}}
className={`inline-flex items-center rounded-[5px] border font-medium transition-colors ${sizeClasses[size]} ${TONE_CLS[tone]}`}
>
<span
className={`w-1.5 h-1.5 rounded-full ${isLive ? 'live-pulse' : ''}`}
style={{
background: isLive ? 'var(--success)' : 'var(--text-muted)',
boxShadow: isLive ? '0 0 6px var(--success)' : 'none',
}}
style={{ background: TONE_DOT[tone] }}
/>
{label || (isLive ? 'Active' : 'Stopped')}
</div>
@@ -12,3 +12,4 @@ export { useToast } from '../hooks/use-toast';
export type { ToastType, ToastContextValue } from '../hooks/use-toast';
export * from './BrandWordmark';
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: {
// 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': {
target: 'http://localhost:8082',
target: process.env.VITE_API_PROXY ?? 'http://localhost:8082',
changeOrigin: true,
ws: true,
},