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
Tomáš Dvořák 6bcbc1369e refactor(db): port git providers, repos, and webhook receiver to sqlc (#82)
- git.sql: provider/repo/webhook CRUD, push-dispatch service matcher
- ListServicesForPush folds the dynamic branch clause into a static
  predicate; COALESCE fallbacks match other service loaders
- git_receiver dispatch writes reuse deployment queries
- TestGitLiveInvariants: live coverage for upsert, search, webhook,
  and branch matching semantics

Authored By: TDvorak <[email protected]>
2026-10-10 05:31:21 +02:00
Tomáš DvořákandDevin 81a4ca28d8 refactor(db): port sleep/wake sweeper to sqlc (#81)
Tranche 7 — all 11 raw sites in sleeper.go. Adds sleep_enabled and
sleep_idle_minutes to schema.sql and three runtime.sql queries:
ListSleepCandidates, GetServiceDomainText, GetServiceStatusText. Status
writes reuse SetServiceStatus; the wake loader reuses
GetServiceRuntimeWithOwner (the projects join is FK-safe).

Also drops a dead port read in handleServiceSleep that was loaded but
never used.

TestRuntimeLiveInvariants now covers the sweep filter and both text
lookups on live Postgres.

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

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-10 05:11:34 +02:00
Tomáš DvořákandDevin 269e031761 refactor(db): port cron jobs to sqlc (#80)
Tranche 6 — 15 of 17 raw sites in cron.go. Adds cron_jobs and
cron_executions to schema.sql and a full query set: job CRUD, owner
joins (via project and via service), execution list/insert/finish,
due-job scan for the scheduler, run bookkeeping, and retention trim.

The filtered list query and the sparse UPDATE builder stay raw — both
are genuinely dynamic. Go models keep string IDs; cronJobFrom and
cronExecutionFrom map uuid/null types at the boundary.

TestCronLiveInvariants covers insert/read, both owner joins, the
trigger read, execution lifecycle, retention trim, and delete on live
Postgres.

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

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-10 04:27:16 +02:00
Tomáš DvořákandDevin ce71b5af24 refactor(db): port runtime ops to sqlc (#79)
Tranche 5 — all 24 raw sites in runtime.go. Adds runtime.sql with the
legacy-column COALESCE loader (type/image/command/git_repo fall back to
service_type/image_name/start_command/source_url), env-var reads,
sibling refs, deployed-image concat, and conditional published-port /
replica writes. Status transitions reuse SetServiceStatus.

loadReadableService now shares GetServiceRuntimeWithOwner — the projects
join is safe (FK) and drops a duplicated 24-column select.

TestRuntimeLiveInvariants verifies the COALESCE chain against a service
seeded with only legacy columns, plus refs, port, variables, agent
listing, image concat, and conditional updates on live Postgres.

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

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-10 04:18:51 +02:00
Tomáš DvořákandDevin cca20f7eae refactor(db): port deployments to sqlc (#78)
Tranche 4: all 32 raw sites in deployments.go — paginated service list,
the accessible-feed joins (owner/member/admin/approved), service+owner
load, deployment insert, status transitions (fail/complete/set), the
10-field progress sync, cancel access check, rollback reads/inserts, and
the published_port reuse read. The shared serviceForDeployFromRow mapper
now feeds both the preview and deployments loaders. Live test covers the
full lifecycle: insert, list, access join, feed predicate, queued →
deployed sync, failed transition.

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

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-10 04:01:40 +02:00
111 changed files with 11634 additions and 4809 deletions

No files matched your search

+3
View File
@@ -124,3 +124,6 @@ ref/
.playwright-mcp/ .playwright-mcp/
canvas-*.png canvas-*.png
.vercel .vercel
# Local design previews (not shipped)
design/
+14 -4
View File
@@ -1,6 +1,6 @@
# Containr — Roadmap 2: The dflow Convergence # Containr — Roadmap 2: The dflow Convergence
Last verified: **2026-10-08** — deep pass over the full dflow codebase Last verified: **2026-10-10** — deep pass over the full dflow codebase
(`~/Desktop/PROG+HTML/dflow`, community fork of dflow-sh/dflow, frozen for (`~/Desktop/PROG+HTML/dflow`, community fork of dflow-sh/dflow, frozen for
preservation) against Containr `main` (`15b5b0e`). preservation) against Containr `main` (`15b5b0e`).
@@ -337,8 +337,9 @@ and wired refs, one click or one CLI call.
and local + tags are rejected, empty array clears. Surfaces: and local + tags are rejected, empty array clears. Surfaces:
`--placement-tags` on services create/update, `--tags` on `--placement-tags` on services create/update, `--tags` on
`nodes update`, MCP args, OpenAPI, node-detail tag editor + `nodes update`, MCP args, OpenAPI, node-detail tag editor +
service-detail placement-tags input. Remaining: build-on-node, service-detail placement-tags input. Build-on-node and remote
remote sleep/wake, spread weighting by real capacity. sleep/wake both shipped (see below). Remaining: spread weighting
by real capacity.
- [x] **Agent self-upgrade** — `POST /agents/:id/upgrade` enqueues a - [x] **Agent self-upgrade** — `POST /agents/:id/upgrade` enqueues a
`self_upgrade` command carrying platform + sha256 + server version; `self_upgrade` command carrying platform + sha256 + server version;
the agent downloads its replacement from `GET /agents/download/ the agent downloads its replacement from `GET /agents/download/
@@ -537,9 +538,18 @@ and wired refs, one click or one CLI call.
workspace, CLI `environments` + MCP parity. Per-env vars/domains ride the workspace, CLI `environments` + MCP parity. Per-env vars/domains ride the
service's own scope (one env per service); promote ships via preview service's own scope (one env per service); promote ships via preview
promote. promote.
- [ ] **sqlc port** — ~300 raw `database/sql` sites → `sqlc/queries/`. Done in tranches (dynamic queries stay raw): metrics storage + service domains ported. - [x] **sqlc port** — all static `database/sql` call sites → `sqlc/queries/` + generated `sqlcdb`. Merged: metrics + domains (#75), service clone/move (#76), preview environments (#77), deployments + rollback (#78), runtime ops (#79), cron (#80), sleep/wake sweeper (#81), git providers/repos/webhooks (#82), auth + security (#83), operations/settings/audit/activity/builds/misc + apwhy gateway (#84). Raw SQL remains only for runtime-assembled statements (filtered lists, sparse updates) and Better Auth `auth_users` probes.
- [ ] **Tests** — canvas/wizard frontend coverage, deployment/template/ - [ ] **Tests** — canvas/wizard frontend coverage, deployment/template/
webhook table tests, Playwright e2e golden path. webhook table tests, Playwright e2e golden path.
- [x] **UI/UX audit** — browser-driven pass over the whole app (PR #85):
fixed the stuck connectivity banner (`pingServer` returned
`undefined`), stale persisted sessions, fabricated observability
metrics (now real `getServiceMetrics` aggregation), mobile overflow,
Security blank state, deep-linkable service tabs, and a dozen
polish items.
- [x] **Design previews** — gitignored `/design` holds three on-brand
HTML concepts (console, service detail, landing) on the product's
token system with a live white-label accent switcher.
- [x] **First-run wizard** — public `GET /setup/status` + authed - [x] **First-run wizard** — public `GET /setup/status` + authed
`POST /setup/complete`; `/setup` page steps admin account → GitHub App `POST /setup/complete`; `/setup` page steps admin account → GitHub App
manifest flow (callback returns to the wizard via a session marker) → manifest flow (callback returns to the wizard via a session marker) →
+21 -30
View File
@@ -12,6 +12,7 @@ import (
"time" "time"
"containr/internal/database" "containr/internal/database"
"containr/internal/database/sqlcdb"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
@@ -180,13 +181,13 @@ func handleGetProjectActivity(c *gin.Context) {
} }
// Access check: approved / owner / member / admin. // Access check: approved / owner / member / admin.
var visible bool userUUID, _ := uuid.Parse(userID)
if err := db.QueryRow( visible, err := sqlcdb.New(db.DB).CheckSecurityProjectAccess(context.Background(), sqlcdb.CheckSecurityProjectAccessParams{
`SELECT p.is_approved OR p.owner_id = $2::uuid OR $3::bool ID: uuid.MustParse(projectID),
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $2::uuid) OwnerID: userUUID,
FROM projects p WHERE p.id = $1`, Column3: contextIsAdmin(c),
projectID, userID, contextIsAdmin(c), })
).Scan(&visible); err != nil || !visible { if err != nil || !visible {
respondError(c, http.StatusNotFound, "NOT_FOUND", "project not found") respondError(c, http.StatusNotFound, "NOT_FOUND", "project not found")
return return
} }
@@ -237,12 +238,12 @@ func StartAuditRetention(ctx context.Context, db *database.DB) {
} }
prune := func() { prune := func() {
res, err := db.Exec(`DELETE FROM audit_logs WHERE created_at < NOW() - ($1::int * INTERVAL '1 day')`, days) n, err := sqlcdb.New(db.DB).PruneAuditLogs(context.Background(), int32(days))
if err != nil { if err != nil {
log.Printf("audit retention prune failed: %v", err) log.Printf("audit retention prune failed: %v", err)
return return
} }
if n, _ := res.RowsAffected(); n > 0 { if n > 0 {
log.Printf("audit retention: pruned %d rows older than %d days", n, days) log.Printf("audit retention: pruned %d rows older than %d days", n, days)
} }
} }
@@ -265,34 +266,24 @@ func StartAuditRetention(ctx context.Context, db *database.DB) {
// backfillAuditMetadata classifies rows written before severity/category/label // backfillAuditMetadata classifies rows written before severity/category/label
// existed so filters and the feed see uniform metadata. // existed so filters and the feed see uniform metadata.
func backfillAuditMetadata(db *database.DB) { func backfillAuditMetadata(db *database.DB) {
cursor := uuid.Nil.String() q := sqlcdb.New(db.DB)
cursor := uuid.Nil
for { for {
rows, err := db.Query( rows, err := q.ListAuditBackfillBatch(context.Background(), cursor)
`SELECT id::text, resource, action FROM audit_logs
WHERE label = '' AND id > $1::uuid ORDER BY id LIMIT 500`, cursor)
if err != nil { if err != nil {
log.Printf("audit backfill scan failed: %v", err) log.Printf("audit backfill scan failed: %v", err)
return return
} }
type row struct{ id, resource, action string } if len(rows) == 0 {
var batch []row
for rows.Next() {
var r row
if rows.Scan(&r.id, &r.resource, &r.action) == nil {
batch = append(batch, r)
}
}
rows.Close()
if len(batch) == 0 {
return return
} }
for _, r := range batch { for _, r := range rows {
cursor = r.id id, _ := uuid.Parse(r.ID)
sev, cat, label := classifyAuditEvent(r.resource, r.action) cursor = id
if _, err := db.Exec( sev, cat, label := classifyAuditEvent(r.Resource, r.Action)
`UPDATE audit_logs SET severity = $1, category = $2, label = $3 WHERE id = $4::uuid`, if err := q.SetAuditLogMetadata(context.Background(), sqlcdb.SetAuditLogMetadataParams{
sev, cat, label, r.id, Severity: sev, Category: cat, Label: label, Column4: id,
); err != nil { }); err != nil {
log.Printf("audit backfill update failed: %v", err) log.Printf("audit backfill update failed: %v", err)
} }
} }
+21 -32
View File
@@ -52,15 +52,11 @@ func EnsureAdminAccount(ctx context.Context, db *database.DB, cfg *config.Config
return return
} }
var userID string if _, err := sqlcdb.New(db.DB).UpsertAdminUser(ctx, sqlcdb.UpsertAdminUserParams{
err = db.QueryRowContext(ctx, ` Email: email,
INSERT INTO users (email, password_hash, name, is_admin) PasswordHash: string(hashed),
VALUES ($1, $2, $3, true) Name: name,
ON CONFLICT (email) DO UPDATE }); err != nil {
SET is_admin = true, updated_at = NOW()
RETURNING id
`, email, string(hashed), name).Scan(&userID)
if err != nil {
log.Printf("Admin bootstrap failed to upsert user %s: %v", email, err) log.Printf("Admin bootstrap failed to upsert user %s: %v", email, err)
return return
} }
@@ -185,24 +181,18 @@ func handleAdminOverview(c *gin.Context) {
func handleAdminListUsers(c *gin.Context) { func handleAdminListUsers(c *gin.Context) {
db := c.MustGet("db").(*database.DB) db := c.MustGet("db").(*database.DB)
rows, err := db.QueryContext(c.Request.Context(), ` rows, err := sqlcdb.New(db.DB).ListAdminUsers(c.Request.Context())
SELECT id, email, name, COALESCE(avatar_url, ''), is_admin, COALESCE(to_char(created_at, 'YYYY-MM-DD"T"HH24:MI:SS"Z"'), '')
FROM users
ORDER BY created_at ASC
`)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list users"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list users"})
return return
} }
defer rows.Close()
users := make([]adminUserRow, 0) users := make([]adminUserRow, 0, len(rows))
for rows.Next() { for _, r := range rows {
var u adminUserRow users = append(users, adminUserRow{
if err := rows.Scan(&u.ID, &u.Email, &u.Name, &u.AvatarURL, &u.IsAdmin, &u.CreatedAt); err != nil { ID: r.ID.String(), Email: r.Email, Name: r.Name,
continue AvatarURL: r.AvatarUrl, IsAdmin: r.IsAdmin, CreatedAt: r.CreatedAt,
} })
users = append(users, u)
} }
c.JSON(http.StatusOK, gin.H{"users": users}) c.JSON(http.StatusOK, gin.H{"users": users})
} }
@@ -229,15 +219,16 @@ func handleAdminSetUserAdmin(c *gin.Context) {
return return
} }
result, err := db.ExecContext(c.Request.Context(), targetUUID, _ := uuid.Parse(targetID)
`UPDATE users SET is_admin = $1, updated_at = NOW() WHERE id = $2`, n, err := sqlcdb.New(db.DB).SetUserAdmin(c.Request.Context(), sqlcdb.SetUserAdminParams{
req.IsAdmin, targetID, IsAdmin: req.IsAdmin,
) ID: targetUUID,
})
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user"})
return return
} }
if n, _ := result.RowsAffected(); n == 0 { if n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return return
} }
@@ -258,15 +249,13 @@ func handleAdminImpersonateUser(c *gin.Context) {
return return
} }
var email, name string userUUID, _ := uuid.Parse(targetID)
var isAdmin bool info, err := sqlcdb.New(db.DB).GetAdminUserInfo(c.Request.Context(), userUUID)
err := db.QueryRowContext(c.Request.Context(),
`SELECT email, COALESCE(name, ''), is_admin FROM users WHERE id = $1`, targetID).
Scan(&email, &name, &isAdmin)
if err != nil { if err != nil {
respondError(c, http.StatusNotFound, "NOT_FOUND", "user not found") respondError(c, http.StatusNotFound, "NOT_FOUND", "user not found")
return return
} }
email, name, isAdmin := info.Email, info.Name, info.IsAdmin
if isAdmin { if isAdmin {
respondError(c, http.StatusForbidden, "FORBIDDEN", "admin accounts cannot be impersonated") respondError(c, http.StatusForbidden, "FORBIDDEN", "admin accounts cannot be impersonated")
return return
+113 -181
View File
@@ -2,6 +2,7 @@ package api
import ( import (
"containr/internal/database" "containr/internal/database"
"containr/internal/database/sqlcdb"
"context" "context"
"crypto/rand" "crypto/rand"
"database/sql" "database/sql"
@@ -14,6 +15,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
"github.com/google/uuid"
"github.com/lib/pq" "github.com/lib/pq"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
@@ -160,21 +162,22 @@ func handleAPwhyServiceValidate(c *gin.Context) {
db := c.MustGet("db").(*database.DB) db := c.MustGet("db").(*database.DB)
ctx := c.Request.Context() ctx := c.Request.Context()
var upstreamURL, healthPath string svcUUID, parseErr := uuid.Parse(serviceID)
var authHeader, authValue sql.NullString var svc sqlcdb.GetAPServiceForValidateRow
var timeoutMs sql.NullInt64 var err error
err := db.QueryRowContext(ctx, ` if parseErr == nil {
SELECT upstream_url, health_path, upstream_auth_header, upstream_auth_value, request_timeout_ms svc, err = sqlcdb.New(db.DB).GetAPServiceForValidate(ctx, svcUUID)
FROM api_services WHERE id = $1 }
`, serviceID).Scan(&upstreamURL, &healthPath, &authHeader, &authValue, &timeoutMs) if parseErr != nil || err != nil {
if err != nil {
sendErrorResponse(c, http.StatusNotFound, "SERVICE_NOT_FOUND", "Service not found", nil) sendErrorResponse(c, http.StatusNotFound, "SERVICE_NOT_FOUND", "Service not found", nil)
return return
} }
upstreamURL, healthPath := svc.UpstreamUrl, svc.HealthPath
authHeader, authValue := svc.UpstreamAuthHeader, svc.UpstreamAuthValue
timeout := 8000 timeout := 8000
if timeoutMs.Valid && timeoutMs.Int64 > 0 { if svc.RequestTimeoutMs.Valid && svc.RequestTimeoutMs.Int32 > 0 {
timeout = int(timeoutMs.Int64) timeout = int(svc.RequestTimeoutMs.Int32)
} }
target := strings.TrimRight(upstreamURL, "/") + "/" + strings.TrimLeft(healthPath, "/") target := strings.TrimRight(upstreamURL, "/") + "/" + strings.TrimLeft(healthPath, "/")
@@ -203,17 +206,19 @@ func handleAPwhyServiceValidate(c *gin.Context) {
validationStatus = "healthy" validationStatus = "healthy"
} }
_, _ = db.ExecContext(ctx, ` q := sqlcdb.New(db.DB)
UPDATE api_services _ = q.SetAPServiceValidation(ctx, sqlcdb.SetAPServiceValidationParams{
SET last_validation_at = NOW(), last_validation_status = $1, last_validation_message = $2, updated_at = NOW() LastValidationStatus: sql.NullString{String: validationStatus, Valid: true},
WHERE id = $3 LastValidationMessage: sql.NullString{String: message, Valid: true},
`, validationStatus, message, serviceID) ID: svcUUID,
})
if !ok { if !ok {
_, _ = db.ExecContext(ctx, ` _ = q.InsertServiceIncident(ctx, sqlcdb.InsertServiceIncidentParams{
INSERT INTO incident_events (service_id, code, message, severity, http_status, occurred_at) ServiceID: uuid.NullUUID{UUID: svcUUID, Valid: true},
VALUES ($1, 'SERVICE_VALIDATION_FAILED', $2, 'medium', $3, NOW()) Message: message,
`, serviceID, message, status) HttpStatus: sql.NullInt32{Int32: int32(status), Valid: true},
})
} }
sendSuccessResponse(c, http.StatusOK, map[string]interface{}{ sendSuccessResponse(c, http.StatusOK, map[string]interface{}{
@@ -231,36 +236,24 @@ func handleAPwhyServicesList(c *gin.Context) {
ctx := context.Background() ctx := context.Background()
// Query services from database // Query services from database
rows, err := db.QueryContext(ctx, ` rows, err := sqlcdb.New(db.DB).ListAPServices(ctx)
SELECT id, name, slug, upstream_url, route_prefix, enabled, created_at, updated_at
FROM api_services
ORDER BY created_at DESC
`)
if err != nil { if err != nil {
sendErrorResponse(c, http.StatusInternalServerError, "DATABASE_ERROR", sendErrorResponse(c, http.StatusInternalServerError, "DATABASE_ERROR",
"Failed to query services", err.Error()) "Failed to query services", err.Error())
return return
} }
defer rows.Close()
services := make([]map[string]interface{}, 0)
for rows.Next() {
var id, name, slug, upstreamURL, routePrefix, createdAt, updatedAt string
var enabled int
err := rows.Scan(&id, &name, &slug, &upstreamURL, &routePrefix, &enabled, &createdAt, &updatedAt)
if err != nil {
continue // Skip malformed rows
}
services := make([]map[string]interface{}, 0, len(rows))
for _, r := range rows {
services = append(services, map[string]interface{}{ services = append(services, map[string]interface{}{
"id": id, "id": r.ID.String(),
"name": name, "name": r.Name,
"slug": slug, "slug": r.Slug,
"upstreamUrl": upstreamURL, "upstreamUrl": r.UpstreamUrl,
"routePrefix": routePrefix, "routePrefix": r.RoutePrefix,
"enabled": enabled != 0, "enabled": r.Enabled != 0,
"createdAt": createdAt, "createdAt": r.CreatedAt,
"updatedAt": updatedAt, "updatedAt": r.UpdatedAt,
}) })
} }
@@ -299,14 +292,14 @@ func handleAPwhyServicesCreate(c *gin.Context) {
} }
// id defaults to gen_random_uuid(); enabled defaults to 1. // id defaults to gen_random_uuid(); enabled defaults to 1.
var id string svcID, err := sqlcdb.New(db.DB).InsertAPService(ctx, sqlcdb.InsertAPServiceParams{
err := db.QueryRowContext(ctx, ` Name: req.Name,
INSERT INTO api_services ( Slug: slug,
name, slug, upstream_url, route_prefix, health_path, UpstreamUrl: req.UpstreamURL,
rpm_limit, monthly_quota, created_at, updated_at RoutePrefix: req.RoutePrefix,
) VALUES ($1, $2, $3, $4, '/health', $5, $6, NOW(), NOW()) RpmLimit: sql.NullInt32{Int32: int32(rpmLimit), Valid: true},
RETURNING id MonthlyQuota: sql.NullInt32{Int32: int32(monthlyQuota), Valid: true},
`, req.Name, slug, req.UpstreamURL, req.RoutePrefix, rpmLimit, monthlyQuota).Scan(&id) })
if err != nil { if err != nil {
if isUniqueViolation(err) { if isUniqueViolation(err) {
@@ -320,7 +313,7 @@ func handleAPwhyServicesCreate(c *gin.Context) {
} }
serviceData := map[string]interface{}{ serviceData := map[string]interface{}{
"id": id, "id": svcID.String(),
"name": req.Name, "name": req.Name,
"slug": slug, "slug": slug,
"upstreamUrl": req.UpstreamURL, "upstreamUrl": req.UpstreamURL,
@@ -356,13 +349,15 @@ func handleAPwhyServicesPatch(c *gin.Context) {
db := c.MustGet("db").(*database.DB) db := c.MustGet("db").(*database.DB)
if input.Enabled != nil { if input.Enabled != nil {
enabled := 0 enabled := int32(0)
if *input.Enabled { if *input.Enabled {
enabled = 1 enabled = 1
} }
_, err := db.ExecContext(context.Background(), svcUUID, _ := uuid.Parse(serviceID)
"UPDATE api_services SET enabled = $1, updated_at = NOW() WHERE id = $2", err := sqlcdb.New(db.DB).SetAPServiceEnabled(context.Background(), sqlcdb.SetAPServiceEnabledParams{
enabled, serviceID) Enabled: enabled,
ID: svcUUID,
})
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
@@ -383,11 +378,7 @@ func handleAPwhyServicesPatch(c *gin.Context) {
func handleAPwhyKeysList(c *gin.Context) { func handleAPwhyKeysList(c *gin.Context) {
db := c.MustGet("db").(*database.DB) db := c.MustGet("db").(*database.DB)
rows, err := db.QueryContext(context.Background(), ` rows, err := sqlcdb.New(db.DB).ListAPKeys(context.Background())
SELECT id, name, key_prefix, plan, enabled, rpm_limit, monthly_quota, created_at, updated_at
FROM api_keys
ORDER BY created_at DESC
`)
if err != nil { if err != nil {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"ok": true, "ok": true,
@@ -395,34 +386,24 @@ func handleAPwhyKeysList(c *gin.Context) {
}) })
return return
} }
defer rows.Close()
keys := make([]map[string]interface{}, 0)
for rows.Next() {
var id, name, keyPrefix, plan, createdAt, updatedAt string
var enabled int
var rpmLimit, monthlyQuota sql.NullInt64
err := rows.Scan(&id, &name, &keyPrefix, &plan, &enabled, &rpmLimit, &monthlyQuota, &createdAt, &updatedAt)
if err != nil {
continue
}
keys := make([]map[string]interface{}, 0, len(rows))
for _, r := range rows {
key := map[string]interface{}{ key := map[string]interface{}{
"id": id, "id": r.ID.String(),
"name": name, "name": r.Name,
"keyPrefix": keyPrefix, "keyPrefix": r.KeyPrefix,
"plan": plan, "plan": r.Plan,
"enabled": enabled != 0, "enabled": r.Enabled != 0,
"createdAt": createdAt, "createdAt": r.CreatedAt,
"updatedAt": updatedAt, "updatedAt": r.UpdatedAt,
} }
if rpmLimit.Valid { if r.RpmLimit.Valid {
key["rpmLimit"] = rpmLimit.Int64 key["rpmLimit"] = int64(r.RpmLimit.Int32)
} }
if monthlyQuota.Valid { if r.MonthlyQuota.Valid {
key["monthlyQuota"] = monthlyQuota.Int64 key["monthlyQuota"] = int64(r.MonthlyQuota.Int32)
} }
keys = append(keys, key) keys = append(keys, key)
@@ -481,14 +462,14 @@ func handleAPwhyKeysCreate(c *gin.Context) {
} }
// id defaults to gen_random_uuid(); enabled defaults to 1. // id defaults to gen_random_uuid(); enabled defaults to 1.
var id string keyID, err := sqlcdb.New(db.DB).InsertAPKey(context.Background(), sqlcdb.InsertAPKeyParams{
err = db.QueryRowContext(context.Background(), ` Name: input.Name,
INSERT INTO api_keys ( KeyHash: keyHash,
name, key_hash, key_prefix, plan, allowed_service_ids, KeyPrefix: keyPrefix,
rpm_limit, monthly_quota, created_at, updated_at Plan: plan,
) VALUES ($1, $2, $3, $4, '[]', $5, $6, NOW(), NOW()) RpmLimit: sql.NullInt32{Int32: int32(rpmLimit), Valid: true},
RETURNING id MonthlyQuota: sql.NullInt32{Int32: int32(monthlyQuota), Valid: true},
`, input.Name, keyHash, keyPrefix, plan, rpmLimit, monthlyQuota).Scan(&id) })
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
@@ -501,7 +482,7 @@ func handleAPwhyKeysCreate(c *gin.Context) {
c.JSON(http.StatusCreated, gin.H{ c.JSON(http.StatusCreated, gin.H{
"ok": true, "ok": true,
"data": gin.H{ "data": gin.H{
"id": id, "id": keyID.String(),
"name": input.Name, "name": input.Name,
"plan": plan, "plan": plan,
"key": apiKey, // Only return the actual key once "key": apiKey, // Only return the actual key once
@@ -532,13 +513,15 @@ func handleAPwhyKeysPatch(c *gin.Context) {
db := c.MustGet("db").(*database.DB) db := c.MustGet("db").(*database.DB)
if input.Enabled != nil { if input.Enabled != nil {
enabled := 0 enabled := int32(0)
if *input.Enabled { if *input.Enabled {
enabled = 1 enabled = 1
} }
_, err := db.ExecContext(context.Background(), keyUUID, _ := uuid.Parse(keyID)
"UPDATE api_keys SET enabled = $1, updated_at = NOW() WHERE id = $2", err := sqlcdb.New(db.DB).SetAPKeyEnabled(context.Background(), sqlcdb.SetAPKeyEnabledParams{
enabled, keyID) Enabled: enabled,
ID: keyUUID,
})
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
@@ -560,23 +543,27 @@ func handleAPwhyAnalyticsOps(c *gin.Context) {
db := c.MustGet("db").(*database.DB) db := c.MustGet("db").(*database.DB)
// Get counts from database // Get counts from database
var totalServices, totalKeys, totalUsers int q := sqlcdb.New(db.DB)
ctx := context.Background()
if err := db.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM api_services").Scan(&totalServices); err != nil { totalServices, err := q.CountAPServices(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"ok": false, "ok": false,
"error": "Failed to count services: " + err.Error(), "error": "Failed to count services: " + err.Error(),
}) })
return return
} }
if err := db.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM api_keys").Scan(&totalKeys); err != nil { totalKeys, err := q.CountAPKeys(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"ok": false, "ok": false,
"error": "Failed to count api keys: " + err.Error(), "error": "Failed to count api keys: " + err.Error(),
}) })
return return
} }
if err := db.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM users").Scan(&totalUsers); err != nil { totalUsers, err := q.CountUsers(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"ok": false, "ok": false,
"error": "Failed to count users: " + err.Error(), "error": "Failed to count users: " + err.Error(),
@@ -584,8 +571,8 @@ func handleAPwhyAnalyticsOps(c *gin.Context) {
return return
} }
var totalRequests int totalRequests, err := q.SumUsageCounters(ctx)
if err := db.QueryRowContext(context.Background(), "SELECT COALESCE(SUM(request_count), 0) FROM usage_counters").Scan(&totalRequests); err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"ok": false, "ok": false,
"error": "Failed to aggregate request counters: " + err.Error(), "error": "Failed to aggregate request counters: " + err.Error(),
@@ -593,12 +580,8 @@ func handleAPwhyAnalyticsOps(c *gin.Context) {
return return
} }
var requestsToday int requestsToday, err := q.SumRequestMetricSince(ctx, "day")
if err := db.QueryRowContext(context.Background(), ` if err != nil {
SELECT COALESCE(SUM(value), 0)::int
FROM metrics_timeseries
WHERE metric = 'request_total' AND occurred_at >= DATE_TRUNC('day', NOW())
`).Scan(&requestsToday); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"ok": false, "ok": false,
"error": "Failed to aggregate today's requests: " + err.Error(), "error": "Failed to aggregate today's requests: " + err.Error(),
@@ -606,12 +589,8 @@ func handleAPwhyAnalyticsOps(c *gin.Context) {
return return
} }
var requestsThisMonth int requestsThisMonth, err := q.SumRequestMetricSince(ctx, "month")
if err := db.QueryRowContext(context.Background(), ` if err != nil {
SELECT COALESCE(SUM(value), 0)::int
FROM metrics_timeseries
WHERE metric = 'request_total' AND occurred_at >= DATE_TRUNC('month', NOW())
`).Scan(&requestsThisMonth); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"ok": false, "ok": false,
"error": "Failed to aggregate monthly requests: " + err.Error(), "error": "Failed to aggregate monthly requests: " + err.Error(),
@@ -636,96 +615,49 @@ func handleAPwhyAnalyticsOps(c *gin.Context) {
func handleAPwhyAnalyticsTraffic(c *gin.Context) { func handleAPwhyAnalyticsTraffic(c *gin.Context) {
db := c.MustGet("db").(*database.DB) db := c.MustGet("db").(*database.DB)
q := sqlcdb.New(db.DB)
ctx := context.Background()
topServices := make([]map[string]interface{}, 0) topServices := make([]map[string]interface{}, 0)
serviceRows, err := db.QueryContext(context.Background(), ` if serviceRows, err := q.ListTopAPServices(ctx); err == nil {
SELECT s.id, s.name, COALESCE(SUM(u.request_count), 0) AS total_requests for _, r := range serviceRows {
FROM api_services s
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 {
topServices = append(topServices, map[string]interface{}{ topServices = append(topServices, map[string]interface{}{
"service_id": serviceID, "service_id": r.ID.String(),
"name": serviceName, "name": r.Name,
"requests": totalRequests, "requests": r.TotalRequests,
}) })
} }
} }
}
requestsByDay := make([]map[string]interface{}, 0) requestsByDay := make([]map[string]interface{}, 0)
trafficRows, err := db.QueryContext(context.Background(), ` if trafficRows, err := q.ListRequestsByDay(ctx); err == nil {
SELECT TO_CHAR(DATE_TRUNC('day', occurred_at), 'YYYY-MM-DD') AS day_bucket, COUNT(*) AS total for _, r := range trafficRows {
FROM metrics_timeseries
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 {
requestsByDay = append(requestsByDay, map[string]interface{}{ requestsByDay = append(requestsByDay, map[string]interface{}{
"day": day, "day": r.DayBucket,
"requests": count, "requests": r.Total,
}) })
} }
} }
}
statusCodes := make([]map[string]interface{}, 0) statusCodes := make([]map[string]interface{}, 0)
statusRows, err := db.QueryContext(context.Background(), ` if statusRows, err := q.ListIncidentStatusCodes(ctx); err == nil {
SELECT COALESCE(http_status, 0) AS status_code, COALESCE(SUM(count), 0) AS total for _, r := range statusRows {
FROM incident_events
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 {
statusCodes = append(statusCodes, map[string]interface{}{ statusCodes = append(statusCodes, map[string]interface{}{
"status_code": code, "status_code": r.StatusCode,
"count": total, "count": r.Total,
}) })
} }
} }
}
clientEvents := make([]map[string]interface{}, 0) clientEvents := make([]map[string]interface{}, 0)
eventRows, err := db.QueryContext(context.Background(), ` if eventRows, err := q.ListClientEventPaths(ctx); err == nil {
SELECT COALESCE((labels_json::jsonb ->> 'path'), 'unknown') AS path, COUNT(*) AS total for _, r := range eventRows {
FROM metrics_timeseries
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 {
clientEvents = append(clientEvents, map[string]interface{}{ clientEvents = append(clientEvents, map[string]interface{}{
"path": path, "path": r.Path,
"count": total, "count": r.Total,
}) })
} }
} }
}
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"ok": true, "ok": true,
+54 -34
View File
@@ -2,6 +2,9 @@ package api
import ( import (
"containr/internal/database" "containr/internal/database"
"containr/internal/database/sqlcdb"
"context"
"database/sql"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
@@ -11,6 +14,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/sqlc-dev/pqtype"
) )
type AuditLog struct { type AuditLog struct {
@@ -98,16 +102,20 @@ func LogAudit(userID, resource, resourceID, action string, details map[string]in
} }
detailsJSON, _ := json.Marshal(details) detailsJSON, _ := json.Marshal(details)
resourceUUID := parseUUIDOrNil(resourceID)
userUUID := parseUUIDOrNil(userID)
severity, category, label := classifyAuditEvent(resource, action) severity, category, label := classifyAuditEvent(resource, action)
auditID := uuid.New().String() err := sqlcdb.New(db.DB).InsertAuditLog(context.Background(), sqlcdb.InsertAuditLogParams{
_, err := db.Exec( ID: uuid.New(),
`INSERT INTO audit_logs (id, user_id, resource, resource_id, action, details, severity, category, label, created_at) UserID: nullUUIDOrNil(userID),
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, Resource: resource,
auditID, userUUID, resource, resourceUUID, action, string(detailsJSON), severity, category, label, time.Now().UTC(), ResourceID: nullUUIDOrNil(resourceID),
) Action: action,
Details: pqtype.NullRawMessage{RawMessage: detailsJSON, Valid: true},
Severity: severity,
Category: category,
Label: label,
CreatedAt: time.Now().UTC(),
})
if err != nil { if err != nil {
} }
@@ -131,16 +139,24 @@ func LogAuditWithRequest(c *gin.Context, resource, resourceID, action string, de
if uid, ok := userID.(string); ok { if uid, ok := userID.(string); ok {
userIDStr = uid userIDStr = uid
} }
userUUID := parseUUIDOrNil(userIDStr)
resourceUUID := parseUUIDOrNil(resourceID)
severity, category, label := classifyAuditEvent(resource, action) severity, category, label := classifyAuditEvent(resource, action)
auditID := uuid.New().String() var inet pqtype.Inet
_, err := db.Exec( _ = inet.Scan(c.ClientIP())
`INSERT INTO audit_logs (id, user_id, resource, resource_id, action, details, ip_address, user_agent, severity, category, label, created_at) err := sqlcdb.New(db.DB).InsertAuditLogWithRequest(context.Background(), sqlcdb.InsertAuditLogWithRequestParams{
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9, $10, $11, $12)`, ID: uuid.New(),
auditID, userUUID, resource, resourceUUID, action, string(detailsJSON), c.ClientIP(), c.GetHeader("User-Agent"), severity, category, label, time.Now().UTC(), UserID: nullUUIDOrNil(userIDStr),
) Resource: resource,
ResourceID: nullUUIDOrNil(resourceID),
Action: action,
Column6: json.RawMessage(detailsJSON),
Column7: inet,
UserAgent: sql.NullString{String: c.GetHeader("User-Agent"), Valid: true},
Severity: severity,
Category: category,
Label: label,
CreatedAt: time.Now().UTC(),
})
if err != nil { if err != nil {
} }
@@ -255,30 +271,29 @@ func handleGetResourceAuditLogs(c *gin.Context) {
resource := c.Param("resource") resource := c.Param("resource")
resourceID := c.Param("id") resourceID := c.Param("id")
rows, err := db.Query( rows, err := sqlcdb.New(db.DB).ListResourceAuditLogs(context.Background(), sqlcdb.ListResourceAuditLogsParams{
`SELECT id, COALESCE(user_id::text, ''), resource, COALESCE(resource_id::text, ''), action, COALESCE(details::text, '{}'), UserID: nullUUIDOrNil(userID),
COALESCE(ip_address::text, ''), COALESCE(user_agent, ''), created_at Resource: resource,
FROM audit_logs ResourceID: nullUUIDOrNil(resourceID),
WHERE user_id::text = $1 AND resource = $2 AND resource_id::text = $3 })
ORDER BY created_at DESC
LIMIT 100`,
userID, resource, resourceID,
)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch audit logs"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch audit logs"})
return return
} }
defer rows.Close()
var logs []AuditLog var logs []AuditLog
for rows.Next() { for _, row := range rows {
var log AuditLog logs = append(logs, AuditLog{
err := rows.Scan(&log.ID, &log.UserID, &log.Resource, &log.ResourceID, &log.Action, &log.Details, &log.IPAddress, &log.UserAgent, &log.CreatedAt) ID: row.ID.String(),
if err != nil { UserID: row.UserID,
continue Resource: row.Resource,
} ResourceID: row.ResourceID,
logs = append(logs, log) Action: row.Action,
Details: row.Details,
IPAddress: row.IpAddress,
UserAgent: row.UserAgent,
CreatedAt: row.CreatedAt,
})
} }
c.JSON(http.StatusOK, gin.H{"audit_logs": logs}) c.JSON(http.StatusOK, gin.H{"audit_logs": logs})
@@ -302,3 +317,8 @@ func parseUUIDOrNil(raw string) interface{} {
} }
return trimmed return trimmed
} }
func nullUUIDOrNil(raw string) uuid.NullUUID {
parsed, err := uuid.Parse(strings.TrimSpace(raw))
return uuid.NullUUID{UUID: parsed, Valid: err == nil}
}
+78 -46
View File
@@ -3,6 +3,8 @@ package api
import ( import (
"bytes" "bytes"
"containr/internal/database" "containr/internal/database"
"containr/internal/database/sqlcdb"
"context"
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"errors" "errors"
@@ -63,11 +65,16 @@ func handleLogin(c *gin.Context) {
// Find user by email // Find user by email
var user User var user User
var hashedPassword string var hashedPassword string
err := db.QueryRow(` urow, err := sqlcdb.New(db.DB).GetUserByEmailForAuth(context.Background(), req.Email)
SELECT id, email, password_hash, name, COALESCE(avatar_url, ''), is_admin, created_at user = User{
FROM users ID: urow.ID.String(),
WHERE email = $1 Email: urow.Email,
`, req.Email).Scan(&user.ID, &user.Email, &hashedPassword, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt) Name: urow.Name,
AvatarURL: urow.AvatarUrl,
IsAdmin: urow.IsAdmin,
CreatedAt: urow.CreatedAt.Time.String(),
}
hashedPassword = urow.PasswordHash
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
authUser, ok := verifyBetterAuthCredentials(c, req.Email, req.Password) authUser, ok := verifyBetterAuthCredentials(c, req.Email, req.Password)
@@ -257,25 +264,30 @@ func createLocalUserFromBetterAuth(db *database.DB, authUser betterAuthLoginUser
} }
// A mirrored first account owns the platform, same as handleRegister. // A mirrored first account owns the platform, same as handleRegister.
var total int q := sqlcdb.New(db.DB)
if err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&total); err != nil { total64, err := q.CountUsers(context.Background())
if err != nil {
return User{}, err return User{}, err
} }
var user User urow, err := q.UpsertLocalUser(context.Background(), sqlcdb.UpsertLocalUserParams{
err = db.QueryRow(` Email: email,
INSERT INTO users (email, password_hash, name, avatar_url, is_admin) PasswordHash: string(hashedPassword),
VALUES ($1, $2, $3, NULLIF($4, ''), $5) Name: name,
ON CONFLICT (email) DO UPDATE Column4: strings.TrimSpace(authUser.Image),
SET name = EXCLUDED.name, IsAdmin: total64 == 0,
avatar_url = COALESCE(EXCLUDED.avatar_url, users.avatar_url), })
updated_at = NOW()
RETURNING id, email, name, COALESCE(avatar_url, ''), is_admin, created_at
`, email, string(hashedPassword), name, strings.TrimSpace(authUser.Image), total == 0).
Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt)
if err != nil { if err != nil {
return User{}, err return User{}, err
} }
user := User{
ID: urow.ID.String(),
Email: urow.Email,
Name: urow.Name,
AvatarURL: urow.AvatarUrl,
IsAdmin: urow.IsAdmin,
CreatedAt: urow.CreatedAt.Time.String(),
}
return user, nil return user, nil
} }
@@ -302,8 +314,8 @@ func handleRegister(c *gin.Context) {
return return
} }
var existing int q := sqlcdb.New(db.DB)
err = db.QueryRow("SELECT COUNT(*) FROM users WHERE email = $1", strings.ToLower(strings.TrimSpace(req.Email))).Scan(&existing) existing, err := q.CountUsersByEmail(context.Background(), strings.ToLower(strings.TrimSpace(req.Email)))
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return return
@@ -322,12 +334,20 @@ func handleRegister(c *gin.Context) {
} }
// First account owns the platform. // First account owns the platform.
var user User urow, err := q.InsertUserAdmin(context.Background(), sqlcdb.InsertUserAdminParams{
err = db.QueryRow(` Email: req.Email,
INSERT INTO users (email, password_hash, name, is_admin) PasswordHash: string(hashedPassword),
VALUES ($1, $2, $3, $4) Name: req.Name,
RETURNING id, email, name, COALESCE(avatar_url, ''), is_admin, created_at IsAdmin: total == 0,
`, req.Email, string(hashedPassword), req.Name, total == 0).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt) })
user := User{
ID: urow.ID.String(),
Email: urow.Email,
Name: urow.Name,
AvatarURL: urow.AvatarUrl,
IsAdmin: urow.IsAdmin,
CreatedAt: urow.CreatedAt.Time.String(),
}
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
@@ -365,8 +385,8 @@ func handleCreateUser(c *gin.Context) {
return return
} }
var existing int existing, err := sqlcdb.New(db.DB).CountUsersByEmail(context.Background(), req.Email)
if err := db.QueryRow("SELECT COUNT(*) FROM users WHERE email = $1", req.Email).Scan(&existing); err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return return
} }
@@ -390,12 +410,19 @@ func handleCreateUser(c *gin.Context) {
return return
} }
var user User urow, err := sqlcdb.New(db.DB).InsertUser(context.Background(), sqlcdb.InsertUserParams{
err = db.QueryRow(` Email: req.Email,
INSERT INTO users (email, password_hash, name) PasswordHash: string(hashedPassword),
VALUES ($1, $2, $3) Name: req.Name,
RETURNING id, email, name, COALESCE(avatar_url, ''), is_admin, created_at })
`, req.Email, string(hashedPassword), req.Name).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt) user := User{
ID: urow.ID.String(),
Email: urow.Email,
Name: urow.Name,
AvatarURL: urow.AvatarUrl,
IsAdmin: urow.IsAdmin,
CreatedAt: urow.CreatedAt.Time.String(),
}
if err != nil { if err != nil {
c.JSON(http.StatusConflict, gin.H{"error": "User already exists"}) c.JSON(http.StatusConflict, gin.H{"error": "User already exists"})
return return
@@ -458,10 +485,11 @@ func createBetterAuthUser(c *gin.Context, name string, email string, password st
} }
func countLocalUsers(db *database.DB) (int, error) { func countLocalUsers(db *database.DB) (int, error) {
var count int count64, err := sqlcdb.New(db.DB).CountUsers(context.Background())
if err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count); err != nil { if err != nil {
return 0, err return 0, err
} }
count := int(count64)
// Better Auth writes to auth_users; a row there only mirrors into users on // Better Auth writes to auth_users; a row there only mirrors into users on
// the first authenticated call. Count it too or a second public sign-up // the first authenticated call. Count it too or a second public sign-up
// slips through the bootstrap window. UNION by email avoids double-counting // slips through the bootstrap window. UNION by email avoids double-counting
@@ -489,11 +517,15 @@ func handleGetProfile(c *gin.Context) {
db := c.MustGet("db").(*database.DB) db := c.MustGet("db").(*database.DB)
var user User var user User
err := db.QueryRow(` urow, err := sqlcdb.New(db.DB).GetUserByID(context.Background(), uuid.MustParse(userID))
SELECT id, email, name, COALESCE(avatar_url, ''), is_admin, created_at user = User{
FROM users ID: urow.ID.String(),
WHERE id = $1 Email: urow.Email,
`, userID).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt) Name: urow.Name,
AvatarURL: urow.AvatarUrl,
IsAdmin: urow.IsAdmin,
CreatedAt: urow.CreatedAt.Time.String(),
}
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
@@ -521,11 +553,11 @@ func handleUpdateProfile(c *gin.Context) {
} }
// Update user profile // Update user profile
_, err := db.Exec(` err := sqlcdb.New(db.DB).UpdateUserProfile(context.Background(), sqlcdb.UpdateUserProfileParams{
UPDATE users Column1: req.Name,
SET name = COALESCE($1, name), avatar_url = COALESCE($2, avatar_url) Column2: req.AvatarURL,
WHERE id = $3 ID: uuid.MustParse(userID),
`, req.Name, req.AvatarURL, userID) })
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update profile"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update profile"})
+54 -64
View File
@@ -16,6 +16,7 @@ import (
"containr/internal/build" "containr/internal/build"
"containr/internal/database" "containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/docker" "containr/internal/docker"
"containr/internal/types" "containr/internal/types"
@@ -669,39 +670,22 @@ func (h *BuildHandler) upsertBuild(status *BuildStatusResponse) error {
} }
now := time.Now().UTC() now := time.Now().UTC()
_, err := h.db.Exec( err := sqlcdb.New(h.db.DB).UpsertBuild(context.Background(), sqlcdb.UpsertBuildParams{
`INSERT INTO builds ID: status.ID,
(id, project_id, service_id, status, progress, started_at, completed_at, image_name, image_tag, size, error, log, metadata, created_at, updated_at) ProjectID: nsNullIfEmpty(status.ProjectID),
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb, $14, $14) ServiceID: nsNullIfEmpty(status.ServiceID),
ON CONFLICT (id) DO UPDATE SET Status: status.Status,
project_id = EXCLUDED.project_id, Progress: int32(status.Progress),
service_id = EXCLUDED.service_id, StartedAt: status.StartedAt,
status = EXCLUDED.status, CompletedAt: ntPtr(status.CompletedAt),
progress = EXCLUDED.progress, ImageName: status.ImageName,
started_at = EXCLUDED.started_at, ImageTag: status.ImageTag,
completed_at = EXCLUDED.completed_at, Size: status.Size,
image_name = EXCLUDED.image_name, Error: nsNullIfEmpty(status.Error),
image_tag = EXCLUDED.image_tag, Log: status.Log,
size = EXCLUDED.size, Column13: json.RawMessage(jsonOrEmptyObject(metadataRaw)),
error = EXCLUDED.error, CreatedAt: now,
log = EXCLUDED.log, })
metadata = EXCLUDED.metadata,
updated_at = EXCLUDED.updated_at`,
status.ID,
nullIfEmptyString(status.ProjectID),
nullIfEmptyString(status.ServiceID),
status.Status,
status.Progress,
status.StartedAt,
status.CompletedAt,
status.ImageName,
status.ImageTag,
status.Size,
nullIfEmptyString(status.Error),
status.Log,
jsonOrEmptyObject(metadataRaw),
now,
)
if err != nil && h.isMissingBuildsTable(err) { if err != nil && h.isMissingBuildsTable(err) {
return nil return nil
} }
@@ -713,18 +697,37 @@ func (h *BuildHandler) getBuildFromDB(buildID string) (BuildStatusResponse, bool
return BuildStatusResponse{}, false, nil return BuildStatusResponse{}, false, nil
} }
row := h.db.QueryRow( row, err := sqlcdb.New(h.db.DB).GetBuild(context.Background(), buildID)
`SELECT id, project_id, service_id, status, progress, started_at, completed_at, image_name, image_tag, size, error, log, metadata if err != nil {
FROM builds if errors.Is(err, sql.ErrNoRows) || h.isMissingBuildsTable(err) {
WHERE id = $1`,
buildID,
)
build, found, err := scanBuildRow(row.Scan)
if err != nil && h.isMissingBuildsTable(err) {
return BuildStatusResponse{}, false, nil 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) { func (h *BuildHandler) listBuildsFromDB(projectID, serviceID, status string, page, limit int) ([]BuildStatusResponse, int, error) {
@@ -808,19 +811,11 @@ func (h *BuildHandler) cancelBuildInDB(buildID string) bool {
return false return false
} }
now := time.Now().UTC() now := time.Now().UTC()
result, err := h.db.Exec( affected, err := sqlcdb.New(h.db.DB).CancelBuild(context.Background(), sqlcdb.CancelBuildParams{
`UPDATE builds CompletedAt: sql.NullTime{Time: now, Valid: true},
SET status = 'cancelled', Log: h.formatLogLine("Cancellation requested") + "\n",
progress = 100, ID: buildID,
completed_at = $1, })
log = COALESCE(log, '') || $2,
updated_at = $1
WHERE id = $3
AND status NOT IN ('success', 'failed', 'cancelled')`,
now,
h.formatLogLine("Cancellation requested")+"\n",
buildID,
)
if err != nil { if err != nil {
if h.isMissingBuildsTable(err) { if h.isMissingBuildsTable(err) {
return false return false
@@ -828,9 +823,7 @@ func (h *BuildHandler) cancelBuildInDB(buildID string) bool {
log.Printf("failed to cancel build %s in database: %v", buildID, err) log.Printf("failed to cancel build %s in database: %v", buildID, err)
return false return false
} }
if affected == 0 {
affected, err := result.RowsAffected()
if err != nil || affected == 0 {
return false return false
} }
@@ -909,12 +902,9 @@ func scanBuildRow(scan func(dest ...interface{}) error) (BuildStatusResponse, bo
return parsed, true, nil return parsed, true, nil
} }
func nullIfEmptyString(value string) interface{} { func nsNullIfEmpty(value string) sql.NullString {
trimmed := strings.TrimSpace(value) trimmed := strings.TrimSpace(value)
if trimmed == "" { return sql.NullString{String: trimmed, Valid: trimmed != ""}
return nil
}
return trimmed
} }
func jsonOrEmptyObject(raw []byte) string { func jsonOrEmptyObject(raw []byte) string {
+18 -23
View File
@@ -1,9 +1,11 @@
package api package api
import ( import (
"context"
"net/http" "net/http"
"containr/internal/database" "containr/internal/database"
"containr/internal/database/sqlcdb"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
@@ -57,15 +59,12 @@ func contextIsAdmin(c *gin.Context) bool {
// public; unapproved ones are visible to the owner, project members, and // public; unapproved ones are visible to the owner, project members, and
// platform admins only. // platform admins only.
func projectReadAccess(c *gin.Context, db *database.DB, projectID uuid.UUID) (bool, bool) { func projectReadAccess(c *gin.Context, db *database.DB, projectID uuid.UUID) (bool, bool) {
var isApproved bool q := sqlcdb.New(db.DB)
var ownerID string row, err := q.GetProjectReadAccess(context.Background(), projectID)
err := db.QueryRow(
`SELECT is_approved, owner_id::text FROM projects WHERE id = $1`,
projectID,
).Scan(&isApproved, &ownerID)
if err != nil { if err != nil {
return false, false return false, false
} }
isApproved, ownerID := row.IsApproved, row.OwnerID
if isApproved || contextIsAdmin(c) { if isApproved || contextIsAdmin(c) {
return true, true return true, true
} }
@@ -76,11 +75,11 @@ func projectReadAccess(c *gin.Context, db *database.DB, projectID uuid.UUID) (bo
if ownerID == userID { if ownerID == userID {
return true, true return true, true
} }
var member bool userUUID, _ := uuid.Parse(userID)
if err := db.QueryRow( if member, err := q.ProjectMemberExists(context.Background(), sqlcdb.ProjectMemberExistsParams{
`SELECT EXISTS(SELECT 1 FROM project_members WHERE project_id = $1 AND user_id = $2)`, ProjectID: projectID,
projectID, userID, UserID: userUUID,
).Scan(&member); err == nil && member { }); err == nil && member {
return true, true return true, true
} }
return true, false return true, false
@@ -91,11 +90,8 @@ func projectReadAccess(c *gin.Context, db *database.DB, projectID uuid.UUID) (bo
// project members, and platform admins may reach secret-adjacent data // project members, and platform admins may reach secret-adjacent data
// such as variables and runtime logs. // such as variables and runtime logs.
func projectManageAccess(c *gin.Context, db *database.DB, projectID uuid.UUID) (bool, bool) { func projectManageAccess(c *gin.Context, db *database.DB, projectID uuid.UUID) (bool, bool) {
var ownerID string q := sqlcdb.New(db.DB)
err := db.QueryRow( ownerID, err := q.GetProjectOwnerText(context.Background(), projectID)
`SELECT owner_id::text FROM projects WHERE id = $1`,
projectID,
).Scan(&ownerID)
if err != nil { if err != nil {
return false, false return false, false
} }
@@ -109,11 +105,11 @@ func projectManageAccess(c *gin.Context, db *database.DB, projectID uuid.UUID) (
if ownerID == userID { if ownerID == userID {
return true, true return true, true
} }
var member bool userUUID, _ := uuid.Parse(userID)
if err := db.QueryRow( if member, err := q.ProjectMemberExists(context.Background(), sqlcdb.ProjectMemberExistsParams{
`SELECT EXISTS(SELECT 1 FROM project_members WHERE project_id = $1 AND user_id = $2)`, ProjectID: projectID,
projectID, userID, UserID: userUUID,
).Scan(&member); err == nil && member { }); err == nil && member {
return true, true return true, true
} }
return true, false return true, false
@@ -121,8 +117,7 @@ func projectManageAccess(c *gin.Context, db *database.DB, projectID uuid.UUID) (
// serviceProjectID resolves the project that owns a service. // serviceProjectID resolves the project that owns a service.
func serviceProjectID(db *database.DB, serviceID uuid.UUID) (uuid.UUID, bool) { func serviceProjectID(db *database.DB, serviceID uuid.UUID) (uuid.UUID, bool) {
var projectID uuid.UUID projectID, err := sqlcdb.New(db.DB).GetServiceProjectID(context.Background(), serviceID)
err := db.QueryRow(`SELECT project_id FROM services WHERE id = $1`, serviceID).Scan(&projectID)
if err != nil { if err != nil {
return uuid.Nil, false return uuid.Nil, false
} }
+142 -118
View File
@@ -65,13 +65,47 @@ type UpdateCronJobRequest struct {
Retention int `json:"retention"` Retention int `json:"retention"`
} }
// cronJobFrom maps the sqlc row onto the string-ID API model.
func cronJobFrom(j sqlcdb.CronJob) CronJob {
return CronJob{
ID: j.ID.String(),
ProjectID: j.ProjectID.String(),
ServiceID: j.ServiceID.String(),
Name: j.Name,
Schedule: j.Schedule,
Command: j.Command,
Timezone: j.Timezone.String,
Enabled: j.Enabled.Bool,
LastRunAt: timePtrNT(j.LastRunAt),
NextRunAt: timePtrNT(j.NextRunAt),
LastStatus: j.LastStatus.String,
LastOutput: j.LastOutput.String,
Retention: int(j.Retention.Int32),
CreatedAt: j.CreatedAt.Time,
UpdatedAt: j.UpdatedAt.Time,
}
}
// cronExecutionFrom maps the sqlc row onto the string-ID API model.
func cronExecutionFrom(e sqlcdb.CronExecution) CronExecution {
return CronExecution{
ID: e.ID.String(),
CronJobID: e.CronJobID.String(),
StartedAt: e.StartedAt,
FinishedAt: timePtrNT(e.FinishedAt),
Status: e.Status.String,
Output: e.Output.String,
Error: e.Error.String,
}
}
// cronJobProjectID resolves the project a cron job belongs to. // cronJobProjectID resolves the project a cron job belongs to.
func cronJobProjectID(db *database.DB, jobID string) (uuid.UUID, bool) { func cronJobProjectID(db *database.DB, jobID string) (uuid.UUID, bool) {
var projectID uuid.UUID id, err := uuid.Parse(jobID)
err := db.QueryRow( if err != nil {
`SELECT cj.project_id FROM cron_jobs cj WHERE cj.id = $1`, return uuid.Nil, false
jobID, }
).Scan(&projectID) projectID, err := sqlcdb.New(db.DB).GetCronJobProjectID(context.Background(), id)
if err != nil { if err != nil {
return uuid.Nil, false return uuid.Nil, false
} }
@@ -137,15 +171,14 @@ func handleCreateCronJob(c *gin.Context) {
return return
} }
var ownerCheck string svcID, err := uuid.Parse(req.ServiceID)
err := db.QueryRow( if err != nil {
`SELECT p.owner_id FROM projects p c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
JOIN services s ON s.project_id = p.id return
WHERE s.id = $1`, }
req.ServiceID, ownerCheck, err := sqlcdb.New(db.DB).GetCronOwnerViaService(context.Background(), svcID)
).Scan(&ownerCheck)
if err != nil || (ownerCheck != userID && !contextIsAdmin(c)) { if err != nil || (ownerCheck.String() != userID && !contextIsAdmin(c)) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return return
} }
@@ -178,11 +211,20 @@ func handleCreateCronJob(c *gin.Context) {
UpdatedAt: time.Now(), UpdatedAt: time.Now(),
} }
_, err = db.Exec( err = sqlcdb.New(db.DB).InsertCronJob(context.Background(), sqlcdb.InsertCronJobParams{
`INSERT INTO cron_jobs (id, project_id, service_id, name, schedule, command, timezone, enabled, next_run_at, retention, created_at, updated_at) ID: uuid.MustParse(job.ID),
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`, ProjectID: uuid.MustParse(job.ProjectID),
job.ID, job.ProjectID, job.ServiceID, job.Name, job.Schedule, job.Command, job.Timezone, job.Enabled, job.NextRunAt, job.Retention, job.CreatedAt, job.UpdatedAt, ServiceID: uuid.MustParse(job.ServiceID),
) Name: job.Name,
Schedule: job.Schedule,
Command: job.Command,
Timezone: sql.NullString{String: job.Timezone, Valid: true},
Enabled: sql.NullBool{Bool: job.Enabled, Valid: true},
NextRunAt: ntPtr(job.NextRunAt),
Retention: sql.NullInt32{Int32: int32(job.Retention), Valid: true},
CreatedAt: sql.NullTime{Time: job.CreatedAt, Valid: true},
UpdatedAt: sql.NullTime{Time: job.UpdatedAt, Valid: true},
})
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create cron job"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create cron job"})
@@ -201,22 +243,17 @@ func handleGetCronJob(c *gin.Context) {
db := c.MustGet("db").(*database.DB) db := c.MustGet("db").(*database.DB)
jobID := c.Param("id") jobID := c.Param("id")
var job CronJob jid, err := uuid.Parse(jobID)
err := db.QueryRow(
`SELECT cj.id, cj.project_id, cj.service_id, cj.name, cj.schedule, cj.timezone,
cj.enabled, cj.last_run_at, cj.next_run_at, cj.last_status, cj.last_output,
cj.retention, cj.created_at, cj.updated_at
FROM cron_jobs cj
WHERE cj.id = $1`,
jobID,
).Scan(&job.ID, &job.ProjectID, &job.ServiceID, &job.Name, &job.Schedule, &job.Timezone,
&job.Enabled, &job.LastRunAt, &job.NextRunAt, &job.LastStatus, &job.LastOutput,
&job.Retention, &job.CreatedAt, &job.UpdatedAt)
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Cron job not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Cron job not found"})
return return
} }
row, err := sqlcdb.New(db.DB).GetCronJob(context.Background(), jid)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Cron job not found"})
return
}
job := cronJobFrom(row)
projectID, found := cronJobProjectID(db, jobID) projectID, found := cronJobProjectID(db, jobID)
if !found { if !found {
@@ -242,15 +279,14 @@ func handleUpdateCronJob(c *gin.Context) {
return return
} }
var ownerCheck string jid, err := uuid.Parse(jobID)
err := db.QueryRow( if err != nil {
`SELECT p.owner_id FROM cron_jobs cj c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
JOIN projects p ON cj.project_id = p.id return
WHERE cj.id = $1`, }
jobID, ownerCheck, err := sqlcdb.New(db.DB).GetCronJobOwner(context.Background(), jid)
).Scan(&ownerCheck)
if err != nil || (ownerCheck != userID && !contextIsAdmin(c)) { if err != nil || (ownerCheck.String() != userID && !contextIsAdmin(c)) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return return
} }
@@ -312,21 +348,19 @@ func handleDeleteCronJob(c *gin.Context) {
db := c.MustGet("db").(*database.DB) db := c.MustGet("db").(*database.DB)
jobID := c.Param("id") jobID := c.Param("id")
var ownerCheck string jid, err := uuid.Parse(jobID)
err := db.QueryRow( if err != nil {
`SELECT p.owner_id FROM cron_jobs cj c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
JOIN projects p ON cj.project_id = p.id return
WHERE cj.id = $1`, }
jobID, ownerCheck, err := sqlcdb.New(db.DB).GetCronJobOwner(context.Background(), jid)
).Scan(&ownerCheck)
if err != nil || (ownerCheck != userID && !contextIsAdmin(c)) { if err != nil || (ownerCheck.String() != userID && !contextIsAdmin(c)) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return return
} }
_, err = db.Exec("DELETE FROM cron_jobs WHERE id = $1", jobID) if err := sqlcdb.New(db.DB).DeleteCronJob(context.Background(), jid); err != nil {
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete cron job"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete cron job"})
return return
} }
@@ -350,29 +384,20 @@ func handleGetCronExecutions(c *gin.Context) {
return return
} }
rows, err := db.Query( jid, err := uuid.Parse(jobID)
`SELECT id, cron_job_id, started_at, finished_at, status, output, error if err != nil {
FROM cron_executions c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch executions"})
WHERE cron_job_id = $1 return
ORDER BY started_at DESC }
LIMIT 100`, rows, err := sqlcdb.New(db.DB).ListCronExecutions(context.Background(), jid)
jobID,
)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch executions"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch executions"})
return return
} }
defer rows.Close()
var executions []CronExecution var executions []CronExecution
for rows.Next() { for _, e := range rows {
var exec CronExecution executions = append(executions, cronExecutionFrom(e))
err := rows.Scan(&exec.ID, &exec.CronJobID, &exec.StartedAt, &exec.FinishedAt, &exec.Status, &exec.Output, &exec.Error)
if err != nil {
continue
}
executions = append(executions, exec)
} }
c.JSON(http.StatusOK, gin.H{"executions": executions}) c.JSON(http.StatusOK, gin.H{"executions": executions})
@@ -383,29 +408,33 @@ func handleTriggerCronJob(c *gin.Context) {
db := c.MustGet("db").(*database.DB) db := c.MustGet("db").(*database.DB)
jobID := c.Param("id") jobID := c.Param("id")
var job CronJob jid, err := uuid.Parse(jobID)
var ownerCheck string if err != nil {
err := db.QueryRow(
`SELECT cj.service_id, cj.command, cj.schedule, cj.timezone, cj.retention, p.owner_id
FROM cron_jobs cj
JOIN projects p ON cj.project_id = p.id
WHERE cj.id = $1`,
jobID,
).Scan(&job.ServiceID, &job.Command, &job.Schedule, &job.Timezone, &job.Retention, &ownerCheck)
if err != nil || (ownerCheck != userID && !contextIsAdmin(c)) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return return
} }
trig, err := sqlcdb.New(db.DB).GetCronJobForTrigger(context.Background(), jid)
if err != nil || (trig.OwnerID.String() != userID && !contextIsAdmin(c)) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
job := CronJob{
ServiceID: trig.ServiceID.String(),
Command: trig.Command,
Schedule: trig.Schedule,
Timezone: trig.Timezone.String,
Retention: int(trig.Retention.Int32),
}
execID := uuid.New().String() execID := uuid.New().String()
now := time.Now() now := time.Now()
_, err = db.Exec( _ = sqlcdb.New(db.DB).InsertCronExecution(context.Background(), sqlcdb.InsertCronExecutionParams{
`INSERT INTO cron_executions (id, cron_job_id, started_at, status) ID: uuid.MustParse(execID),
VALUES ($1, $2, $3, $4)`, CronJobID: jid,
execID, jobID, now, "running", StartedAt: now,
) Status: sql.NullString{String: "running", Valid: true},
})
dockerClient, _ := c.Get("docker_client") dockerClient, _ := c.Get("docker_client")
dockerCli, _ := dockerClient.(*docker.Client) dockerCli, _ := dockerClient.(*docker.Client)
@@ -458,39 +487,25 @@ func StartCronScheduler(ctx context.Context, db *database.DB, dockerClient *dock
} }
func runDueCronJobs(db *database.DB, dockerClient *docker.Client) { func runDueCronJobs(db *database.DB, dockerClient *docker.Client) {
rows, err := db.Query( q := sqlcdb.New(db.DB)
`SELECT id, project_id, service_id, name, schedule, command, timezone, enabled, retention jobs, err := q.ListDueCronJobs(context.Background())
FROM cron_jobs
WHERE enabled = TRUE AND next_run_at IS NOT NULL AND next_run_at <= NOW()`)
if err != nil { if err != nil {
log.Printf("cron scheduler: failed to list due jobs: %v", err) log.Printf("cron scheduler: failed to list due jobs: %v", err)
return return
} }
defer rows.Close()
type dueJob struct {
CronJob
Command string
}
var jobs []dueJob
for rows.Next() {
var j dueJob
if err := rows.Scan(&j.ID, &j.ProjectID, &j.ServiceID, &j.Name, &j.Schedule,
&j.Command, &j.Timezone, &j.Enabled, &j.Retention); err != nil {
continue
}
jobs = append(jobs, j)
}
for _, j := range jobs { for _, j := range jobs {
execID := uuid.New().String() execID := uuid.New().String()
if _, err := db.Exec( if err := q.InsertCronExecution(context.Background(), sqlcdb.InsertCronExecutionParams{
`INSERT INTO cron_executions (id, cron_job_id, started_at, status) VALUES ($1, $2, $3, $4)`, ID: uuid.MustParse(execID),
execID, j.ID, time.Now(), "running"); err != nil { CronJobID: j.ID,
StartedAt: time.Now(),
Status: sql.NullString{String: "running", Valid: true},
}); err != nil {
log.Printf("cron scheduler: failed to record execution for %s: %v", j.ID, err) log.Printf("cron scheduler: failed to record execution for %s: %v", j.ID, err)
continue continue
} }
go executeCronJob(db, dockerClient, j.ID, j.ServiceID, execID, j.Command, j.Schedule, j.Timezone, j.Retention) go executeCronJob(db, dockerClient, j.ID.String(), j.ServiceID.String(), execID, j.Command, j.Schedule, j.Timezone.String, int(j.Retention.Int32))
} }
} }
@@ -584,23 +599,32 @@ func executeCronJob(db *database.DB, dockerClient *docker.Client, jobID, service
} }
now := time.Now() now := time.Now()
db.Exec( q := sqlcdb.New(db.DB)
`UPDATE cron_executions SET finished_at = $1, status = $2, output = $3, error = $4 WHERE id = $5`, if eid, err := uuid.Parse(execID); err == nil {
now, status, output, errText, execID, _ = q.FinishCronExecution(context.Background(), sqlcdb.FinishCronExecutionParams{
) FinishedAt: sql.NullTime{Time: now, Valid: true},
Status: sql.NullString{String: status, Valid: true},
Output: sql.NullString{String: output, Valid: true},
Error: sql.NullString{String: errText, Valid: true},
ID: eid,
})
}
next, _ := calculateNextRun(schedule, timezone) next, _ := calculateNextRun(schedule, timezone)
db.Exec( if jid, err := uuid.Parse(jobID); err == nil {
`UPDATE cron_jobs SET last_run_at = $1, last_status = $2, last_output = $3, next_run_at = $4 WHERE id = $5`, _ = q.UpdateCronJobRun(context.Background(), sqlcdb.UpdateCronJobRunParams{
now, status, output, next, jobID, LastRunAt: sql.NullTime{Time: now, Valid: true},
) LastStatus: sql.NullString{String: status, Valid: true},
LastOutput: sql.NullString{String: output, Valid: true},
NextRunAt: ntPtr(next),
ID: jid,
})
if retention > 0 { if retention > 0 {
db.Exec( _ = q.TrimCronExecutions(context.Background(), sqlcdb.TrimCronExecutionsParams{
`DELETE FROM cron_executions WHERE cron_job_id = $1 AND id NOT IN ( CronJobID: jid,
SELECT id FROM cron_executions WHERE cron_job_id = $1 Limit: int32(retention),
ORDER BY started_at DESC LIMIT $2)`, })
jobID, retention, }
)
} }
} }
+127
View File
@@ -1,9 +1,16 @@
package api package api
import ( import (
"context"
"database/sql"
"strings" "strings"
"testing" "testing"
"time" "time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"github.com/google/uuid"
) )
func TestCalculateNextRun(t *testing.T) { func TestCalculateNextRun(t *testing.T) {
@@ -64,3 +71,123 @@ func TestCalculateNextRunTimezone(t *testing.T) {
t.Logf("utc next: %v / tokyo next: %v", utcNext, tokyoNext) t.Logf("utc next: %v / tokyo next: %v", utcNext, tokyoNext)
} }
} }
// TestCronLiveInvariants exercises the cron.sql queries on live Postgres:
// insert/read/owner join, execution lifecycle, run bookkeeping, retention trim.
func TestCronLiveInvariants(t *testing.T) {
dsn := testDSNString(t)
db, err := database.NewConnection(dsn)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer db.Close()
ctx := context.Background()
q := sqlcdb.New(db.DB)
userID := uuid.New()
projectID := uuid.New()
envID := uuid.New()
serviceID := uuid.New()
jobID := uuid.New()
suffix := strings.ReplaceAll(jobID.String()[:8], "-", "")
must := func(err error) {
t.Helper()
if err != nil {
t.Fatalf("setup: %v", err)
}
}
_, err = db.Exec(`INSERT INTO users (id, email, password_hash, name) VALUES ($1, $2, 'x', 'test')`,
userID, "cron-"+suffix+"@test.local")
must(err)
_, err = db.Exec(`INSERT INTO projects (id, name, owner_id, is_approved) VALUES ($1, $2, $3, true)`,
projectID, "cron-"+suffix, userID)
must(err)
_, err = db.Exec(`INSERT INTO environments (id, name, project_id) VALUES ($1, 'production', $2)`,
envID, projectID)
must(err)
_, err = db.Exec(`INSERT INTO services (id, name, project_id, environment_id, service_type, source_type)
VALUES ($1, $2, $3, $4, 'web', 'image')`,
serviceID, "cron-"+suffix, projectID, envID)
must(err)
defer func() { _, _ = db.Exec(`DELETE FROM users WHERE id = $1`, userID) }()
next, err := calculateNextRun("0 4 * * *", "UTC")
if err != nil {
t.Fatalf("schedule: %v", err)
}
must(q.InsertCronJob(ctx, sqlcdb.InsertCronJobParams{
ID: jobID, ProjectID: projectID, ServiceID: serviceID,
Name: "job-" + suffix, Schedule: "0 4 * * *", Command: "echo hi",
Timezone: sql.NullString{String: "UTC", Valid: true},
Enabled: sql.NullBool{Bool: true, Valid: true},
NextRunAt: ntPtr(next),
}))
job, err := q.GetCronJob(ctx, jobID)
if err != nil || job.Name != "job-"+suffix || job.Command != "echo hi" {
t.Fatalf("job: %v %+v", err, job)
}
owner, err := q.GetCronJobOwner(ctx, jobID)
if err != nil || owner != userID {
t.Fatalf("owner: %v %v", err, owner)
}
owner2, err := q.GetCronOwnerViaService(ctx, serviceID)
if err != nil || owner2 != userID {
t.Fatalf("owner via service: %v %v", err, owner2)
}
pid, err := q.GetCronJobProjectID(ctx, jobID)
if err != nil || pid != projectID {
t.Fatalf("project: %v %v", err, pid)
}
// Trigger-shaped read.
trig, err := q.GetCronJobForTrigger(ctx, jobID)
if err != nil || trig.ServiceID != serviceID || trig.OwnerID != userID {
t.Fatalf("trigger: %v %+v", err, trig)
}
// Execution lifecycle + retention trim.
for i := 0; i < 3; i++ {
execID := uuid.New()
must(q.InsertCronExecution(ctx, sqlcdb.InsertCronExecutionParams{
ID: execID, CronJobID: jobID,
StartedAt: time.Now().Add(time.Duration(i) * time.Second),
Status: sql.NullString{String: "running", Valid: true},
}))
must(q.FinishCronExecution(ctx, sqlcdb.FinishCronExecutionParams{
FinishedAt: sql.NullTime{Time: time.Now(), Valid: true},
Status: sql.NullString{String: "success", Valid: true},
Output: sql.NullString{String: "ok", Valid: true},
Error: sql.NullString{String: "", Valid: true},
ID: execID,
}))
}
execs, err := q.ListCronExecutions(ctx, jobID)
if err != nil || len(execs) != 3 {
t.Fatalf("execs: %v %d", err, len(execs))
}
if execs[0].Status.String != "success" || !execs[0].FinishedAt.Valid {
t.Fatalf("exec row: %+v", execs[0])
}
must(q.TrimCronExecutions(ctx, sqlcdb.TrimCronExecutionsParams{CronJobID: jobID, Limit: 1}))
execs, _ = q.ListCronExecutions(ctx, jobID)
if len(execs) != 1 {
t.Fatalf("trim kept %d, want 1", len(execs))
}
must(q.UpdateCronJobRun(ctx, sqlcdb.UpdateCronJobRunParams{
LastRunAt: sql.NullTime{Time: time.Now(), Valid: true},
LastStatus: sql.NullString{String: "success", Valid: true},
LastOutput: sql.NullString{String: "ok", Valid: true},
NextRunAt: ntPtr(next),
ID: jobID,
}))
job, _ = q.GetCronJob(ctx, jobID)
if job.LastStatus.String != "success" || !job.LastRunAt.Valid {
t.Fatalf("post-run job: %+v", job)
}
must(q.DeleteCronJob(ctx, jobID))
if _, err := q.GetCronJob(ctx, jobID); err == nil {
t.Fatal("deleted job still readable")
}
}
+155
View File
@@ -444,3 +444,158 @@ func TestDeploymentsLiveInvariants(t *testing.T) {
t.Fatalf("post-fail row: %+v", r) t.Fatalf("post-fail row: %+v", r)
} }
} }
// TestRuntimeLiveInvariants exercises the runtime.sql queries: the
// legacy-column COALESCE chain in GetServiceRuntimeWithOwner, the
// deployed-image concat, and the conditional published-port update.
func TestRuntimeLiveInvariants(t *testing.T) {
dsn := testDSNString(t)
db, err := database.NewConnection(dsn)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer db.Close()
ctx := context.Background()
q := sqlcdb.New(db.DB)
userID := uuid.New()
projectID := uuid.New()
envID := uuid.New()
serviceID := uuid.New()
suffix := strings.ReplaceAll(serviceID.String()[:8], "-", "")
must := func(err error) {
t.Helper()
if err != nil {
t.Fatalf("setup: %v", err)
}
}
_, err = db.Exec(`INSERT INTO users (id, email, password_hash, name) VALUES ($1, $2, 'x', 'test')`,
userID, "rt-"+suffix+"@test.local")
must(err)
_, err = db.Exec(`INSERT INTO projects (id, name, owner_id, is_approved) VALUES ($1, $2, $3, true)`,
projectID, "rt-"+suffix, userID)
must(err)
_, err = db.Exec(`INSERT INTO environments (id, name, project_id) VALUES ($1, 'production', $2)`,
envID, projectID)
must(err)
// Only legacy columns populated — the COALESCE chain must surface them.
_, err = db.Exec(`INSERT INTO services (id, name, project_id, environment_id, service_type, source_type,
source_url, image_name, start_command, port) VALUES ($1, $2, $3, $4, 'worker', 'git', $5, $6, 'run.sh', 8080)`,
serviceID, "rt-"+suffix, projectID, envID, "https://git.example/x.git", "repo/img:latest")
must(err)
defer func() { _, _ = db.Exec(`DELETE FROM users WHERE id = $1`, userID) }()
row, err := q.GetServiceRuntimeWithOwner(ctx, serviceID)
if err != nil {
t.Fatalf("runtime row: %v", err)
}
if row.Type != "worker" || row.Image != "repo/img:latest" || row.Command != "run.sh" ||
row.GitRepo != "https://git.example/x.git" || row.OwnerID != userID {
t.Fatalf("COALESCE chain lost legacy columns: %+v", row)
}
svc := serviceFromRuntimeRow(row)
if svc.Type != "worker" || svc.Image != "repo/img:latest" || svc.Port != 8080 {
t.Fatalf("mapped service wrong: %+v", svc)
}
// Sibling refs + port lookup.
refs, err := q.ListProjectServiceRefs(ctx, projectID)
if err != nil || len(refs) != 1 || refs[0].Name != "rt-"+suffix {
t.Fatalf("refs: %v %+v", err, refs)
}
port, err := q.GetServicePort(ctx, serviceID)
if err != nil || port != 8080 {
t.Fatalf("port: %v %d", err, port)
}
// Variables round-trip.
_, err = db.Exec(`INSERT INTO environment_variables (id, service_id, key, value, is_secret)
VALUES (gen_random_uuid(), $1, 'API_KEY', 'shh', true), (gen_random_uuid(), $1, 'PLAIN', 'p', false)`,
serviceID)
must(err)
vars, err := q.ListServiceVariablesWithSecret(ctx, serviceID)
if err != nil || len(vars) != 2 {
t.Fatalf("vars: %v %+v", err, vars)
}
vals, err := q.ListServiceVariableValues(ctx, serviceID)
if err != nil || len(vals) != 2 {
t.Fatalf("vals: %v %+v", err, vals)
}
got, err := q.GetServiceVariableValue(ctx, sqlcdb.GetServiceVariableValueParams{ServiceID: serviceID, Key: "API_KEY"})
if err != nil || got != "shh" {
t.Fatalf("var value: %v %q", err, got)
}
// Node agent listing through container_instances.
_, err = db.Exec(`INSERT INTO node_agents (id, name, hostname, ip_address, port) VALUES ($1, $2, 'h', '127.0.0.1', 9999)`,
"agent-"+suffix, "agent-"+suffix)
must(err)
_, err = db.Exec(`INSERT INTO container_instances (id, name, image, project_id, service_id, node_agent_id)
VALUES ($1, 'c', 'img', $2, $3, $4)`,
"ci-"+suffix, projectID.String(), serviceID.String(), "agent-"+suffix)
must(err)
agents, err := q.ListNodeAgentsForService(ctx, serviceID.String())
if err != nil || len(agents) != 1 || agents[0] != "agent-"+suffix {
t.Fatalf("agents: %v %+v", err, agents)
}
// Last deployed image: only 'deployed' rows, latest first.
d1, d2 := uuid.New(), uuid.New()
must(q.InsertDeployment(ctx, sqlcdb.InsertDeploymentParams{
ID: d1, ServiceID: serviceID, Version: "v1",
Status: sql.NullString{String: "failed", Valid: true},
}))
must(q.InsertDeployment(ctx, sqlcdb.InsertDeploymentParams{
ID: d2, ServiceID: serviceID, Version: "v2",
Status: sql.NullString{String: "deployed", Valid: true},
}))
must(q.SyncDeploymentProgress(ctx, sqlcdb.SyncDeploymentProgressParams{
Status: sql.NullString{String: "deployed", Valid: true},
ImageName: sql.NullString{String: "app", Valid: true},
ImageTag: sql.NullString{String: "v2", Valid: true},
UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true},
ID: d2,
}))
img, err := q.GetLastDeployedImage(ctx, serviceID)
if err != nil || img != "app:v2" {
t.Fatalf("last image: %v %q", err, img)
}
// Conditional published-port update: writes once, no-ops on repeat.
must(q.SetServicePublishedPort(ctx, sqlcdb.SetServicePublishedPortParams{PublishedPort: 30001, ID: serviceID}))
p, err := q.GetServicePublishedPort(ctx, serviceID)
if err != nil || p != 30001 {
t.Fatalf("published port: %v %d", err, p)
}
must(q.SetServiceReplicas(ctx, sqlcdb.SetServiceReplicasParams{
Replicas: 3, UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true}, ID: serviceID}))
row, _ = q.GetServiceRuntimeWithOwner(ctx, serviceID)
if row.Replicas != 3 {
t.Fatalf("replicas: %+v", row)
}
// Sleeper reads: candidate sweep + domain/status text lookups.
_, err = db.Exec(`UPDATE services SET sleep_enabled = true, domain = 'rt.example.com', status = 'running' WHERE id = $1`, serviceID)
must(err)
cands, err := q.ListSleepCandidates(ctx)
if err != nil {
t.Fatalf("sleep candidates: %v", err)
}
var mine *sqlcdb.ListSleepCandidatesRow
for i := range cands {
if cands[i].ID == serviceID {
mine = &cands[i]
}
}
if mine == nil || mine.SleepIdleMinutes != 15 {
t.Fatalf("candidate missing/idle wrong: %+v", mine)
}
dom, err := q.GetServiceDomainText(ctx, serviceID)
if err != nil || dom != "rt.example.com" {
t.Fatalf("domain text: %v %q", err, dom)
}
st, err := q.GetServiceStatusText(ctx, serviceID)
if err != nil || st != "running" {
t.Fatalf("status text: %v %q", err, st)
}
}
+1 -1
View File
@@ -116,7 +116,7 @@ func handleDeleteEnvironment(c *gin.Context) {
return return
} }
var projectID uuid.UUID var projectID uuid.UUID
if err := db.QueryRow(`SELECT project_id FROM environments WHERE id = $1`, envID).Scan(&projectID); err != nil { if projectID, err = sqlcdb.New(db.DB).GetEnvironmentProjectID(context.Background(), envID); err != nil {
respondError(c, http.StatusNotFound, "NOT_FOUND", "Environment not found") respondError(c, http.StatusNotFound, "NOT_FOUND", "Environment not found")
return return
} }
+65 -52
View File
@@ -17,9 +17,11 @@ import (
"time" "time"
"containr/internal/database" "containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/gateway" "containr/internal/gateway"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
@@ -107,23 +109,28 @@ func extractGatewayKey(c *gin.Context) string {
// lookupGatewayService resolves the slug to an enabled service row. // lookupGatewayService resolves the slug to an enabled service row.
func lookupGatewayService(ctx context.Context, db *database.DB, slug string) (*gatewayService, error) { func lookupGatewayService(ctx context.Context, db *database.DB, slug string) (*gatewayService, error) {
var s gatewayService r, err := sqlcdb.New(db.DB).GetAPServiceBySlug(ctx, slug)
var enabled int
err := db.QueryRowContext(ctx, `
SELECT id, upstream_url, route_prefix, upstream_auth_header,
upstream_auth_value, enabled, rpm_limit, monthly_quota,
request_timeout_ms
FROM api_services WHERE slug = $1
`, slug).Scan(&s.ID, &s.UpstreamURL, &s.RoutePrefix,
&s.UpstreamAuthHeader, &s.UpstreamAuthValue, &enabled,
&s.RPMLimit, &s.MonthlyQuota, &s.RequestTimeoutMS)
if err != nil { if err != nil {
return nil, err return nil, err
} }
s.Enabled = enabled != 0 s := gatewayService{
ID: r.ID.String(),
UpstreamURL: r.UpstreamUrl,
RoutePrefix: r.RoutePrefix,
UpstreamAuthHeader: r.UpstreamAuthHeader,
UpstreamAuthValue: r.UpstreamAuthValue,
Enabled: r.Enabled != 0,
RPMLimit: nullInt32To64(r.RpmLimit),
MonthlyQuota: nullInt32To64(r.MonthlyQuota),
RequestTimeoutMS: int(r.RequestTimeoutMs.Int32),
}
return &s, nil return &s, nil
} }
func nullInt32To64(v sql.NullInt32) sql.NullInt64 {
return sql.NullInt64{Int64: int64(v.Int32), Valid: v.Valid}
}
// authenticateGatewayKey resolves a presented key by its indexed prefix and // authenticateGatewayKey resolves a presented key by its indexed prefix and
// bcrypt-verifies candidates. Returns nil when nothing matches. // bcrypt-verifies candidates. Returns nil when nothing matches.
func authenticateGatewayKey(ctx context.Context, db *database.DB, presented string) (*gatewayKey, error) { func authenticateGatewayKey(ctx context.Context, db *database.DB, presented string) (*gatewayKey, error) {
@@ -131,26 +138,24 @@ func authenticateGatewayKey(ctx context.Context, db *database.DB, presented stri
if len(prefix) > 8 { if len(prefix) > 8 {
prefix = prefix[:8] prefix = prefix[:8]
} }
rows, err := db.QueryContext(ctx, ` rows, err := sqlcdb.New(db.DB).ListAPKeysByPrefix(ctx, prefix)
SELECT id, key_hash, enabled, rpm_limit, monthly_quota, allowed_service_ids
FROM api_keys WHERE key_prefix = $1
`, prefix)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() for _, r := range rows {
for rows.Next() { k := gatewayKey{
var k gatewayKey ID: r.ID.String(),
var enabled int Hash: r.KeyHash,
if err := rows.Scan(&k.ID, &k.Hash, &enabled, &k.RPMLimit, &k.MonthlyQuota, &k.AllowedServiceIDs); err != nil { Enabled: r.Enabled != 0,
return nil, err RPMLimit: nullInt32To64(r.RpmLimit),
MonthlyQuota: nullInt32To64(r.MonthlyQuota),
AllowedServiceIDs: r.AllowedServiceIds,
} }
k.Enabled = enabled != 0
if bcrypt.CompareHashAndPassword([]byte(k.Hash), []byte(presented)) == nil { if bcrypt.CompareHashAndPassword([]byte(k.Hash), []byte(presented)) == nil {
return &k, nil return &k, nil
} }
} }
return nil, rows.Err() return nil, nil
} }
// keyAllowsService applies the allowed_service_ids JSON allowlist. An empty // keyAllowsService applies the allowed_service_ids JSON allowlist. An empty
@@ -168,48 +173,56 @@ func keyAllowsService(k *gatewayKey, serviceID string) bool {
return false return false
} }
// monthlyUsage sums a counter dimension for the current calendar month. // monthlyKeyUsage / monthlyServiceUsage sum a counter dimension for the
func monthlyUsage(ctx context.Context, db *database.DB, column, id, period string) (int, error) { // current calendar month.
var total int func monthlyKeyUsage(ctx context.Context, db *database.DB, id, period string) (int64, error) {
err := db.QueryRowContext(ctx, ` keyUUID, _ := uuid.Parse(id)
SELECT COALESCE(SUM(request_count), 0) FROM usage_counters return sqlcdb.New(db.DB).SumUsageByKey(ctx, sqlcdb.SumUsageByKeyParams{
WHERE `+column+` = $1 AND period_month = $2 ApiKeyID: keyUUID, PeriodMonth: period,
`, id, period).Scan(&total) })
return total, err }
func monthlyServiceUsage(ctx context.Context, db *database.DB, id, period string) (int64, error) {
svcUUID, _ := uuid.Parse(id)
return sqlcdb.New(db.DB).SumUsageByService(ctx, sqlcdb.SumUsageByServiceParams{
ServiceID: svcUUID, PeriodMonth: period,
})
} }
// recordGatewayRequest persists usage, metrics, and failure incidents for a // recordGatewayRequest persists usage, metrics, and failure incidents for a
// proxied call. Runs in the request path but is three small writes. // proxied call. Runs in the request path but is three small writes.
func recordGatewayRequest(ctx context.Context, db *database.DB, svc *gatewayService, k *gatewayKey, status int, period string) { func recordGatewayRequest(ctx context.Context, db *database.DB, svc *gatewayService, k *gatewayKey, status int, period string) {
_, _ = db.ExecContext(ctx, ` q := sqlcdb.New(db.DB)
INSERT INTO usage_counters (api_key_id, service_id, period_month, request_count, updated_at) keyUUID, _ := uuid.Parse(k.ID)
VALUES ($1, $2, $3, 1, NOW()) svcUUID, _ := uuid.Parse(svc.ID)
ON CONFLICT (api_key_id, service_id, period_month) _ = q.UpsertUsageCounter(ctx, sqlcdb.UpsertUsageCounterParams{
DO UPDATE SET request_count = usage_counters.request_count + 1, updated_at = NOW() ApiKeyID: keyUUID, ServiceID: svcUUID, PeriodMonth: period,
`, k.ID, svc.ID, period) })
_, _ = db.ExecContext(ctx, ` _ = q.TouchAPKeyLastUsed(ctx, keyUUID)
UPDATE api_keys SET last_used_at = NOW() WHERE id = $1
`, k.ID)
labels, _ := json.Marshal(map[string]interface{}{ labels, _ := json.Marshal(map[string]interface{}{
"service_id": svc.ID, "service_id": svc.ID,
"key_id": k.ID, "key_id": k.ID,
"status": status, "status": status,
}) })
_, _ = db.ExecContext(ctx, ` _ = q.InsertMetricPoint(ctx, sql.NullString{String: string(labels), Valid: true})
INSERT INTO metrics_timeseries (metric, value, labels_json, occurred_at)
VALUES ('gateway_request', 1, $1, NOW())
`, string(labels))
} }
func recordGatewayIncident(ctx context.Context, db *database.DB, svcID string, keyID *string, code, msg, severity string, status int) { func recordGatewayIncident(ctx context.Context, db *database.DB, svcID string, keyID *string, code, msg, severity string, status int) {
var kid interface{} var kid uuid.NullUUID
if keyID != nil { if keyID != nil {
kid = *keyID if u, err := uuid.Parse(*keyID); err == nil {
kid = uuid.NullUUID{UUID: u, Valid: true}
} }
_, _ = db.ExecContext(ctx, ` }
INSERT INTO incident_events (service_id, api_key_id, code, message, severity, http_status, occurred_at) svcUUID, _ := uuid.Parse(svcID)
VALUES ($1, $2, $3, $4, $5, $6, NOW()) _ = sqlcdb.New(db.DB).InsertGatewayIncident(ctx, sqlcdb.InsertGatewayIncidentParams{
`, svcID, kid, code, msg, severity, status) 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 // handleGatewayProxy is mounted at /g/:slug and /g/:slug/*path on the root
@@ -267,13 +280,13 @@ func handleGatewayProxy(c *gin.Context) {
period := now.Format("2006-01") period := now.Format("2006-01")
if key.MonthlyQuota.Valid && key.MonthlyQuota.Int64 > 0 { if key.MonthlyQuota.Valid && key.MonthlyQuota.Int64 > 0 {
if used, err := monthlyUsage(ctx, db, "api_key_id", key.ID, period); err == nil && int64(used) >= key.MonthlyQuota.Int64 { if used, err := monthlyKeyUsage(ctx, db, key.ID, period); err == nil && used >= key.MonthlyQuota.Int64 {
respondError(c, http.StatusTooManyRequests, "QUOTA_EXCEEDED", "Monthly request quota exceeded") respondError(c, http.StatusTooManyRequests, "QUOTA_EXCEEDED", "Monthly request quota exceeded")
return return
} }
} }
if svc.MonthlyQuota.Valid && svc.MonthlyQuota.Int64 > 0 { if svc.MonthlyQuota.Valid && svc.MonthlyQuota.Int64 > 0 {
if used, err := monthlyUsage(ctx, db, "service_id", svc.ID, period); err == nil && int64(used) >= svc.MonthlyQuota.Int64 { if used, err := monthlyServiceUsage(ctx, db, svc.ID, period); err == nil && used >= svc.MonthlyQuota.Int64 {
respondError(c, http.StatusTooManyRequests, "QUOTA_EXCEEDED", "Service monthly quota exceeded") respondError(c, http.StatusTooManyRequests, "QUOTA_EXCEEDED", "Service monthly quota exceeded")
return return
} }
+195 -219
View File
@@ -2,6 +2,8 @@ package api
import ( import (
"containr/internal/database" "containr/internal/database"
"containr/internal/database/sqlcdb"
"context"
"crypto/rand" "crypto/rand"
"crypto/rsa" "crypto/rsa"
"crypto/x509" "crypto/x509"
@@ -92,41 +94,73 @@ type CreateWebhookRequest struct {
Branch string `json:"branch"` Branch string `json:"branch"`
} }
func gitRepoFrom(r sqlcdb.ListGitRepositoriesRow) GitRepository {
return GitRepository{
ID: r.ID.String(),
ProviderID: r.ProviderID.String(),
Name: r.Name,
FullName: r.FullName,
Description: r.Description,
CloneURL: r.CloneUrl,
WebhookURL: r.WebhookUrl,
DefaultBranch: r.DefaultBranch.String,
IsPrivate: r.IsPrivate.Bool,
UserID: r.UserID.String(),
CreatedAt: r.CreatedAt.Time,
UpdatedAt: r.UpdatedAt.Time,
}
}
func gitRepoFromFullNameRow(r sqlcdb.GetGitRepositoryByFullNameRow) GitRepository {
return GitRepository{
ID: r.ID.String(),
ProviderID: r.ProviderID.String(),
Name: r.Name,
FullName: r.FullName,
Description: r.Description,
CloneURL: r.CloneUrl,
WebhookURL: r.WebhookUrl,
DefaultBranch: r.DefaultBranch.String,
IsPrivate: r.IsPrivate.Bool,
UserID: r.UserID.String(),
CreatedAt: r.CreatedAt.Time,
UpdatedAt: r.UpdatedAt.Time,
}
}
func gitProviderFrom(p sqlcdb.ListGitProvidersRow) GitProvider {
return GitProvider{
ID: p.ID.String(),
Name: p.Name,
DisplayName: p.DisplayName,
APIUrl: p.ApiUrl,
WebhookUrl: p.WebhookUrl,
UserID: p.UserID.String(),
CreatedAt: p.CreatedAt.Time,
UpdatedAt: p.UpdatedAt.Time,
}
}
func handleGetGitProviders(c *gin.Context) { func handleGetGitProviders(c *gin.Context) {
ctx, ok := requireGitRequestContext(c) ctx, ok := requireGitRequestContext(c)
if !ok { if !ok {
return return
} }
rows, err := ctx.db.Query(` userID, err := uuid.Parse(ctx.userID)
SELECT id, name, display_name, api_url, webhook_url, user_id, created_at, updated_at
FROM git_providers
WHERE user_id = $1
ORDER BY created_at DESC
`, ctx.userID)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return return
} }
defer rows.Close() rows, err := sqlcdb.New(ctx.db.DB).ListGitProviders(context.Background(), userID)
if err != nil {
providers := make([]GitProvider, 0)
for rows.Next() {
var provider GitProvider
if err := rows.Scan(
&provider.ID,
&provider.Name,
&provider.DisplayName,
&provider.APIUrl,
&provider.WebhookUrl,
&provider.UserID,
&provider.CreatedAt,
&provider.UpdatedAt,
); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return return
} }
providers = append(providers, provider)
providers := make([]GitProvider, 0, len(rows))
for _, p := range rows {
providers = append(providers, gitProviderFrom(p))
} }
c.JSON(http.StatusOK, gin.H{"providers": providers}) c.JSON(http.StatusOK, gin.H{"providers": providers})
@@ -173,21 +207,22 @@ func handleConnectGitHubApp(c *gin.Context) {
UserID: ctx.userID, UserID: ctx.userID,
} }
if err := ctx.db.QueryRow(` prow, err := sqlcdb.New(ctx.db.DB).UpsertGitHubAppProvider(context.Background(), sqlcdb.UpsertGitHubAppProviderParams{
INSERT INTO git_providers (id, name, display_name, api_url, webhook_url, access_token, user_id, created_at, updated_at) ID: uuid.MustParse(provider.ID),
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW()) Name: provider.Name,
ON CONFLICT (name, user_id) DisplayName: provider.DisplayName,
DO UPDATE SET ApiUrl: provider.APIUrl,
display_name = EXCLUDED.display_name, WebhookUrl: provider.WebhookUrl,
api_url = EXCLUDED.api_url, AccessToken: provider.AccessToken,
webhook_url = EXCLUDED.webhook_url, UserID: uuid.MustParse(provider.UserID),
access_token = EXCLUDED.access_token, })
updated_at = NOW() if err != nil {
RETURNING id, created_at, updated_at
`, provider.ID, provider.Name, provider.DisplayName, provider.APIUrl, provider.WebhookUrl, provider.AccessToken, provider.UserID).Scan(&provider.ID, &provider.CreatedAt, &provider.UpdatedAt); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to connect GitHub App installation"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to connect GitHub App installation"})
return return
} }
provider.ID = prow.ID.String()
provider.CreatedAt = prow.CreatedAt.Time
provider.UpdatedAt = prow.UpdatedAt.Time
provider.AccessToken = "" provider.AccessToken = ""
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
@@ -259,10 +294,16 @@ func handleCreateGitProvider(c *gin.Context) {
return return
} }
if _, err := ctx.db.Exec(` q := sqlcdb.New(ctx.db.DB)
INSERT INTO git_providers (id, name, display_name, api_url, webhook_url, access_token, user_id, created_at, updated_at) if err := q.InsertGitProvider(context.Background(), sqlcdb.InsertGitProviderParams{
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW()) ID: uuid.MustParse(provider.ID),
`, provider.ID, provider.Name, provider.DisplayName, provider.APIUrl, provider.WebhookUrl, provider.AccessToken, provider.UserID); err != nil { Name: provider.Name,
DisplayName: provider.DisplayName,
ApiUrl: provider.APIUrl,
WebhookUrl: provider.WebhookUrl,
AccessToken: provider.AccessToken,
UserID: uuid.MustParse(provider.UserID),
}); err != nil {
if isUniqueConstraintError(err) { if isUniqueConstraintError(err) {
c.JSON(http.StatusConflict, gin.H{"error": "Provider already connected for this account"}) c.JSON(http.StatusConflict, gin.H{"error": "Provider already connected for this account"})
return return
@@ -271,13 +312,12 @@ func handleCreateGitProvider(c *gin.Context) {
return return
} }
if err := ctx.db.QueryRow(` if ts, err := q.GetGitProviderTimestamps(context.Background(), uuid.MustParse(provider.ID)); err != nil {
SELECT created_at, updated_at
FROM git_providers
WHERE id = $1
`, provider.ID).Scan(&provider.CreatedAt, &provider.UpdatedAt); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read created provider"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read created provider"})
return return
} else {
provider.CreatedAt = ts.CreatedAt.Time
provider.UpdatedAt = ts.UpdatedAt.Time
} }
provider.AccessToken = "" provider.AccessToken = ""
@@ -296,12 +336,11 @@ func handleGetGitRepositories(c *gin.Context) {
return return
} }
var providerName, providerDisplayName, providerAccessToken, providerAPIURL string q := sqlcdb.New(ctx.db.DB)
err := ctx.db.QueryRow(` provRow, err := q.GetGitProviderForRepos(context.Background(), sqlcdb.GetGitProviderForReposParams{
SELECT name, display_name, access_token, api_url ID: uuid.MustParse(providerID),
FROM git_providers UserID: uuid.MustParse(ctx.userID),
WHERE id = $1 AND user_id = $2 })
`, providerID, ctx.userID).Scan(&providerName, &providerDisplayName, &providerAccessToken, &providerAPIURL)
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
c.JSON(http.StatusNotFound, gin.H{"error": "Provider not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Provider not found"})
@@ -310,6 +349,10 @@ func handleGetGitRepositories(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return return
} }
providerName := provRow.Name
providerDisplayName := provRow.DisplayName
providerAccessToken := provRow.AccessToken
providerAPIURL := provRow.ApiUrl
if c.DefaultQuery("sync", "1") != "0" { if c.DefaultQuery("sync", "1") != "0" {
if syncErr := syncProviderRepositories(ctx.db, providerID, ctx.userID, providerName, providerAPIURL, providerAccessToken); syncErr != nil { if syncErr := syncProviderRepositories(ctx.db, providerID, ctx.userID, providerName, providerAPIURL, providerAccessToken); syncErr != nil {
@@ -324,53 +367,30 @@ func handleGetGitRepositories(c *gin.Context) {
} }
offset := (page - 1) * limit offset := (page - 1) * limit
search := strings.TrimSpace(c.Query("search")) search := strings.TrimSpace(c.Query("search"))
searchNullable := sql.NullString{String: search, Valid: search != ""}
rows, err := ctx.db.Query(` repoRows, err := q.ListGitRepositories(context.Background(), sqlcdb.ListGitRepositoriesParams{
SELECT id, provider_id, name, full_name, COALESCE(description, ''), clone_url, COALESCE(webhook_url, ''), ProviderID: uuid.MustParse(providerID),
default_branch, is_private, user_id, created_at, updated_at UserID: uuid.MustParse(ctx.userID),
FROM git_repositories Column3: search,
WHERE provider_id = $1 AND user_id = $2 Limit: int32(limit),
AND ($3::text IS NULL OR full_name ILIKE ('%' || $3::text || '%') OR name ILIKE ('%' || $3::text || '%')) Offset: int32(offset),
ORDER BY updated_at DESC })
LIMIT $4 OFFSET $5
`, providerID, ctx.userID, searchNullable, limit, offset)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return return
} }
defer rows.Close()
repositories := make([]GitRepository, 0) repositories := make([]GitRepository, 0, len(repoRows))
for rows.Next() { for _, r := range repoRows {
var repo GitRepository repositories = append(repositories, gitRepoFrom(r))
if err := rows.Scan(
&repo.ID,
&repo.ProviderID,
&repo.Name,
&repo.FullName,
&repo.Description,
&repo.CloneURL,
&repo.WebhookURL,
&repo.DefaultBranch,
&repo.IsPrivate,
&repo.UserID,
&repo.CreatedAt,
&repo.UpdatedAt,
); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
repositories = append(repositories, repo)
} }
var total int total, err := q.CountGitRepositories(context.Background(), sqlcdb.CountGitRepositoriesParams{
if err := ctx.db.QueryRow(` ProviderID: uuid.MustParse(providerID),
SELECT COUNT(*) UserID: uuid.MustParse(ctx.userID),
FROM git_repositories Column3: search,
WHERE provider_id = $1 AND user_id = $2 })
AND ($3::text IS NULL OR full_name ILIKE ('%' || $3::text || '%') OR name ILIKE ('%' || $3::text || '%')) if err != nil {
`, providerID, ctx.userID, searchNullable).Scan(&total); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return return
} }
@@ -408,12 +428,11 @@ func handleConnectGitRepository(c *gin.Context) {
return return
} }
var providerName string q := sqlcdb.New(ctx.db.DB)
err := ctx.db.QueryRow(` providerName, err := q.GetGitProviderName(context.Background(), sqlcdb.GetGitProviderNameParams{
SELECT name ID: uuid.MustParse(providerID),
FROM git_providers UserID: uuid.MustParse(ctx.userID),
WHERE id = $1 AND user_id = $2 })
`, providerID, ctx.userID).Scan(&providerName)
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
c.JSON(http.StatusNotFound, gin.H{"error": "Provider not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Provider not found"})
@@ -429,31 +448,16 @@ func handleConnectGitRepository(c *gin.Context) {
return return
} }
var existing GitRepository existingRow, err := q.GetGitRepositoryByFullName(context.Background(), sqlcdb.GetGitRepositoryByFullNameParams{
err = ctx.db.QueryRow(` ProviderID: uuid.MustParse(providerID),
SELECT id, provider_id, name, full_name, COALESCE(description, ''), clone_url, COALESCE(webhook_url, ''), FullName: fullName,
default_branch, is_private, user_id, created_at, updated_at UserID: uuid.MustParse(ctx.userID),
FROM git_repositories })
WHERE provider_id = $1 AND full_name = $2 AND user_id = $3
`, providerID, fullName, ctx.userID).Scan(
&existing.ID,
&existing.ProviderID,
&existing.Name,
&existing.FullName,
&existing.Description,
&existing.CloneURL,
&existing.WebhookURL,
&existing.DefaultBranch,
&existing.IsPrivate,
&existing.UserID,
&existing.CreatedAt,
&existing.UpdatedAt,
)
switch { switch {
case err == nil: case err == nil:
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"message": "Repository already connected", "message": "Repository already connected",
"repository": existing, "repository": gitRepoFromFullNameRow(existingRow),
}) })
return return
case !errors.Is(err, sql.ErrNoRows): case !errors.Is(err, sql.ErrNoRows):
@@ -473,13 +477,18 @@ func handleConnectGitRepository(c *gin.Context) {
UserID: ctx.userID, UserID: ctx.userID,
} }
if err := ctx.db.QueryRow(` ins, err := q.InsertGitRepository(context.Background(), sqlcdb.InsertGitRepositoryParams{
INSERT INTO git_repositories ( ID: uuid.MustParse(repo.ID),
id, provider_id, name, full_name, description, clone_url, default_branch, is_private, user_id, created_at, updated_at ProviderID: uuid.MustParse(repo.ProviderID),
) Name: repo.Name,
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW()) FullName: repo.FullName,
RETURNING created_at, updated_at Description: sql.NullString{String: repo.Description, Valid: true},
`, repo.ID, repo.ProviderID, repo.Name, repo.FullName, repo.Description, repo.CloneURL, repo.DefaultBranch, repo.IsPrivate, repo.UserID).Scan(&repo.CreatedAt, &repo.UpdatedAt); err != nil { CloneUrl: repo.CloneURL,
DefaultBranch: sql.NullString{String: repo.DefaultBranch, Valid: true},
IsPrivate: sql.NullBool{Bool: repo.IsPrivate, Valid: true},
UserID: uuid.MustParse(repo.UserID),
})
if err != nil {
if isUniqueConstraintError(err) { if isUniqueConstraintError(err) {
c.JSON(http.StatusConflict, gin.H{"error": "Repository is already connected"}) c.JSON(http.StatusConflict, gin.H{"error": "Repository is already connected"})
return return
@@ -487,6 +496,8 @@ func handleConnectGitRepository(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to connect repository"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to connect repository"})
return return
} }
repo.CreatedAt = ins.CreatedAt.Time
repo.UpdatedAt = ins.UpdatedAt.Time
c.JSON(http.StatusCreated, gin.H{ c.JSON(http.StatusCreated, gin.H{
"message": "Repository connected successfully", "message": "Repository connected successfully",
@@ -516,12 +527,10 @@ func handleCreateWebhook(c *gin.Context) {
return return
} }
var providerID string providerUUID, err := sqlcdb.New(ctx.db.DB).GetGitRepositoryProviderID(context.Background(), sqlcdb.GetGitRepositoryProviderIDParams{
err := ctx.db.QueryRow(` ID: uuid.MustParse(repoID),
SELECT provider_id UserID: uuid.MustParse(ctx.userID),
FROM git_repositories })
WHERE id = $1 AND user_id = $2
`, repoID, ctx.userID).Scan(&providerID)
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
c.JSON(http.StatusNotFound, gin.H{"error": "Repository not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Repository not found"})
@@ -530,6 +539,7 @@ func handleCreateWebhook(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return return
} }
providerID := providerUUID.String()
eventsJSON, err := json.Marshal(req.Events) eventsJSON, err := json.Marshal(req.Events)
if err != nil { if err != nil {
@@ -552,23 +562,22 @@ func handleCreateWebhook(c *gin.Context) {
Active: true, Active: true,
} }
if err := ctx.db.QueryRow(` wrow, err := sqlcdb.New(ctx.db.DB).UpsertGitWebhook(context.Background(), sqlcdb.UpsertGitWebhookParams{
INSERT INTO git_webhooks ( ID: uuid.MustParse(webhook.ID),
id, repo_id, provider_id, events, webhook_secret, active, branch_filter, created_at, updated_at RepoID: uuid.MustParse(webhook.RepoID),
) ProviderID: uuid.MustParse(webhook.ProviderID),
VALUES ($1, $2, $3, $4, $5, TRUE, $6, NOW(), NOW()) Events: webhook.Events,
ON CONFLICT (repo_id, provider_id) WebhookSecret: webhook.Secret,
DO UPDATE SET BranchFilter: sql.NullString{String: strings.TrimSpace(req.Branch), Valid: true},
events = EXCLUDED.events, })
webhook_secret = EXCLUDED.webhook_secret, if err != nil {
active = TRUE,
branch_filter = EXCLUDED.branch_filter,
updated_at = NOW()
RETURNING id, active, created_at, updated_at
`, webhook.ID, webhook.RepoID, webhook.ProviderID, webhook.Events, webhook.Secret, strings.TrimSpace(req.Branch)).Scan(&webhook.ID, &webhook.Active, &webhook.CreatedAt, &webhook.UpdatedAt); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to configure webhook"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to configure webhook"})
return return
} }
webhook.ID = wrow.ID.String()
webhook.Active = wrow.Active.Bool
webhook.CreatedAt = wrow.CreatedAt.Time
webhook.UpdatedAt = wrow.UpdatedAt.Time
c.JSON(http.StatusCreated, gin.H{ c.JSON(http.StatusCreated, gin.H{
"message": "Webhook configured successfully", "message": "Webhook configured successfully",
@@ -598,69 +607,39 @@ func handleGetConnectedRepositories(c *gin.Context) {
} }
offset := (page - 1) * limit offset := (page - 1) * limit
rows, err := ctx.db.Query(` q := sqlcdb.New(ctx.db.DB)
SELECT r.id, r.provider_id, r.name, r.full_name, COALESCE(r.description, ''), r.clone_url, repoRows, err := q.ListConnectedRepositories(context.Background(), sqlcdb.ListConnectedRepositoriesParams{
r.default_branch, r.is_private, r.user_id, r.created_at, r.updated_at, UserID: uuid.MustParse(ctx.userID),
p.name as provider_name, p.display_name Limit: int32(limit),
FROM git_repositories r Offset: int32(offset),
JOIN git_providers p ON r.provider_id = p.id })
WHERE r.user_id = $1
ORDER BY r.updated_at DESC
LIMIT $2 OFFSET $3
`, ctx.userID, limit, offset)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return return
} }
defer rows.Close()
repositories := make([]map[string]interface{}, 0)
for rows.Next() {
var repo GitRepository
var providerName, providerDisplayName string
if err := rows.Scan(
&repo.ID,
&repo.ProviderID,
&repo.Name,
&repo.FullName,
&repo.Description,
&repo.CloneURL,
&repo.DefaultBranch,
&repo.IsPrivate,
&repo.UserID,
&repo.CreatedAt,
&repo.UpdatedAt,
&providerName,
&providerDisplayName,
); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
repositories := make([]map[string]interface{}, 0, len(repoRows))
for _, r := range repoRows {
repositories = append(repositories, map[string]interface{}{ repositories = append(repositories, map[string]interface{}{
"id": repo.ID, "id": r.ID.String(),
"provider_id": repo.ProviderID, "provider_id": r.ProviderID.String(),
"name": repo.Name, "name": r.Name,
"full_name": repo.FullName, "full_name": r.FullName,
"description": repo.Description, "description": r.Description,
"clone_url": repo.CloneURL, "clone_url": r.CloneUrl,
"default_branch": repo.DefaultBranch, "default_branch": r.DefaultBranch.String,
"is_private": repo.IsPrivate, "is_private": r.IsPrivate.Bool,
"created_at": repo.CreatedAt, "created_at": r.CreatedAt.Time,
"updated_at": repo.UpdatedAt, "updated_at": r.UpdatedAt.Time,
"provider": map[string]string{ "provider": map[string]string{
"name": providerName, "name": r.ProviderName,
"display_name": providerDisplayName, "display_name": r.DisplayName,
}, },
}) })
} }
var total int total, err := q.CountUserRepositories(context.Background(), uuid.MustParse(ctx.userID))
if err := ctx.db.QueryRow(` if err != nil {
SELECT COUNT(*)
FROM git_repositories
WHERE user_id = $1
`, ctx.userID).Scan(&total); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return return
} }
@@ -705,20 +684,17 @@ func syncProviderRepositories(db *database.DB, providerID, userID, providerName,
defaultBranch = "main" defaultBranch = "main"
} }
_, err := db.Exec(` err := sqlcdb.New(db.DB).UpsertGitRepository(context.Background(), sqlcdb.UpsertGitRepositoryParams{
INSERT INTO git_repositories ( ID: uuid.New(),
id, provider_id, name, full_name, description, clone_url, default_branch, is_private, user_id, created_at, updated_at ProviderID: uuid.MustParse(providerID),
) Name: repository.Name,
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW()) FullName: repository.FullName,
ON CONFLICT (provider_id, full_name) Description: sql.NullString{String: repository.Description, Valid: true},
DO UPDATE SET CloneUrl: cloneURL,
name = EXCLUDED.name, DefaultBranch: sql.NullString{String: defaultBranch, Valid: true},
description = EXCLUDED.description, IsPrivate: sql.NullBool{Bool: repository.IsPrivate, Valid: true},
clone_url = EXCLUDED.clone_url, UserID: uuid.MustParse(userID),
default_branch = EXCLUDED.default_branch, })
is_private = EXCLUDED.is_private,
updated_at = NOW()
`, uuid.NewString(), providerID, repository.Name, repository.FullName, repository.Description, cloneURL, defaultBranch, repository.IsPrivate, userID)
if err != nil { if err != nil {
return err return err
} }
@@ -1323,16 +1299,15 @@ func handleDeleteGitProvider(c *gin.Context) {
return return
} }
result, err := ctx.db.Exec( affected, err := sqlcdb.New(ctx.db.DB).DeleteGitProvider(context.Background(), sqlcdb.DeleteGitProviderParams{
"DELETE FROM git_providers WHERE id = $1 AND user_id = $2", ID: uuid.MustParse(providerID),
providerID, ctx.userID, UserID: uuid.MustParse(ctx.userID),
) })
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete provider"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete provider"})
return return
} }
affected, _ := result.RowsAffected()
if affected == 0 { if affected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Provider not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Provider not found"})
return return
@@ -1362,12 +1337,10 @@ func handleGetGitRepositoryBranches(c *gin.Context) {
return return
} }
var providerName, providerAPIURL, providerAccessToken string pf, err := sqlcdb.New(ctx.db.DB).GetGitProviderForFetch(context.Background(), sqlcdb.GetGitProviderForFetchParams{
err := ctx.db.QueryRow(` ID: uuid.MustParse(providerID),
SELECT name, api_url, access_token UserID: uuid.MustParse(ctx.userID),
FROM git_providers })
WHERE id = $1 AND user_id = $2
`, providerID, ctx.userID).Scan(&providerName, &providerAPIURL, &providerAccessToken)
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
c.JSON(http.StatusNotFound, gin.H{"error": "Provider not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Provider not found"})
@@ -1376,6 +1349,9 @@ func handleGetGitRepositoryBranches(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return return
} }
providerName := pf.Name
providerAPIURL := pf.ApiUrl
providerAccessToken := pf.AccessToken
branches, err := fetchRepositoryBranches(providerName, providerAPIURL, providerAccessToken, owner, repo) branches, err := fetchRepositoryBranches(providerName, providerAPIURL, providerAccessToken, owner, repo)
if err != nil { if err != nil {
+69 -64
View File
@@ -5,6 +5,7 @@ import (
"crypto/hmac" "crypto/hmac"
"crypto/sha256" "crypto/sha256"
"crypto/subtle" "crypto/subtle"
"database/sql"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
@@ -14,6 +15,7 @@ import (
"time" "time"
"containr/internal/database" "containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/deployment" "containr/internal/deployment"
"containr/internal/deployqueue" "containr/internal/deployqueue"
@@ -38,22 +40,15 @@ func handleGitWebhookPush(c *gin.Context) {
return return
} }
var ( q := sqlcdb.New(db.DB)
secret string hook, err := q.GetGitWebhookForPush(context.Background(), uuid.MustParse(webhookID))
repoID string
providerID string
branchFilter string
active bool
)
err := db.QueryRow(`
SELECT webhook_secret, repo_id, provider_id, COALESCE(branch_filter, ''), active
FROM git_webhooks WHERE id = $1`, webhookID).
Scan(&secret, &repoID, &providerID, &branchFilter, &active)
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Webhook not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Webhook not found"})
return return
} }
if !active { secret := hook.WebhookSecret
branchFilter := hook.BranchFilter
if !hook.Active.Bool {
c.JSON(http.StatusForbidden, gin.H{"error": "Webhook is disabled"}) c.JSON(http.StatusForbidden, gin.H{"error": "Webhook is disabled"})
return return
} }
@@ -64,8 +59,7 @@ func handleGitWebhookPush(c *gin.Context) {
return return
} }
var providerName string providerName, _ := q.GetGitProviderNameByID(context.Background(), hook.ProviderID)
_ = db.QueryRow(`SELECT name FROM git_providers WHERE id = $1`, providerID).Scan(&providerName)
if !verifyGitWebhookSignature(providerName, secret, c.Request, body) { if !verifyGitWebhookSignature(providerName, secret, c.Request, body) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid signature"}) c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid signature"})
@@ -118,14 +112,12 @@ func handleGitWebhookPush(c *gin.Context) {
commit = payload.After commit = payload.After
} }
var cloneURL, fullName, repoUserID string repo, err := q.GetGitRepoForPush(context.Background(), hook.RepoID)
err = db.QueryRow(
`SELECT clone_url, full_name, user_id FROM git_repositories WHERE id = $1`, repoID,
).Scan(&cloneURL, &fullName, &repoUserID)
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Repository not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Repository not found"})
return return
} }
cloneURL, fullName, repoUserID := repo.CloneUrl, repo.FullName, repo.UserID.String()
engineValue, _ := c.Get("deployment_engine") engineValue, _ := c.Get("deployment_engine")
engine, _ := engineValue.(*deployment.DeploymentEngine) engine, _ := engineValue.(*deployment.DeploymentEngine)
@@ -147,34 +139,37 @@ func handleGitWebhookPush(c *gin.Context) {
// the pushed repo+branch. Shared by the per-repo webhook receiver and the // the pushed repo+branch. Shared by the per-repo webhook receiver and the
// GitHub App webhook endpoint. // GitHub App webhook endpoint.
func dispatchPushToServices(c *gin.Context, db *database.DB, engine *deployment.DeploymentEngine, cloneURL, fullName, branch, commit, repoUserID string) (int, error) { func dispatchPushToServices(c *gin.Context, db *database.DB, engine *deployment.DeploymentEngine, cloneURL, fullName, branch, commit, repoUserID string) (int, error) {
// Sync-style events may carry no branch — match services on repo only. q := sqlcdb.New(db.DB)
branchClause := "s.git_branch = $1" ctx := context.Background()
if branch == "" { // Sync-style events may carry no branch — empty branch matches all.
branchClause = "TRUE" rows, err := q.ListServicesForPush(ctx, sqlcdb.ListServicesForPushParams{
} GitBranch: sql.NullString{String: branch, Valid: true},
rows, err := db.Query(fmt.Sprintf(` GitRepo: sql.NullString{String: cloneURL, Valid: true},
SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command, GitRepo_2: sql.NullString{String: fullName, Valid: true},
s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory, })
s.created_at, s.updated_at
FROM services s
WHERE %s AND (s.git_repo = $2 OR s.git_repo = $3)`, branchClause),
branch, cloneURL, fullName)
if err != nil { if err != nil {
return 0, err return 0, err
} }
defer rows.Close()
var services []Service var services []Service
for rows.Next() { for _, row := range rows {
var s Service services = append(services, Service{
if err := rows.Scan( ID: row.ID,
&s.ID, &s.ProjectID, &s.Name, &s.Type, &s.Status, &s.Image, &s.Command, ProjectID: row.ProjectID,
&s.Environment, &s.GitRepo, &s.GitBranch, &s.BuildPath, &s.CPU, &s.Memory, Name: row.Name,
&s.CreatedAt, &s.UpdatedAt, Type: row.Type,
); err != nil { Status: row.Status,
return 0, err Image: row.Image,
} Command: row.Command,
services = append(services, s) Environment: row.Environment,
GitRepo: row.GitRepo,
GitBranch: row.GitBranch,
BuildPath: row.BuildPath,
CPU: row.Cpu,
Memory: row.Memory,
CreatedAt: row.CreatedAt.Time,
UpdatedAt: row.UpdatedAt.Time,
})
} }
enqueued := 0 enqueued := 0
@@ -192,27 +187,34 @@ func dispatchPushToServices(c *gin.Context, db *database.DB, engine *deployment.
CreatedAt: now, CreatedAt: now,
UpdatedAt: now, UpdatedAt: now,
} }
if _, err := db.Exec( if err := q.InsertDeployment(ctx, sqlcdb.InsertDeploymentParams{
`INSERT INTO deployments ID: d.ID,
(id, service_id, version, commit_hash, status, image_name, image_tag, created_at, updated_at) ServiceID: d.ServiceID,
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, Version: fmt.Sprintf("v%d", now.Unix()),
d.ID, d.ServiceID, fmt.Sprintf("v%d", now.Unix()), d.CommitHash, d.Status, d.ImageName, d.ImageTag, d.CreatedAt, d.UpdatedAt, CommitHash: sql.NullString{String: ptrStr(d.CommitHash), Valid: d.CommitHash != nil},
); err != nil { Status: sql.NullString{String: d.Status, Valid: true},
ImageName: sql.NullString{String: d.ImageName, Valid: true},
ImageTag: sql.NullString{String: d.ImageTag, Valid: true},
CreatedAt: sql.NullTime{Time: d.CreatedAt, Valid: true},
UpdatedAt: sql.NullTime{Time: d.UpdatedAt, Valid: true},
}); err != nil {
continue continue
} }
if engine == nil { if engine == nil {
failedAt := time.Now()
failure := "Deployment engine unavailable. Docker may not be configured on this server." failure := "Deployment engine unavailable. Docker may not be configured on this server."
_, _ = db.Exec( failedAt := time.Now()
`UPDATE deployments SET status = 'failed', error = $1, completed_at = $2, updated_at = $2 WHERE id = $3`, _ = q.FailDeployment(ctx, sqlcdb.FailDeploymentParams{
failure, failedAt, d.ID, Error: sql.NullString{String: failure, Valid: true},
) CompletedAt: sql.NullTime{Time: failedAt, Valid: true},
ID: d.ID,
})
continue continue
} }
_, _ = db.Exec( _ = q.SetServiceStatus(ctx, sqlcdb.SetServiceStatusParams{
`UPDATE services SET status = 'building', updated_at = $1 WHERE id = $2`, Status: sql.NullString{String: "building", Valid: true},
time.Now(), service.ID, UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true},
) ID: service.ID,
})
// Webhook pushes default to a clean build — stale layers are the // Webhook pushes default to a clean build — stale layers are the
// classic silent-rollback failure (dflow convention). // classic silent-rollback failure (dflow convention).
branchForBuild := branch branchForBuild := branch
@@ -226,14 +228,17 @@ func dispatchPushToServices(c *gin.Context, db *database.DB, engine *deployment.
runDeploymentAndSync(jctx, db, engine, &d, service, webhookReq, repoUserID) runDeploymentAndSync(jctx, db, engine, &d, service, webhookReq, repoUserID)
}, },
}); pos > 0 { }); pos > 0 {
_, _ = db.Exec( now := sql.NullTime{Time: time.Now(), Valid: true}
`UPDATE deployments SET status = 'queued', updated_at = $1 WHERE id = $2`, _ = q.SetDeploymentStatus(ctx, sqlcdb.SetDeploymentStatusParams{
time.Now(), d.ID, Status: sql.NullString{String: "queued", Valid: true},
) UpdatedAt: now,
_, _ = db.Exec( ID: d.ID,
`UPDATE services SET status = 'queued', updated_at = $1 WHERE id = $2`, })
time.Now(), service.ID, _ = q.SetServiceStatus(ctx, sqlcdb.SetServiceStatusParams{
) Status: sql.NullString{String: "queued", Valid: true},
UpdatedAt: now,
ID: service.ID,
})
} }
enqueued++ enqueued++
} }
+203 -1
View File
@@ -1,6 +1,16 @@
package api package api
import "testing" import (
"context"
"database/sql"
"strings"
"testing"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"github.com/google/uuid"
)
func TestNormalizeRepositoryFullName(t *testing.T) { func TestNormalizeRepositoryFullName(t *testing.T) {
cases := []struct { cases := []struct {
@@ -83,3 +93,195 @@ func TestDeriveCloneURL(t *testing.T) {
}) })
} }
} }
// Live invariant test against real Postgres — set METRICS_TEST_DSN (or
// DATABASE_URL). Covers the generated git provider/repo/webhook queries plus
// the push-dispatch service matcher (empty branch must match all branches).
func TestGitLiveInvariants(t *testing.T) {
dsn := testDSNString(t)
db, err := database.NewConnection(dsn)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer db.Close()
ctx := context.Background()
q := sqlcdb.New(db.DB)
userID := uuid.New()
projectID := uuid.New()
envID := uuid.New()
providerID := uuid.New()
repoID := uuid.New()
webhookID := uuid.New()
svcMain := uuid.New()
svcDev := uuid.New()
suffix := strings.ReplaceAll(userID.String()[:8], "-", "")
must := func(err error) {
t.Helper()
if err != nil {
t.Fatalf("setup: %v", err)
}
}
_, err = db.Exec(`INSERT INTO users (id, email, password_hash, name) VALUES ($1, $2, 'x', 'test')`,
userID, "git-"+suffix+"@test.local")
must(err)
_, err = db.Exec(`INSERT INTO projects (id, name, owner_id, is_approved) VALUES ($1, $2, $3, true)`,
projectID, "git-"+suffix, userID)
must(err)
_, err = db.Exec(`INSERT INTO environments (id, name, project_id) VALUES ($1, 'production', $2)`,
envID, projectID)
must(err)
defer func() { _, _ = db.Exec(`DELETE FROM users WHERE id = $1`, userID) }()
// Provider lifecycle.
must(q.InsertGitProvider(ctx, sqlcdb.InsertGitProviderParams{
ID: providerID, Name: "github", DisplayName: "GitHub",
ApiUrl: "https://api.github.com", WebhookUrl: "https://hooks.test",
AccessToken: "tok", UserID: userID,
}))
providers, err := q.ListGitProviders(ctx, userID)
if err != nil || len(providers) == 0 {
t.Fatalf("providers: %v %v", err, providers)
}
name, err := q.GetGitProviderNameByID(ctx, providerID)
if err != nil || name != "github" {
t.Fatalf("provider name: %v %q", err, name)
}
fetch, err := q.GetGitProviderForFetch(ctx, sqlcdb.GetGitProviderForFetchParams{ID: providerID, UserID: userID})
if err != nil || fetch.AccessToken != "tok" || fetch.ApiUrl != "https://api.github.com" {
t.Fatalf("fetch: %v %+v", err, fetch)
}
// Repository insert + search.
cloneURL := "https://github.com/acme/svc-" + suffix + ".git"
fullName := "acme/svc-" + suffix
_, err = q.InsertGitRepository(ctx, sqlcdb.InsertGitRepositoryParams{
ID: repoID, ProviderID: providerID, Name: "svc-" + suffix, FullName: fullName,
Description: sql.NullString{String: "", Valid: true},
CloneUrl: cloneURL,
DefaultBranch: sql.NullString{String: "main", Valid: true},
IsPrivate: sql.NullBool{Bool: false, Valid: true},
UserID: userID,
})
must(err)
byName, err := q.GetGitRepositoryByFullName(ctx, sqlcdb.GetGitRepositoryByFullNameParams{
ProviderID: providerID, FullName: fullName, UserID: userID,
})
if err != nil || byName.ID != repoID || byName.CloneUrl != cloneURL {
t.Fatalf("repo by full name: %v %+v", err, byName)
}
all, err := q.ListGitRepositories(ctx, sqlcdb.ListGitRepositoriesParams{
ProviderID: providerID, UserID: userID, Column3: "", Limit: 50, Offset: 0,
})
if err != nil || len(all) == 0 {
t.Fatalf("repo list (empty search): %v %v", err, all)
}
hit, err := q.ListGitRepositories(ctx, sqlcdb.ListGitRepositoriesParams{
ProviderID: providerID, UserID: userID, Column3: "svc-" + suffix, Limit: 50, Offset: 0,
})
if err != nil || len(hit) != 1 || hit[0].ID != repoID {
t.Fatalf("repo list (matching search): %v %v", err, hit)
}
miss, err := q.ListGitRepositories(ctx, sqlcdb.ListGitRepositoriesParams{
ProviderID: providerID, UserID: userID, Column3: "zzz-no-match", Limit: 50, Offset: 0,
})
if err != nil || len(miss) != 0 {
t.Fatalf("repo list (non-matching search): %v %v", err, miss)
}
count, err := q.CountGitRepositories(ctx, sqlcdb.CountGitRepositoriesParams{
ProviderID: providerID, UserID: userID, Column3: "",
})
if err != nil || count < 1 {
t.Fatalf("repo count: %v %d", err, count)
}
// Upsert on (provider_id, full_name) conflict — updates rather than errors.
must(q.UpsertGitRepository(ctx, sqlcdb.UpsertGitRepositoryParams{
ID: uuid.New(), ProviderID: providerID, Name: "renamed-" + suffix, FullName: fullName,
Description: sql.NullString{String: "d", Valid: true},
CloneUrl: cloneURL,
DefaultBranch: sql.NullString{String: "main", Valid: true},
IsPrivate: sql.NullBool{Bool: true, Valid: true},
UserID: userID,
}))
byName, err = q.GetGitRepositoryByFullName(ctx, sqlcdb.GetGitRepositoryByFullNameParams{
ProviderID: providerID, FullName: fullName, UserID: userID,
})
if err != nil || byName.Name != "renamed-"+suffix {
t.Fatalf("repo after upsert: %v %+v", err, byName)
}
// Connected-repos join + webhook lifecycle.
connected, err := q.ListConnectedRepositories(ctx, sqlcdb.ListConnectedRepositoriesParams{
UserID: userID, Limit: 50, Offset: 0,
})
if err != nil || len(connected) == 0 {
t.Fatalf("connected repos: %v %v", err, connected)
}
wh, err := q.UpsertGitWebhook(ctx, sqlcdb.UpsertGitWebhookParams{
ID: webhookID, RepoID: repoID, ProviderID: providerID,
Events: "push", WebhookSecret: "sec-" + suffix,
BranchFilter: sql.NullString{String: "", Valid: true},
})
must(err)
if !wh.Active.Bool {
t.Fatalf("webhook inactive after upsert: %+v", wh)
}
push, err := q.GetGitWebhookForPush(ctx, webhookID)
if err != nil || push.WebhookSecret != "sec-"+suffix || push.ProviderID != providerID {
t.Fatalf("webhook for push: %v %+v", err, push)
}
pushRepo, err := q.GetGitRepoForPush(ctx, repoID)
if err != nil || pushRepo.CloneUrl != cloneURL || pushRepo.FullName != fullName || pushRepo.UserID != userID {
t.Fatalf("repo for push: %v %+v", err, pushRepo)
}
// Push-dispatch service matcher: branch filter and empty-branch match-all.
for id, branch := range map[uuid.UUID]string{svcMain: "main", svcDev: "dev"} {
_, err = db.Exec(`INSERT INTO services (id, name, project_id, environment_id, service_type, source_type, git_repo, git_branch)
VALUES ($1, $2, $3, $4, 'web', 'git', $5, $6)`,
id, "svc-"+suffix+"-"+branch, projectID, envID, cloneURL, branch)
must(err)
}
servicesFor := func(branch string) []sqlcdb.ListServicesForPushRow {
rows, err := q.ListServicesForPush(ctx, sqlcdb.ListServicesForPushParams{
GitBranch: sql.NullString{String: branch, Valid: true},
GitRepo: sql.NullString{String: cloneURL, Valid: true},
GitRepo_2: sql.NullString{String: fullName, Valid: true},
})
if err != nil {
t.Fatalf("services for push %q: %v", branch, err)
}
return rows
}
mainRows := servicesFor("main")
if len(mainRows) != 1 || mainRows[0].ID != svcMain || mainRows[0].GitBranch != "main" {
t.Fatalf("main branch dispatch: %+v", mainRows)
}
devRows := servicesFor("dev")
if len(devRows) != 1 || devRows[0].ID != svcDev {
t.Fatalf("dev branch dispatch: %+v", devRows)
}
allRows := servicesFor("")
if len(allRows) != 2 {
t.Fatalf("empty-branch dispatch should match all: %+v", allRows)
}
// Repo may also be referenced by its full_name.
fullRows, err := q.ListServicesForPush(ctx, sqlcdb.ListServicesForPushParams{
GitBranch: sql.NullString{String: "main", Valid: true},
GitRepo: sql.NullString{String: "[email protected]:other/none.git", Valid: true},
GitRepo_2: sql.NullString{String: cloneURL, Valid: true},
})
if err != nil || len(fullRows) != 1 {
t.Fatalf("full-name repo match: %v %+v", err, fullRows)
}
// Provider delete is owner-scoped.
other, err := q.DeleteGitProvider(ctx, sqlcdb.DeleteGitProviderParams{ID: providerID, UserID: uuid.New()})
if err != nil || other != 0 {
t.Fatalf("foreign delete affected %d rows: %v", other, err)
}
deleted, err := q.DeleteGitProvider(ctx, sqlcdb.DeleteGitProviderParams{ID: providerID, UserID: userID})
if err != nil || deleted != 1 {
t.Fatalf("owner delete affected %d rows: %v", deleted, err)
}
}
+27 -18
View File
@@ -1,12 +1,16 @@
package api package api
import ( import (
"context"
"fmt" "fmt"
"strconv" "strconv"
"strings" "strings"
"containr/internal/database" "containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/source" "containr/internal/source"
"github.com/google/uuid"
) )
// resolveGitCloneSpec turns a service's git_repo (clone URL, ssh remote, or // resolveGitCloneSpec turns a service's git_repo (clone URL, ssh remote, or
@@ -22,26 +26,31 @@ func resolveGitCloneSpec(db *database.DB, ownerID, gitRepo, branch, commit, buil
} }
spec := source.Spec{Branch: branch, Commit: commit, BuildPath: buildPath} spec := source.Spec{Branch: branch, Commit: commit, BuildPath: buildPath}
q := sqlcdb.New(db.DB)
ctx := context.Background()
var providerID string ownerUUID, _ := uuid.Parse(ownerID)
var providerID uuid.UUID
if !strings.Contains(cloneURL, "://") { if !strings.Contains(cloneURL, "://") {
// Bare owner/repo — the connected repository row knows the host. // Bare owner/repo — the connected repository row knows the host.
err = db.QueryRow( row, err := q.GetGitRepoCloneByFullName(ctx, sqlcdb.GetGitRepoCloneByFullNameParams{
`SELECT clone_url, provider_id FROM git_repositories FullName: cloneURL,
WHERE full_name = $1 AND user_id = $2 LIMIT 1`, UserID: ownerUUID,
cloneURL, ownerID, })
).Scan(&cloneURL, &providerID)
if err != nil { if err != nil {
return source.Spec{}, fmt.Errorf("repository %s is not connected — connect it under Git settings or store a clone URL", raw) return source.Spec{}, fmt.Errorf("repository %s is not connected — connect it under Git settings or store a clone URL", raw)
} }
cloneURL = row.CloneUrl
providerID = row.ProviderID
} else { } else {
// URL form — match a connected repo for its provider when possible; // URL form — match a connected repo for its provider when possible;
// an unmatched public URL still clones, just without credentials. // an unmatched public URL still clones, just without credentials.
_ = db.QueryRow( if pid, err := q.GetGitRepoProviderByCloneURL(ctx, sqlcdb.GetGitRepoProviderByCloneURLParams{
`SELECT provider_id FROM git_repositories CloneUrl: cloneURL,
WHERE clone_url = $1 AND user_id = $2 LIMIT 1`, UserID: ownerUUID,
cloneURL, ownerID, }); err == nil {
).Scan(&providerID) providerID = pid
}
} }
if err := source.ValidateRemoteURL(cloneURL); err != nil { if err := source.ValidateRemoteURL(cloneURL); err != nil {
@@ -49,17 +58,17 @@ func resolveGitCloneSpec(db *database.DB, ownerID, gitRepo, branch, commit, buil
} }
spec.CloneURL = cloneURL spec.CloneURL = cloneURL
if providerID == "" { if providerID == uuid.Nil {
return spec, nil return spec, nil
} }
var name, apiURL, token string prov, err := q.GetGitProviderForFetch(ctx, sqlcdb.GetGitProviderForFetchParams{
if err := db.QueryRow( ID: providerID,
`SELECT name, api_url, access_token FROM git_providers UserID: ownerUUID,
WHERE id = $1 AND user_id = $2`, })
providerID, ownerID, if err != nil {
).Scan(&name, &apiURL, &token); err != nil {
return spec, nil // provider row gone — anonymous clone still works for public repos return spec, nil // provider row gone — anonymous clone still works for public repos
} }
name, apiURL, token := prov.Name, prov.ApiUrl, prov.AccessToken
switch strings.ToLower(name) { switch strings.ToLower(name) {
case "github_app": case "github_app":
+2 -2
View File
@@ -15,6 +15,7 @@ import (
"time" "time"
"containr/internal/database" "containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/deployment" "containr/internal/deployment"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -206,9 +207,8 @@ func handleAdminGitHubAppConvert(c *gin.Context) {
// GET /admin/git/github-app — provisioning status for the settings UI. // GET /admin/git/github-app — provisioning status for the settings UI.
func handleAdminGetGitHubApp(c *gin.Context) { func handleAdminGetGitHubApp(c *gin.Context) {
db := c.MustGet("db").(*database.DB) db := c.MustGet("db").(*database.DB)
var stored string
source := "none" source := "none"
if err := db.QueryRow(`SELECT value FROM app_settings WHERE key = $1`, settingGitHubAppID).Scan(&stored); err == nil && strings.TrimSpace(stored) != "" { if v, err := sqlcdb.New(db.DB).GetAppSetting(c.Request.Context(), settingGitHubAppID); err == nil && strings.TrimSpace(v) != "" {
source = "settings" source = "settings"
} else if strings.TrimSpace(os.Getenv("GITHUB_APP_ID")) != "" { } else if strings.TrimSpace(os.Getenv("GITHUB_APP_ID")) != "" {
source = "env" source = "env"
+15 -9
View File
@@ -193,8 +193,8 @@ func handleAcceptInvite(c *gin.Context) {
return return
} }
var existing int existing, err := q.CountUsersByEmail(c.Request.Context(), req.Email)
if err := db.QueryRow(`SELECT COUNT(*) FROM users WHERE email = $1`, req.Email).Scan(&existing); err != nil { if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "database error") respondError(c, http.StatusInternalServerError, "INTERNAL", "database error")
return return
} }
@@ -214,17 +214,23 @@ func handleAcceptInvite(c *gin.Context) {
return return
} }
var user User row, err := q.InsertUser(c.Request.Context(), sqlcdb.InsertUserParams{
err = db.QueryRow(` Email: req.Email,
INSERT INTO users (email, password_hash, name) PasswordHash: string(hashedPassword),
VALUES ($1, $2, $3) Name: strings.TrimSpace(req.Name),
RETURNING id, email, name, COALESCE(avatar_url, ''), is_admin, created_at })
`, req.Email, string(hashedPassword), strings.TrimSpace(req.Name)).
Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt)
if err != nil { if err != nil {
respondError(c, http.StatusConflict, "CONFLICT", "user already exists") respondError(c, http.StatusConflict, "CONFLICT", "user already exists")
return return
} }
user := User{
ID: row.ID.String(),
Email: row.Email,
Name: row.Name,
AvatarURL: row.AvatarUrl,
IsAdmin: row.IsAdmin,
CreatedAt: row.CreatedAt.Time.String(),
}
userUUID, _ := uuid.Parse(user.ID) userUUID, _ := uuid.Parse(user.ID)
if err := q.MarkUserInviteUsed(c.Request.Context(), sqlcdb.MarkUserInviteUsedParams{ if err := q.MarkUserInviteUsed(c.Request.Context(), sqlcdb.MarkUserInviteUsedParams{
+3 -7
View File
@@ -9,6 +9,7 @@ import (
"time" "time"
"containr/internal/database" "containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/docker" "containr/internal/docker"
containertypes "github.com/docker/docker/api/types/container" containertypes "github.com/docker/docker/api/types/container"
@@ -60,17 +61,12 @@ func handleGetServiceMetrics(c *gin.Context) {
return return
} }
var projectID uuid.UUID svc, err := sqlcdb.New(db.DB).GetServiceProjectAndStatus(context.Background(), serviceID)
var status string
err = db.QueryRow(
`SELECT s.project_id, COALESCE(s.status, '')
FROM services s WHERE s.id = $1`,
serviceID,
).Scan(&projectID, &status)
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return return
} }
projectID := svc.ProjectID
if _, allowed := projectReadAccess(c, db, projectID); !allowed { if _, allowed := projectReadAccess(c, db, projectID); !allowed {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return return
+3 -4
View File
@@ -315,10 +315,9 @@ func (r *agentNodeRunner) replicaPayload(spec deployment.RuntimeSpec, agentID st
// pullCredentials resolves the project owner's registry auth for the image // pullCredentials resolves the project owner's registry auth for the image
// host so private images pull on remote nodes. Empty map → anonymous pull. // host so private images pull on remote nodes. Empty map → anonymous pull.
func (r *agentNodeRunner) pullCredentials(projectID, imageRef string) map[string]interface{} { func (r *agentNodeRunner) pullCredentials(projectID, imageRef string) map[string]interface{} {
var ownerID string pid, _ := uuid.Parse(projectID)
if err := r.db.QueryRow( ownerID, err := sqlcdb.New(r.db.DB).GetProjectOwnerText(context.Background(), pid)
`SELECT owner_id FROM projects WHERE id = $1`, projectID, if err != nil {
).Scan(&ownerID); err != nil {
return map[string]interface{}{} return map[string]interface{}{}
} }
auth := registryAuthFor(r.db, ownerID, imageRef) auth := registryAuthFor(r.db, ownerID, imageRef)
@@ -9,6 +9,7 @@ import (
"containr/internal/database" "containr/internal/database"
"containr/internal/database/sqlcdb" "containr/internal/database/sqlcdb"
"context" "context"
"database/sql"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log" "log"
@@ -29,15 +30,13 @@ func notifyAdmins(db *database.DB, kind, title, body, resourceType, resourceID s
if db == nil { if db == nil {
return return
} }
rows, err := db.Query(`SELECT id FROM users WHERE is_admin`) adminIDs, err := sqlcdb.New(db.DB).ListAdminUserIDs(context.Background())
if err != nil { if err != nil {
log.Printf("containr: notify admins lookup failed: %v", err) log.Printf("containr: notify admins lookup failed: %v", err)
return return
} }
defer rows.Close() for _, id := range adminIDs {
for rows.Next() { {
var id uuid.UUID
if err := rows.Scan(&id); err == nil {
insertUserNotification(db, id.String(), kind, title, body, resourceType, resourceID) insertUserNotification(db, id.String(), kind, title, body, resourceType, resourceID)
} }
} }
@@ -73,34 +72,32 @@ type agentHealthRow struct {
func reconcileAgentHealth(db *database.DB, staleAfter time.Duration) { func reconcileAgentHealth(db *database.DB, staleAfter time.Duration) {
cutoff := time.Now().Add(-staleAfter) cutoff := time.Now().Add(-staleAfter)
var agents []agentHealthRow var agents []agentHealthRow
rows, err := db.Query( q := sqlcdb.New(db.DB)
`SELECT id, COALESCE(name, hostname, id::text), COALESCE(status, ''), agentRows, err := q.ListAgentHealthRows(context.Background(), sql.NullTime{Time: cutoff, Valid: true})
COALESCE(last_heartbeat < $1, TRUE), auto_prune
FROM node_agents`, cutoff)
if err != nil { if err != nil {
log.Printf("containr: agent health sweep: %v", err) log.Printf("containr: agent health sweep: %v", err)
return return
} }
for rows.Next() { for _, r := range agentRows {
var a agentHealthRow agents = append(agents, agentHealthRow{
if err := rows.Scan(&a.id, &a.name, &a.status, &a.stale, &a.autoPrune); err == nil { id: r.ID, name: r.Name, status: r.Status, stale: r.Stale, autoPrune: r.AutoPrune,
agents = append(agents, a) })
} }
}
rows.Close()
for _, a := range agents { for _, a := range agents {
switch { switch {
case a.stale && a.status != "offline": case a.stale && a.status != "offline":
if _, err := db.Exec( if err := q.SetAgentStatus(context.Background(), sqlcdb.SetAgentStatusParams{
`UPDATE node_agents SET status = 'offline', updated_at = NOW() WHERE id = $1`, a.id); err != nil { Status: sql.NullString{String: "offline", Valid: true}, ID: a.id,
}); err != nil {
continue continue
} }
notifyAdmins(db, "agent", "Node agent offline", notifyAdmins(db, "agent", "Node agent offline",
fmt.Sprintf("Agent %s missed its heartbeat for over %s.", a.name, staleAfter), "agent", a.id) fmt.Sprintf("Agent %s missed its heartbeat for over %s.", a.name, staleAfter), "agent", a.id)
case !a.stale && a.status == "offline": case !a.stale && a.status == "offline":
if _, err := db.Exec( if err := q.SetAgentStatus(context.Background(), sqlcdb.SetAgentStatusParams{
`UPDATE node_agents SET status = 'online', updated_at = NOW() WHERE id = $1`, a.id); err != nil { Status: sql.NullString{String: "online", Valid: true}, ID: a.id,
}); err != nil {
continue continue
} }
notifyAdmins(db, "agent", "Node agent back online", notifyAdmins(db, "agent", "Node agent back online",
@@ -186,17 +183,17 @@ func checkForUpgrade(db *database.DB, currentVersion string) {
return return
} }
var lastNotified string lastNotified, _ := sqlcdb.New(db.DB).GetAppSetting(context.Background(), "upgrade_notified_tag")
_ = db.QueryRow(`SELECT value FROM app_settings WHERE key = 'upgrade_notified_tag'`).Scan(&lastNotified)
if lastNotified == release.TagName { if lastNotified == release.TagName {
return return
} }
notifyAdmins(db, "upgrade", "Containr update available", notifyAdmins(db, "upgrade", "Containr update available",
fmt.Sprintf("Release %s is available (running %s). %s", release.TagName, currentVersion, release.HTMLURL), fmt.Sprintf("Release %s is available (running %s). %s", release.TagName, currentVersion, release.HTMLURL),
"release", release.TagName) "release", release.TagName)
_, _ = db.Exec( _ = sqlcdb.New(db.DB).UpsertAppSettingValue(context.Background(), sqlcdb.UpsertAppSettingValueParams{
`INSERT INTO app_settings (key, value, updated_at) VALUES ('upgrade_notified_tag', $1, NOW()) Key: "upgrade_notified_tag",
ON CONFLICT (key) DO UPDATE SET value = $1, updated_at = NOW()`, release.TagName) Value: release.TagName,
})
} }
// semverNewer reports whether candidate is a higher semver than current. // semverNewer reports whether candidate is a higher semver than current.
+40 -69
View File
@@ -1,13 +1,16 @@
package api package api
import ( import (
"context"
"net/http" "net/http"
"time" "time"
"containr/internal/database" "containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/deployqueue" "containr/internal/deployqueue"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid"
) )
// GET /operations — a single cross-cutting view of platform work: active // GET /operations — a single cross-cutting view of platform work: active
@@ -55,91 +58,59 @@ func handleGetOperations(c *gin.Context) {
db := c.MustGet("db").(*database.DB) db := c.MustGet("db").(*database.DB)
isAdmin := contextIsAdmin(c) isAdmin := contextIsAdmin(c)
// Same visibility rule as the deployment list endpoint. userUUID, _ := uuid.Parse(userID)
visibility := `p.is_approved OR p.owner_id = $1 OR $2::bool q := sqlcdb.New(db.DB)
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1)` visibility := sqlcdb.ListActiveOperationDeploymentsParams{OwnerID: userUUID, Column2: isAdmin}
active := []operationDeployment{} active := []operationDeployment{}
rows, err := db.Query( if rows, err := q.ListActiveOperationDeployments(context.Background(), visibility); err == nil {
`SELECT d.id, d.service_id, s.name, p.name, d.status, for _, r := range rows {
COALESCE(d.image_name, ''), d.error, d.started_at, d.completed_at, d.created_at active = append(active, operationDeployment{
FROM deployments d ID: r.ID.String(), ServiceID: r.ServiceID.String(), ServiceName: r.ServiceName,
JOIN services s ON s.id = d.service_id ProjectName: r.ProjectName, Status: r.Status.String, Image: r.Image,
JOIN projects p ON p.id = s.project_id Error: strPtrNS(r.Error), StartedAt: timePtrNT(r.StartedAt),
WHERE d.status IN ('queued','pending','building','deploying','rolling_back') CompletedAt: timePtrNT(r.CompletedAt), CreatedAt: r.CreatedAt.Time,
AND (`+visibility+`) })
ORDER BY d.created_at ASC`, userID, isAdmin)
if err == nil {
defer rows.Close()
for rows.Next() {
var d operationDeployment
var sid string
_ = rows.Scan(&d.ID, &sid, &d.ServiceName, &d.ProjectName, &d.Status, &d.Image, &d.Error, &d.StartedAt, &d.CompletedAt, &d.CreatedAt)
d.ServiceID = sid
active = append(active, d)
} }
} }
recentFailed := []operationDeployment{} recentFailed := []operationDeployment{}
rows2, err2 := db.Query( if rows, err := q.ListRecentFailedDeployments(context.Background(), sqlcdb.ListRecentFailedDeploymentsParams{
`SELECT d.id, d.service_id, s.name, p.name, d.status, OwnerID: userUUID, Column2: isAdmin,
COALESCE(d.image_name, ''), d.error, d.started_at, d.completed_at, d.created_at }); err == nil {
FROM deployments d for _, r := range rows {
JOIN services s ON s.id = d.service_id recentFailed = append(recentFailed, operationDeployment{
JOIN projects p ON p.id = s.project_id ID: r.ID.String(), ServiceID: r.ServiceID.String(), ServiceName: r.ServiceName,
WHERE d.status IN ('failed','cancelled','rolled_back') ProjectName: r.ProjectName, Status: r.Status.String, Image: r.Image,
AND d.updated_at > NOW() - INTERVAL '24 hours' Error: strPtrNS(r.Error), StartedAt: timePtrNT(r.StartedAt),
AND (`+visibility+`) CompletedAt: timePtrNT(r.CompletedAt), CreatedAt: r.CreatedAt.Time,
ORDER BY d.updated_at DESC })
LIMIT 20`, userID, isAdmin)
if err2 == nil {
defer rows2.Close()
for rows2.Next() {
var d operationDeployment
var sid string
_ = rows2.Scan(&d.ID, &sid, &d.ServiceName, &d.ProjectName, &d.Status, &d.Image, &d.Error, &d.StartedAt, &d.CompletedAt, &d.CreatedAt)
d.ServiceID = sid
recentFailed = append(recentFailed, d)
} }
} }
cronRuns := []operationCronRun{} cronRuns := []operationCronRun{}
rows3, err3 := db.Query( if rows, err := q.ListRecentCronRuns(context.Background(), sqlcdb.ListRecentCronRunsParams{
`SELECT e.id, j.name, j.schedule, e.status, e.started_at, e.finished_at, e.error OwnerID: userUUID, Column2: isAdmin,
FROM cron_executions e }); err == nil {
JOIN cron_jobs j ON j.id = e.cron_job_id for _, r := range rows {
JOIN projects p ON p.id = j.project_id cronRuns = append(cronRuns, operationCronRun{
WHERE e.started_at > NOW() - INTERVAL '24 hours' ID: r.ID.String(), JobName: r.JobName, Schedule: r.Schedule, Status: r.Status.String,
AND (`+visibility+`) StartedAt: r.StartedAt, FinishedAt: timePtrNT(r.FinishedAt), Error: strPtrNS(r.Error),
ORDER BY e.started_at DESC })
LIMIT 30`, userID, isAdmin)
if err3 == nil {
defer rows3.Close()
for rows3.Next() {
var r operationCronRun
_ = rows3.Scan(&r.ID, &r.JobName, &r.Schedule, &r.Status, &r.StartedAt, &r.FinishedAt, &r.Error)
cronRuns = append(cronRuns, r)
} }
} }
backups := []operationBackup{} backups := []operationBackup{}
rows4, err4 := db.Query( if rows, err := q.ListRecentOperationBackups(context.Background(), sqlcdb.ListRecentOperationBackupsParams{
`SELECT b.id, b.database_id, ds.name, b.status, b.size, b.created_at, b.completed_at UserID: userID, Column2: isAdmin,
FROM database_backups b }); err == nil {
JOIN database_services ds ON ds.id = b.database_id for _, r := range rows {
WHERE b.created_at > NOW() - INTERVAL '24 hours' backups = append(backups, operationBackup{
AND (ds.user_id = $1 OR $2::bool) ID: r.ID, DatabaseID: r.DatabaseID, DBName: r.DbName, Status: r.Status,
ORDER BY b.created_at DESC Size: r.Size, CreatedAt: r.CreatedAt.Time, Completed: timePtrNT(r.CompletedAt),
LIMIT 20`, userID, isAdmin) })
if err4 == nil {
defer rows4.Close()
for rows4.Next() {
var b operationBackup
_ = rows4.Scan(&b.ID, &b.DatabaseID, &b.DBName, &b.Status, &b.Size, &b.CreatedAt, &b.Completed)
backups = append(backups, b)
} }
} }
var queue []deployqueue.ServiceQueueState var queue []deployqueue.ServiceQueueState
if queueValue, exists := c.Get("deploy_queue"); exists && queueValue != nil { if queueValue, exists := c.Get("deploy_queue"); exists && queueValue != nil {
queue = queueValue.(*deployqueue.Queue).Snapshot() queue = queueValue.(*deployqueue.Queue).Snapshot()
+17 -11
View File
@@ -1,10 +1,12 @@
package api package api
import ( import (
"context"
"net/http" "net/http"
"time" "time"
"containr/internal/database" "containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/secrets" "containr/internal/secrets"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -112,7 +114,8 @@ func handleUpdateProjectVariables(c *gin.Context) {
} }
defer tx.Rollback() defer tx.Rollback()
if _, err = tx.Exec(`DELETE FROM project_variables WHERE project_id = $1`, projectID); err != nil { txq := sqlcdb.New(tx)
if err = txq.DeleteProjectVariables(c.Request.Context(), projectID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clear existing variables"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clear existing variables"})
return return
} }
@@ -127,11 +130,15 @@ func handleUpdateProjectVariables(c *gin.Context) {
if v.IsSecret { if v.IsSecret {
value = secrets.Encrypt(value) value = secrets.Encrypt(value)
} }
if _, err = tx.Exec( if err = txq.InsertProjectVariable(c.Request.Context(), sqlcdb.InsertProjectVariableParams{
`INSERT INTO project_variables (id, project_id, key, value, is_secret, created_at, updated_at) ID: uuid.New(),
VALUES ($1, $2, $3, $4, $5, $6, $7)`, ProjectID: projectID,
uuid.New(), projectID, v.Key, value, v.IsSecret, now, now, Key: v.Key,
); err != nil { Value: value,
IsSecret: v.IsSecret,
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to insert variable: " + v.Key}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to insert variable: " + v.Key})
return return
} }
@@ -168,11 +175,10 @@ func handleUpdateProjectVariables(c *gin.Context) {
// resolveSharedVar returns the decrypted value of a project variable. // resolveSharedVar returns the decrypted value of a project variable.
func resolveSharedVar(db *database.DB, projectID uuid.UUID, key string) (string, bool) { func resolveSharedVar(db *database.DB, projectID uuid.UUID, key string) (string, bool) {
var value string value, err := sqlcdb.New(db.DB).GetProjectVariableValue(context.Background(), sqlcdb.GetProjectVariableValueParams{
err := db.QueryRow( ProjectID: projectID,
`SELECT value FROM project_variables WHERE project_id = $1 AND key = $2`, Key: key,
projectID, key, })
).Scan(&value)
if err != nil { if err != nil {
return "", false return "", false
} }
+9 -7
View File
@@ -1,11 +1,13 @@
package api package api
import ( import (
"context"
"net/http" "net/http"
"strings" "strings"
"time" "time"
"containr/internal/database" "containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/secrets" "containr/internal/secrets"
"github.com/docker/docker/api/types/registry" "github.com/docker/docker/api/types/registry"
@@ -177,17 +179,17 @@ func handleDeleteRegistry(c *gin.Context) {
// bare image names (nginx:latest). // bare image names (nginx:latest).
func registryAuthFor(db *database.DB, ownerID, imageRef string) registry.AuthConfig { func registryAuthFor(db *database.DB, ownerID, imageRef string) registry.AuthConfig {
host := registryHost(imageRef) host := registryHost(imageRef)
var username, password string ownerUUID, _ := uuid.Parse(ownerID)
err := db.QueryRow( row, err := sqlcdb.New(db.DB).GetRegistryAuth(context.Background(), sqlcdb.GetRegistryAuthParams{
`SELECT username, password FROM registries WHERE owner_id = $1 AND host = $2`, OwnerID: ownerUUID,
ownerID, host, Host: host,
).Scan(&username, &password) })
if err != nil { if err != nil {
return registry.AuthConfig{} return registry.AuthConfig{}
} }
return registry.AuthConfig{ return registry.AuthConfig{
Username: username, Username: row.Username,
Password: secrets.Decrypt(password), Password: secrets.Decrypt(row.Password),
ServerAddress: host, ServerAddress: host,
} }
} }
+22 -27
View File
@@ -11,6 +11,7 @@ import (
"containr/internal/build" "containr/internal/build"
"containr/internal/config" "containr/internal/config"
"containr/internal/database" "containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/deployment" "containr/internal/deployment"
"containr/internal/deployqueue" "containr/internal/deployqueue"
"containr/internal/docker" "containr/internal/docker"
@@ -21,6 +22,7 @@ import (
"containr/internal/secrets" "containr/internal/secrets"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid"
) )
func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg *config.Config) { func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg *config.Config) {
@@ -83,19 +85,18 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
// 127.0.0.1 fallback stays for node-bound checks. // 127.0.0.1 fallback stays for node-bound checks.
docker := dockerClient docker := dockerClient
haManager.WithServiceResolver(func(ctx context.Context, serviceID string) (string, int, error) { haManager.WithServiceResolver(func(ctx context.Context, serviceID string) (string, int, error) {
var name, projectID string sid, err := uuid.Parse(serviceID)
var port int
err := db.QueryRowContext(ctx,
`SELECT name, project_id::text, COALESCE(port, 0) FROM services WHERE id = $1`,
serviceID,
).Scan(&name, &projectID, &port)
if err != nil { if err != nil {
return "", 0, err return "", 0, err
} }
if err := docker.ConnectSelfToNetwork(ctx, deployment.ProjectNetworkName(projectID)); err != nil { svc, err := sqlcdb.New(db.DB).GetServiceNameProjectPort(ctx, sid)
if err != nil {
return "", 0, err return "", 0, err
} }
return name, port, nil if err := docker.ConnectSelfToNetwork(ctx, deployment.ProjectNetworkName(svc.ProjectID)); err != nil {
return "", 0, err
}
return svc.Name, int(svc.Port), nil
}) })
} }
haAPIManager := NewHAManager(haManager) haAPIManager := NewHAManager(haManager)
@@ -137,31 +138,25 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
// Boot sweep: in-memory engine state dies on restart, so anything that // Boot sweep: in-memory engine state dies on restart, so anything that
// looked in-flight is actually orphaned — mark it failed rather than // looked in-flight is actually orphaned — mark it failed rather than
// leaving phantom 'building'/'queued' rows. // leaving phantom 'building'/'queued' rows.
_, _ = db.Exec( bootQ := sqlcdb.New(db.DB)
`UPDATE deployments SET status = 'failed', error = 'Interrupted by server restart', completed_at = NOW(), updated_at = NOW() _ = bootQ.FailInterruptedDeployments(context.Background())
WHERE status IN ('queued', 'pending', 'building', 'deploying', 'rolling_back')`,
)
// Boot sweep: encrypt any plaintext secret values left over from before // Boot sweep: encrypt any plaintext secret values left over from before
// encryption-at-rest landed. Transparent and idempotent. // encryption-at-rest landed. Transparent and idempotent.
if encRows, err := db.Query(`SELECT id, value FROM environment_variables WHERE is_secret = TRUE`); err == nil { if encRows, err := bootQ.ListSecretEnvVars(context.Background()); err == nil {
type encRow struct { toEncrypt := 0
id string for _, r := range encRows {
value string if secrets.IsEncrypted(r.Value) {
continue
} }
var toEncrypt []encRow if err := bootQ.UpdateEnvVarValue(context.Background(), sqlcdb.UpdateEnvVarValueParams{
for encRows.Next() { Value: secrets.Encrypt(r.Value), ID: r.ID,
var r encRow }); err == nil {
if err := encRows.Scan(&r.id, &r.value); err == nil && !secrets.IsEncrypted(r.value) { toEncrypt++
toEncrypt = append(toEncrypt, r)
} }
} }
encRows.Close() if toEncrypt > 0 {
for _, r := range toEncrypt { log.Printf("secrets: encrypted %d plaintext secret variable(s)", toEncrypt)
_, _ = db.Exec(`UPDATE environment_variables SET value = $1, updated_at = NOW() WHERE id = $2`, secrets.Encrypt(r.value), r.id)
}
if len(toEncrypt) > 0 {
log.Printf("secrets: encrypted %d plaintext secret variable(s)", len(toEncrypt))
} }
} }
+103 -197
View File
@@ -2,6 +2,7 @@ package api
import ( import (
"context" "context"
"database/sql"
"fmt" "fmt"
"net/http" "net/http"
"regexp" "regexp"
@@ -10,6 +11,7 @@ import (
"time" "time"
"containr/internal/database" "containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/deployment" "containr/internal/deployment"
"containr/internal/deployqueue" "containr/internal/deployqueue"
"containr/internal/secrets" "containr/internal/secrets"
@@ -22,22 +24,17 @@ import (
// service — covers pinned placements, spread replicas, and leftovers from a // service — covers pinned placements, spread replicas, and leftovers from a
// since-cleared pin. // since-cleared pin.
func remoteAgentsForService(ctx context.Context, db *database.DB, serviceID uuid.UUID) []string { func remoteAgentsForService(ctx context.Context, db *database.DB, serviceID uuid.UUID) []string {
rows, err := db.Query( ids, err := sqlcdb.New(db.DB).ListNodeAgentsForService(ctx, serviceID.String())
`SELECT DISTINCT node_agent_id FROM container_instances WHERE service_id = $1`,
serviceID,
)
if err != nil { if err != nil {
return nil return nil
} }
defer rows.Close() out := make([]string, 0, len(ids))
var ids []string for _, id := range ids {
for rows.Next() { if id != "" {
var id string out = append(out, id)
if rows.Scan(&id) == nil && id != "" {
ids = append(ids, id)
} }
} }
return ids return out
} }
// serviceLifecycle routes a start/stop/restart action to every agent holding // serviceLifecycle routes a start/stop/restart action to every agent holding
@@ -99,7 +96,9 @@ func serviceRuntimeSpec(db *database.DB, service Service) (deployment.RuntimeSpe
// Best effort: reuse the host port from the last live deployment so the // Best effort: reuse the host port from the last live deployment so the
// public URL survives restarts. Column exists post-migration; older DBs // public URL survives restarts. Column exists post-migration; older DBs
// silently get ephemeral ports. // silently get ephemeral ports.
_ = db.QueryRow(`SELECT published_port FROM services WHERE id = $1`, service.ID).Scan(&spec.PublishedPort) if p, err := sqlcdb.New(db.DB).GetServicePublishedPort(context.Background(), service.ID); err == nil {
spec.PublishedPort = p
}
if cmd := strings.TrimSpace(service.Command); cmd != "" { if cmd := strings.TrimSpace(service.Command); cmd != "" {
spec.Command = strings.Fields(cmd) spec.Command = strings.Fields(cmd)
} }
@@ -137,42 +136,23 @@ var refVarPattern = regexp.MustCompile(`\$\{\{\s*([A-Za-z0-9_-]+)\.([A-Za-z_][A-
// secrets) and expands ${{service.KEY}} references. KEY may be a variable on // secrets) and expands ${{service.KEY}} references. KEY may be a variable on
// the referenced service, or the builtins HOST (service name alias) and PORT. // the referenced service, or the builtins HOST (service name alias) and PORT.
func resolveServiceEnv(db *database.DB, service Service) (map[string]string, error) { func resolveServiceEnv(db *database.DB, service Service) (map[string]string, error) {
rows, err := db.Query( q := sqlcdb.New(db.DB)
`SELECT key, value FROM environment_variables WHERE service_id = $1`, vars, err := q.ListServiceVariableValues(context.Background(), service.ID)
service.ID,
)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close()
env := map[string]string{} env := map[string]string{}
for rows.Next() { for _, v := range vars {
var k, v string env[v.Key] = secrets.Decrypt(v.Value)
if err := rows.Scan(&k, &v); err != nil {
return nil, err
}
env[k] = secrets.Decrypt(v)
} }
var refServices []struct{ ID, Name string } refServices, err := q.ListProjectServiceRefs(context.Background(), service.ProjectID)
svcRows, err := db.Query(
`SELECT id, name FROM services WHERE project_id = $1`,
service.ProjectID,
)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer svcRows.Close()
for svcRows.Next() {
var s struct{ ID, Name string }
if err := svcRows.Scan(&s.ID, &s.Name); err != nil {
return nil, err
}
refServices = append(refServices, s)
}
byName := map[string]string{} byName := map[string]uuid.UUID{}
for _, s := range refServices { for _, s := range refServices {
byName[strings.ToLower(s.Name)] = s.ID byName[strings.ToLower(s.Name)] = s.ID
} }
@@ -189,17 +169,15 @@ func resolveServiceEnv(db *database.DB, service Service) (map[string]string, err
case "HOST": case "HOST":
return serviceName, true return serviceName, true
case "PORT": case "PORT":
var port int if port, err := q.GetServicePort(context.Background(), id); err == nil && port > 0 {
if err := db.QueryRow(`SELECT COALESCE(port, 0) FROM services WHERE id = $1`, id).Scan(&port); err == nil && port > 0 { return strconv.Itoa(int(port)), true
return strconv.Itoa(port), true
} }
return "", false return "", false
} }
var value string value, err := q.GetServiceVariableValue(context.Background(), sqlcdb.GetServiceVariableValueParams{
err := db.QueryRow( ServiceID: id,
`SELECT value FROM environment_variables WHERE service_id = $1 AND key = $2`, Key: key,
id, key, })
).Scan(&value)
if err != nil { if err != nil {
return "", false return "", false
} }
@@ -272,6 +250,15 @@ func parseMemoryLimit(mem string) int64 {
return 0 return 0
} }
// setServiceStatusNow writes the stored status with a fresh updated_at.
func setServiceStatusNow(db *database.DB, id uuid.UUID, status string) {
_ = sqlcdb.New(db.DB).SetServiceStatus(context.Background(), sqlcdb.SetServiceStatusParams{
Status: sql.NullString{String: status, Valid: true},
UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true},
ID: id,
})
}
func getRuntimeEngine(c *gin.Context) (*deployment.DeploymentEngine, *database.DB, bool) { func getRuntimeEngine(c *gin.Context) (*deployment.DeploymentEngine, *database.DB, bool) {
dbValue, exists := c.Get("db") dbValue, exists := c.Get("db")
if !exists { if !exists {
@@ -286,6 +273,22 @@ func getRuntimeEngine(c *gin.Context) (*deployment.DeploymentEngine, *database.D
return engineValue.(*deployment.DeploymentEngine), dbValue.(*database.DB), true return engineValue.(*deployment.DeploymentEngine), dbValue.(*database.DB), true
} }
// serviceFromRuntimeRow maps the sqlc runtime row (with legacy-column
// fallbacks applied in SQL) onto the API Service model.
func serviceFromRuntimeRow(r sqlcdb.GetServiceRuntimeWithOwnerRow) Service {
return Service{
ID: r.ID, ProjectID: r.ProjectID, Name: r.Name, Type: r.Type,
Status: r.Status, Image: r.Image, Command: r.Command,
Environment: r.Environment, GitRepo: r.GitRepo, GitBranch: r.GitBranch,
BuildPath: r.BuildPath, CPU: r.Cpu, Memory: r.Memory,
Replicas: int(r.Replicas), Port: int(r.Port), Domain: r.Domain,
HealthCheckPath: r.HealthcheckPath, RestartPolicy: r.RestartPolicy,
Builder: r.Builder, CPUReserve: r.CpuReserve,
MemoryReserve: r.MemoryReserve, StaticBuildCmd: r.StaticBuildCmd,
StaticDir: r.StaticDir, CreatedAt: r.CreatedAt.Time, UpdatedAt: r.UpdatedAt.Time,
}
}
func loadOwnedService(c *gin.Context, db *database.DB) (Service, bool) { func loadOwnedService(c *gin.Context, db *database.DB) (Service, bool) {
serviceID, err := uuid.Parse(c.Param("id")) serviceID, err := uuid.Parse(c.Param("id"))
if err != nil { if err != nil {
@@ -298,43 +301,16 @@ func loadOwnedService(c *gin.Context, db *database.DB) (Service, bool) {
return Service{}, false return Service{}, false
} }
var service Service row, err := sqlcdb.New(db.DB).GetServiceRuntimeWithOwner(context.Background(), serviceID)
var owner string
err = db.QueryRow(
`SELECT s.id, s.project_id, s.name,
COALESCE(s.type, s.service_type, ''), COALESCE(s.status, ''),
COALESCE(s.image, s.image_name, ''), COALESCE(s.command, s.start_command, ''),
COALESCE(s.environment, ''), COALESCE(s.git_repo, s.source_url, ''),
COALESCE(s.git_branch, ''), COALESCE(s.build_path, ''),
COALESCE(s.cpu, ''), COALESCE(s.memory, ''),
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
COALESCE(s.restart_policy, 'unless-stopped'),
COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''),
COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''),
COALESCE(s.static_dir, ''),
s.created_at, s.updated_at, p.owner_id
FROM services s JOIN projects p ON s.project_id = p.id
WHERE s.id = $1`,
serviceID,
).Scan(
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
&service.CreatedAt, &service.UpdatedAt, &owner,
)
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return Service{}, false return Service{}, false
} }
if owner != userID.(string) && !isAdminUser(db, userID.(string)) { if row.OwnerID.String() != userID.(string) && !isAdminUser(db, userID.(string)) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return Service{}, false return Service{}, false
} }
return service, true return serviceFromRuntimeRow(row), true
} }
// loadReadableService loads a service when the caller may read its project: // loadReadableService loads a service when the caller may read its project:
@@ -346,39 +322,13 @@ func loadReadableService(c *gin.Context, db *database.DB) (Service, bool) {
return Service{}, false return Service{}, false
} }
var service Service row, err := sqlcdb.New(db.DB).GetServiceRuntimeWithOwner(context.Background(), serviceID)
var projectID uuid.UUID
err = db.QueryRow(
`SELECT s.id, s.project_id, s.name,
COALESCE(s.type, s.service_type, ''), COALESCE(s.status, ''),
COALESCE(s.image, s.image_name, ''), COALESCE(s.command, s.start_command, ''),
COALESCE(s.environment, ''), COALESCE(s.git_repo, s.source_url, ''),
COALESCE(s.git_branch, ''), COALESCE(s.build_path, ''),
COALESCE(s.cpu, ''), COALESCE(s.memory, ''),
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
COALESCE(s.restart_policy, 'unless-stopped'),
COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''),
COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''),
COALESCE(s.static_dir, ''),
s.created_at, s.updated_at, s.project_id
FROM services s
WHERE s.id = $1`,
serviceID,
).Scan(
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
&service.CreatedAt, &service.UpdatedAt, &projectID,
)
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return Service{}, false return Service{}, false
} }
if _, allowed := projectReadAccess(c, db, projectID); !allowed { service := serviceFromRuntimeRow(row)
if _, allowed := projectReadAccess(c, db, service.ProjectID); !allowed {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return Service{}, false return Service{}, false
} }
@@ -429,7 +379,7 @@ func reconcileNow(c *gin.Context, engine *deployment.DeploymentEngine, db *datab
status = state.Status status = state.Status
persistPublishedPort(db, service.ID, state.Ports) persistPublishedPort(db, service.ID, state.Ports)
} }
_, _ = db.Exec(`UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`, status, time.Now(), service.ID) setServiceStatusNow(db, service.ID, status)
c.JSON(http.StatusOK, gin.H{"runtime": state}) c.JSON(http.StatusOK, gin.H{"runtime": state})
return true return true
} }
@@ -448,7 +398,7 @@ func handleServiceStart(c *gin.Context) {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return return
} }
_, _ = db.Exec(`UPDATE services SET status = 'running', updated_at = $1 WHERE id = $2`, time.Now(), service.ID) setServiceStatusNow(db, service.ID, "running")
c.JSON(http.StatusOK, gin.H{"status": "running"}) c.JSON(http.StatusOK, gin.H{"status": "running"})
} }
@@ -466,7 +416,7 @@ func handleServiceStop(c *gin.Context) {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return return
} }
_, _ = db.Exec(`UPDATE services SET status = 'stopped', updated_at = $1 WHERE id = $2`, time.Now(), service.ID) setServiceStatusNow(db, service.ID, "stopped")
c.JSON(http.StatusOK, gin.H{"status": "stopped"}) c.JSON(http.StatusOK, gin.H{"status": "stopped"})
} }
@@ -485,7 +435,7 @@ func handleServiceRestart(c *gin.Context) {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return return
} }
_, _ = db.Exec(`UPDATE services SET status = 'running', updated_at = $1 WHERE id = $2`, time.Now(), service.ID) setServiceStatusNow(db, service.ID, "running")
c.JSON(http.StatusOK, gin.H{"status": "running"}) c.JSON(http.StatusOK, gin.H{"status": "running"})
} }
@@ -505,12 +455,8 @@ func handleServiceRedeploy(c *gin.Context) {
image := service.Image image := service.Image
if image == "" { if image == "" {
// Git-sourced service: reuse the last successful deployment image. // Git-sourced service: reuse the last successful deployment image.
err := db.QueryRow( var err error
`SELECT image_name || ':' || image_tag FROM deployments image, err = sqlcdb.New(db.DB).GetLastDeployedImage(context.Background(), service.ID)
WHERE service_id = $1 AND status = 'deployed' AND image_name <> ''
ORDER BY created_at DESC LIMIT 1`,
service.ID,
).Scan(&image)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "No deployable image yet — run a deployment first"}) c.JSON(http.StatusBadRequest, gin.H{"error": "No deployable image yet — run a deployment first"})
return return
@@ -556,7 +502,7 @@ func reconcileServiceJob(ctx context.Context, db *database.DB, engine *deploymen
if state.Status != "" { if state.Status != "" {
status = state.Status status = state.Status
} }
_, _ = db.Exec(`UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`, status, time.Now(), service.ID) setServiceStatusNow(db, service.ID, status)
} }
return state, nil return state, nil
} }
@@ -575,12 +521,9 @@ func enqueueServiceRedeploy(c *gin.Context, db *database.DB, service Service) bo
} }
image := service.Image image := service.Image
if image == "" { if image == "" {
if err := db.QueryRow( var err error
`SELECT image_name || ':' || image_tag FROM deployments image, err = sqlcdb.New(db.DB).GetLastDeployedImage(context.Background(), service.ID)
WHERE service_id = $1 AND status = 'deployed' AND image_name <> '' if err != nil {
ORDER BY created_at DESC LIMIT 1`,
service.ID,
).Scan(&image); err != nil {
return false return false
} }
} }
@@ -628,10 +571,10 @@ func persistPublishedPort(db *database.DB, serviceID uuid.UUID, ports []uint16)
if len(ports) == 0 { if len(ports) == 0 {
return return
} }
_, _ = db.Exec( _ = sqlcdb.New(db.DB).SetServicePublishedPort(context.Background(), sqlcdb.SetServicePublishedPortParams{
`UPDATE services SET published_port = $1 WHERE id = $2 AND published_port <> $1`, PublishedPort: int32(ports[0]),
int(ports[0]), serviceID, ID: serviceID,
) })
} }
// liveServiceStatus reconciles the stored status with real container state. // liveServiceStatus reconciles the stored status with real container state.
@@ -654,7 +597,7 @@ func liveServiceStatus(c *gin.Context, db *database.DB, service *Service) {
} }
if state.Status != service.Status { if state.Status != service.Status {
service.Status = state.Status service.Status = state.Status
_, _ = db.Exec(`UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`, state.Status, time.Now(), service.ID) setServiceStatusNow(db, service.ID, state.Status)
} }
persistPublishedPort(db, service.ID, state.Ports) persistPublishedPort(db, service.ID, state.Ports)
if service.Domain != "" { if service.Domain != "" {
@@ -676,44 +619,22 @@ func runtimeScaleService(c *gin.Context, serviceID string, replicas int) error {
return fmt.Errorf("runtime unavailable") return fmt.Errorf("runtime unavailable")
} }
var service Service q := sqlcdb.New(db.DB)
var owner string sid, err := uuid.Parse(serviceID)
err := db.QueryRow(
`SELECT s.id, s.project_id, s.name,
COALESCE(s.type, s.service_type, ''), COALESCE(s.status, ''),
COALESCE(s.image, s.image_name, ''), COALESCE(s.command, s.start_command, ''),
COALESCE(s.environment, ''), COALESCE(s.git_repo, s.source_url, ''),
COALESCE(s.git_branch, ''), COALESCE(s.build_path, ''),
COALESCE(s.cpu, ''), COALESCE(s.memory, ''),
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
COALESCE(s.restart_policy, 'unless-stopped'),
COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''),
COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''),
COALESCE(s.static_dir, ''),
s.created_at, s.updated_at, p.owner_id
FROM services s JOIN projects p ON s.project_id = p.id
WHERE s.id = $1`, serviceID,
).Scan(
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
&service.CreatedAt, &service.UpdatedAt, &owner,
)
if err != nil { if err != nil {
return err return err
} }
row, err := q.GetServiceRuntimeWithOwner(context.Background(), sid)
if err != nil {
return err
}
service := serviceFromRuntimeRow(row)
if service.Image == "" { if service.Image == "" {
if err := db.QueryRow( image, err := q.GetLastDeployedImage(context.Background(), service.ID)
`SELECT image_name || ':' || image_tag FROM deployments if err != nil {
WHERE service_id = $1 AND status = 'deployed' AND image_name <> ''
ORDER BY created_at DESC LIMIT 1`, service.ID,
).Scan(&service.Image); err != nil {
return fmt.Errorf("no deployed image to scale") return fmt.Errorf("no deployed image to scale")
} }
service.Image = image
} }
spec, err := serviceRuntimeSpec(db, service) spec, err := serviceRuntimeSpec(db, service)
@@ -731,7 +652,11 @@ func runtimeScaleService(c *gin.Context, serviceID string, replicas int) error {
if state != nil { if state != nil {
persistPublishedPort(db, service.ID, state.Ports) persistPublishedPort(db, service.ID, state.Ports)
} }
_, _ = db.Exec(`UPDATE services SET replicas = $1, updated_at = $2 WHERE id = $3`, replicas, time.Now(), service.ID) _ = q.SetServiceReplicas(context.Background(), sqlcdb.SetServiceReplicasParams{
Replicas: int32(replicas),
UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true},
ID: service.ID,
})
return nil return nil
} }
@@ -750,48 +675,35 @@ func handleServiceEnvCheck(c *gin.Context) {
return return
} }
rows, err := db.Query( q := sqlcdb.New(db.DB)
`SELECT key, value, COALESCE(is_secret, false) FROM environment_variables WHERE service_id = $1`, vars, err := q.ListServiceVariablesWithSecret(context.Background(), service.ID)
service.ID,
)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
defer rows.Close()
var unresolved, empty, unreadable []string var unresolved, empty, unreadable []string
var raw []struct{ key, val string } var raw []struct{ key, val string }
for rows.Next() { for _, v := range vars {
var k, v string decrypted := secrets.Decrypt(v.Value)
var secret bool if v.IsSecret && secrets.IsEncrypted(v.Value) && decrypted == v.Value {
if err := rows.Scan(&k, &v, &secret); err != nil {
continue
}
decrypted := secrets.Decrypt(v)
if secret && secrets.IsEncrypted(v) && decrypted == v {
// Ciphertext failed to decrypt — Decrypt returns the input. // Ciphertext failed to decrypt — Decrypt returns the input.
unreadable = append(unreadable, k) unreadable = append(unreadable, v.Key)
continue continue
} }
if strings.TrimSpace(decrypted) == "" { if strings.TrimSpace(decrypted) == "" {
empty = append(empty, k) empty = append(empty, v.Key)
} }
raw = append(raw, struct{ key, val string }{k, decrypted}) raw = append(raw, struct{ key, val string }{v.Key, decrypted})
} }
// Project siblings for ${{name.KEY}} resolution. // Project siblings for ${{name.KEY}} resolution.
siblingIDs := map[string]string{} siblingIDs := map[string]uuid.UUID{}
svcRows, err := db.Query(`SELECT id, name FROM services WHERE project_id = $1`, service.ProjectID) if refs, err := q.ListProjectServiceRefs(context.Background(), service.ProjectID); err == nil {
if err == nil { for _, s := range refs {
for svcRows.Next() { siblingIDs[strings.ToLower(s.Name)] = s.ID
var id, name string
if err := svcRows.Scan(&id, &name); err == nil {
siblingIDs[strings.ToLower(name)] = id
} }
} }
svcRows.Close()
}
resolvable := func(serviceName, key string) bool { resolvable := func(serviceName, key string) bool {
id, ok := siblingIDs[strings.ToLower(serviceName)] id, ok := siblingIDs[strings.ToLower(serviceName)]
@@ -802,20 +714,14 @@ func handleServiceEnvCheck(c *gin.Context) {
return true return true
} }
if strings.EqualFold(key, "PORT") { if strings.EqualFold(key, "PORT") {
var port int port, err := q.GetServicePort(context.Background(), id)
if err := db.QueryRow(`SELECT COALESCE(port, 0) FROM services WHERE id = $1`, id).Scan(&port); err == nil { return err == nil && port > 0
return port > 0
} }
return false _, err := q.GetServiceVariableValue(context.Background(), sqlcdb.GetServiceVariableValueParams{
} ServiceID: id,
var n int Key: key,
if err := db.QueryRow( })
`SELECT COUNT(*) FROM environment_variables WHERE service_id = $1 AND key = $2`, return err == nil
id, key,
).Scan(&n); err == nil {
return n > 0
}
return false
} }
for _, kv := range raw { for _, kv := range raw {
+70 -109
View File
@@ -2,7 +2,9 @@ package api
import ( import (
"containr/internal/database" "containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/security" "containr/internal/security"
"context"
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"fmt" "fmt"
@@ -33,8 +35,8 @@ func NewSecurityHandler(db *database.DB, encryptionKey string) *SecurityHandler
// Notify the project owner when a background scan completes — criticals // Notify the project owner when a background scan completes — criticals
// earn a distinct title so they stand out in the notification list. // earn a distinct title so they stand out in the notification list.
scanner.OnScanComplete = func(scan *security.SecurityScan) { scanner.OnScanComplete = func(scan *security.SecurityScan) {
var ownerID string ownerID, err := sqlcdb.New(db.DB).GetProjectOwnerText(context.Background(), uuid.MustParse(scan.ProjectID))
if err := db.QueryRow(`SELECT owner_id::text FROM projects WHERE id = $1`, scan.ProjectID).Scan(&ownerID); err != nil || ownerID == "" { if err != nil || ownerID == "" {
return return
} }
title := "Security scan completed" title := "Security scan completed"
@@ -81,14 +83,10 @@ func (sh *SecurityHandler) StartSecurityScan(c *gin.Context) {
return return
} }
var serviceExists bool serviceExists, err := sqlcdb.New(sh.db.DB).ServiceExistsInProject(context.Background(), sqlcdb.ServiceExistsInProjectParams{
err := sh.db.QueryRow( ID: uuid.MustParse(req.ServiceID),
`SELECT EXISTS( ProjectID: uuid.MustParse(req.ProjectID),
SELECT 1 FROM services WHERE id = $1 AND project_id = $2 })
)`,
req.ServiceID,
req.ProjectID,
).Scan(&serviceExists)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to validate service"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to validate service"})
return return
@@ -164,40 +162,30 @@ func (sh *SecurityHandler) GetVulnerabilities(c *gin.Context) {
} }
// Query vulnerabilities // Query vulnerabilities
rows, err := sh.db.Query(` rows, err := sqlcdb.New(sh.db.DB).ListVulnerabilities(context.Background(), uuid.MustParse(projectID))
SELECT id, type, severity, title, description, service_id, status, found_at, resolved_at
FROM vulnerabilities
WHERE project_id = $1
ORDER BY
CASE severity
WHEN 'critical' THEN 1
WHEN 'high' THEN 2
WHEN 'medium' THEN 3
WHEN 'low' THEN 4
END,
found_at DESC
`, projectID)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get vulnerabilities"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get vulnerabilities"})
return return
} }
defer rows.Close()
var vulnerabilities []security.Vulnerability var vulnerabilities []security.Vulnerability
for rows.Next() { for _, row := range rows {
var vuln security.Vulnerability // NULL description/service_id failed the original scan and dropped
var resolvedAt *time.Time // the row — keep the same filter.
if !row.Description.Valid || !row.ServiceID.Valid {
err := rows.Scan(&vuln.ID, &vuln.Type, &vuln.Severity, &vuln.Title, &vuln.Description,
&vuln.ServiceID, &vuln.Status, &vuln.FoundAt, &resolvedAt)
if err != nil {
continue continue
} }
vulnerabilities = append(vulnerabilities, security.Vulnerability{
vuln.ResolvedAt = resolvedAt ID: row.ID.String(),
vulnerabilities = append(vulnerabilities, vuln) Type: row.Type,
Severity: row.Severity,
Title: row.Title,
Description: row.Description.String,
ServiceID: row.ServiceID.UUID.String(),
Status: row.Status,
FoundAt: row.FoundAt,
ResolvedAt: timePtrNT(row.ResolvedAt),
})
} }
c.JSON(http.StatusOK, gin.H{"vulnerabilities": vulnerabilities}) c.JSON(http.StatusOK, gin.H{"vulnerabilities": vulnerabilities})
@@ -227,11 +215,11 @@ func (sh *SecurityHandler) UpdateVulnerability(c *gin.Context) {
resolvedAt = &now resolvedAt = &now
} }
_, err := sh.db.Exec(` err := sqlcdb.New(sh.db.DB).UpdateVulnerabilityStatus(context.Background(), sqlcdb.UpdateVulnerabilityStatusParams{
UPDATE vulnerabilities Status: req.Status,
SET status = $1, resolved_at = $2 ResolvedAt: ntPtr(resolvedAt),
WHERE id = $3 ID: uuid.MustParse(vulnID),
`, req.Status, resolvedAt, vulnID) })
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update vulnerability"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update vulnerability"})
@@ -271,13 +259,7 @@ func (sh *SecurityHandler) StartComplianceAssessment(c *gin.Context) {
return return
} }
var frameworkExists bool frameworkExists, err := sqlcdb.New(sh.db.DB).ComplianceFrameworkExists(context.Background(), uuid.MustParse(req.FrameworkID))
err := sh.db.QueryRow(
`SELECT EXISTS(
SELECT 1 FROM compliance_frameworks WHERE id = $1
)`,
req.FrameworkID,
).Scan(&frameworkExists)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to validate framework"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to validate framework"})
return return
@@ -321,30 +303,25 @@ func (sh *SecurityHandler) GetComplianceReport(c *gin.Context) {
// GetComplianceFrameworks retrieves available compliance frameworks // GetComplianceFrameworks retrieves available compliance frameworks
func (sh *SecurityHandler) GetComplianceFrameworks(c *gin.Context) { func (sh *SecurityHandler) GetComplianceFrameworks(c *gin.Context) {
rows, err := sh.db.Query(` rows, err := sqlcdb.New(sh.db.DB).ListComplianceFrameworks(context.Background())
SELECT id, name, description, version, enabled, created_at
FROM compliance_frameworks
WHERE enabled = true
ORDER BY name
`)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get compliance frameworks"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get compliance frameworks"})
return return
} }
defer rows.Close()
var frameworks []security.ComplianceFramework var frameworks []security.ComplianceFramework
for rows.Next() { for _, row := range rows {
var framework security.ComplianceFramework if !row.Description.Valid {
err := rows.Scan(&framework.ID, &framework.Name, &framework.Description,
&framework.Version, &framework.Enabled, &framework.CreatedAt)
if err != nil {
continue continue
} }
frameworks = append(frameworks, security.ComplianceFramework{
frameworks = append(frameworks, framework) ID: row.ID.String(),
Name: row.Name,
Description: row.Description.String,
Version: row.Version,
Enabled: row.Enabled,
CreatedAt: row.CreatedAt,
})
} }
c.JSON(http.StatusOK, gin.H{"frameworks": frameworks}) c.JSON(http.StatusOK, gin.H{"frameworks": frameworks})
@@ -385,23 +362,19 @@ func (sh *SecurityHandler) GetSecurityMetrics(c *gin.Context) {
Resolved int `json:"resolved"` Resolved int `json:"resolved"`
} }
err := sh.db.QueryRow(` q := sqlcdb.New(sh.db.DB)
SELECT mrow, err := q.GetVulnerabilityMetrics(context.Background(), uuid.MustParse(projectID))
COUNT(*) as total,
COUNT(*) FILTER (WHERE severity = 'critical') as critical,
COUNT(*) FILTER (WHERE severity = 'high') as high,
COUNT(*) FILTER (WHERE severity = 'medium') as medium,
COUNT(*) FILTER (WHERE severity = 'low') as low,
COUNT(*) FILTER (WHERE status = 'open') as open,
COUNT(*) FILTER (WHERE status = 'resolved') as resolved
FROM vulnerabilities
WHERE project_id = $1
`, projectID).Scan(&vulnMetrics.Total, &vulnMetrics.Critical, &vulnMetrics.High,
&vulnMetrics.Medium, &vulnMetrics.Low, &vulnMetrics.Open, &vulnMetrics.Resolved)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get vulnerability metrics"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get vulnerability metrics"})
return return
} }
vulnMetrics.Total = int(mrow.Total)
vulnMetrics.Critical = int(mrow.Critical)
vulnMetrics.High = int(mrow.High)
vulnMetrics.Medium = int(mrow.Medium)
vulnMetrics.Low = int(mrow.Low)
vulnMetrics.Open = int(mrow.Open)
vulnMetrics.Resolved = int(mrow.Resolved)
// Get latest scan // Get latest scan
var latestScan struct { var latestScan struct {
@@ -411,13 +384,11 @@ func (sh *SecurityHandler) GetSecurityMetrics(c *gin.Context) {
Status string `json:"status"` Status string `json:"status"`
} }
err = sh.db.QueryRow(` srow, err := q.GetLatestSecurityScan(context.Background(), uuid.MustParse(projectID))
SELECT id, COALESCE((summary->>'score')::int, 0), started_at as scanned_at, status latestScan.ID = srow.ID.String()
FROM security_scans latestScan.Score = int(srow.Score)
WHERE project_id = $1 latestScan.ScannedAt = srow.ScannedAt
ORDER BY started_at DESC latestScan.Status = srow.Status
LIMIT 1
`, projectID).Scan(&latestScan.ID, &latestScan.Score, &latestScan.ScannedAt, &latestScan.Status)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
latestScan = struct { latestScan = struct {
@@ -438,13 +409,10 @@ func (sh *SecurityHandler) GetSecurityMetrics(c *gin.Context) {
LastAssessed *time.Time `json:"last_assessed"` LastAssessed *time.Time `json:"last_assessed"`
} }
err = sh.db.QueryRow(` crow, err := q.GetLatestComplianceReport(context.Background(), uuid.MustParse(projectID))
SELECT overall_status, score, assessment_date complianceStatus.OverallStatus = crow.OverallStatus
FROM compliance_reports complianceStatus.Score = int(crow.Score)
WHERE project_id = $1 complianceStatus.LastAssessed = &crow.AssessmentDate
ORDER BY assessment_date DESC
LIMIT 1
`, projectID).Scan(&complianceStatus.OverallStatus, &complianceStatus.Score, &complianceStatus.LastAssessed)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
complianceStatus = struct { complianceStatus = struct {
@@ -614,21 +582,11 @@ func (sh *SecurityHandler) requireProjectAccess(c *gin.Context, projectID string
return "", false return "", false
} }
var hasAccess bool hasAccess, err := sqlcdb.New(sh.db.DB).CheckSecurityProjectAccess(context.Background(), sqlcdb.CheckSecurityProjectAccessParams{
err := sh.db.QueryRow( ID: uuid.MustParse(projectID),
`SELECT EXISTS ( OwnerID: optionalUserUUID(c),
SELECT 1 Column3: contextIsAdmin(c),
FROM projects p })
WHERE p.id = $1
AND (p.is_approved
OR $3::bool
OR p.owner_id = $2 OR EXISTS (
SELECT 1 FROM project_members pm
WHERE pm.project_id = p.id AND pm.user_id = $2
))
)`,
projectID, optionalUserUUID(c), contextIsAdmin(c),
).Scan(&hasAccess)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to verify project access"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to verify project access"})
return "", false return "", false
@@ -649,7 +607,8 @@ func (sh *SecurityHandler) requireSecurityScanAccess(c *gin.Context, scanID stri
} }
var projectID string var projectID string
err := sh.db.QueryRow("SELECT project_id FROM security_scans WHERE id = $1", scanID).Scan(&projectID) pid, err := sqlcdb.New(sh.db.DB).GetSecurityScanProjectID(context.Background(), uuid.MustParse(scanID))
projectID = pid.String()
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "Security scan not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Security scan not found"})
return false return false
@@ -670,7 +629,8 @@ func (sh *SecurityHandler) requireComplianceReportAccess(c *gin.Context, reportI
} }
var projectID string var projectID string
err := sh.db.QueryRow("SELECT project_id FROM compliance_reports WHERE id = $1", reportID).Scan(&projectID) pid, err := sqlcdb.New(sh.db.DB).GetComplianceReportProjectID(context.Background(), uuid.MustParse(reportID))
projectID = pid.String()
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "Compliance report not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Compliance report not found"})
return false return false
@@ -691,7 +651,8 @@ func (sh *SecurityHandler) requireVulnerabilityAccess(c *gin.Context, vulnID str
} }
var projectID string var projectID string
err := sh.db.QueryRow("SELECT project_id FROM vulnerabilities WHERE id = $1", vulnID).Scan(&projectID) pid, err := sqlcdb.New(sh.db.DB).GetVulnerabilityProjectID(context.Background(), uuid.MustParse(vulnID))
projectID = pid.String()
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "Vulnerability not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Vulnerability not found"})
return "", false return "", false
+212
View File
@@ -0,0 +1,212 @@
package api
import (
"context"
"database/sql"
"strings"
"testing"
"time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"github.com/google/uuid"
)
// Live invariant test against real Postgres — set METRICS_TEST_DSN (or
// DATABASE_URL). Covers the generated auth/security queries: user upsert
// conflict path, project-access EXISTS predicate, vulnerability ordering and
// status update, latest-scan/report lookups, and the framework list.
func TestSecurityLiveInvariants(t *testing.T) {
dsn := testDSNString(t)
db, err := database.NewConnection(dsn)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer db.Close()
ctx := context.Background()
q := sqlcdb.New(db.DB)
userID := uuid.New()
otherID := uuid.New()
projectID := uuid.New()
envID := uuid.New()
serviceID := uuid.New()
scanID := uuid.New()
reportID := uuid.New()
frameworkID := uuid.New()
vulnCrit := uuid.New()
vulnLow := uuid.New()
suffix := strings.ReplaceAll(userID.String()[:8], "-", "")
must := func(err error) {
t.Helper()
if err != nil {
t.Fatalf("setup: %v", err)
}
}
_, err = db.Exec(`INSERT INTO users (id, email, password_hash, name) VALUES ($1, $2, 'x', 'test'), ($3, $4, 'x', 'other')`,
userID, "sec-"+suffix+"@test.local", otherID, "sec-"+suffix+"[email protected]")
must(err)
_, err = db.Exec(`INSERT INTO projects (id, name, owner_id, is_approved) VALUES ($1, $2, $3, false)`,
projectID, "sec-"+suffix, userID)
must(err)
_, err = db.Exec(`INSERT INTO environments (id, name, project_id) VALUES ($1, 'production', $2)`,
envID, projectID)
must(err)
_, err = db.Exec(`INSERT INTO services (id, name, project_id, environment_id, service_type, source_type)
VALUES ($1, $2, $3, $4, 'web', 'image')`,
serviceID, "sec-"+suffix, projectID, envID)
must(err)
defer func() { _, _ = db.Exec(`DELETE FROM users WHERE id IN ($1, $2)`, userID, otherID) }()
// Auth queries: upsert conflicts update rather than error, counts see rows.
_, err = q.InsertUserAdmin(ctx, sqlcdb.InsertUserAdminParams{
Email: "ins-" + suffix + "@test.local", PasswordHash: "x", Name: "ins", IsAdmin: false,
})
must(err)
defer func() { _, _ = db.Exec(`DELETE FROM users WHERE email = $1`, "ins-"+suffix+"@test.local") }()
urow, err := q.UpsertLocalUser(ctx, sqlcdb.UpsertLocalUserParams{
Email: "ins-" + suffix + "@test.local", PasswordHash: "y", Name: "renamed",
Column4: "http://img", IsAdmin: false,
})
must(err)
if urow.Name != "renamed" || urow.AvatarUrl != "http://img" {
t.Fatalf("upsert: %+v", urow)
}
byEmail, err := q.GetUserByEmailForAuth(ctx, "ins-"+suffix+"@test.local")
must(err)
if byEmail.ID != urow.ID || byEmail.PasswordHash != "x" {
t.Fatalf("by email: %+v", byEmail)
}
count, err := q.CountUsersByEmail(ctx, "ins-"+suffix+"@test.local")
must(err)
if count != 1 {
t.Fatalf("count by email: %d", count)
}
byID, err := q.GetUserByID(ctx, urow.ID)
must(err)
if byID.Email != "ins-"+suffix+"@test.local" {
t.Fatalf("by id: %+v", byID)
}
// Access predicate: unapproved project is owner/admin/member only.
ownerAccess, err := q.CheckSecurityProjectAccess(ctx, sqlcdb.CheckSecurityProjectAccessParams{
ID: projectID, OwnerID: userID, Column3: false,
})
must(err)
if !ownerAccess {
t.Fatal("owner should have access")
}
strangerAccess, err := q.CheckSecurityProjectAccess(ctx, sqlcdb.CheckSecurityProjectAccessParams{
ID: projectID, OwnerID: otherID, Column3: false,
})
must(err)
if strangerAccess {
t.Fatal("stranger should not have access")
}
adminAccess, err := q.CheckSecurityProjectAccess(ctx, sqlcdb.CheckSecurityProjectAccessParams{
ID: projectID, OwnerID: otherID, Column3: true,
})
must(err)
if !adminAccess {
t.Fatal("admin should have access")
}
ownerText, err := q.GetProjectOwnerText(ctx, projectID)
must(err)
if ownerText != userID.String() {
t.Fatalf("owner text: %q", ownerText)
}
exists, err := q.ServiceExistsInProject(ctx, sqlcdb.ServiceExistsInProjectParams{
ID: serviceID, ProjectID: projectID,
})
must(err)
if !exists {
t.Fatal("service should exist in project")
}
// Scan/report/vuln rows via raw inserts (fixtures), reads via sqlc.
_, err = db.Exec(`INSERT INTO security_scans (id, project_id, service_id, scan_type, status, summary, started_at)
VALUES ($1, $2, $3, 'dependency', 'completed', '{"score": 85}', NOW() - interval '1 hour')`,
scanID, projectID, serviceID)
must(err)
_, err = db.Exec(`INSERT INTO compliance_frameworks (id, name, description, version, enabled)
VALUES ($1, $2, 'desc', '1.0', true)`,
frameworkID, "fw-"+suffix)
must(err)
defer func() { _, _ = db.Exec(`DELETE FROM compliance_frameworks WHERE id = $1`, frameworkID) }()
_, err = db.Exec(`INSERT INTO compliance_reports (id, project_id, framework_id, overall_status, score, assessment_date)
VALUES ($1, $2, $3, 'compliant', 92, NOW() - interval '30 minutes')`,
reportID, projectID, frameworkID)
must(err)
defer func() { _, _ = db.Exec(`DELETE FROM compliance_reports WHERE id = $1`, reportID) }()
_, err = db.Exec(`INSERT INTO vulnerabilities (id, type, severity, title, description, service_id, project_id, status, found_at)
VALUES ($1, 'dependency', 'low', 'low-v', 'd', $3, $4, 'open', NOW() - interval '2 hours'),
($2, 'dependency', 'critical', 'crit-v', 'd', $3, $4, 'open', NOW() - interval '1 hour')`,
vulnLow, vulnCrit, serviceID, projectID)
must(err)
defer func() { _, _ = db.Exec(`DELETE FROM vulnerabilities WHERE project_id = $1`, projectID) }()
defer func() { _, _ = db.Exec(`DELETE FROM security_scans WHERE id = $1`, scanID) }()
// Severity ordering: critical first.
vulns, err := q.ListVulnerabilities(ctx, projectID)
must(err)
if len(vulns) != 2 || vulns[0].ID != vulnCrit {
t.Fatalf("vuln ordering: %+v", vulns)
}
metrics, err := q.GetVulnerabilityMetrics(ctx, projectID)
must(err)
if metrics.Total != 2 || metrics.Critical != 1 || metrics.Low != 1 || metrics.Open != 2 {
t.Fatalf("metrics: %+v", metrics)
}
must(q.UpdateVulnerabilityStatus(ctx, sqlcdb.UpdateVulnerabilityStatusParams{
Status: "resolved",
ResolvedAt: sql.NullTime{Time: time.Now(), Valid: true},
ID: vulnCrit,
}))
metrics, err = q.GetVulnerabilityMetrics(ctx, projectID)
must(err)
if metrics.Open != 1 || metrics.Resolved != 1 {
t.Fatalf("metrics after resolve: %+v", metrics)
}
// Latest scan/report lookups.
scan, err := q.GetLatestSecurityScan(ctx, projectID)
must(err)
if scan.ID != scanID || scan.Score != 85 || scan.Status != "completed" {
t.Fatalf("latest scan: %+v", scan)
}
report, err := q.GetLatestComplianceReport(ctx, projectID)
must(err)
if report.OverallStatus != "compliant" || report.Score != 92 {
t.Fatalf("latest report: %+v", report)
}
// Project-ID lookups used by the access guards.
scanPID, err := q.GetSecurityScanProjectID(ctx, scanID)
must(err)
reportPID, err := q.GetComplianceReportProjectID(ctx, reportID)
must(err)
vulnPID, err := q.GetVulnerabilityProjectID(ctx, vulnCrit)
must(err)
if scanPID != projectID || reportPID != projectID || vulnPID != projectID {
t.Fatalf("project id lookups: %v %v %v", scanPID, reportPID, vulnPID)
}
// Framework list only returns enabled rows.
frameworks, err := q.ListComplianceFrameworks(ctx)
must(err)
found := false
for _, f := range frameworks {
if f.ID == frameworkID && f.Enabled {
found = true
}
}
if !found {
t.Fatal("enabled framework missing from list")
}
frameworkExists, err := q.ComplianceFrameworkExists(ctx, frameworkID)
must(err)
if !frameworkExists {
t.Fatal("framework should exist")
}
}
+22 -29
View File
@@ -2,6 +2,7 @@ package api
import ( import (
"containr/internal/database" "containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/deployment" "containr/internal/deployment"
"containr/internal/deployqueue" "containr/internal/deployqueue"
"context" "context"
@@ -70,20 +71,18 @@ type Service struct {
// loadServiceSleep fills the sleep-mode columns, which live outside the // loadServiceSleep fills the sleep-mode columns, which live outside the
// wide service SELECTs. Missing columns (pre-migration) degrade to off. // wide service SELECTs. Missing columns (pre-migration) degrade to off.
func loadServiceSleep(db *database.DB, s *Service) { func loadServiceSleep(db *database.DB, s *Service) {
var idle int row, err := sqlcdb.New(db.DB).GetServiceSleep(context.Background(), s.ID)
if err := db.QueryRow( if err == nil {
`SELECT COALESCE(sleep_enabled, false), COALESCE(sleep_idle_minutes, 15) FROM services WHERE id = $1`, s.SleepEnabled = row.SleepEnabled
s.ID, s.SleepIdleMinutes = int(row.SleepIdleMinutes)
).Scan(&s.SleepEnabled, &idle); err == nil {
s.SleepIdleMinutes = idle
} }
} }
// serviceNodeID reads the pin column directly — kept out of the wide service // serviceNodeID reads the pin column directly — kept out of the wide service
// SELECTs so pre-migration schemas keep working. // SELECTs so pre-migration schemas keep working.
func serviceNodeID(db *database.DB, serviceID uuid.UUID) string { func serviceNodeID(db *database.DB, serviceID uuid.UUID) string {
var id sql.NullString id, err := sqlcdb.New(db.DB).GetServiceNodeID(context.Background(), serviceID)
if err := db.QueryRow(`SELECT node_id FROM services WHERE id = $1`, serviceID).Scan(&id); err == nil { if err == nil {
return id.String return id.String
} }
return "" return ""
@@ -91,17 +90,14 @@ func serviceNodeID(db *database.DB, serviceID uuid.UUID) string {
// serviceSpread reads the spread column with the same lazy pattern. // serviceSpread reads the spread column with the same lazy pattern.
func serviceSpread(db *database.DB, serviceID uuid.UUID) bool { func serviceSpread(db *database.DB, serviceID uuid.UUID) bool {
var spread bool spread, err := sqlcdb.New(db.DB).GetServiceSpread(context.Background(), serviceID)
if err := db.QueryRow(`SELECT COALESCE(spread, false) FROM services WHERE id = $1`, serviceID).Scan(&spread); err == nil { return err == nil && spread
return spread
}
return false
} }
// servicePlacementTags reads the placement_tags column with the lazy pattern. // servicePlacementTags reads the placement_tags column with the lazy pattern.
func servicePlacementTags(db *database.DB, serviceID uuid.UUID) []string { func servicePlacementTags(db *database.DB, serviceID uuid.UUID) []string {
var raw []byte raw, err := sqlcdb.New(db.DB).GetServicePlacementTags(context.Background(), serviceID)
if err := db.QueryRow(`SELECT placement_tags FROM services WHERE id = $1`, serviceID).Scan(&raw); err != nil { if err != nil {
return nil return nil
} }
var tags []string var tags []string
@@ -179,8 +175,8 @@ func normalizeTraefikLabels(raw map[string]string) (map[string]string, error) {
// serviceTraefikLabels lazy-reads the traefik_labels column. // serviceTraefikLabels lazy-reads the traefik_labels column.
func serviceTraefikLabels(db *database.DB, serviceID uuid.UUID) map[string]string { func serviceTraefikLabels(db *database.DB, serviceID uuid.UUID) map[string]string {
var raw []byte raw, err := sqlcdb.New(db.DB).GetServiceTraefikLabels(context.Background(), serviceID)
if err := db.QueryRow(`SELECT traefik_labels FROM services WHERE id = $1`, serviceID).Scan(&raw); err != nil { if err != nil {
return nil return nil
} }
var labels map[string]string var labels map[string]string
@@ -211,7 +207,6 @@ func tagsJSON(tags []string) []byte {
// loadServiceNode fills the pin + spread + placement tags + the agent's // loadServiceNode fills the pin + spread + placement tags + the agent's
// display name for responses. // display name for responses.
func loadServiceNode(db *database.DB, s *Service) { func loadServiceNode(db *database.DB, s *Service) {
var name sql.NullString
s.NodeID = serviceNodeID(db, s.ID) s.NodeID = serviceNodeID(db, s.ID)
s.Spread = serviceSpread(db, s.ID) s.Spread = serviceSpread(db, s.ID)
s.PlacementTags = servicePlacementTags(db, s.ID) s.PlacementTags = servicePlacementTags(db, s.ID)
@@ -219,8 +214,8 @@ func loadServiceNode(db *database.DB, s *Service) {
if s.NodeID == "" { if s.NodeID == "" {
return return
} }
if err := db.QueryRow(`SELECT name FROM node_agents WHERE id = $1`, s.NodeID).Scan(&name); err == nil { if name, err := sqlcdb.New(db.DB).GetNodeAgentName(context.Background(), s.NodeID); err == nil {
s.NodeName = name.String s.NodeName = name
} }
} }
@@ -235,8 +230,8 @@ type ServiceVolume struct {
// loadServiceVolumes reads the JSONB column; absent/invalid data degrades // loadServiceVolumes reads the JSONB column; absent/invalid data degrades
// to no mounts rather than failing the request. // to no mounts rather than failing the request.
func loadServiceVolumes(db *database.DB, serviceID uuid.UUID) []deployment.VolumeMount { func loadServiceVolumes(db *database.DB, serviceID uuid.UUID) []deployment.VolumeMount {
var raw []byte raw, err := sqlcdb.New(db.DB).GetServiceVolumes(context.Background(), serviceID)
if err := db.QueryRow(`SELECT volumes FROM services WHERE id = $1`, serviceID).Scan(&raw); err != nil { if err != nil {
return nil return nil
} }
var stored []ServiceVolume var stored []ServiceVolume
@@ -703,13 +698,11 @@ func handleCreateService(c *gin.Context) {
} }
func getProjectEnvironmentID(db *database.DB, projectID uuid.UUID, environment string) (uuid.UUID, error) { func getProjectEnvironmentID(db *database.DB, projectID uuid.UUID, environment string) (uuid.UUID, error) {
var environmentID uuid.UUID row, err := sqlcdb.New(db.DB).GetProjectEnvironmentByName(context.Background(), sqlcdb.GetProjectEnvironmentByNameParams{
err := db.QueryRow( ProjectID: projectID,
"SELECT id FROM environments WHERE project_id = $1 AND name = $2", Name: environment,
projectID, })
environment, return row.ID, err
).Scan(&environmentID)
return environmentID, err
} }
func inferServiceSourceType(service Service) string { func inferServiceSourceType(service Service) string {
+14 -16
View File
@@ -10,11 +10,13 @@ import (
"time" "time"
"containr/internal/database" "containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/docker" "containr/internal/docker"
"github.com/docker/docker/api/types/network" "github.com/docker/docker/api/types/network"
"github.com/docker/docker/api/types/registry" "github.com/docker/docker/api/types/registry"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid"
) )
// Platform setting keys. Values stored in app_settings take precedence over // Platform setting keys. Values stored in app_settings take precedence over
@@ -70,8 +72,7 @@ func handleGetBranding(c *gin.Context) {
// then the caller's default. // then the caller's default.
func settingValue(db *database.DB, key, envKey, fallback string) string { func settingValue(db *database.DB, key, envKey, fallback string) string {
if db != nil && db.DB != nil { if db != nil && db.DB != nil {
var value string value, err := sqlcdb.New(db.DB).GetAppSetting(context.Background(), key)
err := db.QueryRow(`SELECT value FROM app_settings WHERE key = $1`, key).Scan(&value)
if err == nil { if err == nil {
return value return value
} }
@@ -88,18 +89,15 @@ func settingValue(db *database.DB, key, envKey, fallback string) string {
} }
func setSetting(db *database.DB, key, value string, secret bool) error { func setSetting(db *database.DB, key, value string, secret bool) error {
_, err := db.Exec(` return sqlcdb.New(db.DB).UpsertAppSetting(context.Background(), sqlcdb.UpsertAppSettingParams{
INSERT INTO app_settings (key, value, is_secret, updated_at) Key: key,
VALUES ($1, $2, $3, NOW()) Value: value,
ON CONFLICT (key) DO UPDATE IsSecret: secret,
SET value = EXCLUDED.value, is_secret = EXCLUDED.is_secret, updated_at = NOW() })
`, key, value, secret)
return err
} }
func deleteSetting(db *database.DB, key string) error { func deleteSetting(db *database.DB, key string) error {
_, err := db.Exec(`DELETE FROM app_settings WHERE key = $1`, key) return sqlcdb.New(db.DB).DeleteAppSetting(context.Background(), key)
return err
} }
// signupEnabled controls whether public registration stays open after the // signupEnabled controls whether public registration stays open after the
@@ -110,11 +108,12 @@ func signupEnabled(db *database.DB) bool {
} }
func isAdminUser(db *database.DB, userID string) bool { func isAdminUser(db *database.DB, userID string) bool {
var isAdmin bool parsed, err := uuid.Parse(userID)
if err := db.QueryRow(`SELECT is_admin FROM users WHERE id = $1`, userID).Scan(&isAdmin); err != nil { if err != nil {
return false return false
} }
return isAdmin isAdmin, err := sqlcdb.New(db.DB).GetUserIsAdmin(context.Background(), parsed)
return err == nil && isAdmin
} }
func requireAdmin(c *gin.Context) (*database.DB, bool) { func requireAdmin(c *gin.Context) (*database.DB, bool) {
@@ -242,8 +241,7 @@ func handleUpdateSettings(c *gin.Context) {
// resolvedTunnelToken prefers the in-app value and falls back to the env. // resolvedTunnelToken prefers the in-app value and falls back to the env.
func resolvedTunnelToken(db *database.DB) (string, string) { func resolvedTunnelToken(db *database.DB) (string, string) {
if db != nil && db.DB != nil { if db != nil && db.DB != nil {
var value string if value, err := sqlcdb.New(db.DB).GetAppSetting(context.Background(), settingCloudflareTunnelToken); err == nil && strings.TrimSpace(value) != "" {
if err := db.QueryRow(`SELECT value FROM app_settings WHERE key = $1`, settingCloudflareTunnelToken).Scan(&value); err == nil && strings.TrimSpace(value) != "" {
return strings.TrimSpace(value), "app" return strings.TrimSpace(value), "app"
} }
} }
+3 -2
View File
@@ -5,10 +5,12 @@ package api
// lives in app_settings so it survives restarts. // lives in app_settings so it survives restarts.
import ( import (
"context"
"net/http" "net/http"
"strings" "strings"
"containr/internal/database" "containr/internal/database"
"containr/internal/database/sqlcdb"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -25,8 +27,7 @@ func setupComplete(db *database.DB, hasUsers bool) bool {
if !hasUsers { if !hasUsers {
return false return false
} }
var projects int if projects, err := sqlcdb.New(db.DB).CountProjects(context.Background()); err == nil && projects > 0 {
if err := db.QueryRow(`SELECT COUNT(*) FROM projects`).Scan(&projects); err == nil && projects > 0 {
return true return true
} }
return false return false
+18 -46
View File
@@ -86,20 +86,17 @@ type sleepCandidate struct {
} }
func sweepSleeping(ctx context.Context, db *database.DB, dc *docker.Client, engine *deployment.DeploymentEngine, apiPort int) { func sweepSleeping(ctx context.Context, db *database.DB, dc *docker.Client, engine *deployment.DeploymentEngine, apiPort int) {
rows, err := db.QueryContext(ctx, sweepRows, err := sqlcdb.New(db.DB).ListSleepCandidates(ctx)
`SELECT id, project_id, name, COALESCE(status,''), COALESCE(sleep_idle_minutes,15)
FROM services WHERE sleep_enabled AND COALESCE(status,'') IN ('running','deployed','degraded','starting')`)
if err != nil { if err != nil {
return return
} }
var candidates []sleepCandidate var candidates []sleepCandidate
for rows.Next() { for _, r := range sweepRows {
var s sleepCandidate candidates = append(candidates, sleepCandidate{
if err := rows.Scan(&s.ID, &s.ProjectID, &s.Name, &s.Status, &s.IdleMinutes); err == nil { ID: r.ID, ProjectID: r.ProjectID, Name: r.Name,
candidates = append(candidates, s) Status: r.Status, IdleMinutes: int(r.SleepIdleMinutes),
})
} }
}
rows.Close()
q := sqlcdb.New(db.DB) q := sqlcdb.New(db.DB)
for _, svc := range candidates { for _, svc := range candidates {
@@ -187,11 +184,10 @@ func putToSleepRemote(ctx context.Context, db *database.DB, q *sqlcdb.Queries, s
tracker.mu.Lock() tracker.mu.Lock()
delete(tracker.activity, svc.ID) delete(tracker.activity, svc.ID)
tracker.mu.Unlock() tracker.mu.Unlock()
_, _ = db.Exec(`UPDATE services SET status = 'sleeping', updated_at = $1 WHERE id = $2`, time.Now(), svc.ID) setServiceStatusNow(db, svc.ID, "sleeping")
log.Printf("sleep: %s (%s) remote replicas removed after idle timeout", svc.Name, svc.ID) log.Printf("sleep: %s (%s) remote replicas removed after idle timeout", svc.Name, svc.ID)
var domain string domain, _ := q.GetServiceDomainText(ctx, svc.ID)
_ = db.QueryRow(`SELECT COALESCE(domain,'') FROM services WHERE id = $1`, svc.ID).Scan(&domain)
domains := serviceDomainNames(db, svc.ID, domain) domains := serviceDomainNames(db, svc.ID, domain)
if len(domains) > 0 { if len(domains) > 0 {
writeWakeIngress(svc.ID.String(), domains, apiPort) writeWakeIngress(svc.ID.String(), domains, apiPort)
@@ -250,11 +246,10 @@ func putToSleep(ctx context.Context, db *database.DB, dc *docker.Client, engine
tracker.mu.Lock() tracker.mu.Lock()
delete(tracker.activity, svc.ID) delete(tracker.activity, svc.ID)
tracker.mu.Unlock() tracker.mu.Unlock()
_, _ = db.Exec(`UPDATE services SET status = 'sleeping', updated_at = $1 WHERE id = $2`, time.Now(), svc.ID) setServiceStatusNow(db, svc.ID, "sleeping")
log.Printf("sleep: %s (%s) scaled to zero after idle timeout", svc.Name, svc.ID) log.Printf("sleep: %s (%s) scaled to zero after idle timeout", svc.Name, svc.ID)
var domain string domain, _ := sqlcdb.New(db.DB).GetServiceDomainText(ctx, svc.ID)
_ = db.QueryRow(`SELECT COALESCE(domain,'') FROM services WHERE id = $1`, svc.ID).Scan(&domain)
domains := serviceDomainNames(db, svc.ID, domain) domains := serviceDomainNames(db, svc.ID, domain)
if len(domains) == 0 { if len(domains) == 0 {
return return
@@ -444,7 +439,9 @@ func pruneWakeContainers(ctx context.Context, db *database.DB, dc *docker.Client
} }
} }
var status string var status string
_ = db.QueryRow(`SELECT COALESCE(status,'') FROM services WHERE id = $1`, svcID).Scan(&status) if sid, err := uuid.Parse(svcID); err == nil {
status, _ = sqlcdb.New(db.DB).GetServiceStatusText(ctx, sid)
}
if live > 0 || status == "stopped" || status == "" { if live > 0 || status == "stopped" || status == "" {
_ = dc.RemoveContainer(ctx, c.ID, true) _ = dc.RemoveContainer(ctx, c.ID, true)
} }
@@ -491,34 +488,11 @@ func triggerServiceWake(c *gin.Context) (int, gin.H) {
if err != nil { if err != nil {
return http.StatusBadRequest, gin.H{"error": "invalid service id"} return http.StatusBadRequest, gin.H{"error": "invalid service id"}
} }
var service Service row, err := sqlcdb.New(db.DB).GetServiceRuntimeWithOwner(context.Background(), id)
err = db.QueryRow(
`SELECT s.id, s.project_id, s.name,
COALESCE(s.type, s.service_type, ''), COALESCE(s.status, ''),
COALESCE(s.image, s.image_name, ''), COALESCE(s.command, s.start_command, ''),
COALESCE(s.environment, ''), COALESCE(s.git_repo, s.source_url, ''),
COALESCE(s.git_branch, ''), COALESCE(s.build_path, ''),
COALESCE(s.cpu, ''), COALESCE(s.memory, ''),
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
COALESCE(s.restart_policy, 'unless-stopped'),
COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''),
COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''),
COALESCE(s.static_dir, ''),
s.created_at, s.updated_at
FROM services s WHERE s.id = $1`, id,
).Scan(
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
&service.CreatedAt, &service.UpdatedAt,
)
if err != nil { if err != nil {
return http.StatusNotFound, gin.H{"error": "service not found"} return http.StatusNotFound, gin.H{"error": "service not found"}
} }
service := serviceFromRuntimeRow(row)
if service.Status == "running" || service.Status == "deployed" { if service.Status == "running" || service.Status == "deployed" {
return http.StatusOK, gin.H{"status": service.Status} return http.StatusOK, gin.H{"status": service.Status}
} }
@@ -528,13 +502,13 @@ func triggerServiceWake(c *gin.Context) (int, gin.H) {
spec, err := serviceRuntimeSpec(db, service) spec, err := serviceRuntimeSpec(db, service)
if err != nil { if err != nil {
log.Printf("wake: runtime spec for %s failed: %v", service.ID, err) log.Printf("wake: runtime spec for %s failed: %v", service.ID, err)
_, _ = db.Exec(`UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`, time.Now(), service.ID) setServiceStatusNow(db, service.ID, "failed")
return return
} }
state, err := engine.ReconcileService(ctx, spec) state, err := engine.ReconcileService(ctx, spec)
if err != nil { if err != nil {
log.Printf("wake: reconcile for %s failed: %v", service.ID, err) log.Printf("wake: reconcile for %s failed: %v", service.ID, err)
_, _ = db.Exec(`UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`, time.Now(), service.ID) setServiceStatusNow(db, service.ID, "failed")
return return
} }
status := "running" status := "running"
@@ -542,7 +516,7 @@ func triggerServiceWake(c *gin.Context) (int, gin.H) {
status = state.Status status = state.Status
persistPublishedPort(db, service.ID, state.Ports) persistPublishedPort(db, service.ID, state.Ports)
} }
_, _ = db.Exec(`UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`, status, time.Now(), service.ID) setServiceStatusNow(db, service.ID, status)
}() }()
return http.StatusAccepted, gin.H{"status": "waking"} return http.StatusAccepted, gin.H{"status": "waking"}
} }
@@ -563,8 +537,6 @@ func handleServiceSleep(c *gin.Context) {
if !ok { if !ok {
return return
} }
var port int
_ = db.QueryRow(`SELECT COALESCE(port,0) FROM services WHERE id = $1`, service.ID).Scan(&port)
svc := sleepCandidate{ svc := sleepCandidate{
ID: service.ID, ID: service.ID,
ProjectID: service.ProjectID, ProjectID: service.ProjectID,
+33 -18
View File
@@ -640,19 +640,20 @@ func deployTemplateGraph(
createdServiceIDs := []uuid.UUID{} createdServiceIDs := []uuid.UUID{}
createdDatabaseIDs := []string{} createdDatabaseIDs := []string{}
cleanup := func() { cleanup := func() {
q := sqlcdb.New(db.DB)
for _, sid := range createdServiceIDs { for _, sid := range createdServiceIDs {
if engine != nil { if engine != nil {
removeServiceRuntime(context.Background(), db, engine, sid) removeServiceRuntime(context.Background(), db, engine, sid)
} }
_, _ = db.Exec(`DELETE FROM environment_variables WHERE service_id = $1`, sid) _ = q.DeleteEnvVarsForService(context.Background(), sid)
_, _ = db.Exec(`DELETE FROM services WHERE id = $1`, sid) _ = q.DeleteServiceByID(context.Background(), sid)
} }
for _, did := range createdDatabaseIDs { for _, did := range createdDatabaseIDs {
if dbHandler != nil && dbHandler.dockerClient != nil { if dbHandler != nil && dbHandler.dockerClient != nil {
_ = dbHandler.dockerClient.RemoveContainer(context.Background(), managedDatabaseContainerName(did), true) _ = dbHandler.dockerClient.RemoveContainer(context.Background(), managedDatabaseContainerName(did), true)
_ = dbHandler.dockerClient.RemoveVolume(context.Background(), managedDatabaseVolumeName(did), true) _ = dbHandler.dockerClient.RemoveVolume(context.Background(), managedDatabaseVolumeName(did), true)
} }
_, _ = db.Exec(`DELETE FROM database_services WHERE id = $1`, did) _ = q.DeleteDatabaseServiceByID(context.Background(), did)
} }
} }
@@ -772,14 +773,19 @@ func deployTemplateGraph(
if builder == "" { if builder == "" {
builder = "auto" builder = "auto"
} }
if _, err = tx.ExecContext(ctx, var volumesRaw json.RawMessage
`UPDATE services SET port = $1, healthcheck_path = $2, volumes = COALESCE($3::jsonb, '[]'::jsonb), if s, ok := volumesJSON.(string); ok {
git_repo = NULLIF($4, ''), git_branch = NULLIF($5, ''), builder = $6 volumesRaw = json.RawMessage(s)
WHERE id = $7`, }
m.spec.Port, strings.TrimSpace(m.spec.HealthCheck), volumesJSON, if err = sqlcdb.New(tx).UpdateServiceDeployMeta(ctx, sqlcdb.UpdateServiceDeployMetaParams{
strings.TrimSpace(m.spec.Repo), strings.TrimSpace(m.spec.Branch), builder, Port: int32(m.spec.Port),
serviceID, HealthcheckPath: strings.TrimSpace(m.spec.HealthCheck),
); err != nil { Column3: volumesRaw,
Column4: strings.TrimSpace(m.spec.Repo),
Column5: strings.TrimSpace(m.spec.Branch),
Builder: builder,
ID: serviceID,
}); err != nil {
tx.Rollback() tx.Rollback()
cleanup() cleanup()
return nil, &gin.H{"error": "Failed to configure service"} return nil, &gin.H{"error": "Failed to configure service"}
@@ -861,15 +867,24 @@ func queueTemplateDeployment(c *gin.Context, db *database.DB, engine *deployment
CreatedAt: now, CreatedAt: now,
UpdatedAt: now, UpdatedAt: now,
} }
if _, err := db.Exec( q := sqlcdb.New(db.DB)
`INSERT INTO deployments if err := q.InsertDeployment(context.Background(), sqlcdb.InsertDeploymentParams{
(id, service_id, version, commit_hash, status, image_name, image_tag, created_at, updated_at) ID: dep.ID,
VALUES ($1, $2, $3, NULL, $4, '', '', $5, $6)`, ServiceID: dep.ServiceID,
dep.ID, dep.ServiceID, fmt.Sprintf("v%d", now.Unix()), dep.Status, dep.CreatedAt, dep.UpdatedAt, Version: fmt.Sprintf("v%d", now.Unix()),
); err != nil { Status: sql.NullString{String: dep.Status, Valid: true},
ImageName: sql.NullString{String: "", Valid: true},
ImageTag: sql.NullString{String: "", Valid: true},
CreatedAt: sql.NullTime{Time: dep.CreatedAt, Valid: true},
UpdatedAt: sql.NullTime{Time: dep.UpdatedAt, Valid: true},
}); err != nil {
return uuid.Nil return uuid.Nil
} }
_, _ = db.Exec(`UPDATE services SET status = 'queued', updated_at = $1 WHERE id = $2`, time.Now(), service.ID) _ = q.SetServiceStatus(context.Background(), sqlcdb.SetServiceStatusParams{
Status: sql.NullString{String: "queued", Valid: true},
UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true},
ID: service.ID,
})
getDeployQueue(c).Enqueue(service.ID, deployqueue.Job{ getDeployQueue(c).Enqueue(service.ID, deployqueue.Job{
DeploymentID: dep.ID, DeploymentID: dep.ID,
+5 -6
View File
@@ -411,12 +411,11 @@ func handleCreateFromTemplate(c *gin.Context) {
return return
} }
// sqlc's schema predates the runtime columns; port and healthcheck_path are if err = sqlcdb.New(tx).UpdateServiceRuntimeMeta(ctx, sqlcdb.UpdateServiceRuntimeMetaParams{
// written via raw SQL like every other service mutation path. Port: int32(config.Port),
if _, err = tx.ExecContext(ctx, HealthcheckPath: strings.TrimSpace(config.HealthCheck),
`UPDATE services SET port = $1, healthcheck_path = $2 WHERE id = $3`, ID: serviceID,
config.Port, strings.TrimSpace(config.HealthCheck), serviceID, }); err != nil {
); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create service from template"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create service from template"})
return return
} }
+16 -9
View File
@@ -1,11 +1,14 @@
package api package api
import ( import (
"containr/internal/database" "database/sql"
"containr/internal/secrets"
"net/http" "net/http"
"time" "time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/secrets"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
) )
@@ -147,7 +150,8 @@ func handleUpdateVariables(c *gin.Context) {
} }
defer tx.Rollback() defer tx.Rollback()
_, err = tx.Exec("DELETE FROM environment_variables WHERE service_id = $1", serviceID) txq := sqlcdb.New(tx)
err = txq.DeleteEnvVarsForService(c.Request.Context(), serviceID)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clear existing variables"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clear existing variables"})
return return
@@ -164,12 +168,15 @@ func handleUpdateVariables(c *gin.Context) {
if v.IsSecret { if v.IsSecret {
value = secrets.Encrypt(value) value = secrets.Encrypt(value)
} }
varID := uuid.New() err = txq.InsertEnvVar(c.Request.Context(), sqlcdb.InsertEnvVarParams{
_, err = tx.Exec( ID: uuid.New(),
`INSERT INTO environment_variables (id, service_id, key, value, is_secret, created_at, updated_at) ServiceID: serviceID,
VALUES ($1, $2, $3, $4, $5, $6, $7)`, Key: v.Key,
varID, serviceID, v.Key, value, v.IsSecret, now, now, Value: value,
) IsSecret: sql.NullBool{Bool: v.IsSecret, Valid: true},
CreatedAt: sql.NullTime{Time: now, Valid: true},
UpdatedAt: sql.NullTime{Time: now, Valid: true},
})
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to insert variable: " + v.Key}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to insert variable: " + v.Key})
return return
@@ -0,0 +1,636 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: apwhy.sql
package sqlcdb
import (
"context"
"database/sql"
"github.com/google/uuid"
)
const countAPKeys = `-- name: CountAPKeys :one
SELECT COUNT(*) FROM api_keys
`
func (q *Queries) CountAPKeys(ctx context.Context) (int64, error) {
row := q.db.QueryRowContext(ctx, countAPKeys)
var count int64
err := row.Scan(&count)
return count, err
}
const countAPServices = `-- name: CountAPServices :one
SELECT COUNT(*) FROM api_services
`
func (q *Queries) CountAPServices(ctx context.Context) (int64, error) {
row := q.db.QueryRowContext(ctx, countAPServices)
var count int64
err := row.Scan(&count)
return count, err
}
const getAPServiceBySlug = `-- name: GetAPServiceBySlug :one
SELECT id, upstream_url, route_prefix, upstream_auth_header,
upstream_auth_value, enabled, rpm_limit, monthly_quota,
request_timeout_ms
FROM api_services WHERE slug = $1
`
type GetAPServiceBySlugRow struct {
ID uuid.UUID `json:"id"`
UpstreamUrl string `json:"upstream_url"`
RoutePrefix string `json:"route_prefix"`
UpstreamAuthHeader sql.NullString `json:"upstream_auth_header"`
UpstreamAuthValue sql.NullString `json:"upstream_auth_value"`
Enabled int32 `json:"enabled"`
RpmLimit sql.NullInt32 `json:"rpm_limit"`
MonthlyQuota sql.NullInt32 `json:"monthly_quota"`
RequestTimeoutMs sql.NullInt32 `json:"request_timeout_ms"`
}
func (q *Queries) GetAPServiceBySlug(ctx context.Context, slug string) (GetAPServiceBySlugRow, error) {
row := q.db.QueryRowContext(ctx, getAPServiceBySlug, slug)
var i GetAPServiceBySlugRow
err := row.Scan(
&i.ID,
&i.UpstreamUrl,
&i.RoutePrefix,
&i.UpstreamAuthHeader,
&i.UpstreamAuthValue,
&i.Enabled,
&i.RpmLimit,
&i.MonthlyQuota,
&i.RequestTimeoutMs,
)
return i, err
}
const getAPServiceForValidate = `-- name: GetAPServiceForValidate :one
SELECT upstream_url, health_path, upstream_auth_header, upstream_auth_value, request_timeout_ms
FROM api_services WHERE id = $1
`
type GetAPServiceForValidateRow struct {
UpstreamUrl string `json:"upstream_url"`
HealthPath string `json:"health_path"`
UpstreamAuthHeader sql.NullString `json:"upstream_auth_header"`
UpstreamAuthValue sql.NullString `json:"upstream_auth_value"`
RequestTimeoutMs sql.NullInt32 `json:"request_timeout_ms"`
}
func (q *Queries) GetAPServiceForValidate(ctx context.Context, id uuid.UUID) (GetAPServiceForValidateRow, error) {
row := q.db.QueryRowContext(ctx, getAPServiceForValidate, id)
var i GetAPServiceForValidateRow
err := row.Scan(
&i.UpstreamUrl,
&i.HealthPath,
&i.UpstreamAuthHeader,
&i.UpstreamAuthValue,
&i.RequestTimeoutMs,
)
return i, err
}
const insertAPKey = `-- name: InsertAPKey :one
INSERT INTO api_keys (
name, key_hash, key_prefix, plan, allowed_service_ids,
rpm_limit, monthly_quota, created_at, updated_at
) VALUES ($1, $2, $3, $4, '[]', $5, $6, NOW(), NOW())
RETURNING id
`
type InsertAPKeyParams struct {
Name string `json:"name"`
KeyHash string `json:"key_hash"`
KeyPrefix string `json:"key_prefix"`
Plan string `json:"plan"`
RpmLimit sql.NullInt32 `json:"rpm_limit"`
MonthlyQuota sql.NullInt32 `json:"monthly_quota"`
}
func (q *Queries) InsertAPKey(ctx context.Context, arg InsertAPKeyParams) (uuid.UUID, error) {
row := q.db.QueryRowContext(ctx, insertAPKey,
arg.Name,
arg.KeyHash,
arg.KeyPrefix,
arg.Plan,
arg.RpmLimit,
arg.MonthlyQuota,
)
var id uuid.UUID
err := row.Scan(&id)
return id, err
}
const insertAPService = `-- name: InsertAPService :one
INSERT INTO api_services (
name, slug, upstream_url, route_prefix, health_path,
rpm_limit, monthly_quota, created_at, updated_at
) VALUES ($1, $2, $3, $4, '/health', $5, $6, NOW(), NOW())
RETURNING id
`
type InsertAPServiceParams struct {
Name string `json:"name"`
Slug string `json:"slug"`
UpstreamUrl string `json:"upstream_url"`
RoutePrefix string `json:"route_prefix"`
RpmLimit sql.NullInt32 `json:"rpm_limit"`
MonthlyQuota sql.NullInt32 `json:"monthly_quota"`
}
func (q *Queries) InsertAPService(ctx context.Context, arg InsertAPServiceParams) (uuid.UUID, error) {
row := q.db.QueryRowContext(ctx, insertAPService,
arg.Name,
arg.Slug,
arg.UpstreamUrl,
arg.RoutePrefix,
arg.RpmLimit,
arg.MonthlyQuota,
)
var id uuid.UUID
err := row.Scan(&id)
return id, err
}
const insertGatewayIncident = `-- name: InsertGatewayIncident :exec
INSERT INTO incident_events (service_id, api_key_id, code, message, severity, http_status, occurred_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW())
`
type InsertGatewayIncidentParams struct {
ServiceID uuid.NullUUID `json:"service_id"`
ApiKeyID uuid.NullUUID `json:"api_key_id"`
Code string `json:"code"`
Message string `json:"message"`
Severity string `json:"severity"`
HttpStatus sql.NullInt32 `json:"http_status"`
}
func (q *Queries) InsertGatewayIncident(ctx context.Context, arg InsertGatewayIncidentParams) error {
_, err := q.db.ExecContext(ctx, insertGatewayIncident,
arg.ServiceID,
arg.ApiKeyID,
arg.Code,
arg.Message,
arg.Severity,
arg.HttpStatus,
)
return err
}
const insertMetricPoint = `-- name: InsertMetricPoint :exec
INSERT INTO metrics_timeseries (metric, value, labels_json, occurred_at)
VALUES ('gateway_request', 1, $1, NOW())
`
func (q *Queries) InsertMetricPoint(ctx context.Context, labelsJson sql.NullString) error {
_, err := q.db.ExecContext(ctx, insertMetricPoint, labelsJson)
return err
}
const insertServiceIncident = `-- name: InsertServiceIncident :exec
INSERT INTO incident_events (service_id, code, message, severity, http_status, occurred_at)
VALUES ($1, 'SERVICE_VALIDATION_FAILED', $2, 'medium', $3, NOW())
`
type InsertServiceIncidentParams struct {
ServiceID uuid.NullUUID `json:"service_id"`
Message string `json:"message"`
HttpStatus sql.NullInt32 `json:"http_status"`
}
func (q *Queries) InsertServiceIncident(ctx context.Context, arg InsertServiceIncidentParams) error {
_, err := q.db.ExecContext(ctx, insertServiceIncident, arg.ServiceID, arg.Message, arg.HttpStatus)
return err
}
const listAPKeys = `-- name: ListAPKeys :many
SELECT id, name, key_prefix, plan, enabled, rpm_limit, monthly_quota,
to_char(created_at, 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS created_at,
to_char(updated_at, 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS updated_at
FROM api_keys
ORDER BY created_at DESC
`
type ListAPKeysRow struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
KeyPrefix string `json:"key_prefix"`
Plan string `json:"plan"`
Enabled int32 `json:"enabled"`
RpmLimit sql.NullInt32 `json:"rpm_limit"`
MonthlyQuota sql.NullInt32 `json:"monthly_quota"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func (q *Queries) ListAPKeys(ctx context.Context) ([]ListAPKeysRow, error) {
rows, err := q.db.QueryContext(ctx, listAPKeys)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListAPKeysRow{}
for rows.Next() {
var i ListAPKeysRow
if err := rows.Scan(
&i.ID,
&i.Name,
&i.KeyPrefix,
&i.Plan,
&i.Enabled,
&i.RpmLimit,
&i.MonthlyQuota,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listAPKeysByPrefix = `-- name: ListAPKeysByPrefix :many
SELECT id, key_hash, enabled, rpm_limit, monthly_quota, allowed_service_ids
FROM api_keys WHERE key_prefix = $1
`
type ListAPKeysByPrefixRow struct {
ID uuid.UUID `json:"id"`
KeyHash string `json:"key_hash"`
Enabled int32 `json:"enabled"`
RpmLimit sql.NullInt32 `json:"rpm_limit"`
MonthlyQuota sql.NullInt32 `json:"monthly_quota"`
AllowedServiceIds string `json:"allowed_service_ids"`
}
func (q *Queries) ListAPKeysByPrefix(ctx context.Context, keyPrefix string) ([]ListAPKeysByPrefixRow, error) {
rows, err := q.db.QueryContext(ctx, listAPKeysByPrefix, keyPrefix)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListAPKeysByPrefixRow{}
for rows.Next() {
var i ListAPKeysByPrefixRow
if err := rows.Scan(
&i.ID,
&i.KeyHash,
&i.Enabled,
&i.RpmLimit,
&i.MonthlyQuota,
&i.AllowedServiceIds,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listAPServices = `-- name: ListAPServices :many
SELECT id, name, slug, upstream_url, route_prefix, enabled,
to_char(created_at, 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS created_at,
to_char(updated_at, 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS updated_at
FROM api_services
ORDER BY created_at DESC
`
type ListAPServicesRow struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
UpstreamUrl string `json:"upstream_url"`
RoutePrefix string `json:"route_prefix"`
Enabled int32 `json:"enabled"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func (q *Queries) ListAPServices(ctx context.Context) ([]ListAPServicesRow, error) {
rows, err := q.db.QueryContext(ctx, listAPServices)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListAPServicesRow{}
for rows.Next() {
var i ListAPServicesRow
if err := rows.Scan(
&i.ID,
&i.Name,
&i.Slug,
&i.UpstreamUrl,
&i.RoutePrefix,
&i.Enabled,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listClientEventPaths = `-- name: ListClientEventPaths :many
SELECT COALESCE((labels_json::jsonb ->> 'path'), 'unknown') AS path, COUNT(*) AS total
FROM metrics_timeseries
WHERE metric = 'client_event' AND occurred_at >= NOW() - INTERVAL '7 days'
GROUP BY COALESCE((labels_json::jsonb ->> 'path'), 'unknown')
ORDER BY total DESC, path ASC
LIMIT 20
`
type ListClientEventPathsRow struct {
Path interface{} `json:"path"`
Total int64 `json:"total"`
}
func (q *Queries) ListClientEventPaths(ctx context.Context) ([]ListClientEventPathsRow, error) {
rows, err := q.db.QueryContext(ctx, listClientEventPaths)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListClientEventPathsRow{}
for rows.Next() {
var i ListClientEventPathsRow
if err := rows.Scan(&i.Path, &i.Total); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listIncidentStatusCodes = `-- name: ListIncidentStatusCodes :many
SELECT COALESCE(http_status, 0) AS status_code, COALESCE(SUM(count), 0) AS total
FROM incident_events
WHERE occurred_at >= NOW() - INTERVAL '7 days'
GROUP BY COALESCE(http_status, 0)
ORDER BY total DESC, status_code ASC
`
type ListIncidentStatusCodesRow struct {
StatusCode int32 `json:"status_code"`
Total interface{} `json:"total"`
}
func (q *Queries) ListIncidentStatusCodes(ctx context.Context) ([]ListIncidentStatusCodesRow, error) {
rows, err := q.db.QueryContext(ctx, listIncidentStatusCodes)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListIncidentStatusCodesRow{}
for rows.Next() {
var i ListIncidentStatusCodesRow
if err := rows.Scan(&i.StatusCode, &i.Total); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listRequestsByDay = `-- name: ListRequestsByDay :many
SELECT TO_CHAR(DATE_TRUNC('day', occurred_at), 'YYYY-MM-DD') AS day_bucket, COUNT(*) AS total
FROM metrics_timeseries
WHERE metric = 'request_total' AND occurred_at >= NOW() - INTERVAL '7 days'
GROUP BY DATE_TRUNC('day', occurred_at)
ORDER BY DATE_TRUNC('day', occurred_at) ASC
`
type ListRequestsByDayRow struct {
DayBucket string `json:"day_bucket"`
Total int64 `json:"total"`
}
func (q *Queries) ListRequestsByDay(ctx context.Context) ([]ListRequestsByDayRow, error) {
rows, err := q.db.QueryContext(ctx, listRequestsByDay)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListRequestsByDayRow{}
for rows.Next() {
var i ListRequestsByDayRow
if err := rows.Scan(&i.DayBucket, &i.Total); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listTopAPServices = `-- name: ListTopAPServices :many
SELECT s.id, s.name, COALESCE(SUM(u.request_count), 0) AS total_requests
FROM api_services s
LEFT JOIN usage_counters u ON u.service_id = s.id
GROUP BY s.id, s.name
ORDER BY total_requests DESC, s.name ASC
LIMIT 10
`
type ListTopAPServicesRow struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
TotalRequests interface{} `json:"total_requests"`
}
func (q *Queries) ListTopAPServices(ctx context.Context) ([]ListTopAPServicesRow, error) {
rows, err := q.db.QueryContext(ctx, listTopAPServices)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListTopAPServicesRow{}
for rows.Next() {
var i ListTopAPServicesRow
if err := rows.Scan(&i.ID, &i.Name, &i.TotalRequests); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const setAPKeyEnabled = `-- name: SetAPKeyEnabled :exec
UPDATE api_keys SET enabled = $1, updated_at = NOW() WHERE id = $2
`
type SetAPKeyEnabledParams struct {
Enabled int32 `json:"enabled"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) SetAPKeyEnabled(ctx context.Context, arg SetAPKeyEnabledParams) error {
_, err := q.db.ExecContext(ctx, setAPKeyEnabled, arg.Enabled, arg.ID)
return err
}
const setAPServiceEnabled = `-- name: SetAPServiceEnabled :exec
UPDATE api_services SET enabled = $1, updated_at = NOW() WHERE id = $2
`
type SetAPServiceEnabledParams struct {
Enabled int32 `json:"enabled"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) SetAPServiceEnabled(ctx context.Context, arg SetAPServiceEnabledParams) error {
_, err := q.db.ExecContext(ctx, setAPServiceEnabled, arg.Enabled, arg.ID)
return err
}
const setAPServiceValidation = `-- name: SetAPServiceValidation :exec
UPDATE api_services
SET last_validation_at = NOW(), last_validation_status = $1, last_validation_message = $2, updated_at = NOW()
WHERE id = $3
`
type SetAPServiceValidationParams struct {
LastValidationStatus sql.NullString `json:"last_validation_status"`
LastValidationMessage sql.NullString `json:"last_validation_message"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) SetAPServiceValidation(ctx context.Context, arg SetAPServiceValidationParams) error {
_, err := q.db.ExecContext(ctx, setAPServiceValidation, arg.LastValidationStatus, arg.LastValidationMessage, arg.ID)
return err
}
const sumRequestMetricSince = `-- name: SumRequestMetricSince :one
SELECT COALESCE(SUM(value), 0)::int
FROM metrics_timeseries
WHERE metric = 'request_total' AND occurred_at >= DATE_TRUNC($1::text, NOW())
`
func (q *Queries) SumRequestMetricSince(ctx context.Context, dollar_1 string) (int32, error) {
row := q.db.QueryRowContext(ctx, sumRequestMetricSince, dollar_1)
var column_1 int32
err := row.Scan(&column_1)
return column_1, err
}
const sumUsageByKey = `-- name: SumUsageByKey :one
SELECT COALESCE(SUM(request_count), 0)::bigint FROM usage_counters
WHERE api_key_id = $1 AND period_month = $2
`
type SumUsageByKeyParams struct {
ApiKeyID uuid.UUID `json:"api_key_id"`
PeriodMonth string `json:"period_month"`
}
func (q *Queries) SumUsageByKey(ctx context.Context, arg SumUsageByKeyParams) (int64, error) {
row := q.db.QueryRowContext(ctx, sumUsageByKey, arg.ApiKeyID, arg.PeriodMonth)
var column_1 int64
err := row.Scan(&column_1)
return column_1, err
}
const sumUsageByService = `-- name: SumUsageByService :one
SELECT COALESCE(SUM(request_count), 0)::bigint FROM usage_counters
WHERE service_id = $1 AND period_month = $2
`
type SumUsageByServiceParams struct {
ServiceID uuid.UUID `json:"service_id"`
PeriodMonth string `json:"period_month"`
}
func (q *Queries) SumUsageByService(ctx context.Context, arg SumUsageByServiceParams) (int64, error) {
row := q.db.QueryRowContext(ctx, sumUsageByService, arg.ServiceID, arg.PeriodMonth)
var column_1 int64
err := row.Scan(&column_1)
return column_1, err
}
const sumUsageCounters = `-- name: SumUsageCounters :one
SELECT COALESCE(SUM(request_count), 0) FROM usage_counters
`
func (q *Queries) SumUsageCounters(ctx context.Context) (interface{}, error) {
row := q.db.QueryRowContext(ctx, sumUsageCounters)
var coalesce interface{}
err := row.Scan(&coalesce)
return coalesce, err
}
const touchAPKeyLastUsed = `-- name: TouchAPKeyLastUsed :exec
UPDATE api_keys SET last_used_at = NOW() WHERE id = $1
`
func (q *Queries) TouchAPKeyLastUsed(ctx context.Context, id uuid.UUID) error {
_, err := q.db.ExecContext(ctx, touchAPKeyLastUsed, id)
return err
}
const upsertUsageCounter = `-- name: UpsertUsageCounter :exec
INSERT INTO usage_counters (api_key_id, service_id, period_month, request_count, updated_at)
VALUES ($1, $2, $3, 1, NOW())
ON CONFLICT (api_key_id, service_id, period_month)
DO UPDATE SET request_count = usage_counters.request_count + 1, updated_at = NOW()
`
type UpsertUsageCounterParams struct {
ApiKeyID uuid.UUID `json:"api_key_id"`
ServiceID uuid.UUID `json:"service_id"`
PeriodMonth string `json:"period_month"`
}
func (q *Queries) UpsertUsageCounter(ctx context.Context, arg UpsertUsageCounterParams) error {
_, err := q.db.ExecContext(ctx, upsertUsageCounter, arg.ApiKeyID, arg.ServiceID, arg.PeriodMonth)
return err
}
@@ -0,0 +1,218 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: audit.sql
package sqlcdb
import (
"context"
"database/sql"
"encoding/json"
"time"
"github.com/google/uuid"
"github.com/sqlc-dev/pqtype"
)
const insertAuditLog = `-- name: InsertAuditLog :exec
INSERT INTO audit_logs (id, user_id, resource, resource_id, action, details, severity, category, label, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
`
type InsertAuditLogParams struct {
ID uuid.UUID `json:"id"`
UserID uuid.NullUUID `json:"user_id"`
Resource string `json:"resource"`
ResourceID uuid.NullUUID `json:"resource_id"`
Action string `json:"action"`
Details pqtype.NullRawMessage `json:"details"`
Severity string `json:"severity"`
Category string `json:"category"`
Label string `json:"label"`
CreatedAt time.Time `json:"created_at"`
}
func (q *Queries) InsertAuditLog(ctx context.Context, arg InsertAuditLogParams) error {
_, err := q.db.ExecContext(ctx, insertAuditLog,
arg.ID,
arg.UserID,
arg.Resource,
arg.ResourceID,
arg.Action,
arg.Details,
arg.Severity,
arg.Category,
arg.Label,
arg.CreatedAt,
)
return err
}
const insertAuditLogWithRequest = `-- name: InsertAuditLogWithRequest :exec
INSERT INTO audit_logs (id, user_id, resource, resource_id, action, details, ip_address, user_agent, severity, category, label, created_at)
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7::inet, $8, $9, $10, $11, $12)
`
type InsertAuditLogWithRequestParams struct {
ID uuid.UUID `json:"id"`
UserID uuid.NullUUID `json:"user_id"`
Resource string `json:"resource"`
ResourceID uuid.NullUUID `json:"resource_id"`
Action string `json:"action"`
Column6 json.RawMessage `json:"column_6"`
Column7 pqtype.Inet `json:"column_7"`
UserAgent sql.NullString `json:"user_agent"`
Severity string `json:"severity"`
Category string `json:"category"`
Label string `json:"label"`
CreatedAt time.Time `json:"created_at"`
}
func (q *Queries) InsertAuditLogWithRequest(ctx context.Context, arg InsertAuditLogWithRequestParams) error {
_, err := q.db.ExecContext(ctx, insertAuditLogWithRequest,
arg.ID,
arg.UserID,
arg.Resource,
arg.ResourceID,
arg.Action,
arg.Column6,
arg.Column7,
arg.UserAgent,
arg.Severity,
arg.Category,
arg.Label,
arg.CreatedAt,
)
return err
}
const listAuditBackfillBatch = `-- name: ListAuditBackfillBatch :many
SELECT id::text, resource, action FROM audit_logs
WHERE label = '' AND id > $1::uuid ORDER BY id LIMIT 500
`
type ListAuditBackfillBatchRow struct {
ID string `json:"id"`
Resource string `json:"resource"`
Action string `json:"action"`
}
func (q *Queries) ListAuditBackfillBatch(ctx context.Context, dollar_1 uuid.UUID) ([]ListAuditBackfillBatchRow, error) {
rows, err := q.db.QueryContext(ctx, listAuditBackfillBatch, dollar_1)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListAuditBackfillBatchRow{}
for rows.Next() {
var i ListAuditBackfillBatchRow
if err := rows.Scan(&i.ID, &i.Resource, &i.Action); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listResourceAuditLogs = `-- name: ListResourceAuditLogs :many
SELECT id, COALESCE(user_id::text, '')::text AS user_id, resource,
COALESCE(resource_id::text, '')::text AS resource_id, action,
COALESCE(details::text, '{}')::text AS details,
COALESCE(ip_address::text, '')::text AS ip_address,
COALESCE(user_agent, '') AS user_agent, created_at
FROM audit_logs
WHERE user_id::text = $1 AND resource = $2 AND resource_id::text = $3
ORDER BY created_at DESC
LIMIT 100
`
type ListResourceAuditLogsParams struct {
UserID uuid.NullUUID `json:"user_id"`
Resource string `json:"resource"`
ResourceID uuid.NullUUID `json:"resource_id"`
}
type ListResourceAuditLogsRow struct {
ID uuid.UUID `json:"id"`
UserID string `json:"user_id"`
Resource string `json:"resource"`
ResourceID string `json:"resource_id"`
Action string `json:"action"`
Details string `json:"details"`
IpAddress string `json:"ip_address"`
UserAgent string `json:"user_agent"`
CreatedAt time.Time `json:"created_at"`
}
func (q *Queries) ListResourceAuditLogs(ctx context.Context, arg ListResourceAuditLogsParams) ([]ListResourceAuditLogsRow, error) {
rows, err := q.db.QueryContext(ctx, listResourceAuditLogs, arg.UserID, arg.Resource, arg.ResourceID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListResourceAuditLogsRow{}
for rows.Next() {
var i ListResourceAuditLogsRow
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.Resource,
&i.ResourceID,
&i.Action,
&i.Details,
&i.IpAddress,
&i.UserAgent,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const pruneAuditLogs = `-- name: PruneAuditLogs :execrows
DELETE FROM audit_logs WHERE created_at < NOW() - ($1::int * INTERVAL '1 day')
`
func (q *Queries) PruneAuditLogs(ctx context.Context, dollar_1 int32) (int64, error) {
result, err := q.db.ExecContext(ctx, pruneAuditLogs, dollar_1)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const setAuditLogMetadata = `-- name: SetAuditLogMetadata :exec
UPDATE audit_logs SET severity = $1, category = $2, label = $3 WHERE id = $4::uuid
`
type SetAuditLogMetadataParams struct {
Severity string `json:"severity"`
Category string `json:"category"`
Label string `json:"label"`
Column4 uuid.UUID `json:"column_4"`
}
func (q *Queries) SetAuditLogMetadata(ctx context.Context, arg SetAuditLogMetadataParams) error {
_, err := q.db.ExecContext(ctx, setAuditLogMetadata,
arg.Severity,
arg.Category,
arg.Label,
arg.Column4,
)
return err
}
@@ -0,0 +1,335 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: auth.sql
package sqlcdb
import (
"context"
"database/sql"
"github.com/google/uuid"
)
const countUsers = `-- name: CountUsers :one
SELECT COUNT(*) FROM users
`
func (q *Queries) CountUsers(ctx context.Context) (int64, error) {
row := q.db.QueryRowContext(ctx, countUsers)
var count int64
err := row.Scan(&count)
return count, err
}
const countUsersByEmail = `-- name: CountUsersByEmail :one
SELECT COUNT(*) FROM users WHERE email = $1
`
func (q *Queries) CountUsersByEmail(ctx context.Context, email string) (int64, error) {
row := q.db.QueryRowContext(ctx, countUsersByEmail, email)
var count int64
err := row.Scan(&count)
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
`
type GetUserByEmailForAuthRow struct {
ID uuid.UUID `json:"id"`
Email string `json:"email"`
PasswordHash string `json:"password_hash"`
Name string `json:"name"`
AvatarUrl string `json:"avatar_url"`
IsAdmin bool `json:"is_admin"`
CreatedAt sql.NullTime `json:"created_at"`
}
func (q *Queries) GetUserByEmailForAuth(ctx context.Context, email string) (GetUserByEmailForAuthRow, error) {
row := q.db.QueryRowContext(ctx, getUserByEmailForAuth, email)
var i GetUserByEmailForAuthRow
err := row.Scan(
&i.ID,
&i.Email,
&i.PasswordHash,
&i.Name,
&i.AvatarUrl,
&i.IsAdmin,
&i.CreatedAt,
)
return i, err
}
const getUserByID = `-- name: GetUserByID :one
SELECT id, email, name, COALESCE(avatar_url, '') AS avatar_url, is_admin, created_at
FROM users WHERE id = $1
`
type GetUserByIDRow 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 sql.NullTime `json:"created_at"`
}
func (q *Queries) GetUserByID(ctx context.Context, id uuid.UUID) (GetUserByIDRow, error) {
row := q.db.QueryRowContext(ctx, getUserByID, id)
var i GetUserByIDRow
err := row.Scan(
&i.ID,
&i.Email,
&i.Name,
&i.AvatarUrl,
&i.IsAdmin,
&i.CreatedAt,
)
return i, err
}
const insertUser = `-- name: InsertUser :one
INSERT INTO users (email, password_hash, name)
VALUES ($1, $2, $3)
RETURNING id, email, name, COALESCE(avatar_url, '') AS avatar_url, is_admin, created_at
`
type InsertUserParams struct {
Email string `json:"email"`
PasswordHash string `json:"password_hash"`
Name string `json:"name"`
}
type InsertUserRow 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 sql.NullTime `json:"created_at"`
}
func (q *Queries) InsertUser(ctx context.Context, arg InsertUserParams) (InsertUserRow, error) {
row := q.db.QueryRowContext(ctx, insertUser, arg.Email, arg.PasswordHash, arg.Name)
var i InsertUserRow
err := row.Scan(
&i.ID,
&i.Email,
&i.Name,
&i.AvatarUrl,
&i.IsAdmin,
&i.CreatedAt,
)
return i, err
}
const insertUserAdmin = `-- name: InsertUserAdmin :one
INSERT INTO users (email, password_hash, name, is_admin)
VALUES ($1, $2, $3, $4)
RETURNING id, email, name, COALESCE(avatar_url, '') AS avatar_url, is_admin, created_at
`
type InsertUserAdminParams struct {
Email string `json:"email"`
PasswordHash string `json:"password_hash"`
Name string `json:"name"`
IsAdmin bool `json:"is_admin"`
}
type InsertUserAdminRow 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 sql.NullTime `json:"created_at"`
}
func (q *Queries) InsertUserAdmin(ctx context.Context, arg InsertUserAdminParams) (InsertUserAdminRow, error) {
row := q.db.QueryRowContext(ctx, insertUserAdmin,
arg.Email,
arg.PasswordHash,
arg.Name,
arg.IsAdmin,
)
var i InsertUserAdminRow
err := row.Scan(
&i.ID,
&i.Email,
&i.Name,
&i.AvatarUrl,
&i.IsAdmin,
&i.CreatedAt,
)
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)
WHERE id = $3
`
type UpdateUserProfileParams struct {
Column1 string `json:"column_1"`
Column2 string `json:"column_2"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) error {
_, err := q.db.ExecContext(ctx, updateUserProfile, arg.Column1, arg.Column2, arg.ID)
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)
ON CONFLICT (email) DO UPDATE
SET name = EXCLUDED.name,
avatar_url = COALESCE(EXCLUDED.avatar_url, users.avatar_url),
updated_at = NOW()
RETURNING id, email, name, COALESCE(avatar_url, '') AS avatar_url, is_admin, created_at
`
type UpsertLocalUserParams struct {
Email string `json:"email"`
PasswordHash string `json:"password_hash"`
Name string `json:"name"`
Column4 string `json:"column_4"`
IsAdmin bool `json:"is_admin"`
}
type UpsertLocalUserRow 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 sql.NullTime `json:"created_at"`
}
func (q *Queries) UpsertLocalUser(ctx context.Context, arg UpsertLocalUserParams) (UpsertLocalUserRow, error) {
row := q.db.QueryRowContext(ctx, upsertLocalUser,
arg.Email,
arg.PasswordHash,
arg.Name,
arg.Column4,
arg.IsAdmin,
)
var i UpsertLocalUserRow
err := row.Scan(
&i.ID,
&i.Email,
&i.Name,
&i.AvatarUrl,
&i.IsAdmin,
&i.CreatedAt,
)
return i, err
}
@@ -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
}
@@ -0,0 +1,332 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: cron.sql
package sqlcdb
import (
"context"
"database/sql"
"time"
"github.com/google/uuid"
)
const deleteCronJob = `-- name: DeleteCronJob :exec
DELETE FROM cron_jobs WHERE id = $1
`
func (q *Queries) DeleteCronJob(ctx context.Context, id uuid.UUID) error {
_, err := q.db.ExecContext(ctx, deleteCronJob, id)
return err
}
const finishCronExecution = `-- name: FinishCronExecution :exec
UPDATE cron_executions SET finished_at = $1, status = $2, output = $3, error = $4 WHERE id = $5
`
type FinishCronExecutionParams struct {
FinishedAt sql.NullTime `json:"finished_at"`
Status sql.NullString `json:"status"`
Output sql.NullString `json:"output"`
Error sql.NullString `json:"error"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) FinishCronExecution(ctx context.Context, arg FinishCronExecutionParams) error {
_, err := q.db.ExecContext(ctx, finishCronExecution,
arg.FinishedAt,
arg.Status,
arg.Output,
arg.Error,
arg.ID,
)
return err
}
const getCronJob = `-- name: GetCronJob :one
SELECT id, project_id, service_id, name, schedule, command, timezone,
enabled, last_run_at, next_run_at, last_status, last_output,
retention, created_at, updated_at
FROM cron_jobs WHERE id = $1
`
func (q *Queries) GetCronJob(ctx context.Context, id uuid.UUID) (CronJob, error) {
row := q.db.QueryRowContext(ctx, getCronJob, id)
var i CronJob
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.ServiceID,
&i.Name,
&i.Schedule,
&i.Command,
&i.Timezone,
&i.Enabled,
&i.LastRunAt,
&i.NextRunAt,
&i.LastStatus,
&i.LastOutput,
&i.Retention,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getCronJobForTrigger = `-- name: GetCronJobForTrigger :one
SELECT cj.service_id, cj.command, cj.schedule, cj.timezone, cj.retention, p.owner_id
FROM cron_jobs cj
JOIN projects p ON cj.project_id = p.id
WHERE cj.id = $1
`
type GetCronJobForTriggerRow struct {
ServiceID uuid.UUID `json:"service_id"`
Command string `json:"command"`
Schedule string `json:"schedule"`
Timezone sql.NullString `json:"timezone"`
Retention sql.NullInt32 `json:"retention"`
OwnerID uuid.UUID `json:"owner_id"`
}
func (q *Queries) GetCronJobForTrigger(ctx context.Context, id uuid.UUID) (GetCronJobForTriggerRow, error) {
row := q.db.QueryRowContext(ctx, getCronJobForTrigger, id)
var i GetCronJobForTriggerRow
err := row.Scan(
&i.ServiceID,
&i.Command,
&i.Schedule,
&i.Timezone,
&i.Retention,
&i.OwnerID,
)
return i, err
}
const getCronJobOwner = `-- name: GetCronJobOwner :one
SELECT p.owner_id FROM cron_jobs cj
JOIN projects p ON cj.project_id = p.id
WHERE cj.id = $1
`
func (q *Queries) GetCronJobOwner(ctx context.Context, id uuid.UUID) (uuid.UUID, error) {
row := q.db.QueryRowContext(ctx, getCronJobOwner, id)
var owner_id uuid.UUID
err := row.Scan(&owner_id)
return owner_id, err
}
const getCronJobProjectID = `-- name: GetCronJobProjectID :one
SELECT project_id FROM cron_jobs WHERE id = $1
`
func (q *Queries) GetCronJobProjectID(ctx context.Context, id uuid.UUID) (uuid.UUID, error) {
row := q.db.QueryRowContext(ctx, getCronJobProjectID, id)
var project_id uuid.UUID
err := row.Scan(&project_id)
return project_id, err
}
const getCronOwnerViaService = `-- name: GetCronOwnerViaService :one
SELECT p.owner_id FROM projects p
JOIN services s ON s.project_id = p.id
WHERE s.id = $1
`
func (q *Queries) GetCronOwnerViaService(ctx context.Context, id uuid.UUID) (uuid.UUID, error) {
row := q.db.QueryRowContext(ctx, getCronOwnerViaService, id)
var owner_id uuid.UUID
err := row.Scan(&owner_id)
return owner_id, err
}
const insertCronExecution = `-- name: InsertCronExecution :exec
INSERT INTO cron_executions (id, cron_job_id, started_at, status)
VALUES ($1, $2, $3, $4)
`
type InsertCronExecutionParams struct {
ID uuid.UUID `json:"id"`
CronJobID uuid.UUID `json:"cron_job_id"`
StartedAt time.Time `json:"started_at"`
Status sql.NullString `json:"status"`
}
func (q *Queries) InsertCronExecution(ctx context.Context, arg InsertCronExecutionParams) error {
_, err := q.db.ExecContext(ctx, insertCronExecution,
arg.ID,
arg.CronJobID,
arg.StartedAt,
arg.Status,
)
return err
}
const insertCronJob = `-- name: InsertCronJob :exec
INSERT INTO cron_jobs (id, project_id, service_id, name, schedule, command, timezone, enabled, next_run_at, retention, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
`
type InsertCronJobParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
ServiceID uuid.UUID `json:"service_id"`
Name string `json:"name"`
Schedule string `json:"schedule"`
Command string `json:"command"`
Timezone sql.NullString `json:"timezone"`
Enabled sql.NullBool `json:"enabled"`
NextRunAt sql.NullTime `json:"next_run_at"`
Retention sql.NullInt32 `json:"retention"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
func (q *Queries) InsertCronJob(ctx context.Context, arg InsertCronJobParams) error {
_, err := q.db.ExecContext(ctx, insertCronJob,
arg.ID,
arg.ProjectID,
arg.ServiceID,
arg.Name,
arg.Schedule,
arg.Command,
arg.Timezone,
arg.Enabled,
arg.NextRunAt,
arg.Retention,
arg.CreatedAt,
arg.UpdatedAt,
)
return err
}
const listCronExecutions = `-- name: ListCronExecutions :many
SELECT id, cron_job_id, started_at, finished_at, status, output, error
FROM cron_executions
WHERE cron_job_id = $1
ORDER BY started_at DESC
LIMIT 100
`
func (q *Queries) ListCronExecutions(ctx context.Context, cronJobID uuid.UUID) ([]CronExecution, error) {
rows, err := q.db.QueryContext(ctx, listCronExecutions, cronJobID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []CronExecution{}
for rows.Next() {
var i CronExecution
if err := rows.Scan(
&i.ID,
&i.CronJobID,
&i.StartedAt,
&i.FinishedAt,
&i.Status,
&i.Output,
&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 listDueCronJobs = `-- name: ListDueCronJobs :many
SELECT id, project_id, service_id, name, schedule, command, timezone, enabled, retention
FROM cron_jobs
WHERE enabled = TRUE AND next_run_at IS NOT NULL AND next_run_at <= NOW()
`
type ListDueCronJobsRow struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
ServiceID uuid.UUID `json:"service_id"`
Name string `json:"name"`
Schedule string `json:"schedule"`
Command string `json:"command"`
Timezone sql.NullString `json:"timezone"`
Enabled sql.NullBool `json:"enabled"`
Retention sql.NullInt32 `json:"retention"`
}
func (q *Queries) ListDueCronJobs(ctx context.Context) ([]ListDueCronJobsRow, error) {
rows, err := q.db.QueryContext(ctx, listDueCronJobs)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListDueCronJobsRow{}
for rows.Next() {
var i ListDueCronJobsRow
if err := rows.Scan(
&i.ID,
&i.ProjectID,
&i.ServiceID,
&i.Name,
&i.Schedule,
&i.Command,
&i.Timezone,
&i.Enabled,
&i.Retention,
); 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 trimCronExecutions = `-- name: TrimCronExecutions :exec
DELETE FROM cron_executions WHERE cron_executions.cron_job_id = $1 AND cron_executions.id NOT IN (
SELECT ce.id FROM cron_executions ce WHERE ce.cron_job_id = $1
ORDER BY ce.started_at DESC LIMIT $2)
`
type TrimCronExecutionsParams struct {
CronJobID uuid.UUID `json:"cron_job_id"`
Limit int32 `json:"limit"`
}
func (q *Queries) TrimCronExecutions(ctx context.Context, arg TrimCronExecutionsParams) error {
_, err := q.db.ExecContext(ctx, trimCronExecutions, arg.CronJobID, arg.Limit)
return err
}
const updateCronJobRun = `-- name: UpdateCronJobRun :exec
UPDATE cron_jobs SET last_run_at = $1, last_status = $2, last_output = $3, next_run_at = $4 WHERE id = $5
`
type UpdateCronJobRunParams struct {
LastRunAt sql.NullTime `json:"last_run_at"`
LastStatus sql.NullString `json:"last_status"`
LastOutput sql.NullString `json:"last_output"`
NextRunAt sql.NullTime `json:"next_run_at"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) UpdateCronJobRun(ctx context.Context, arg UpdateCronJobRunParams) error {
_, err := q.db.ExecContext(ctx, updateCronJobRun,
arg.LastRunAt,
arg.LastStatus,
arg.LastOutput,
arg.NextRunAt,
arg.ID,
)
return err
}
@@ -0,0 +1,776 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: git.sql
package sqlcdb
import (
"context"
"database/sql"
"github.com/google/uuid"
)
const countGitRepositories = `-- name: CountGitRepositories :one
SELECT COUNT(*)
FROM git_repositories
WHERE provider_id = $1 AND user_id = $2
AND ($3::text IS NULL OR full_name ILIKE ('%' || $3::text || '%') OR name ILIKE ('%' || $3::text || '%'))
`
type CountGitRepositoriesParams struct {
ProviderID uuid.UUID `json:"provider_id"`
UserID uuid.UUID `json:"user_id"`
Column3 string `json:"column_3"`
}
func (q *Queries) CountGitRepositories(ctx context.Context, arg CountGitRepositoriesParams) (int64, error) {
row := q.db.QueryRowContext(ctx, countGitRepositories, arg.ProviderID, arg.UserID, arg.Column3)
var count int64
err := row.Scan(&count)
return count, err
}
const countUserRepositories = `-- name: CountUserRepositories :one
SELECT COUNT(*) FROM git_repositories WHERE user_id = $1
`
func (q *Queries) CountUserRepositories(ctx context.Context, userID uuid.UUID) (int64, error) {
row := q.db.QueryRowContext(ctx, countUserRepositories, userID)
var count int64
err := row.Scan(&count)
return count, err
}
const deleteGitProvider = `-- name: DeleteGitProvider :execrows
DELETE FROM git_providers WHERE id = $1 AND user_id = $2
`
type DeleteGitProviderParams struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
}
func (q *Queries) DeleteGitProvider(ctx context.Context, arg DeleteGitProviderParams) (int64, error) {
result, err := q.db.ExecContext(ctx, deleteGitProvider, arg.ID, arg.UserID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const getGitProviderForFetch = `-- name: GetGitProviderForFetch :one
SELECT name, api_url, access_token
FROM git_providers
WHERE id = $1 AND user_id = $2
`
type GetGitProviderForFetchParams struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
}
type GetGitProviderForFetchRow struct {
Name string `json:"name"`
ApiUrl string `json:"api_url"`
AccessToken string `json:"access_token"`
}
func (q *Queries) GetGitProviderForFetch(ctx context.Context, arg GetGitProviderForFetchParams) (GetGitProviderForFetchRow, error) {
row := q.db.QueryRowContext(ctx, getGitProviderForFetch, arg.ID, arg.UserID)
var i GetGitProviderForFetchRow
err := row.Scan(&i.Name, &i.ApiUrl, &i.AccessToken)
return i, err
}
const getGitProviderForRepos = `-- name: GetGitProviderForRepos :one
SELECT name, display_name, access_token, api_url
FROM git_providers
WHERE id = $1 AND user_id = $2
`
type GetGitProviderForReposParams struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
}
type GetGitProviderForReposRow struct {
Name string `json:"name"`
DisplayName string `json:"display_name"`
AccessToken string `json:"access_token"`
ApiUrl string `json:"api_url"`
}
func (q *Queries) GetGitProviderForRepos(ctx context.Context, arg GetGitProviderForReposParams) (GetGitProviderForReposRow, error) {
row := q.db.QueryRowContext(ctx, getGitProviderForRepos, arg.ID, arg.UserID)
var i GetGitProviderForReposRow
err := row.Scan(
&i.Name,
&i.DisplayName,
&i.AccessToken,
&i.ApiUrl,
)
return i, err
}
const getGitProviderName = `-- name: GetGitProviderName :one
SELECT name FROM git_providers WHERE id = $1 AND user_id = $2
`
type GetGitProviderNameParams struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
}
func (q *Queries) GetGitProviderName(ctx context.Context, arg GetGitProviderNameParams) (string, error) {
row := q.db.QueryRowContext(ctx, getGitProviderName, arg.ID, arg.UserID)
var name string
err := row.Scan(&name)
return name, err
}
const getGitProviderNameByID = `-- name: GetGitProviderNameByID :one
SELECT name FROM git_providers WHERE id = $1
`
func (q *Queries) GetGitProviderNameByID(ctx context.Context, id uuid.UUID) (string, error) {
row := q.db.QueryRowContext(ctx, getGitProviderNameByID, id)
var name string
err := row.Scan(&name)
return name, err
}
const getGitProviderTimestamps = `-- name: GetGitProviderTimestamps :one
SELECT created_at, updated_at FROM git_providers WHERE id = $1
`
type GetGitProviderTimestampsRow struct {
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
func (q *Queries) GetGitProviderTimestamps(ctx context.Context, id uuid.UUID) (GetGitProviderTimestampsRow, error) {
row := q.db.QueryRowContext(ctx, getGitProviderTimestamps, id)
var i GetGitProviderTimestampsRow
err := row.Scan(&i.CreatedAt, &i.UpdatedAt)
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
`
type GetGitRepoForPushRow struct {
CloneUrl string `json:"clone_url"`
FullName string `json:"full_name"`
UserID uuid.UUID `json:"user_id"`
}
func (q *Queries) GetGitRepoForPush(ctx context.Context, id uuid.UUID) (GetGitRepoForPushRow, error) {
row := q.db.QueryRowContext(ctx, getGitRepoForPush, id)
var i GetGitRepoForPushRow
err := row.Scan(&i.CloneUrl, &i.FullName, &i.UserID)
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,
default_branch, is_private, user_id, created_at, updated_at
FROM git_repositories
WHERE provider_id = $1 AND full_name = $2 AND user_id = $3
`
type GetGitRepositoryByFullNameParams struct {
ProviderID uuid.UUID `json:"provider_id"`
FullName string `json:"full_name"`
UserID uuid.UUID `json:"user_id"`
}
type GetGitRepositoryByFullNameRow struct {
ID uuid.UUID `json:"id"`
ProviderID uuid.UUID `json:"provider_id"`
Name string `json:"name"`
FullName string `json:"full_name"`
Description string `json:"description"`
CloneUrl string `json:"clone_url"`
WebhookUrl string `json:"webhook_url"`
DefaultBranch sql.NullString `json:"default_branch"`
IsPrivate sql.NullBool `json:"is_private"`
UserID uuid.UUID `json:"user_id"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
func (q *Queries) GetGitRepositoryByFullName(ctx context.Context, arg GetGitRepositoryByFullNameParams) (GetGitRepositoryByFullNameRow, error) {
row := q.db.QueryRowContext(ctx, getGitRepositoryByFullName, arg.ProviderID, arg.FullName, arg.UserID)
var i GetGitRepositoryByFullNameRow
err := row.Scan(
&i.ID,
&i.ProviderID,
&i.Name,
&i.FullName,
&i.Description,
&i.CloneUrl,
&i.WebhookUrl,
&i.DefaultBranch,
&i.IsPrivate,
&i.UserID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getGitRepositoryProviderID = `-- name: GetGitRepositoryProviderID :one
SELECT provider_id FROM git_repositories WHERE id = $1 AND user_id = $2
`
type GetGitRepositoryProviderIDParams struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
}
func (q *Queries) GetGitRepositoryProviderID(ctx context.Context, arg GetGitRepositoryProviderIDParams) (uuid.UUID, error) {
row := q.db.QueryRowContext(ctx, getGitRepositoryProviderID, arg.ID, arg.UserID)
var provider_id uuid.UUID
err := row.Scan(&provider_id)
return provider_id, err
}
const getGitWebhookForPush = `-- name: GetGitWebhookForPush :one
SELECT webhook_secret, repo_id, provider_id, COALESCE(branch_filter, '') AS branch_filter, active
FROM git_webhooks WHERE id = $1
`
type GetGitWebhookForPushRow struct {
WebhookSecret string `json:"webhook_secret"`
RepoID uuid.UUID `json:"repo_id"`
ProviderID uuid.UUID `json:"provider_id"`
BranchFilter string `json:"branch_filter"`
Active sql.NullBool `json:"active"`
}
func (q *Queries) GetGitWebhookForPush(ctx context.Context, id uuid.UUID) (GetGitWebhookForPushRow, error) {
row := q.db.QueryRowContext(ctx, getGitWebhookForPush, id)
var i GetGitWebhookForPushRow
err := row.Scan(
&i.WebhookSecret,
&i.RepoID,
&i.ProviderID,
&i.BranchFilter,
&i.Active,
)
return i, err
}
const insertGitProvider = `-- name: InsertGitProvider :exec
INSERT INTO git_providers (id, name, display_name, api_url, webhook_url, access_token, user_id, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
`
type InsertGitProviderParams struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
ApiUrl string `json:"api_url"`
WebhookUrl string `json:"webhook_url"`
AccessToken string `json:"access_token"`
UserID uuid.UUID `json:"user_id"`
}
func (q *Queries) InsertGitProvider(ctx context.Context, arg InsertGitProviderParams) error {
_, err := q.db.ExecContext(ctx, insertGitProvider,
arg.ID,
arg.Name,
arg.DisplayName,
arg.ApiUrl,
arg.WebhookUrl,
arg.AccessToken,
arg.UserID,
)
return err
}
const insertGitRepository = `-- name: InsertGitRepository :one
INSERT INTO git_repositories (id, provider_id, name, full_name, description, clone_url, default_branch, is_private, user_id, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW())
RETURNING created_at, updated_at
`
type InsertGitRepositoryParams struct {
ID uuid.UUID `json:"id"`
ProviderID uuid.UUID `json:"provider_id"`
Name string `json:"name"`
FullName string `json:"full_name"`
Description sql.NullString `json:"description"`
CloneUrl string `json:"clone_url"`
DefaultBranch sql.NullString `json:"default_branch"`
IsPrivate sql.NullBool `json:"is_private"`
UserID uuid.UUID `json:"user_id"`
}
type InsertGitRepositoryRow struct {
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
func (q *Queries) InsertGitRepository(ctx context.Context, arg InsertGitRepositoryParams) (InsertGitRepositoryRow, error) {
row := q.db.QueryRowContext(ctx, insertGitRepository,
arg.ID,
arg.ProviderID,
arg.Name,
arg.FullName,
arg.Description,
arg.CloneUrl,
arg.DefaultBranch,
arg.IsPrivate,
arg.UserID,
)
var i InsertGitRepositoryRow
err := row.Scan(&i.CreatedAt, &i.UpdatedAt)
return i, err
}
const listConnectedRepositories = `-- name: ListConnectedRepositories :many
SELECT r.id, r.provider_id, r.name, r.full_name, COALESCE(r.description, '') AS description, r.clone_url,
r.default_branch, r.is_private, r.user_id, r.created_at, r.updated_at,
p.name AS provider_name, p.display_name
FROM git_repositories r
JOIN git_providers p ON r.provider_id = p.id
WHERE r.user_id = $1
ORDER BY r.updated_at DESC
LIMIT $2 OFFSET $3
`
type ListConnectedRepositoriesParams struct {
UserID uuid.UUID `json:"user_id"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
type ListConnectedRepositoriesRow struct {
ID uuid.UUID `json:"id"`
ProviderID uuid.UUID `json:"provider_id"`
Name string `json:"name"`
FullName string `json:"full_name"`
Description string `json:"description"`
CloneUrl string `json:"clone_url"`
DefaultBranch sql.NullString `json:"default_branch"`
IsPrivate sql.NullBool `json:"is_private"`
UserID uuid.UUID `json:"user_id"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
ProviderName string `json:"provider_name"`
DisplayName string `json:"display_name"`
}
func (q *Queries) ListConnectedRepositories(ctx context.Context, arg ListConnectedRepositoriesParams) ([]ListConnectedRepositoriesRow, error) {
rows, err := q.db.QueryContext(ctx, listConnectedRepositories, arg.UserID, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListConnectedRepositoriesRow{}
for rows.Next() {
var i ListConnectedRepositoriesRow
if err := rows.Scan(
&i.ID,
&i.ProviderID,
&i.Name,
&i.FullName,
&i.Description,
&i.CloneUrl,
&i.DefaultBranch,
&i.IsPrivate,
&i.UserID,
&i.CreatedAt,
&i.UpdatedAt,
&i.ProviderName,
&i.DisplayName,
); 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 listGitProviders = `-- name: ListGitProviders :many
SELECT id, name, display_name, api_url, webhook_url, user_id, created_at, updated_at
FROM git_providers
WHERE user_id = $1
ORDER BY created_at DESC
`
type ListGitProvidersRow struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
ApiUrl string `json:"api_url"`
WebhookUrl string `json:"webhook_url"`
UserID uuid.UUID `json:"user_id"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
func (q *Queries) ListGitProviders(ctx context.Context, userID uuid.UUID) ([]ListGitProvidersRow, error) {
rows, err := q.db.QueryContext(ctx, listGitProviders, userID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListGitProvidersRow{}
for rows.Next() {
var i ListGitProvidersRow
if err := rows.Scan(
&i.ID,
&i.Name,
&i.DisplayName,
&i.ApiUrl,
&i.WebhookUrl,
&i.UserID,
&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 listGitRepositories = `-- name: ListGitRepositories :many
SELECT id, provider_id, name, full_name, COALESCE(description, '') AS description,
clone_url, COALESCE(webhook_url, '') AS webhook_url,
default_branch, is_private, user_id, created_at, updated_at
FROM git_repositories
WHERE provider_id = $1 AND user_id = $2
AND ($3::text IS NULL OR full_name ILIKE ('%' || $3::text || '%') OR name ILIKE ('%' || $3::text || '%'))
ORDER BY updated_at DESC
LIMIT $4 OFFSET $5
`
type ListGitRepositoriesParams struct {
ProviderID uuid.UUID `json:"provider_id"`
UserID uuid.UUID `json:"user_id"`
Column3 string `json:"column_3"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
type ListGitRepositoriesRow struct {
ID uuid.UUID `json:"id"`
ProviderID uuid.UUID `json:"provider_id"`
Name string `json:"name"`
FullName string `json:"full_name"`
Description string `json:"description"`
CloneUrl string `json:"clone_url"`
WebhookUrl string `json:"webhook_url"`
DefaultBranch sql.NullString `json:"default_branch"`
IsPrivate sql.NullBool `json:"is_private"`
UserID uuid.UUID `json:"user_id"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
func (q *Queries) ListGitRepositories(ctx context.Context, arg ListGitRepositoriesParams) ([]ListGitRepositoriesRow, error) {
rows, err := q.db.QueryContext(ctx, listGitRepositories,
arg.ProviderID,
arg.UserID,
arg.Column3,
arg.Limit,
arg.Offset,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListGitRepositoriesRow{}
for rows.Next() {
var i ListGitRepositoriesRow
if err := rows.Scan(
&i.ID,
&i.ProviderID,
&i.Name,
&i.FullName,
&i.Description,
&i.CloneUrl,
&i.WebhookUrl,
&i.DefaultBranch,
&i.IsPrivate,
&i.UserID,
&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 listServicesForPush = `-- name: ListServicesForPush :many
SELECT s.id, s.project_id, s.name,
COALESCE(s.type, s.service_type, '') AS type, COALESCE(s.status, '') AS status,
COALESCE(s.image, s.image_name, '') AS image, COALESCE(s.command, s.start_command, '') AS command,
COALESCE(s.environment, '') AS environment, COALESCE(s.git_repo, s.source_url, '') AS git_repo,
COALESCE(s.git_branch, '') AS git_branch, COALESCE(s.build_path, '') AS build_path,
COALESCE(s.cpu, '') AS cpu, COALESCE(s.memory, '') AS memory,
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)
`
type ListServicesForPushParams struct {
GitBranch sql.NullString `json:"git_branch"`
GitRepo sql.NullString `json:"git_repo"`
GitRepo_2 sql.NullString `json:"git_repo_2"`
}
type ListServicesForPushRow struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Type string `json:"type"`
Status string `json:"status"`
Image string `json:"image"`
Command string `json:"command"`
Environment string `json:"environment"`
GitRepo string `json:"git_repo"`
GitBranch string `json:"git_branch"`
BuildPath string `json:"build_path"`
Cpu string `json:"cpu"`
Memory string `json:"memory"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
func (q *Queries) ListServicesForPush(ctx context.Context, arg ListServicesForPushParams) ([]ListServicesForPushRow, error) {
rows, err := q.db.QueryContext(ctx, listServicesForPush, arg.GitBranch, arg.GitRepo, arg.GitRepo_2)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListServicesForPushRow{}
for rows.Next() {
var i ListServicesForPushRow
if err := rows.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Type,
&i.Status,
&i.Image,
&i.Command,
&i.Environment,
&i.GitRepo,
&i.GitBranch,
&i.BuildPath,
&i.Cpu,
&i.Memory,
&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 upsertGitHubAppProvider = `-- name: UpsertGitHubAppProvider :one
INSERT INTO git_providers (id, name, display_name, api_url, webhook_url, access_token, user_id, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
ON CONFLICT (name, user_id)
DO UPDATE SET
display_name = EXCLUDED.display_name,
api_url = EXCLUDED.api_url,
webhook_url = EXCLUDED.webhook_url,
access_token = EXCLUDED.access_token,
updated_at = NOW()
RETURNING id, created_at, updated_at
`
type UpsertGitHubAppProviderParams struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
ApiUrl string `json:"api_url"`
WebhookUrl string `json:"webhook_url"`
AccessToken string `json:"access_token"`
UserID uuid.UUID `json:"user_id"`
}
type UpsertGitHubAppProviderRow struct {
ID uuid.UUID `json:"id"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
func (q *Queries) UpsertGitHubAppProvider(ctx context.Context, arg UpsertGitHubAppProviderParams) (UpsertGitHubAppProviderRow, error) {
row := q.db.QueryRowContext(ctx, upsertGitHubAppProvider,
arg.ID,
arg.Name,
arg.DisplayName,
arg.ApiUrl,
arg.WebhookUrl,
arg.AccessToken,
arg.UserID,
)
var i UpsertGitHubAppProviderRow
err := row.Scan(&i.ID, &i.CreatedAt, &i.UpdatedAt)
return i, err
}
const upsertGitRepository = `-- name: UpsertGitRepository :exec
INSERT INTO git_repositories (id, provider_id, name, full_name, description, clone_url, default_branch, is_private, user_id, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW())
ON CONFLICT (provider_id, full_name)
DO UPDATE SET
name = EXCLUDED.name,
description = EXCLUDED.description,
clone_url = EXCLUDED.clone_url,
default_branch = EXCLUDED.default_branch,
is_private = EXCLUDED.is_private,
updated_at = NOW()
`
type UpsertGitRepositoryParams struct {
ID uuid.UUID `json:"id"`
ProviderID uuid.UUID `json:"provider_id"`
Name string `json:"name"`
FullName string `json:"full_name"`
Description sql.NullString `json:"description"`
CloneUrl string `json:"clone_url"`
DefaultBranch sql.NullString `json:"default_branch"`
IsPrivate sql.NullBool `json:"is_private"`
UserID uuid.UUID `json:"user_id"`
}
func (q *Queries) UpsertGitRepository(ctx context.Context, arg UpsertGitRepositoryParams) error {
_, err := q.db.ExecContext(ctx, upsertGitRepository,
arg.ID,
arg.ProviderID,
arg.Name,
arg.FullName,
arg.Description,
arg.CloneUrl,
arg.DefaultBranch,
arg.IsPrivate,
arg.UserID,
)
return err
}
const upsertGitWebhook = `-- name: UpsertGitWebhook :one
INSERT INTO git_webhooks (id, repo_id, provider_id, events, webhook_secret, active, branch_filter, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, TRUE, $6, NOW(), NOW())
ON CONFLICT (repo_id, provider_id)
DO UPDATE SET
events = EXCLUDED.events,
webhook_secret = EXCLUDED.webhook_secret,
active = TRUE,
branch_filter = EXCLUDED.branch_filter,
updated_at = NOW()
RETURNING id, active, created_at, updated_at
`
type UpsertGitWebhookParams struct {
ID uuid.UUID `json:"id"`
RepoID uuid.UUID `json:"repo_id"`
ProviderID uuid.UUID `json:"provider_id"`
Events string `json:"events"`
WebhookSecret string `json:"webhook_secret"`
BranchFilter sql.NullString `json:"branch_filter"`
}
type UpsertGitWebhookRow struct {
ID uuid.UUID `json:"id"`
Active sql.NullBool `json:"active"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
func (q *Queries) UpsertGitWebhook(ctx context.Context, arg UpsertGitWebhookParams) (UpsertGitWebhookRow, error) {
row := q.db.QueryRowContext(ctx, upsertGitWebhook,
arg.ID,
arg.RepoID,
arg.ProviderID,
arg.Events,
arg.WebhookSecret,
arg.BranchFilter,
)
var i UpsertGitWebhookRow
err := row.Scan(
&i.ID,
&i.Active,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
@@ -0,0 +1,174 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: misc.sql
package sqlcdb
import (
"context"
"database/sql"
"encoding/json"
"time"
"github.com/google/uuid"
)
const countProjects = `-- name: CountProjects :one
SELECT COUNT(*) FROM projects
`
func (q *Queries) CountProjects(ctx context.Context) (int64, error) {
row := q.db.QueryRowContext(ctx, countProjects)
var count int64
err := row.Scan(&count)
return count, err
}
const deleteProjectVariables = `-- name: DeleteProjectVariables :exec
DELETE FROM project_variables WHERE project_id = $1
`
func (q *Queries) DeleteProjectVariables(ctx context.Context, projectID uuid.UUID) error {
_, err := q.db.ExecContext(ctx, deleteProjectVariables, projectID)
return err
}
const getProjectVariableValue = `-- name: GetProjectVariableValue :one
SELECT value FROM project_variables WHERE project_id = $1 AND key = $2
`
type GetProjectVariableValueParams struct {
ProjectID uuid.UUID `json:"project_id"`
Key string `json:"key"`
}
func (q *Queries) GetProjectVariableValue(ctx context.Context, arg GetProjectVariableValueParams) (string, error) {
row := q.db.QueryRowContext(ctx, getProjectVariableValue, arg.ProjectID, arg.Key)
var value string
err := row.Scan(&value)
return value, err
}
const getRegistryAuth = `-- name: GetRegistryAuth :one
SELECT username, password FROM registries
WHERE owner_id::text = $1 AND host = $2
`
type GetRegistryAuthParams struct {
OwnerID uuid.UUID `json:"owner_id"`
Host string `json:"host"`
}
type GetRegistryAuthRow struct {
Username string `json:"username"`
Password string `json:"password"`
}
func (q *Queries) GetRegistryAuth(ctx context.Context, arg GetRegistryAuthParams) (GetRegistryAuthRow, error) {
row := q.db.QueryRowContext(ctx, getRegistryAuth, arg.OwnerID, arg.Host)
var i GetRegistryAuthRow
err := row.Scan(&i.Username, &i.Password)
return i, err
}
const insertEnvVar = `-- name: InsertEnvVar :exec
INSERT INTO environment_variables (id, service_id, key, value, is_secret, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`
type InsertEnvVarParams struct {
ID uuid.UUID `json:"id"`
ServiceID uuid.UUID `json:"service_id"`
Key string `json:"key"`
Value string `json:"value"`
IsSecret sql.NullBool `json:"is_secret"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
func (q *Queries) InsertEnvVar(ctx context.Context, arg InsertEnvVarParams) error {
_, err := q.db.ExecContext(ctx, insertEnvVar,
arg.ID,
arg.ServiceID,
arg.Key,
arg.Value,
arg.IsSecret,
arg.CreatedAt,
arg.UpdatedAt,
)
return err
}
const insertProjectVariable = `-- name: InsertProjectVariable :exec
INSERT INTO project_variables (id, project_id, key, value, is_secret, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`
type InsertProjectVariableParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Key string `json:"key"`
Value string `json:"value"`
IsSecret bool `json:"is_secret"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (q *Queries) InsertProjectVariable(ctx context.Context, arg InsertProjectVariableParams) error {
_, err := q.db.ExecContext(ctx, insertProjectVariable,
arg.ID,
arg.ProjectID,
arg.Key,
arg.Value,
arg.IsSecret,
arg.CreatedAt,
arg.UpdatedAt,
)
return err
}
const updateServiceDeployMeta = `-- name: UpdateServiceDeployMeta :exec
UPDATE services SET port = $1, healthcheck_path = $2,
volumes = COALESCE($3::jsonb, '[]'::jsonb),
git_repo = NULLIF($4::text, ''), git_branch = NULLIF($5::text, ''), builder = $6
WHERE id = $7
`
type UpdateServiceDeployMetaParams struct {
Port int32 `json:"port"`
HealthcheckPath string `json:"healthcheck_path"`
Column3 json.RawMessage `json:"column_3"`
Column4 string `json:"column_4"`
Column5 string `json:"column_5"`
Builder string `json:"builder"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) UpdateServiceDeployMeta(ctx context.Context, arg UpdateServiceDeployMetaParams) error {
_, err := q.db.ExecContext(ctx, updateServiceDeployMeta,
arg.Port,
arg.HealthcheckPath,
arg.Column3,
arg.Column4,
arg.Column5,
arg.Builder,
arg.ID,
)
return err
}
const updateServiceRuntimeMeta = `-- name: UpdateServiceRuntimeMeta :exec
UPDATE services SET port = $1, healthcheck_path = $2 WHERE id = $3
`
type UpdateServiceRuntimeMetaParams struct {
Port int32 `json:"port"`
HealthcheckPath string `json:"healthcheck_path"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) UpdateServiceRuntimeMeta(ctx context.Context, arg UpdateServiceRuntimeMetaParams) error {
_, err := q.db.ExecContext(ctx, updateServiceRuntimeMeta, arg.Port, arg.HealthcheckPath, arg.ID)
return err
}
@@ -49,6 +49,66 @@ type AgentHeartbeat struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
type ApiKey struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
KeyHash string `json:"key_hash"`
KeyPrefix string `json:"key_prefix"`
Plan string `json:"plan"`
AllowedServiceIds string `json:"allowed_service_ids"`
Enabled int32 `json:"enabled"`
RpmLimit sql.NullInt32 `json:"rpm_limit"`
MonthlyQuota sql.NullInt32 `json:"monthly_quota"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
LastUsedAt sql.NullTime `json:"last_used_at"`
}
type ApiService struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
UpstreamUrl string `json:"upstream_url"`
RoutePrefix string `json:"route_prefix"`
HealthPath string `json:"health_path"`
UpstreamAuthHeader sql.NullString `json:"upstream_auth_header"`
UpstreamAuthValue sql.NullString `json:"upstream_auth_value"`
InternalToken sql.NullString `json:"internal_token"`
Enabled int32 `json:"enabled"`
RpmLimit sql.NullInt32 `json:"rpm_limit"`
MonthlyQuota sql.NullInt32 `json:"monthly_quota"`
RequestTimeoutMs sql.NullInt32 `json:"request_timeout_ms"`
LastValidationAt sql.NullTime `json:"last_validation_at"`
LastValidationStatus sql.NullString `json:"last_validation_status"`
LastValidationMessage sql.NullString `json:"last_validation_message"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type AppSetting struct {
Key string `json:"key"`
Value string `json:"value"`
IsSecret bool `json:"is_secret"`
UpdatedAt time.Time `json:"updated_at"`
}
type AuditLog struct {
ID uuid.UUID `json:"id"`
Timestamp time.Time `json:"timestamp"`
UserID uuid.NullUUID `json:"user_id"`
Action string `json:"action"`
Resource string `json:"resource"`
ResourceID uuid.NullUUID `json:"resource_id"`
Details pqtype.NullRawMessage `json:"details"`
IpAddress pqtype.Inet `json:"ip_address"`
UserAgent sql.NullString `json:"user_agent"`
Success bool `json:"success"`
CreatedAt time.Time `json:"created_at"`
Severity string `json:"severity"`
Category string `json:"category"`
Label string `json:"label"`
}
type BackupTarget struct { type BackupTarget struct {
ID string `json:"id"` ID string `json:"id"`
UserID string `json:"user_id"` UserID string `json:"user_id"`
@@ -78,6 +138,46 @@ type Banner struct {
UpdatedAt sql.NullTime `json:"updated_at"` UpdatedAt sql.NullTime `json:"updated_at"`
} }
type Build struct {
ID string `json:"id"`
ProjectID sql.NullString `json:"project_id"`
ServiceID sql.NullString `json:"service_id"`
Status string `json:"status"`
Progress int32 `json:"progress"`
StartedAt time.Time `json:"started_at"`
CompletedAt sql.NullTime `json:"completed_at"`
ImageName string `json:"image_name"`
ImageTag string `json:"image_tag"`
Size int64 `json:"size"`
Error sql.NullString `json:"error"`
Log string `json:"log"`
Metadata json.RawMessage `json:"metadata"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ComplianceFramework struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Description sql.NullString `json:"description"`
Version string `json:"version"`
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ComplianceReport struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
FrameworkID uuid.UUID `json:"framework_id"`
AssessmentDate time.Time `json:"assessment_date"`
Assessor sql.NullString `json:"assessor"`
OverallStatus string `json:"overall_status"`
Score int32 `json:"score"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ContainerInstance struct { type ContainerInstance struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -98,6 +198,34 @@ type ContainerInstance struct {
UpdatedAt sql.NullTime `json:"updated_at"` UpdatedAt sql.NullTime `json:"updated_at"`
} }
type CronExecution struct {
ID uuid.UUID `json:"id"`
CronJobID uuid.UUID `json:"cron_job_id"`
StartedAt time.Time `json:"started_at"`
FinishedAt sql.NullTime `json:"finished_at"`
Status sql.NullString `json:"status"`
Output sql.NullString `json:"output"`
Error sql.NullString `json:"error"`
}
type CronJob struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
ServiceID uuid.UUID `json:"service_id"`
Name string `json:"name"`
Schedule string `json:"schedule"`
Command string `json:"command"`
Timezone sql.NullString `json:"timezone"`
Enabled sql.NullBool `json:"enabled"`
LastRunAt sql.NullTime `json:"last_run_at"`
NextRunAt sql.NullTime `json:"next_run_at"`
LastStatus sql.NullString `json:"last_status"`
LastOutput sql.NullString `json:"last_output"`
Retention sql.NullInt32 `json:"retention"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
type DatabaseBackup struct { type DatabaseBackup struct {
ID string `json:"id"` ID string `json:"id"`
DatabaseID string `json:"database_id"` DatabaseID string `json:"database_id"`
@@ -172,6 +300,58 @@ type EnvironmentVariable struct {
UpdatedAt sql.NullTime `json:"updated_at"` UpdatedAt sql.NullTime `json:"updated_at"`
} }
type GitProvider struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
ApiUrl string `json:"api_url"`
WebhookUrl string `json:"webhook_url"`
AccessToken string `json:"access_token"`
UserID uuid.UUID `json:"user_id"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
type GitRepository struct {
ID uuid.UUID `json:"id"`
ProviderID uuid.UUID `json:"provider_id"`
Name string `json:"name"`
FullName string `json:"full_name"`
Description sql.NullString `json:"description"`
CloneUrl string `json:"clone_url"`
WebhookUrl sql.NullString `json:"webhook_url"`
DefaultBranch sql.NullString `json:"default_branch"`
IsPrivate sql.NullBool `json:"is_private"`
UserID uuid.UUID `json:"user_id"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
type GitWebhook struct {
ID uuid.UUID `json:"id"`
RepoID uuid.UUID `json:"repo_id"`
ProviderID uuid.UUID `json:"provider_id"`
Events string `json:"events"`
WebhookSecret string `json:"webhook_secret"`
RemoteWebhookID sql.NullString `json:"remote_webhook_id"`
Active sql.NullBool `json:"active"`
BranchFilter sql.NullString `json:"branch_filter"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
type IncidentEvent struct {
ID int32 `json:"id"`
ServiceID uuid.NullUUID `json:"service_id"`
ApiKeyID uuid.NullUUID `json:"api_key_id"`
Code string `json:"code"`
Message string `json:"message"`
Severity string `json:"severity"`
HttpStatus sql.NullInt32 `json:"http_status"`
Count int32 `json:"count"`
OccurredAt time.Time `json:"occurred_at"`
}
type InstanceMetric struct { type InstanceMetric struct {
ServiceID string `json:"service_id"` ServiceID string `json:"service_id"`
Timestamp time.Time `json:"timestamp"` Timestamp time.Time `json:"timestamp"`
@@ -197,6 +377,14 @@ type InstanceMetric struct {
CreatedAt sql.NullTime `json:"created_at"` CreatedAt sql.NullTime `json:"created_at"`
} }
type MetricsTimeseries struct {
ID int32 `json:"id"`
Metric string `json:"metric"`
Value float32 `json:"value"`
LabelsJson sql.NullString `json:"labels_json"`
OccurredAt time.Time `json:"occurred_at"`
}
type NodeAgent struct { type NodeAgent struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -318,6 +506,16 @@ type ProjectMember struct {
CreatedAt sql.NullTime `json:"created_at"` CreatedAt sql.NullTime `json:"created_at"`
} }
type ProjectVariable struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Key string `json:"key"`
Value string `json:"value"`
IsSecret bool `json:"is_secret"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Registry struct { type Registry struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
OwnerID uuid.UUID `json:"owner_id"` OwnerID uuid.UUID `json:"owner_id"`
@@ -329,6 +527,19 @@ type Registry struct {
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
type SecurityScan struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
ServiceID uuid.NullUUID `json:"service_id"`
ScanType string `json:"scan_type"`
Status string `json:"status"`
StartedAt time.Time `json:"started_at"`
CompletedAt sql.NullTime `json:"completed_at"`
Summary pqtype.NullRawMessage `json:"summary"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Service struct { type Service struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -373,6 +584,8 @@ type Service struct {
StaticDir string `json:"static_dir"` StaticDir string `json:"static_dir"`
NodeID sql.NullString `json:"node_id"` NodeID sql.NullString `json:"node_id"`
Spread bool `json:"spread"` Spread bool `json:"spread"`
SleepEnabled bool `json:"sleep_enabled"`
SleepIdleMinutes int32 `json:"sleep_idle_minutes"`
PlacementTags json.RawMessage `json:"placement_tags"` PlacementTags json.RawMessage `json:"placement_tags"`
TraefikLabels json.RawMessage `json:"traefik_labels"` TraefikLabels json.RawMessage `json:"traefik_labels"`
} }
@@ -430,6 +643,26 @@ type ServiceTemplate struct {
UpdatedAt sql.NullTime `json:"updated_at"` UpdatedAt sql.NullTime `json:"updated_at"`
} }
type UsageCounter struct {
ID int32 `json:"id"`
ApiKeyID uuid.UUID `json:"api_key_id"`
ServiceID uuid.UUID `json:"service_id"`
PeriodMonth string `json:"period_month"`
RequestCount int32 `json:"request_count"`
UpdatedAt time.Time `json:"updated_at"`
}
type User struct {
ID uuid.UUID `json:"id"`
Email string `json:"email"`
PasswordHash string `json:"password_hash"`
Name string `json:"name"`
AvatarUrl sql.NullString `json:"avatar_url"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
IsAdmin bool `json:"is_admin"`
}
type UserInvite struct { type UserInvite struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
TokenHash string `json:"token_hash"` TokenHash string `json:"token_hash"`
@@ -454,6 +687,21 @@ type UserToken struct {
CreatedAt sql.NullTime `json:"created_at"` CreatedAt sql.NullTime `json:"created_at"`
} }
type Vulnerability struct {
ID uuid.UUID `json:"id"`
Type string `json:"type"`
Severity string `json:"severity"`
Title string `json:"title"`
Description sql.NullString `json:"description"`
ServiceID uuid.NullUUID `json:"service_id"`
ProjectID uuid.UUID `json:"project_id"`
Status string `json:"status"`
FoundAt time.Time `json:"found_at"`
ResolvedAt sql.NullTime `json:"resolved_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type WebhookDelivery struct { type WebhookDelivery struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
WebhookID uuid.UUID `json:"webhook_id"` WebhookID uuid.UUID `json:"webhook_id"`
@@ -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,8 @@ type Querier interface {
// Portable bucket bucketing (no TimescaleDB): floor epoch seconds into // Portable bucket bucketing (no TimescaleDB): floor epoch seconds into
// bucket-sized buckets, then back to timestamptz. // bucket-sized buckets, then back to timestamptz.
AggregateNodeMetrics(ctx context.Context, arg AggregateNodeMetricsParams) ([]AggregateNodeMetricsRow, error) AggregateNodeMetrics(ctx context.Context, arg AggregateNodeMetricsParams) ([]AggregateNodeMetricsRow, error)
CancelBuild(ctx context.Context, arg CancelBuildParams) (int64, error)
CheckSecurityProjectAccess(ctx context.Context, arg CheckSecurityProjectAccessParams) (bool, error)
ClearServiceDomainDefault(ctx context.Context, serviceID uuid.UUID) error ClearServiceDomainDefault(ctx context.Context, serviceID uuid.UUID) error
ClearServiceNodePins(ctx context.Context, nodeID sql.NullString) (int64, error) ClearServiceNodePins(ctx context.Context, nodeID sql.NullString) (int64, error)
CloneServiceDomains(ctx context.Context, arg CloneServiceDomainsParams) error CloneServiceDomains(ctx context.Context, arg CloneServiceDomainsParams) error
@@ -23,17 +25,25 @@ type Querier interface {
CloneServiceVariables(ctx context.Context, arg CloneServiceVariablesParams) error CloneServiceVariables(ctx context.Context, arg CloneServiceVariablesParams) error
CompleteCommand(ctx context.Context, arg CompleteCommandParams) (AgentCommand, error) CompleteCommand(ctx context.Context, arg CompleteCommandParams) (AgentCommand, error)
CompleteDeployment(ctx context.Context, arg CompleteDeploymentParams) error CompleteDeployment(ctx context.Context, arg CompleteDeploymentParams) error
ComplianceFrameworkExists(ctx context.Context, id uuid.UUID) (bool, error)
CountAPKeys(ctx context.Context) (int64, error)
CountAPServices(ctx context.Context) (int64, error)
CountAccessibleDeployments(ctx context.Context, arg CountAccessibleDeploymentsParams) (int64, error) CountAccessibleDeployments(ctx context.Context, arg CountAccessibleDeploymentsParams) (int64, error)
CountActivePreviewsForBranch(ctx context.Context, arg CountActivePreviewsForBranchParams) (int64, error) CountActivePreviewsForBranch(ctx context.Context, arg CountActivePreviewsForBranchParams) (int64, error)
CountDatabaseServicesByUserAndName(ctx context.Context, arg CountDatabaseServicesByUserAndNameParams) (int64, error) CountDatabaseServicesByUserAndName(ctx context.Context, arg CountDatabaseServicesByUserAndNameParams) (int64, error)
CountDatabasesUsingBackupTarget(ctx context.Context, targetID sql.NullString) (int64, error) CountDatabasesUsingBackupTarget(ctx context.Context, targetID sql.NullString) (int64, error)
CountDeploymentsForService(ctx context.Context, serviceID uuid.UUID) (int64, error) CountDeploymentsForService(ctx context.Context, serviceID uuid.UUID) (int64, error)
CountEnvironmentServices(ctx context.Context, environmentID uuid.UUID) (int64, error) CountEnvironmentServices(ctx context.Context, environmentID uuid.UUID) (int64, error)
CountGitRepositories(ctx context.Context, arg CountGitRepositoriesParams) (int64, error)
CountProjects(ctx context.Context) (int64, error)
CountProjectsByUser(ctx context.Context, arg CountProjectsByUserParams) (int64, error) CountProjectsByUser(ctx context.Context, arg CountProjectsByUserParams) (int64, error)
CountServiceDomains(ctx context.Context, serviceID uuid.UUID) (int64, error) CountServiceDomains(ctx context.Context, serviceID uuid.UUID) (int64, error)
CountServicesByName(ctx context.Context, arg CountServicesByNameParams) (int64, error) CountServicesByName(ctx context.Context, arg CountServicesByNameParams) (int64, error)
CountServicesByProjectAndName(ctx context.Context, arg CountServicesByProjectAndNameParams) (int64, error) CountServicesByProjectAndName(ctx context.Context, arg CountServicesByProjectAndNameParams) (int64, error)
CountUnreadNotificationsByUser(ctx context.Context, userID uuid.UUID) (int64, error) CountUnreadNotificationsByUser(ctx context.Context, userID uuid.UUID) (int64, error)
CountUserRepositories(ctx context.Context, userID uuid.UUID) (int64, error)
CountUsers(ctx context.Context) (int64, error)
CountUsersByEmail(ctx context.Context, email string) (int64, error)
CreateAgent(ctx context.Context, arg CreateAgentParams) (NodeAgent, error) CreateAgent(ctx context.Context, arg CreateAgentParams) (NodeAgent, error)
CreateAgentAuthToken(ctx context.Context, arg CreateAgentAuthTokenParams) (AgentAuthToken, error) CreateAgentAuthToken(ctx context.Context, arg CreateAgentAuthTokenParams) (AgentAuthToken, error)
CreateBackupTarget(ctx context.Context, arg CreateBackupTargetParams) error CreateBackupTarget(ctx context.Context, arg CreateBackupTargetParams) error
@@ -57,30 +67,48 @@ type Querier interface {
DatabaseServiceExistsByIDAndUser(ctx context.Context, arg DatabaseServiceExistsByIDAndUserParams) (bool, error) DatabaseServiceExistsByIDAndUser(ctx context.Context, arg DatabaseServiceExistsByIDAndUserParams) (bool, error)
DeleteAgent(ctx context.Context, id string) error DeleteAgent(ctx context.Context, id string) error
DeleteAllServiceDomains(ctx context.Context, serviceID uuid.UUID) error DeleteAllServiceDomains(ctx context.Context, serviceID uuid.UUID) error
DeleteAppSetting(ctx context.Context, key string) error
DeleteBackupTargetByIDAndUser(ctx context.Context, arg DeleteBackupTargetByIDAndUserParams) error DeleteBackupTargetByIDAndUser(ctx context.Context, arg DeleteBackupTargetByIDAndUserParams) error
DeleteBanner(ctx context.Context, id uuid.UUID) error DeleteBanner(ctx context.Context, id uuid.UUID) error
DeleteCronJob(ctx context.Context, id uuid.UUID) error
DeleteDatabaseServiceByID(ctx context.Context, id string) error DeleteDatabaseServiceByID(ctx context.Context, id string) error
DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) error DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) error
DeleteEnvVarsForService(ctx context.Context, serviceID uuid.UUID) error
DeleteGitProvider(ctx context.Context, arg DeleteGitProviderParams) (int64, error)
DeleteNotificationChannel(ctx context.Context, arg DeleteNotificationChannelParams) (int64, error) DeleteNotificationChannel(ctx context.Context, arg DeleteNotificationChannelParams) (int64, error)
DeleteOutboundWebhookByIDAndUser(ctx context.Context, arg DeleteOutboundWebhookByIDAndUserParams) error DeleteOutboundWebhookByIDAndUser(ctx context.Context, arg DeleteOutboundWebhookByIDAndUserParams) error
DeletePreviewEnvironment(ctx context.Context, id uuid.UUID) error DeletePreviewEnvironment(ctx context.Context, id uuid.UUID) error
DeleteProjectByID(ctx context.Context, projectID uuid.UUID) (int64, error) DeleteProjectByID(ctx context.Context, projectID uuid.UUID) (int64, error)
DeleteProjectEnvironment(ctx context.Context, arg DeleteProjectEnvironmentParams) error DeleteProjectEnvironment(ctx context.Context, arg DeleteProjectEnvironmentParams) error
DeleteProjectVariables(ctx context.Context, projectID uuid.UUID) error
DeleteServiceByID(ctx context.Context, id uuid.UUID) error DeleteServiceByID(ctx context.Context, id uuid.UUID) error
DeleteServiceContainersOnAgent(ctx context.Context, arg DeleteServiceContainersOnAgentParams) error DeleteServiceContainersOnAgent(ctx context.Context, arg DeleteServiceContainersOnAgentParams) error
DeleteServiceDomain(ctx context.Context, arg DeleteServiceDomainParams) (int64, error) DeleteServiceDomain(ctx context.Context, arg DeleteServiceDomainParams) (int64, error)
DeleteUserInvite(ctx context.Context, id uuid.UUID) error DeleteUserInvite(ctx context.Context, id uuid.UUID) error
DeleteUserTemplate(ctx context.Context, arg DeleteUserTemplateParams) (int64, error) DeleteUserTemplate(ctx context.Context, arg DeleteUserTemplateParams) (int64, error)
FailDeployment(ctx context.Context, arg FailDeploymentParams) error FailDeployment(ctx context.Context, arg FailDeploymentParams) error
FailInterruptedDeployments(ctx context.Context) error
FinishCronExecution(ctx context.Context, arg FinishCronExecutionParams) error
GetAPServiceBySlug(ctx context.Context, slug string) (GetAPServiceBySlugRow, error)
GetAPServiceForValidate(ctx context.Context, id uuid.UUID) (GetAPServiceForValidateRow, error)
GetActiveAgentAuthTokenByHash(ctx context.Context, tokenHash string) (AgentAuthToken, error) GetActiveAgentAuthTokenByHash(ctx context.Context, tokenHash string) (AgentAuthToken, error)
GetAdminUserInfo(ctx context.Context, id uuid.UUID) (GetAdminUserInfoRow, error)
GetAgent(ctx context.Context, id string) (NodeAgent, error) GetAgent(ctx context.Context, id string) (NodeAgent, error)
GetAgentByHostAndIP(ctx context.Context, arg GetAgentByHostAndIPParams) (NodeAgent, error) GetAgentByHostAndIP(ctx context.Context, arg GetAgentByHostAndIPParams) (NodeAgent, error)
GetAppSetting(ctx context.Context, key string) (string, error)
GetBackupTargetByID(ctx context.Context, id string) (BackupTarget, error) GetBackupTargetByID(ctx context.Context, id string) (BackupTarget, error)
GetBackupTargetByIDAndUser(ctx context.Context, arg GetBackupTargetByIDAndUserParams) (BackupTarget, error) GetBackupTargetByIDAndUser(ctx context.Context, arg GetBackupTargetByIDAndUserParams) (BackupTarget, error)
GetBannerByID(ctx context.Context, id uuid.UUID) (Banner, error) GetBannerByID(ctx context.Context, id uuid.UUID) (Banner, error)
GetBuild(ctx context.Context, id string) (GetBuildRow, error)
GetCommandForAgent(ctx context.Context, arg GetCommandForAgentParams) (AgentCommand, error) GetCommandForAgent(ctx context.Context, arg GetCommandForAgentParams) (AgentCommand, error)
GetComplianceReportProjectID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
GetContainer(ctx context.Context, id string) (ContainerInstance, error) GetContainer(ctx context.Context, id string) (ContainerInstance, error)
GetContainerForAgent(ctx context.Context, arg GetContainerForAgentParams) (ContainerInstance, error) GetContainerForAgent(ctx context.Context, arg GetContainerForAgentParams) (ContainerInstance, error)
GetCronJob(ctx context.Context, id uuid.UUID) (CronJob, error)
GetCronJobForTrigger(ctx context.Context, id uuid.UUID) (GetCronJobForTriggerRow, error)
GetCronJobOwner(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
GetCronJobProjectID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
GetCronOwnerViaService(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
GetDatabaseBackupByIDAndDatabaseAndUser(ctx context.Context, arg GetDatabaseBackupByIDAndDatabaseAndUserParams) (DatabaseBackup, error) GetDatabaseBackupByIDAndDatabaseAndUser(ctx context.Context, arg GetDatabaseBackupByIDAndDatabaseAndUserParams) (DatabaseBackup, error)
GetDatabaseServiceByID(ctx context.Context, id string) (DatabaseService, error) GetDatabaseServiceByID(ctx context.Context, id string) (DatabaseService, error)
GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetDatabaseServiceByIDAndUserParams) (DatabaseService, error) GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetDatabaseServiceByIDAndUserParams) (DatabaseService, error)
@@ -88,7 +116,23 @@ type Querier interface {
GetDeploymentAccess(ctx context.Context, id uuid.UUID) (GetDeploymentAccessRow, error) GetDeploymentAccess(ctx context.Context, id uuid.UUID) (GetDeploymentAccessRow, error)
GetDeploymentForRollback(ctx context.Context, id uuid.UUID) (GetDeploymentForRollbackRow, error) GetDeploymentForRollback(ctx context.Context, id uuid.UUID) (GetDeploymentForRollbackRow, error)
GetDeploymentWithProject(ctx context.Context, id uuid.UUID) (GetDeploymentWithProjectRow, error) GetDeploymentWithProject(ctx context.Context, id uuid.UUID) (GetDeploymentWithProjectRow, error)
GetEnvironmentProjectID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
GetGitProviderForFetch(ctx context.Context, arg GetGitProviderForFetchParams) (GetGitProviderForFetchRow, error)
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)
GetLastAgentCommandByType(ctx context.Context, arg GetLastAgentCommandByTypeParams) (AgentCommand, error) GetLastAgentCommandByType(ctx context.Context, arg GetLastAgentCommandByTypeParams) (AgentCommand, error)
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) GetOutboundWebhookByID(ctx context.Context, id uuid.UUID) (OutboundWebhook, error)
GetOutboundWebhookByIDAndUser(ctx context.Context, arg GetOutboundWebhookByIDAndUserParams) (OutboundWebhook, error) GetOutboundWebhookByIDAndUser(ctx context.Context, arg GetOutboundWebhookByIDAndUserParams) (OutboundWebhook, error)
GetPreviewEnvironment(ctx context.Context, arg GetPreviewEnvironmentParams) (GetPreviewEnvironmentRow, error) GetPreviewEnvironment(ctx context.Context, arg GetPreviewEnvironmentParams) (GetPreviewEnvironmentRow, error)
@@ -102,42 +146,98 @@ type Querier interface {
GetProjectOwner(ctx context.Context, id uuid.UUID) (uuid.UUID, error) GetProjectOwner(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
GetProjectOwnerByID(ctx context.Context, projectID uuid.UUID) (uuid.UUID, error) GetProjectOwnerByID(ctx context.Context, projectID uuid.UUID) (uuid.UUID, error)
GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUID, error) GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
GetProjectOwnerText(ctx context.Context, id uuid.UUID) (string, error)
GetProjectReadAccess(ctx context.Context, id uuid.UUID) (GetProjectReadAccessRow, error)
GetProjectRoleForUser(ctx context.Context, arg GetProjectRoleForUserParams) (string, error) GetProjectRoleForUser(ctx context.Context, arg GetProjectRoleForUserParams) (string, error)
GetProjectVariableValue(ctx context.Context, arg GetProjectVariableValueParams) (string, error)
GetRegistryAuth(ctx context.Context, arg GetRegistryAuthParams) (GetRegistryAuthRow, error)
GetSecurityScanProjectID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
GetServiceAccess(ctx context.Context, id uuid.UUID) (GetServiceAccessRow, error) GetServiceAccess(ctx context.Context, id uuid.UUID) (GetServiceAccessRow, error)
GetServiceDomainDefault(ctx context.Context, serviceID uuid.UUID) (string, error) GetServiceDomainDefault(ctx context.Context, serviceID uuid.UUID) (string, error)
GetServiceDomainFirst(ctx context.Context, serviceID uuid.UUID) (string, error) GetServiceDomainFirst(ctx context.Context, serviceID uuid.UUID) (string, error)
GetServiceDomainText(ctx context.Context, id uuid.UUID) (string, error)
GetServiceEnvironment(ctx context.Context, id uuid.UUID) (string, error) GetServiceEnvironment(ctx context.Context, id uuid.UUID) (string, error)
GetServiceForDeploy(ctx context.Context, id uuid.UUID) (GetServiceForDeployRow, error) GetServiceForDeploy(ctx context.Context, id uuid.UUID) (GetServiceForDeployRow, error)
GetServiceForDeployWithOwner(ctx context.Context, id uuid.UUID) (GetServiceForDeployWithOwnerRow, error) GetServiceForDeployWithOwner(ctx context.Context, id uuid.UUID) (GetServiceForDeployWithOwnerRow, error)
GetServiceNameProjectPort(ctx context.Context, id uuid.UUID) (GetServiceNameProjectPortRow, error)
GetServiceNodeID(ctx context.Context, id uuid.UUID) (sql.NullString, error)
GetServicePlacementBrief(ctx context.Context, id uuid.UUID) (GetServicePlacementBriefRow, error) GetServicePlacementBrief(ctx context.Context, id uuid.UUID) (GetServicePlacementBriefRow, error)
GetServicePlacementTags(ctx context.Context, id uuid.UUID) (json.RawMessage, error)
GetServicePort(ctx context.Context, id uuid.UUID) (int32, error)
GetServiceProjectAndStatus(ctx context.Context, id uuid.UUID) (GetServiceProjectAndStatusRow, error)
GetServiceProjectID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
GetServicePublishedPort(ctx context.Context, id uuid.UUID) (int32, error) GetServicePublishedPort(ctx context.Context, id uuid.UUID) (int32, error)
GetServiceRuntimeWithOwner(ctx context.Context, id uuid.UUID) (GetServiceRuntimeWithOwnerRow, error)
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) GetServiceTemplateByID(ctx context.Context, id string) (ServiceTemplate, error)
GetServiceTraefikLabels(ctx context.Context, id uuid.UUID) (json.RawMessage, error)
GetServiceTypeBrief(ctx context.Context, arg GetServiceTypeBriefParams) (GetServiceTypeBriefRow, error) GetServiceTypeBrief(ctx context.Context, arg GetServiceTypeBriefParams) (GetServiceTypeBriefRow, error)
GetServiceVariableValue(ctx context.Context, arg GetServiceVariableValueParams) (string, error)
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) GetUserInviteByTokenHash(ctx context.Context, tokenHash string) (UserInvite, error)
GetUserIsAdmin(ctx context.Context, id uuid.UUID) (bool, error)
GetUserTokenByHash(ctx context.Context, tokenHash string) (UserToken, error) GetUserTokenByHash(ctx context.Context, tokenHash string) (UserToken, error)
GetVulnerabilityMetrics(ctx context.Context, projectID uuid.UUID) (GetVulnerabilityMetricsRow, error)
GetVulnerabilityProjectID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
InsertAPKey(ctx context.Context, arg InsertAPKeyParams) (uuid.UUID, error)
InsertAPService(ctx context.Context, arg InsertAPServiceParams) (uuid.UUID, error)
InsertAgentHeartbeat(ctx context.Context, arg InsertAgentHeartbeatParams) error InsertAgentHeartbeat(ctx context.Context, arg InsertAgentHeartbeatParams) error
InsertAuditLog(ctx context.Context, arg InsertAuditLogParams) error
InsertAuditLogWithRequest(ctx context.Context, arg InsertAuditLogWithRequestParams) error
InsertCronExecution(ctx context.Context, arg InsertCronExecutionParams) error
InsertCronJob(ctx context.Context, arg InsertCronJobParams) error
InsertDeployment(ctx context.Context, arg InsertDeploymentParams) error InsertDeployment(ctx context.Context, arg InsertDeploymentParams) error
InsertEnvVar(ctx context.Context, arg InsertEnvVarParams) error
InsertGatewayIncident(ctx context.Context, arg InsertGatewayIncidentParams) error
InsertGitProvider(ctx context.Context, arg InsertGitProviderParams) error
InsertGitRepository(ctx context.Context, arg InsertGitRepositoryParams) (InsertGitRepositoryRow, error)
InsertMetricPoint(ctx context.Context, labelsJson sql.NullString) error
InsertNotificationChannel(ctx context.Context, arg InsertNotificationChannelParams) (NotificationChannel, error) InsertNotificationChannel(ctx context.Context, arg InsertNotificationChannelParams) (NotificationChannel, error)
InsertPendingDeployment(ctx context.Context, arg InsertPendingDeploymentParams) error InsertPendingDeployment(ctx context.Context, arg InsertPendingDeploymentParams) error
InsertProjectEnvironment(ctx context.Context, arg InsertProjectEnvironmentParams) error InsertProjectEnvironment(ctx context.Context, arg InsertProjectEnvironmentParams) error
InsertProjectVariable(ctx context.Context, arg InsertProjectVariableParams) error
InsertServiceIncident(ctx context.Context, arg InsertServiceIncidentParams) error
InsertUser(ctx context.Context, arg InsertUserParams) (InsertUserRow, error)
InsertUserAdmin(ctx context.Context, arg InsertUserAdminParams) (InsertUserAdminRow, error)
ListAPKeys(ctx context.Context) ([]ListAPKeysRow, error)
ListAPKeysByPrefix(ctx context.Context, keyPrefix string) ([]ListAPKeysByPrefixRow, error)
ListAPServices(ctx context.Context) ([]ListAPServicesRow, error)
ListActiveBanners(ctx context.Context) ([]Banner, error) ListActiveBanners(ctx context.Context) ([]Banner, error)
ListActiveOperationDeployments(ctx context.Context, arg ListActiveOperationDeploymentsParams) ([]ListActiveOperationDeploymentsRow, error)
ListAdminUserIDs(ctx context.Context) ([]uuid.UUID, error)
ListAdminUsers(ctx context.Context) ([]ListAdminUsersRow, error)
ListAgentAuthTokens(ctx context.Context) ([]AgentAuthToken, error) ListAgentAuthTokens(ctx context.Context) ([]AgentAuthToken, error)
ListAgentHealthRows(ctx context.Context, lastHeartbeat sql.NullTime) ([]ListAgentHealthRowsRow, error)
ListAgentHeartbeatsSince(ctx context.Context, arg ListAgentHeartbeatsSinceParams) ([]AgentHeartbeat, error) ListAgentHeartbeatsSince(ctx context.Context, arg ListAgentHeartbeatsSinceParams) ([]AgentHeartbeat, error)
ListAgents(ctx context.Context) ([]NodeAgent, error) ListAgents(ctx context.Context) ([]NodeAgent, error)
ListAllDatabaseServices(ctx context.Context) ([]DatabaseService, error) ListAllDatabaseServices(ctx context.Context) ([]DatabaseService, error)
ListAllNotificationChannelsByUser(ctx context.Context, userID uuid.UUID) ([]NotificationChannel, error) ListAllNotificationChannelsByUser(ctx context.Context, userID uuid.UUID) ([]NotificationChannel, error)
ListAuditBackfillBatch(ctx context.Context, dollar_1 uuid.UUID) ([]ListAuditBackfillBatchRow, error)
ListBackupTargetsByUser(ctx context.Context, userID string) ([]BackupTarget, error) ListBackupTargetsByUser(ctx context.Context, userID string) ([]BackupTarget, error)
ListBanners(ctx context.Context) ([]Banner, error) ListBanners(ctx context.Context) ([]Banner, error)
ListClientEventPaths(ctx context.Context) ([]ListClientEventPathsRow, error)
ListCommandsForAgent(ctx context.Context, nodeAgentID string) ([]AgentCommand, error) ListCommandsForAgent(ctx context.Context, nodeAgentID string) ([]AgentCommand, error)
ListComplianceFrameworks(ctx context.Context) ([]ListComplianceFrameworksRow, error)
ListConnectedRepositories(ctx context.Context, arg ListConnectedRepositoriesParams) ([]ListConnectedRepositoriesRow, error)
ListContainersForAgent(ctx context.Context, nodeAgentID string) ([]ContainerInstance, error) ListContainersForAgent(ctx context.Context, nodeAgentID string) ([]ContainerInstance, error)
ListCronExecutions(ctx context.Context, cronJobID uuid.UUID) ([]CronExecution, error)
ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg ListDatabaseBackupsByDatabaseAndUserParams) ([]DatabaseBackup, error) ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg ListDatabaseBackupsByDatabaseAndUserParams) ([]DatabaseBackup, error)
ListDatabaseServicesByProject(ctx context.Context, projectID uuid.NullUUID) ([]DatabaseService, error) ListDatabaseServicesByProject(ctx context.Context, projectID uuid.NullUUID) ([]DatabaseService, error)
ListDatabaseServicesByUser(ctx context.Context, userID string) ([]DatabaseService, error) ListDatabaseServicesByUser(ctx context.Context, userID string) ([]DatabaseService, error)
ListDeploymentsForService(ctx context.Context, arg ListDeploymentsForServiceParams) ([]ListDeploymentsForServiceRow, error) ListDeploymentsForService(ctx context.Context, arg ListDeploymentsForServiceParams) ([]ListDeploymentsForServiceRow, error)
ListDueCronJobs(ctx context.Context) ([]ListDueCronJobsRow, error)
ListDueDatabaseBackups(ctx context.Context) ([]ListDueDatabaseBackupsRow, error) ListDueDatabaseBackups(ctx context.Context) ([]ListDueDatabaseBackupsRow, error)
ListEnabledOutboundWebhooks(ctx context.Context) ([]OutboundWebhook, error) ListEnabledOutboundWebhooks(ctx context.Context) ([]OutboundWebhook, error)
ListExpiredPreviewsForUser(ctx context.Context, arg ListExpiredPreviewsForUserParams) ([]ListExpiredPreviewsForUserRow, error) ListExpiredPreviewsForUser(ctx context.Context, arg ListExpiredPreviewsForUserParams) ([]ListExpiredPreviewsForUserRow, error)
ListGitProviders(ctx context.Context, userID uuid.UUID) ([]ListGitProvidersRow, error)
ListGitRepositories(ctx context.Context, arg ListGitRepositoriesParams) ([]ListGitRepositoriesRow, error)
ListIncidentStatusCodes(ctx context.Context) ([]ListIncidentStatusCodesRow, error)
ListInstanceMetrics(ctx context.Context, arg ListInstanceMetricsParams) ([]ListInstanceMetricsRow, error) ListInstanceMetrics(ctx context.Context, arg ListInstanceMetricsParams) ([]ListInstanceMetricsRow, error)
ListNodeAgentsForService(ctx context.Context, serviceID string) ([]string, error)
ListNodeMetrics(ctx context.Context, arg ListNodeMetricsParams) ([]ListNodeMetricsRow, error) ListNodeMetrics(ctx context.Context, arg ListNodeMetricsParams) ([]ListNodeMetricsRow, error)
ListNotificationChannelsByUser(ctx context.Context, userID uuid.UUID) ([]NotificationChannel, error) ListNotificationChannelsByUser(ctx context.Context, userID uuid.UUID) ([]NotificationChannel, error)
ListNotificationsByUser(ctx context.Context, arg ListNotificationsByUserParams) ([]Notification, error) ListNotificationsByUser(ctx context.Context, arg ListNotificationsByUserParams) ([]Notification, error)
@@ -146,17 +246,24 @@ type Querier interface {
ListPendingProjects(ctx context.Context) ([]Project, error) ListPendingProjects(ctx context.Context) ([]Project, error)
ListPreviewEnvironmentsForProject(ctx context.Context, projectID uuid.UUID) ([]ListPreviewEnvironmentsForProjectRow, error) ListPreviewEnvironmentsForProject(ctx context.Context, projectID uuid.UUID) ([]ListPreviewEnvironmentsForProjectRow, error)
ListProjectEnvironments(ctx context.Context, projectID uuid.UUID) ([]ListProjectEnvironmentsRow, error) ListProjectEnvironments(ctx context.Context, projectID uuid.UUID) ([]ListProjectEnvironmentsRow, error)
ListProjectServiceRefs(ctx context.Context, projectID uuid.UUID) ([]ListProjectServiceRefsRow, error)
// Visibility model: a project is readable when it is approved for public // Visibility model: a project is readable when it is approved for public
// display, when the caller owns or is a member of it, or when the caller is a // display, when the caller owns or is a member of it, or when the caller is a
// platform admin. Anonymous callers pass user_id = uuid.Nil, is_admin = false. // platform admin. Anonymous callers pass user_id = uuid.Nil, is_admin = false.
ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error) ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error)
ListRecentAccessibleDeployments(ctx context.Context, arg ListRecentAccessibleDeploymentsParams) ([]ListRecentAccessibleDeploymentsRow, error) ListRecentAccessibleDeployments(ctx context.Context, arg ListRecentAccessibleDeploymentsParams) ([]ListRecentAccessibleDeploymentsRow, error)
ListRecentCronRuns(ctx context.Context, arg ListRecentCronRunsParams) ([]ListRecentCronRunsRow, error)
ListRecentFailedDeployments(ctx context.Context, arg ListRecentFailedDeploymentsParams) ([]ListRecentFailedDeploymentsRow, error)
ListRecentOperationBackups(ctx context.Context, arg ListRecentOperationBackupsParams) ([]ListRecentOperationBackupsRow, error)
ListRequestsByDay(ctx context.Context) ([]ListRequestsByDayRow, error)
ListResourceAuditLogs(ctx context.Context, arg ListResourceAuditLogsParams) ([]ListResourceAuditLogsRow, error)
// Base domains of every online, schedulable agent carrying the required // Base domains of every online, schedulable agent carrying the required
// placement tags — auto-domain candidates for spread services. // placement tags — auto-domain candidates for spread services.
ListSchedulableAgentDomains(ctx context.Context, dollar_1 json.RawMessage) ([]string, error) ListSchedulableAgentDomains(ctx context.Context, dollar_1 json.RawMessage) ([]string, error)
ListSchedulableAgents(ctx context.Context) ([]ListSchedulableAgentsRow, error) ListSchedulableAgents(ctx context.Context) ([]ListSchedulableAgentsRow, error)
// Only agents carrying every required placement tag (jsonb array containment). // Only agents carrying every required placement tag (jsonb array containment).
ListSchedulableAgentsMatching(ctx context.Context, dollar_1 json.RawMessage) ([]ListSchedulableAgentsMatchingRow, error) ListSchedulableAgentsMatching(ctx context.Context, dollar_1 json.RawMessage) ([]ListSchedulableAgentsMatchingRow, error)
ListSecretEnvVars(ctx context.Context) ([]ListSecretEnvVarsRow, error)
// Distinct agents holding inventory rows for a service. // Distinct agents holding inventory rows for a service.
ListServiceAgents(ctx context.Context, serviceID string) ([]string, error) ListServiceAgents(ctx context.Context, serviceID string) ([]string, error)
ListServiceContainers(ctx context.Context, serviceID string) ([]ListServiceContainersRow, error) ListServiceContainers(ctx context.Context, serviceID string) ([]ListServiceContainersRow, error)
@@ -164,9 +271,15 @@ type Querier interface {
ListServiceMetrics(ctx context.Context, arg ListServiceMetricsParams) ([]ListServiceMetricsRow, error) ListServiceMetrics(ctx context.Context, arg ListServiceMetricsParams) ([]ListServiceMetricsRow, error)
ListServiceTemplatesByCategoryForUser(ctx context.Context, arg ListServiceTemplatesByCategoryForUserParams) ([]ServiceTemplate, error) ListServiceTemplatesByCategoryForUser(ctx context.Context, arg ListServiceTemplatesByCategoryForUserParams) ([]ServiceTemplate, error)
ListServiceTemplatesForUser(ctx context.Context, ownerID uuid.NullUUID) ([]ServiceTemplate, error) ListServiceTemplatesForUser(ctx context.Context, ownerID uuid.NullUUID) ([]ServiceTemplate, error)
ListServiceVariableValues(ctx context.Context, serviceID uuid.UUID) ([]ListServiceVariableValuesRow, error)
ListServiceVariablesWithSecret(ctx context.Context, serviceID uuid.UUID) ([]ListServiceVariablesWithSecretRow, error)
ListServicesForPush(ctx context.Context, arg ListServicesForPushParams) ([]ListServicesForPushRow, error)
ListSleepCandidates(ctx context.Context) ([]ListSleepCandidatesRow, error)
ListSweepablePreviews(ctx context.Context) ([]ListSweepablePreviewsRow, error) ListSweepablePreviews(ctx context.Context) ([]ListSweepablePreviewsRow, error)
ListTopAPServices(ctx context.Context) ([]ListTopAPServicesRow, error)
ListUserInvites(ctx context.Context) ([]UserInvite, error) ListUserInvites(ctx context.Context) ([]UserInvite, error)
ListUserTokens(ctx context.Context, userID uuid.UUID) ([]UserToken, error) ListUserTokens(ctx context.Context, userID uuid.UUID) ([]UserToken, error)
ListVulnerabilities(ctx context.Context, projectID uuid.UUID) ([]ListVulnerabilitiesRow, error)
ListWebhookDeliveriesByWebhook(ctx context.Context, webhookID uuid.UUID) ([]WebhookDelivery, error) ListWebhookDeliveriesByWebhook(ctx context.Context, webhookID uuid.UUID) ([]WebhookDelivery, error)
MarkAllNotificationsReadByUser(ctx context.Context, arg MarkAllNotificationsReadByUserParams) error MarkAllNotificationsReadByUser(ctx context.Context, arg MarkAllNotificationsReadByUserParams) error
MarkNotificationReadByIDAndUser(ctx context.Context, arg MarkNotificationReadByIDAndUserParams) error MarkNotificationReadByIDAndUser(ctx context.Context, arg MarkNotificationReadByIDAndUserParams) error
@@ -179,13 +292,21 @@ type Querier interface {
// Same resource-aware ordering as PickLeastLoadedAgent, restricted to agents // Same resource-aware ordering as PickLeastLoadedAgent, restricted to agents
// carrying every required placement tag. // carrying every required placement tag.
PickLeastLoadedAgentMatching(ctx context.Context, dollar_1 json.RawMessage) (string, error) PickLeastLoadedAgentMatching(ctx context.Context, dollar_1 json.RawMessage) (string, error)
ProjectMemberExists(ctx context.Context, arg ProjectMemberExistsParams) (bool, error)
PruneAuditLogs(ctx context.Context, dollar_1 int32) (int64, error)
RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error) RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error)
RevokeUserToken(ctx context.Context, arg RevokeUserTokenParams) (int64, error) RevokeUserToken(ctx context.Context, arg RevokeUserTokenParams) (int64, error)
ScrubCommandPayload(ctx context.Context, arg ScrubCommandPayloadParams) error ScrubCommandPayload(ctx context.Context, arg ScrubCommandPayloadParams) error
ServiceExistsInProject(ctx context.Context, arg ServiceExistsInProjectParams) (bool, error)
ServiceWriteAccess(ctx context.Context, arg ServiceWriteAccessParams) (bool, error) ServiceWriteAccess(ctx context.Context, arg ServiceWriteAccessParams) (bool, error)
SetAPKeyEnabled(ctx context.Context, arg SetAPKeyEnabledParams) error
SetAPServiceEnabled(ctx context.Context, arg SetAPServiceEnabledParams) error
SetAPServiceValidation(ctx context.Context, arg SetAPServiceValidationParams) error
SetAgentDefaultDomain(ctx context.Context, arg SetAgentDefaultDomainParams) error SetAgentDefaultDomain(ctx context.Context, arg SetAgentDefaultDomainParams) error
SetAgentSchedulable(ctx context.Context, arg SetAgentSchedulableParams) error SetAgentSchedulable(ctx context.Context, arg SetAgentSchedulableParams) error
SetAgentStatus(ctx context.Context, arg SetAgentStatusParams) error
SetAgentTags(ctx context.Context, arg SetAgentTagsParams) error SetAgentTags(ctx context.Context, arg SetAgentTagsParams) error
SetAuditLogMetadata(ctx context.Context, arg SetAuditLogMetadataParams) error
SetDatabaseBackupRemoteKeyByID(ctx context.Context, arg SetDatabaseBackupRemoteKeyByIDParams) error SetDatabaseBackupRemoteKeyByID(ctx context.Context, arg SetDatabaseBackupRemoteKeyByIDParams) error
SetDatabaseBackupScheduleByIDAndUser(ctx context.Context, arg SetDatabaseBackupScheduleByIDAndUserParams) error SetDatabaseBackupScheduleByIDAndUser(ctx context.Context, arg SetDatabaseBackupScheduleByIDAndUserParams) error
SetDatabaseBackupStatusByID(ctx context.Context, arg SetDatabaseBackupStatusByIDParams) error SetDatabaseBackupStatusByID(ctx context.Context, arg SetDatabaseBackupStatusByIDParams) error
@@ -199,12 +320,21 @@ type Querier interface {
SetProjectApproved(ctx context.Context, arg SetProjectApprovedParams) (int64, error) SetProjectApproved(ctx context.Context, arg SetProjectApprovedParams) (int64, error)
SetServiceDomainDefault(ctx context.Context, arg SetServiceDomainDefaultParams) (int64, error) SetServiceDomainDefault(ctx context.Context, arg SetServiceDomainDefaultParams) (int64, error)
SetServiceLegacyDomain(ctx context.Context, arg SetServiceLegacyDomainParams) error SetServiceLegacyDomain(ctx context.Context, arg SetServiceLegacyDomainParams) error
SetServicePublishedPort(ctx context.Context, arg SetServicePublishedPortParams) error
SetServiceReplicas(ctx context.Context, arg SetServiceReplicasParams) error
SetServiceStatus(ctx context.Context, arg SetServiceStatusParams) error SetServiceStatus(ctx context.Context, arg SetServiceStatusParams) error
SetUserAdmin(ctx context.Context, arg SetUserAdminParams) (int64, error)
SumRequestMetricSince(ctx context.Context, dollar_1 string) (int32, error)
SumUsageByKey(ctx context.Context, arg SumUsageByKeyParams) (int64, error)
SumUsageByService(ctx context.Context, arg SumUsageByServiceParams) (int64, error)
SumUsageCounters(ctx context.Context) (interface{}, error)
SyncDeploymentProgress(ctx context.Context, arg SyncDeploymentProgressParams) error SyncDeploymentProgress(ctx context.Context, arg SyncDeploymentProgressParams) error
SyncPreviewStatuses(ctx context.Context) error SyncPreviewStatuses(ctx context.Context) error
TouchAPKeyLastUsed(ctx context.Context, id uuid.UUID) error
TouchAgentAuthToken(ctx context.Context, tokenHash string) error TouchAgentAuthToken(ctx context.Context, tokenHash string) error
TouchServiceDomainChecked(ctx context.Context, id uuid.UUID) error TouchServiceDomainChecked(ctx context.Context, id uuid.UUID) error
TouchUserToken(ctx context.Context, id uuid.UUID) error TouchUserToken(ctx context.Context, id uuid.UUID) error
TrimCronExecutions(ctx context.Context, arg TrimCronExecutionsParams) error
UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeAgent, error) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeAgent, error)
UpdateAgentHeartbeat(ctx context.Context, arg UpdateAgentHeartbeatParams) error UpdateAgentHeartbeat(ctx context.Context, arg UpdateAgentHeartbeatParams) error
UpdateBackupTargetByIDAndUser(ctx context.Context, arg UpdateBackupTargetByIDAndUserParams) error UpdateBackupTargetByIDAndUser(ctx context.Context, arg UpdateBackupTargetByIDAndUserParams) error
@@ -217,21 +347,36 @@ type Querier interface {
// removed replica. $4 is the cumulative rx+tx byte counter. // removed replica. $4 is the cumulative rx+tx byte counter.
UpdateContainerStateByName(ctx context.Context, arg UpdateContainerStateByNameParams) error UpdateContainerStateByName(ctx context.Context, arg UpdateContainerStateByNameParams) error
UpdateContainerStatus(ctx context.Context, arg UpdateContainerStatusParams) error UpdateContainerStatus(ctx context.Context, arg UpdateContainerStatusParams) error
UpdateCronJobRun(ctx context.Context, arg UpdateCronJobRunParams) error
UpdateDatabaseServiceNameAndPlanByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameAndPlanByIDAndUserParams) error UpdateDatabaseServiceNameAndPlanByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameAndPlanByIDAndUserParams) error
UpdateDatabaseServiceNameByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameByIDAndUserParams) error UpdateDatabaseServiceNameByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameByIDAndUserParams) error
UpdateDatabaseServicePlanByIDAndUser(ctx context.Context, arg UpdateDatabaseServicePlanByIDAndUserParams) error UpdateDatabaseServicePlanByIDAndUser(ctx context.Context, arg UpdateDatabaseServicePlanByIDAndUserParams) error
UpdateEnvVarValue(ctx context.Context, arg UpdateEnvVarValueParams) error
UpdateOutboundWebhook(ctx context.Context, arg UpdateOutboundWebhookParams) error UpdateOutboundWebhook(ctx context.Context, arg UpdateOutboundWebhookParams) error
UpdatePreviewEnvironment(ctx context.Context, arg UpdatePreviewEnvironmentParams) error UpdatePreviewEnvironment(ctx context.Context, arg UpdatePreviewEnvironmentParams) error
UpdateProjectByID(ctx context.Context, arg UpdateProjectByIDParams) (int64, error) UpdateProjectByID(ctx context.Context, arg UpdateProjectByIDParams) (int64, error)
UpdateServiceDeployMeta(ctx context.Context, arg UpdateServiceDeployMetaParams) error
UpdateServicePreviewBranch(ctx context.Context, arg UpdateServicePreviewBranchParams) error UpdateServicePreviewBranch(ctx context.Context, arg UpdateServicePreviewBranchParams) error
UpdateServiceRuntimeMeta(ctx context.Context, arg UpdateServiceRuntimeMetaParams) error
UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) error
UpdateUserTemplate(ctx context.Context, arg UpdateUserTemplateParams) (int64, error) UpdateUserTemplate(ctx context.Context, arg UpdateUserTemplateParams) (int64, error)
UpdateVulnerabilityStatus(ctx context.Context, arg UpdateVulnerabilityStatusParams) error
UpdateWebhookDeliveryResult(ctx context.Context, arg UpdateWebhookDeliveryResultParams) error UpdateWebhookDeliveryResult(ctx context.Context, arg UpdateWebhookDeliveryResultParams) error
UpsertAdminUser(ctx context.Context, arg UpsertAdminUserParams) (uuid.UUID, error)
UpsertAppSetting(ctx context.Context, arg UpsertAppSettingParams) error
UpsertAppSettingValue(ctx context.Context, arg UpsertAppSettingValueParams) error
UpsertBuild(ctx context.Context, arg UpsertBuildParams) error
UpsertEnvironmentVariable(ctx context.Context, arg UpsertEnvironmentVariableParams) error UpsertEnvironmentVariable(ctx context.Context, arg UpsertEnvironmentVariableParams) error
UpsertGitHubAppProvider(ctx context.Context, arg UpsertGitHubAppProviderParams) (UpsertGitHubAppProviderRow, error)
UpsertGitRepository(ctx context.Context, arg UpsertGitRepositoryParams) error
UpsertGitWebhook(ctx context.Context, arg UpsertGitWebhookParams) (UpsertGitWebhookRow, error)
UpsertInstanceMetrics(ctx context.Context, arg UpsertInstanceMetricsParams) error UpsertInstanceMetrics(ctx context.Context, arg UpsertInstanceMetricsParams) error
UpsertLocalUser(ctx context.Context, arg UpsertLocalUserParams) (UpsertLocalUserRow, error)
UpsertNodeMetrics(ctx context.Context, arg UpsertNodeMetricsParams) error UpsertNodeMetrics(ctx context.Context, arg UpsertNodeMetricsParams) error
UpsertServiceContainer(ctx context.Context, arg UpsertServiceContainerParams) error UpsertServiceContainer(ctx context.Context, arg UpsertServiceContainerParams) error
UpsertServiceMetrics(ctx context.Context, arg UpsertServiceMetricsParams) error UpsertServiceMetrics(ctx context.Context, arg UpsertServiceMetricsParams) error
UpsertServiceTemplate(ctx context.Context, arg UpsertServiceTemplateParams) error UpsertServiceTemplate(ctx context.Context, arg UpsertServiceTemplateParams) error
UpsertUsageCounter(ctx context.Context, arg UpsertUsageCounterParams) error
} }
var _ Querier = (*Queries)(nil) var _ Querier = (*Queries)(nil)
@@ -0,0 +1,355 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: runtime.sql
package sqlcdb
import (
"context"
"database/sql"
"github.com/google/uuid"
)
const getLastDeployedImage = `-- name: GetLastDeployedImage :one
SELECT (image_name || ':' || image_tag)::text AS image FROM deployments
WHERE service_id = $1 AND status = 'deployed' AND image_name <> ''
ORDER BY created_at DESC LIMIT 1
`
func (q *Queries) GetLastDeployedImage(ctx context.Context, serviceID uuid.UUID) (string, error) {
row := q.db.QueryRowContext(ctx, getLastDeployedImage, serviceID)
var image string
err := row.Scan(&image)
return image, err
}
const getServiceDomainText = `-- name: GetServiceDomainText :one
SELECT COALESCE(domain,'') AS domain FROM services WHERE id = $1
`
func (q *Queries) GetServiceDomainText(ctx context.Context, id uuid.UUID) (string, error) {
row := q.db.QueryRowContext(ctx, getServiceDomainText, id)
var domain string
err := row.Scan(&domain)
return domain, err
}
const getServicePort = `-- name: GetServicePort :one
SELECT COALESCE(port, 0) AS port FROM services WHERE id = $1
`
func (q *Queries) GetServicePort(ctx context.Context, id uuid.UUID) (int32, error) {
row := q.db.QueryRowContext(ctx, getServicePort, id)
var port int32
err := row.Scan(&port)
return port, err
}
const getServiceRuntimeWithOwner = `-- name: GetServiceRuntimeWithOwner :one
SELECT s.id, s.project_id, s.name,
COALESCE(s.type, s.service_type, '') AS type, COALESCE(s.status, '') AS status,
COALESCE(s.image, s.image_name, '') AS image, COALESCE(s.command, s.start_command, '') AS command,
COALESCE(s.environment, '') AS environment, COALESCE(s.git_repo, s.source_url, '') AS git_repo,
COALESCE(s.git_branch, '') AS git_branch, COALESCE(s.build_path, '') AS build_path,
COALESCE(s.cpu, '') AS cpu, COALESCE(s.memory, '') AS memory,
COALESCE(s.replicas, 1) AS replicas, COALESCE(s.port, 0) AS port,
COALESCE(s.domain, '') AS domain, COALESCE(s.healthcheck_path, '') AS healthcheck_path,
COALESCE(s.restart_policy, 'unless-stopped') AS restart_policy,
COALESCE(s.builder, 'auto') AS builder, COALESCE(s.cpu_reserve, '') AS cpu_reserve,
COALESCE(s.memory_reserve, '') AS memory_reserve, COALESCE(s.static_build_cmd, '') AS static_build_cmd,
COALESCE(s.static_dir, '') AS static_dir,
s.created_at, s.updated_at, p.owner_id
FROM services s
JOIN projects p ON s.project_id = p.id
WHERE s.id = $1
`
type GetServiceRuntimeWithOwnerRow struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Type string `json:"type"`
Status string `json:"status"`
Image string `json:"image"`
Command string `json:"command"`
Environment string `json:"environment"`
GitRepo string `json:"git_repo"`
GitBranch string `json:"git_branch"`
BuildPath string `json:"build_path"`
Cpu string `json:"cpu"`
Memory string `json:"memory"`
Replicas int32 `json:"replicas"`
Port int32 `json:"port"`
Domain string `json:"domain"`
HealthcheckPath string `json:"healthcheck_path"`
RestartPolicy string `json:"restart_policy"`
Builder string `json:"builder"`
CpuReserve string `json:"cpu_reserve"`
MemoryReserve string `json:"memory_reserve"`
StaticBuildCmd string `json:"static_build_cmd"`
StaticDir string `json:"static_dir"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
OwnerID uuid.UUID `json:"owner_id"`
}
func (q *Queries) GetServiceRuntimeWithOwner(ctx context.Context, id uuid.UUID) (GetServiceRuntimeWithOwnerRow, error) {
row := q.db.QueryRowContext(ctx, getServiceRuntimeWithOwner, id)
var i GetServiceRuntimeWithOwnerRow
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Type,
&i.Status,
&i.Image,
&i.Command,
&i.Environment,
&i.GitRepo,
&i.GitBranch,
&i.BuildPath,
&i.Cpu,
&i.Memory,
&i.Replicas,
&i.Port,
&i.Domain,
&i.HealthcheckPath,
&i.RestartPolicy,
&i.Builder,
&i.CpuReserve,
&i.MemoryReserve,
&i.StaticBuildCmd,
&i.StaticDir,
&i.CreatedAt,
&i.UpdatedAt,
&i.OwnerID,
)
return i, err
}
const getServiceStatusText = `-- name: GetServiceStatusText :one
SELECT COALESCE(status,'') AS status FROM services WHERE id = $1
`
func (q *Queries) GetServiceStatusText(ctx context.Context, id uuid.UUID) (string, error) {
row := q.db.QueryRowContext(ctx, getServiceStatusText, id)
var status string
err := row.Scan(&status)
return status, err
}
const getServiceVariableValue = `-- name: GetServiceVariableValue :one
SELECT value FROM environment_variables WHERE service_id = $1 AND key = $2
`
type GetServiceVariableValueParams struct {
ServiceID uuid.UUID `json:"service_id"`
Key string `json:"key"`
}
func (q *Queries) GetServiceVariableValue(ctx context.Context, arg GetServiceVariableValueParams) (string, error) {
row := q.db.QueryRowContext(ctx, getServiceVariableValue, arg.ServiceID, arg.Key)
var value string
err := row.Scan(&value)
return value, err
}
const listNodeAgentsForService = `-- name: ListNodeAgentsForService :many
SELECT DISTINCT node_agent_id FROM container_instances WHERE service_id = $1
`
func (q *Queries) ListNodeAgentsForService(ctx context.Context, serviceID string) ([]string, error) {
rows, err := q.db.QueryContext(ctx, listNodeAgentsForService, serviceID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []string{}
for rows.Next() {
var node_agent_id string
if err := rows.Scan(&node_agent_id); err != nil {
return nil, err
}
items = append(items, node_agent_id)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listProjectServiceRefs = `-- name: ListProjectServiceRefs :many
SELECT id, name FROM services WHERE project_id = $1
`
type ListProjectServiceRefsRow struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
}
func (q *Queries) ListProjectServiceRefs(ctx context.Context, projectID uuid.UUID) ([]ListProjectServiceRefsRow, error) {
rows, err := q.db.QueryContext(ctx, listProjectServiceRefs, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListProjectServiceRefsRow{}
for rows.Next() {
var i ListProjectServiceRefsRow
if err := rows.Scan(&i.ID, &i.Name); 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 listServiceVariableValues = `-- name: ListServiceVariableValues :many
SELECT key, value FROM environment_variables WHERE service_id = $1
`
type ListServiceVariableValuesRow struct {
Key string `json:"key"`
Value string `json:"value"`
}
func (q *Queries) ListServiceVariableValues(ctx context.Context, serviceID uuid.UUID) ([]ListServiceVariableValuesRow, error) {
rows, err := q.db.QueryContext(ctx, listServiceVariableValues, serviceID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListServiceVariableValuesRow{}
for rows.Next() {
var i ListServiceVariableValuesRow
if err := rows.Scan(&i.Key, &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 listServiceVariablesWithSecret = `-- name: ListServiceVariablesWithSecret :many
SELECT key, value, COALESCE(is_secret, false) AS is_secret
FROM environment_variables WHERE service_id = $1
`
type ListServiceVariablesWithSecretRow struct {
Key string `json:"key"`
Value string `json:"value"`
IsSecret bool `json:"is_secret"`
}
func (q *Queries) ListServiceVariablesWithSecret(ctx context.Context, serviceID uuid.UUID) ([]ListServiceVariablesWithSecretRow, error) {
rows, err := q.db.QueryContext(ctx, listServiceVariablesWithSecret, serviceID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListServiceVariablesWithSecretRow{}
for rows.Next() {
var i ListServiceVariablesWithSecretRow
if err := rows.Scan(&i.Key, &i.Value, &i.IsSecret); 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 listSleepCandidates = `-- name: ListSleepCandidates :many
SELECT id, project_id, name, COALESCE(status,'') AS status,
COALESCE(sleep_idle_minutes, 15) AS sleep_idle_minutes
FROM services
WHERE sleep_enabled AND COALESCE(status,'') IN ('running','deployed','degraded','starting')
`
type ListSleepCandidatesRow struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Status string `json:"status"`
SleepIdleMinutes int32 `json:"sleep_idle_minutes"`
}
func (q *Queries) ListSleepCandidates(ctx context.Context) ([]ListSleepCandidatesRow, error) {
rows, err := q.db.QueryContext(ctx, listSleepCandidates)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListSleepCandidatesRow{}
for rows.Next() {
var i ListSleepCandidatesRow
if err := rows.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Status,
&i.SleepIdleMinutes,
); 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 setServicePublishedPort = `-- name: SetServicePublishedPort :exec
UPDATE services SET published_port = $1 WHERE id = $2 AND published_port <> $1
`
type SetServicePublishedPortParams struct {
PublishedPort int32 `json:"published_port"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) SetServicePublishedPort(ctx context.Context, arg SetServicePublishedPortParams) error {
_, err := q.db.ExecContext(ctx, setServicePublishedPort, arg.PublishedPort, arg.ID)
return err
}
const setServiceReplicas = `-- name: SetServiceReplicas :exec
UPDATE services SET replicas = $1, updated_at = $2 WHERE id = $3
`
type SetServiceReplicasParams struct {
Replicas int32 `json:"replicas"`
UpdatedAt sql.NullTime `json:"updated_at"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) SetServiceReplicas(ctx context.Context, arg SetServiceReplicasParams) error {
_, err := q.db.ExecContext(ctx, setServiceReplicas, arg.Replicas, arg.UpdatedAt, arg.ID)
return err
}
@@ -0,0 +1,318 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: security.sql
package sqlcdb
import (
"context"
"database/sql"
"time"
"github.com/google/uuid"
)
const checkSecurityProjectAccess = `-- name: CheckSecurityProjectAccess :one
SELECT EXISTS (
SELECT 1
FROM projects p
WHERE p.id = $1
AND (p.is_approved
OR $3::bool
OR p.owner_id = $2 OR EXISTS (
SELECT 1 FROM project_members pm
WHERE pm.project_id = p.id AND pm.user_id = $2
))
)
`
type CheckSecurityProjectAccessParams struct {
ID uuid.UUID `json:"id"`
OwnerID uuid.UUID `json:"owner_id"`
Column3 bool `json:"column_3"`
}
func (q *Queries) CheckSecurityProjectAccess(ctx context.Context, arg CheckSecurityProjectAccessParams) (bool, error) {
row := q.db.QueryRowContext(ctx, checkSecurityProjectAccess, arg.ID, arg.OwnerID, arg.Column3)
var exists bool
err := row.Scan(&exists)
return exists, err
}
const complianceFrameworkExists = `-- name: ComplianceFrameworkExists :one
SELECT EXISTS(SELECT 1 FROM compliance_frameworks WHERE id = $1)
`
func (q *Queries) ComplianceFrameworkExists(ctx context.Context, id uuid.UUID) (bool, error) {
row := q.db.QueryRowContext(ctx, complianceFrameworkExists, id)
var exists bool
err := row.Scan(&exists)
return exists, err
}
const getComplianceReportProjectID = `-- name: GetComplianceReportProjectID :one
SELECT project_id FROM compliance_reports WHERE id = $1
`
func (q *Queries) GetComplianceReportProjectID(ctx context.Context, id uuid.UUID) (uuid.UUID, error) {
row := q.db.QueryRowContext(ctx, getComplianceReportProjectID, id)
var project_id uuid.UUID
err := row.Scan(&project_id)
return project_id, err
}
const getLatestComplianceReport = `-- name: GetLatestComplianceReport :one
SELECT overall_status, score, assessment_date
FROM compliance_reports
WHERE project_id = $1
ORDER BY assessment_date DESC
LIMIT 1
`
type GetLatestComplianceReportRow struct {
OverallStatus string `json:"overall_status"`
Score int32 `json:"score"`
AssessmentDate time.Time `json:"assessment_date"`
}
func (q *Queries) GetLatestComplianceReport(ctx context.Context, projectID uuid.UUID) (GetLatestComplianceReportRow, error) {
row := q.db.QueryRowContext(ctx, getLatestComplianceReport, projectID)
var i GetLatestComplianceReportRow
err := row.Scan(&i.OverallStatus, &i.Score, &i.AssessmentDate)
return i, err
}
const getLatestSecurityScan = `-- name: GetLatestSecurityScan :one
SELECT id, COALESCE((summary->>'score')::int, 0)::int AS score, started_at AS scanned_at, status
FROM security_scans
WHERE project_id = $1
ORDER BY started_at DESC
LIMIT 1
`
type GetLatestSecurityScanRow struct {
ID uuid.UUID `json:"id"`
Score int32 `json:"score"`
ScannedAt time.Time `json:"scanned_at"`
Status string `json:"status"`
}
func (q *Queries) GetLatestSecurityScan(ctx context.Context, projectID uuid.UUID) (GetLatestSecurityScanRow, error) {
row := q.db.QueryRowContext(ctx, getLatestSecurityScan, projectID)
var i GetLatestSecurityScanRow
err := row.Scan(
&i.ID,
&i.Score,
&i.ScannedAt,
&i.Status,
)
return i, err
}
const getProjectOwnerText = `-- name: GetProjectOwnerText :one
SELECT owner_id::text FROM projects WHERE id = $1
`
func (q *Queries) GetProjectOwnerText(ctx context.Context, id uuid.UUID) (string, error) {
row := q.db.QueryRowContext(ctx, getProjectOwnerText, id)
var owner_id string
err := row.Scan(&owner_id)
return owner_id, err
}
const getSecurityScanProjectID = `-- name: GetSecurityScanProjectID :one
SELECT project_id FROM security_scans WHERE id = $1
`
func (q *Queries) GetSecurityScanProjectID(ctx context.Context, id uuid.UUID) (uuid.UUID, error) {
row := q.db.QueryRowContext(ctx, getSecurityScanProjectID, id)
var project_id uuid.UUID
err := row.Scan(&project_id)
return project_id, err
}
const getVulnerabilityMetrics = `-- name: GetVulnerabilityMetrics :one
SELECT
COUNT(*) AS total,
COUNT(*) FILTER (WHERE severity = 'critical') AS critical,
COUNT(*) FILTER (WHERE severity = 'high') AS high,
COUNT(*) FILTER (WHERE severity = 'medium') AS medium,
COUNT(*) FILTER (WHERE severity = 'low') AS low,
COUNT(*) FILTER (WHERE status = 'open') AS open,
COUNT(*) FILTER (WHERE status = 'resolved') AS resolved
FROM vulnerabilities
WHERE project_id = $1
`
type GetVulnerabilityMetricsRow struct {
Total int64 `json:"total"`
Critical int64 `json:"critical"`
High int64 `json:"high"`
Medium int64 `json:"medium"`
Low int64 `json:"low"`
Open int64 `json:"open"`
Resolved int64 `json:"resolved"`
}
func (q *Queries) GetVulnerabilityMetrics(ctx context.Context, projectID uuid.UUID) (GetVulnerabilityMetricsRow, error) {
row := q.db.QueryRowContext(ctx, getVulnerabilityMetrics, projectID)
var i GetVulnerabilityMetricsRow
err := row.Scan(
&i.Total,
&i.Critical,
&i.High,
&i.Medium,
&i.Low,
&i.Open,
&i.Resolved,
)
return i, err
}
const getVulnerabilityProjectID = `-- name: GetVulnerabilityProjectID :one
SELECT project_id FROM vulnerabilities WHERE id = $1
`
func (q *Queries) GetVulnerabilityProjectID(ctx context.Context, id uuid.UUID) (uuid.UUID, error) {
row := q.db.QueryRowContext(ctx, getVulnerabilityProjectID, id)
var project_id uuid.UUID
err := row.Scan(&project_id)
return project_id, err
}
const listComplianceFrameworks = `-- name: ListComplianceFrameworks :many
SELECT id, name, description, version, enabled, created_at
FROM compliance_frameworks
WHERE enabled = true
ORDER BY name
`
type ListComplianceFrameworksRow struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Description sql.NullString `json:"description"`
Version string `json:"version"`
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"created_at"`
}
func (q *Queries) ListComplianceFrameworks(ctx context.Context) ([]ListComplianceFrameworksRow, error) {
rows, err := q.db.QueryContext(ctx, listComplianceFrameworks)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListComplianceFrameworksRow{}
for rows.Next() {
var i ListComplianceFrameworksRow
if err := rows.Scan(
&i.ID,
&i.Name,
&i.Description,
&i.Version,
&i.Enabled,
&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 listVulnerabilities = `-- name: ListVulnerabilities :many
SELECT id, type, severity, title, description, service_id, status, found_at, resolved_at
FROM vulnerabilities
WHERE project_id = $1
ORDER BY
CASE severity
WHEN 'critical' THEN 1
WHEN 'high' THEN 2
WHEN 'medium' THEN 3
WHEN 'low' THEN 4
END,
found_at DESC
`
type ListVulnerabilitiesRow struct {
ID uuid.UUID `json:"id"`
Type string `json:"type"`
Severity string `json:"severity"`
Title string `json:"title"`
Description sql.NullString `json:"description"`
ServiceID uuid.NullUUID `json:"service_id"`
Status string `json:"status"`
FoundAt time.Time `json:"found_at"`
ResolvedAt sql.NullTime `json:"resolved_at"`
}
func (q *Queries) ListVulnerabilities(ctx context.Context, projectID uuid.UUID) ([]ListVulnerabilitiesRow, error) {
rows, err := q.db.QueryContext(ctx, listVulnerabilities, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListVulnerabilitiesRow{}
for rows.Next() {
var i ListVulnerabilitiesRow
if err := rows.Scan(
&i.ID,
&i.Type,
&i.Severity,
&i.Title,
&i.Description,
&i.ServiceID,
&i.Status,
&i.FoundAt,
&i.ResolvedAt,
); 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 serviceExistsInProject = `-- name: ServiceExistsInProject :one
SELECT EXISTS(SELECT 1 FROM services WHERE id = $1 AND project_id = $2)
`
type ServiceExistsInProjectParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
}
func (q *Queries) ServiceExistsInProject(ctx context.Context, arg ServiceExistsInProjectParams) (bool, error) {
row := q.db.QueryRowContext(ctx, serviceExistsInProject, arg.ID, arg.ProjectID)
var exists bool
err := row.Scan(&exists)
return exists, err
}
const updateVulnerabilityStatus = `-- name: UpdateVulnerabilityStatus :exec
UPDATE vulnerabilities SET status = $1, resolved_at = $2 WHERE id = $3
`
type UpdateVulnerabilityStatusParams struct {
Status string `json:"status"`
ResolvedAt sql.NullTime `json:"resolved_at"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) UpdateVulnerabilityStatus(ctx context.Context, arg UpdateVulnerabilityStatusParams) error {
_, err := q.db.ExecContext(ctx, updateVulnerabilityStatus, arg.Status, arg.ResolvedAt, arg.ID)
return err
}
@@ -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;
+57
View File
@@ -0,0 +1,57 @@
-- name: GetUserByEmailForAuth :one
SELECT id, email, password_hash, name, COALESCE(avatar_url, '') AS avatar_url, is_admin, created_at
FROM users WHERE email = $1;
-- name: CountUsers :one
SELECT COUNT(*) FROM users;
-- name: CountUsersByEmail :one
SELECT COUNT(*) FROM users WHERE email = $1;
-- name: UpsertLocalUser :one
INSERT INTO users (email, password_hash, name, avatar_url, is_admin)
VALUES ($1, $2, $3, NULLIF($4::varchar, ''), $5)
ON CONFLICT (email) DO UPDATE
SET name = EXCLUDED.name,
avatar_url = COALESCE(EXCLUDED.avatar_url, users.avatar_url),
updated_at = NOW()
RETURNING id, email, name, COALESCE(avatar_url, '') AS avatar_url, is_admin, created_at;
-- name: InsertUserAdmin :one
INSERT INTO users (email, password_hash, name, is_admin)
VALUES ($1, $2, $3, $4)
RETURNING id, email, name, COALESCE(avatar_url, '') AS avatar_url, is_admin, created_at;
-- name: InsertUser :one
INSERT INTO users (email, password_hash, name)
VALUES ($1, $2, $3)
RETURNING id, email, name, COALESCE(avatar_url, '') AS avatar_url, is_admin, created_at;
-- name: GetUserByID :one
SELECT id, email, name, COALESCE(avatar_url, '') AS avatar_url, is_admin, created_at
FROM users WHERE id = $1;
-- name: UpdateUserProfile :exec
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');
+58
View File
@@ -0,0 +1,58 @@
-- name: GetCronJobProjectID :one
SELECT project_id FROM cron_jobs WHERE id = $1;
-- name: GetCronOwnerViaService :one
SELECT p.owner_id FROM projects p
JOIN services s ON s.project_id = p.id
WHERE s.id = $1;
-- name: InsertCronJob :exec
INSERT INTO cron_jobs (id, project_id, service_id, name, schedule, command, timezone, enabled, next_run_at, retention, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12);
-- name: GetCronJob :one
SELECT id, project_id, service_id, name, schedule, command, timezone,
enabled, last_run_at, next_run_at, last_status, last_output,
retention, created_at, updated_at
FROM cron_jobs WHERE id = $1;
-- name: GetCronJobOwner :one
SELECT p.owner_id FROM cron_jobs cj
JOIN projects p ON cj.project_id = p.id
WHERE cj.id = $1;
-- name: DeleteCronJob :exec
DELETE FROM cron_jobs WHERE id = $1;
-- name: ListCronExecutions :many
SELECT id, cron_job_id, started_at, finished_at, status, output, error
FROM cron_executions
WHERE cron_job_id = $1
ORDER BY started_at DESC
LIMIT 100;
-- name: GetCronJobForTrigger :one
SELECT cj.service_id, cj.command, cj.schedule, cj.timezone, cj.retention, p.owner_id
FROM cron_jobs cj
JOIN projects p ON cj.project_id = p.id
WHERE cj.id = $1;
-- name: InsertCronExecution :exec
INSERT INTO cron_executions (id, cron_job_id, started_at, status)
VALUES ($1, $2, $3, $4);
-- name: ListDueCronJobs :many
SELECT id, project_id, service_id, name, schedule, command, timezone, enabled, retention
FROM cron_jobs
WHERE enabled = TRUE AND next_run_at IS NOT NULL AND next_run_at <= NOW();
-- name: FinishCronExecution :exec
UPDATE cron_executions SET finished_at = $1, status = $2, output = $3, error = $4 WHERE id = $5;
-- name: UpdateCronJobRun :exec
UPDATE cron_jobs SET last_run_at = $1, last_status = $2, last_output = $3, next_run_at = $4 WHERE id = $5;
-- name: TrimCronExecutions :exec
DELETE FROM cron_executions WHERE cron_executions.cron_job_id = $1 AND cron_executions.id NOT IN (
SELECT ce.id FROM cron_executions ce WHERE ce.cron_job_id = $1
ORDER BY ce.started_at DESC LIMIT $2);
+137
View File
@@ -0,0 +1,137 @@
-- name: ListGitProviders :many
SELECT id, name, display_name, api_url, webhook_url, user_id, created_at, updated_at
FROM git_providers
WHERE user_id = $1
ORDER BY created_at DESC;
-- name: UpsertGitHubAppProvider :one
INSERT INTO git_providers (id, name, display_name, api_url, webhook_url, access_token, user_id, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
ON CONFLICT (name, user_id)
DO UPDATE SET
display_name = EXCLUDED.display_name,
api_url = EXCLUDED.api_url,
webhook_url = EXCLUDED.webhook_url,
access_token = EXCLUDED.access_token,
updated_at = NOW()
RETURNING id, created_at, updated_at;
-- name: InsertGitProvider :exec
INSERT INTO git_providers (id, name, display_name, api_url, webhook_url, access_token, user_id, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW());
-- name: GetGitProviderTimestamps :one
SELECT created_at, updated_at FROM git_providers WHERE id = $1;
-- name: GetGitProviderForRepos :one
SELECT name, display_name, access_token, api_url
FROM git_providers
WHERE id = $1 AND user_id = $2;
-- name: GetGitProviderName :one
SELECT name FROM git_providers WHERE id = $1 AND user_id = $2;
-- name: GetGitProviderForFetch :one
SELECT name, api_url, access_token
FROM git_providers
WHERE id = $1 AND user_id = $2;
-- name: DeleteGitProvider :execrows
DELETE FROM git_providers WHERE id = $1 AND user_id = $2;
-- name: ListGitRepositories :many
SELECT id, provider_id, name, full_name, COALESCE(description, '') AS description,
clone_url, COALESCE(webhook_url, '') AS webhook_url,
default_branch, is_private, user_id, created_at, updated_at
FROM git_repositories
WHERE provider_id = $1 AND user_id = $2
AND ($3::text IS NULL OR full_name ILIKE ('%' || $3::text || '%') OR name ILIKE ('%' || $3::text || '%'))
ORDER BY updated_at DESC
LIMIT $4 OFFSET $5;
-- name: CountGitRepositories :one
SELECT COUNT(*)
FROM git_repositories
WHERE provider_id = $1 AND user_id = $2
AND ($3::text IS NULL OR full_name ILIKE ('%' || $3::text || '%') OR name ILIKE ('%' || $3::text || '%'));
-- name: GetGitRepositoryByFullName :one
SELECT id, provider_id, name, full_name, COALESCE(description, '') AS description,
clone_url, COALESCE(webhook_url, '') AS webhook_url,
default_branch, is_private, user_id, created_at, updated_at
FROM git_repositories
WHERE provider_id = $1 AND full_name = $2 AND user_id = $3;
-- name: InsertGitRepository :one
INSERT INTO git_repositories (id, provider_id, name, full_name, description, clone_url, default_branch, is_private, user_id, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW())
RETURNING created_at, updated_at;
-- name: UpsertGitRepository :exec
INSERT INTO git_repositories (id, provider_id, name, full_name, description, clone_url, default_branch, is_private, user_id, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW())
ON CONFLICT (provider_id, full_name)
DO UPDATE SET
name = EXCLUDED.name,
description = EXCLUDED.description,
clone_url = EXCLUDED.clone_url,
default_branch = EXCLUDED.default_branch,
is_private = EXCLUDED.is_private,
updated_at = NOW();
-- name: GetGitRepositoryProviderID :one
SELECT provider_id FROM git_repositories WHERE id = $1 AND user_id = $2;
-- name: UpsertGitWebhook :one
INSERT INTO git_webhooks (id, repo_id, provider_id, events, webhook_secret, active, branch_filter, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, TRUE, $6, NOW(), NOW())
ON CONFLICT (repo_id, provider_id)
DO UPDATE SET
events = EXCLUDED.events,
webhook_secret = EXCLUDED.webhook_secret,
active = TRUE,
branch_filter = EXCLUDED.branch_filter,
updated_at = NOW()
RETURNING id, active, created_at, updated_at;
-- name: ListConnectedRepositories :many
SELECT r.id, r.provider_id, r.name, r.full_name, COALESCE(r.description, '') AS description, r.clone_url,
r.default_branch, r.is_private, r.user_id, r.created_at, r.updated_at,
p.name AS provider_name, p.display_name
FROM git_repositories r
JOIN git_providers p ON r.provider_id = p.id
WHERE r.user_id = $1
ORDER BY r.updated_at DESC
LIMIT $2 OFFSET $3;
-- name: CountUserRepositories :one
SELECT COUNT(*) FROM git_repositories WHERE user_id = $1;
-- name: GetGitWebhookForPush :one
SELECT webhook_secret, repo_id, provider_id, COALESCE(branch_filter, '') AS branch_filter, active
FROM git_webhooks WHERE id = $1;
-- name: GetGitProviderNameByID :one
SELECT name FROM git_providers WHERE id = $1;
-- name: GetGitRepoForPush :one
SELECT clone_url, full_name, user_id FROM git_repositories WHERE id = $1;
-- name: ListServicesForPush :many
SELECT s.id, s.project_id, s.name,
COALESCE(s.type, s.service_type, '') AS type, COALESCE(s.status, '') AS status,
COALESCE(s.image, s.image_name, '') AS image, COALESCE(s.command, s.start_command, '') AS command,
COALESCE(s.environment, '') AS environment, COALESCE(s.git_repo, s.source_url, '') AS git_repo,
COALESCE(s.git_branch, '') AS git_branch, COALESCE(s.build_path, '') AS build_path,
COALESCE(s.cpu, '') AS cpu, COALESCE(s.memory, '') AS memory,
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;
+59
View File
@@ -0,0 +1,59 @@
-- name: ListNodeAgentsForService :many
SELECT DISTINCT node_agent_id FROM container_instances WHERE service_id = $1;
-- name: ListServiceVariableValues :many
SELECT key, value FROM environment_variables WHERE service_id = $1;
-- name: ListServiceVariablesWithSecret :many
SELECT key, value, COALESCE(is_secret, false) AS is_secret
FROM environment_variables WHERE service_id = $1;
-- name: ListProjectServiceRefs :many
SELECT id, name FROM services WHERE project_id = $1;
-- name: GetServicePort :one
SELECT COALESCE(port, 0) AS port FROM services WHERE id = $1;
-- name: GetServiceVariableValue :one
SELECT value FROM environment_variables WHERE service_id = $1 AND key = $2;
-- name: GetServiceRuntimeWithOwner :one
SELECT s.id, s.project_id, s.name,
COALESCE(s.type, s.service_type, '') AS type, COALESCE(s.status, '') AS status,
COALESCE(s.image, s.image_name, '') AS image, COALESCE(s.command, s.start_command, '') AS command,
COALESCE(s.environment, '') AS environment, COALESCE(s.git_repo, s.source_url, '') AS git_repo,
COALESCE(s.git_branch, '') AS git_branch, COALESCE(s.build_path, '') AS build_path,
COALESCE(s.cpu, '') AS cpu, COALESCE(s.memory, '') AS memory,
COALESCE(s.replicas, 1) AS replicas, COALESCE(s.port, 0) AS port,
COALESCE(s.domain, '') AS domain, COALESCE(s.healthcheck_path, '') AS healthcheck_path,
COALESCE(s.restart_policy, 'unless-stopped') AS restart_policy,
COALESCE(s.builder, 'auto') AS builder, COALESCE(s.cpu_reserve, '') AS cpu_reserve,
COALESCE(s.memory_reserve, '') AS memory_reserve, COALESCE(s.static_build_cmd, '') AS static_build_cmd,
COALESCE(s.static_dir, '') AS static_dir,
s.created_at, s.updated_at, p.owner_id
FROM services s
JOIN projects p ON s.project_id = p.id
WHERE s.id = $1;
-- name: GetLastDeployedImage :one
SELECT (image_name || ':' || image_tag)::text AS image FROM deployments
WHERE service_id = $1 AND status = 'deployed' AND image_name <> ''
ORDER BY created_at DESC LIMIT 1;
-- name: SetServicePublishedPort :exec
UPDATE services SET published_port = $1 WHERE id = $2 AND published_port <> $1;
-- name: SetServiceReplicas :exec
UPDATE services SET replicas = $1, updated_at = $2 WHERE id = $3;
-- name: ListSleepCandidates :many
SELECT id, project_id, name, COALESCE(status,'') AS status,
COALESCE(sleep_idle_minutes, 15) AS sleep_idle_minutes
FROM services
WHERE sleep_enabled AND COALESCE(status,'') IN ('running','deployed','degraded','starting');
-- name: GetServiceDomainText :one
SELECT COALESCE(domain,'') AS domain FROM services WHERE id = $1;
-- name: GetServiceStatusText :one
SELECT COALESCE(status,'') AS status FROM services WHERE id = $1;
+78
View File
@@ -0,0 +1,78 @@
-- name: GetProjectOwnerText :one
SELECT owner_id::text FROM projects WHERE id = $1;
-- name: ServiceExistsInProject :one
SELECT EXISTS(SELECT 1 FROM services WHERE id = $1 AND project_id = $2);
-- name: ListVulnerabilities :many
SELECT id, type, severity, title, description, service_id, status, found_at, resolved_at
FROM vulnerabilities
WHERE project_id = $1
ORDER BY
CASE severity
WHEN 'critical' THEN 1
WHEN 'high' THEN 2
WHEN 'medium' THEN 3
WHEN 'low' THEN 4
END,
found_at DESC;
-- name: UpdateVulnerabilityStatus :exec
UPDATE vulnerabilities SET status = $1, resolved_at = $2 WHERE id = $3;
-- name: ComplianceFrameworkExists :one
SELECT EXISTS(SELECT 1 FROM compliance_frameworks WHERE id = $1);
-- name: ListComplianceFrameworks :many
SELECT id, name, description, version, enabled, created_at
FROM compliance_frameworks
WHERE enabled = true
ORDER BY name;
-- name: GetVulnerabilityMetrics :one
SELECT
COUNT(*) AS total,
COUNT(*) FILTER (WHERE severity = 'critical') AS critical,
COUNT(*) FILTER (WHERE severity = 'high') AS high,
COUNT(*) FILTER (WHERE severity = 'medium') AS medium,
COUNT(*) FILTER (WHERE severity = 'low') AS low,
COUNT(*) FILTER (WHERE status = 'open') AS open,
COUNT(*) FILTER (WHERE status = 'resolved') AS resolved
FROM vulnerabilities
WHERE project_id = $1;
-- name: GetLatestSecurityScan :one
SELECT id, COALESCE((summary->>'score')::int, 0)::int AS score, started_at AS scanned_at, status
FROM security_scans
WHERE project_id = $1
ORDER BY started_at DESC
LIMIT 1;
-- name: GetLatestComplianceReport :one
SELECT overall_status, score, assessment_date
FROM compliance_reports
WHERE project_id = $1
ORDER BY assessment_date DESC
LIMIT 1;
-- name: CheckSecurityProjectAccess :one
SELECT EXISTS (
SELECT 1
FROM projects p
WHERE p.id = $1
AND (p.is_approved
OR $3::bool
OR p.owner_id = $2 OR EXISTS (
SELECT 1 FROM project_members pm
WHERE pm.project_id = p.id AND pm.user_id = $2
))
);
-- name: GetSecurityScanProjectID :one
SELECT project_id FROM security_scans WHERE id = $1;
-- name: GetComplianceReportProjectID :one
SELECT project_id FROM compliance_reports WHERE id = $1;
-- name: GetVulnerabilityProjectID :one
SELECT project_id FROM vulnerabilities WHERE id = $1;
@@ -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;
+251
View File
@@ -69,6 +69,8 @@ CREATE TABLE services (
static_dir VARCHAR(255) NOT NULL DEFAULT '', static_dir VARCHAR(255) NOT NULL DEFAULT '',
node_id VARCHAR(255), node_id VARCHAR(255),
spread BOOLEAN NOT NULL DEFAULT false, spread BOOLEAN NOT NULL DEFAULT false,
sleep_enabled BOOLEAN NOT NULL DEFAULT false,
sleep_idle_minutes INTEGER NOT NULL DEFAULT 15,
placement_tags JSONB NOT NULL DEFAULT '[]', placement_tags JSONB NOT NULL DEFAULT '[]',
traefik_labels JSONB NOT NULL DEFAULT '{}' traefik_labels JSONB NOT NULL DEFAULT '{}'
); );
@@ -142,6 +144,17 @@ CREATE TABLE environment_variables (
UNIQUE(service_id, key) UNIQUE(service_id, key)
); );
CREATE TABLE project_variables (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
key VARCHAR(255) NOT NULL,
value TEXT NOT NULL DEFAULT '',
is_secret BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
UNIQUE(project_id, key)
);
CREATE TABLE service_templates ( CREATE TABLE service_templates (
id VARCHAR(50) PRIMARY KEY, id VARCHAR(50) PRIMARY KEY,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
@@ -459,3 +472,241 @@ CREATE TABLE instance_metrics (
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
PRIMARY KEY (service_id, timestamp, instance_id) PRIMARY KEY (service_id, timestamp, instance_id)
); );
CREATE TABLE cron_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
service_id UUID NOT NULL REFERENCES services(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
schedule VARCHAR(100) NOT NULL,
command TEXT NOT NULL,
timezone VARCHAR(50) DEFAULT 'UTC',
enabled BOOLEAN DEFAULT true,
last_run_at TIMESTAMP WITH TIME ZONE,
next_run_at TIMESTAMP WITH TIME ZONE,
last_status VARCHAR(50),
last_output TEXT,
retention INTEGER DEFAULT 30,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE TABLE cron_executions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
cron_job_id UUID NOT NULL REFERENCES cron_jobs(id) ON DELETE CASCADE,
started_at TIMESTAMP WITH TIME ZONE NOT NULL,
finished_at TIMESTAMP WITH TIME ZONE,
status VARCHAR(50) DEFAULT 'pending',
output TEXT,
error TEXT
);
CREATE TABLE git_providers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
display_name VARCHAR(255) NOT NULL,
api_url VARCHAR(255) NOT NULL,
webhook_url VARCHAR(255) NOT NULL,
access_token TEXT NOT NULL,
user_id UUID NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE TABLE git_repositories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
provider_id UUID NOT NULL,
name VARCHAR(255) NOT NULL,
full_name VARCHAR(255) NOT NULL,
description TEXT,
clone_url VARCHAR(255) NOT NULL,
webhook_url VARCHAR(255),
default_branch VARCHAR(255) DEFAULT 'main',
is_private BOOLEAN DEFAULT false,
user_id UUID NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE TABLE git_webhooks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
repo_id UUID NOT NULL,
provider_id UUID NOT NULL,
events TEXT NOT NULL,
webhook_secret TEXT NOT NULL,
remote_webhook_id VARCHAR(255),
active BOOLEAN DEFAULT true,
branch_filter VARCHAR(255),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
avatar_url VARCHAR(500),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
is_admin BOOLEAN NOT NULL DEFAULT false
);
CREATE TABLE compliance_frameworks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL UNIQUE,
description TEXT,
version VARCHAR(20) NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
CREATE TABLE security_scans (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL,
service_id UUID,
scan_type VARCHAR(50) NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'running',
started_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
completed_at TIMESTAMP WITH TIME ZONE,
summary JSONB DEFAULT '{}',
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
CREATE TABLE compliance_reports (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL,
framework_id UUID NOT NULL,
assessment_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
assessor VARCHAR(255),
overall_status VARCHAR(50) NOT NULL,
score INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
CREATE TABLE vulnerabilities (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
type VARCHAR(50) NOT NULL,
severity VARCHAR(20) NOT NULL,
title VARCHAR(255) NOT NULL,
description TEXT,
service_id UUID,
project_id UUID NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'open',
found_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
resolved_at TIMESTAMP WITH TIME ZONE,
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" /> <link rel="icon" type="image/svg+xml" href="/containr.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>containr</title> <title>containr</title>
<script>
try {
document.documentElement.dataset.theme =
localStorage.getItem('containr.theme') === 'light' ? 'light' : 'dark';
} catch { document.documentElement.dataset.theme = 'dark'; }
</script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+3 -3
View File
@@ -1,12 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs> <defs>
<linearGradient id="bg" x1="0" y1="0" x2="64" y2="64" gradientUnits="userSpaceOnUse"> <linearGradient id="bg" x1="0" y1="0" x2="64" y2="64" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#cdf06f"/> <stop offset="0" stop-color="#56c9f8"/>
<stop offset="1" stop-color="#7fb524"/> <stop offset="1" stop-color="#0e86c9"/>
</linearGradient> </linearGradient>
</defs> </defs>
<rect width="64" height="64" rx="15" fill="url(#bg)"/> <rect width="64" height="64" rx="15" fill="url(#bg)"/>
<g fill="none" stroke="#131507" stroke-width="4.2" stroke-linecap="round" stroke-linejoin="round"> <g fill="none" stroke="#061018" stroke-width="4.2" stroke-linecap="round" stroke-linejoin="round">
<rect x="15" y="18" width="34" height="26" rx="4"/> <rect x="15" y="18" width="34" height="26" rx="4"/>
<path d="M23 18v26M31 18v26M39 18v26"/> <path d="M23 18v26M31 18v26M39 18v26"/>
<path d="M20 51h24"/> <path d="M20 51h24"/>

Before

Width:  |  Height:  |  Size: 582 B

After

Width:  |  Height:  |  Size: 582 B

+66 -54
View File
@@ -1,39 +1,50 @@
import { lazy, Suspense, type ReactNode } from 'react';
import { Link, Navigate, Outlet, Route, Routes, useLocation } from 'react-router-dom'; import { Link, Navigate, Outlet, Route, Routes, useLocation } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Compass, ShieldCheck } from 'lucide-react'; import { Compass, ShieldCheck } from 'lucide-react';
import { PlatformShell } from './layout/PlatformShell'; import { PlatformShell } from './layout/PlatformShell';
import { ProjectsPage } from '@/features/projects/pages/ProjectsPage';
import { ProjectWorkspacePage } from '@/features/workspace/pages/ProjectWorkspacePage';
import { ServiceDetailPage } from '@/features/service/pages/ServiceDetailPage';
import { ServiceMetricsDashboard } from '@/features/service/pages/ServiceMetricsDashboard';
import { BuildsPage } from '@/features/builds/pages/BuildsPage';
import { TemplatesPage } from '@/features/templates/pages/TemplatesPage';
import { AuditLogsPage } from '@/features/audit/pages/AuditLogsPage';
import { WebhooksPage } from '@/features/webhooks/pages/WebhooksPage';
import { OperationsPage } from '@/features/operations/pages/OperationsPage';
import { ActivityPage } from '@/features/activity/pages/ActivityPage';
import { AdminPage } from '@/features/admin/pages/AdminPage';
import { LandingPage } from '@/features/landing/LandingPage';
import { useBranding } from '@/lib/use-branding'; import { useBranding } from '@/lib/use-branding';
import {
DocsPage,
PeoplePage,
SettingsPage,
UsagePage,
DatabasesPage,
HighAvailabilityPage,
SecurityPage,
ComponentShowcase,
GitHubAppCallbackPage,
} from '@/features/secondary/pages';
import { NodeDetailPage } from '@/features/nodes/NodeDetailPage';
import { AcceptInvitePage, SignInPage, SignUpPage } from '@/features/auth/pages';
import { SetupWizardPage } from '@/features/setup/SetupWizardPage';
import { useAuthSession } from '@/lib/use-auth-session'; import { useAuthSession } from '@/lib/use-auth-session';
import { useDemoMode } from '@/lib/demo-mode'; import { useDemoMode } from '@/lib/demo-mode';
import { getCurrentUserProfile } from '@/lib/api-client'; import { getCurrentUserProfile } from '@/lib/api-client';
import { ErrorBoundary, LoadingState } from '@/shared/components'; import { ErrorBoundary, LoadingState } from '@/shared/components';
const LandingPage = lazy(() => import('@/features/landing/LandingPage').then((m) => ({ default: m.LandingPage })));
const SignInPage = lazy(() => import('@/features/auth/pages').then((m) => ({ default: m.SignInPage })));
const SignUpPage = lazy(() => import('@/features/auth/pages').then((m) => ({ default: m.SignUpPage })));
const AcceptInvitePage = lazy(() => import('@/features/auth/pages').then((m) => ({ default: m.AcceptInvitePage })));
const SetupWizardPage = lazy(() => import('@/features/setup/SetupWizardPage').then((m) => ({ default: m.SetupWizardPage })));
const ProjectWorkspacePage = lazy(() => import('@/features/workspace/pages/ProjectWorkspacePage').then((m) => ({ default: m.ProjectWorkspacePage })));
const ServiceDetailPage = lazy(() => import('@/features/service/pages/ServiceDetailPage').then((m) => ({ default: m.ServiceDetailPage })));
const ServiceMetricsDashboard = lazy(() => import('@/features/service/pages/ServiceMetricsDashboard').then((m) => ({ default: m.ServiceMetricsDashboard })));
const DashboardPage = lazy(() => import('@/features/dashboard/pages/DashboardPage').then((m) => ({ default: m.DashboardPage })));
const ProjectsPage = lazy(() => import('@/features/projects/pages/ProjectsPage').then((m) => ({ default: m.ProjectsPage })));
const TemplatesPage = lazy(() => import('@/features/templates/pages/TemplatesPage').then((m) => ({ default: m.TemplatesPage })));
const DatabasesPage = lazy(() => import('@/features/secondary/pages').then((m) => ({ default: m.DatabasesPage })));
const HighAvailabilityPage = lazy(() => import('@/features/secondary/pages').then((m) => ({ default: m.HighAvailabilityPage })));
const OperationsPage = lazy(() => import('@/features/operations/pages/OperationsPage').then((m) => ({ default: m.OperationsPage })));
const ActivityPage = lazy(() => import('@/features/activity/pages/ActivityPage').then((m) => ({ default: m.ActivityPage })));
const SecurityPage = lazy(() => import('@/features/secondary/pages').then((m) => ({ default: m.SecurityPage })));
const UsagePage = lazy(() => import('@/features/secondary/pages').then((m) => ({ default: m.UsagePage })));
const DocsPage = lazy(() => import('@/features/secondary/pages').then((m) => ({ default: m.DocsPage })));
const BuildsPage = lazy(() => import('@/features/builds/pages/BuildsPage').then((m) => ({ default: m.BuildsPage })));
const PeoplePage = lazy(() => import('@/features/secondary/pages').then((m) => ({ default: m.PeoplePage })));
const SettingsPage = lazy(() => import('@/features/secondary/pages').then((m) => ({ default: m.SettingsPage })));
const GitHubAppCallbackPage = lazy(() => import('@/features/secondary/pages').then((m) => ({ default: m.GitHubAppCallbackPage })));
const AuditLogsPage = lazy(() => import('@/features/audit/pages/AuditLogsPage').then((m) => ({ default: m.AuditLogsPage })));
const WebhooksPage = lazy(() => import('@/features/webhooks/pages/WebhooksPage').then((m) => ({ default: m.WebhooksPage })));
const ComponentShowcase = lazy(() => import('@/features/secondary/pages').then((m) => ({ default: m.ComponentShowcase })));
const AdminPage = lazy(() => import('@/features/admin/pages/AdminPage').then((m) => ({ default: m.AdminPage })));
const NodeDetailPage = lazy(() => import('@/features/nodes/NodeDetailPage').then((m) => ({ default: m.NodeDetailPage })));
function Page({ children }: { children: ReactNode }) {
return (
<Suspense fallback={<LoadingState message="Loading..." className="min-h-[60vh]" />}>
{children}
</Suspense>
);
}
function AuthRequired() { function AuthRequired() {
const location = useLocation(); const location = useLocation();
const isDemoMode = useDemoMode(); const isDemoMode = useDemoMode();
@@ -92,7 +103,7 @@ function AdminRequired() {
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-[var(--bg-void)] px-6 text-center"> <div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-[var(--bg-void)] px-6 text-center">
<ShieldCheck size={32} className="text-[var(--error)]" /> <ShieldCheck size={32} className="text-[var(--error)]" />
<div> <div>
<h1 className="text-lg font-semibold text-[var(--text-primary)]">Admin access required</h1> <h1 className="font-headline text-lg font-semibold text-[var(--text-primary)]">Admin access required</h1>
<p className="mt-1 max-w-sm text-sm text-[var(--text-secondary)]"> <p className="mt-1 max-w-sm text-sm text-[var(--text-secondary)]">
This area is restricted to the platform administrator. This area is restricted to the platform administrator.
</p> </p>
@@ -115,7 +126,7 @@ function NotFoundPage() {
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-4 px-6 text-center"> <div className="flex min-h-[60vh] flex-col items-center justify-center gap-4 px-6 text-center">
<Compass size={32} className="text-[var(--text-tertiary)]" /> <Compass size={32} className="text-[var(--text-tertiary)]" />
<div> <div>
<h1 className="text-lg font-semibold text-[var(--text-primary)]">Page not found</h1> <h1 className="font-headline text-lg font-semibold text-[var(--text-primary)]">Page not found</h1>
<p className="mt-1 max-w-sm text-sm text-[var(--text-secondary)]"> <p className="mt-1 max-w-sm text-sm text-[var(--text-secondary)]">
The page you are looking for does not exist or was moved. The page you are looking for does not exist or was moved.
</p> </p>
@@ -135,43 +146,44 @@ export default function App() {
return ( return (
<ErrorBoundary> <ErrorBoundary>
<Routes> <Routes>
<Route path="/" element={<LandingPage />} /> <Route path="/landing" element={<Page><LandingPage /></Page>} />
<Route path="/auth/sign-in" element={<SignInPage />} /> <Route path="/auth/sign-in" element={<Page><SignInPage /></Page>} />
<Route path="/auth/sign-up" element={<SignUpPage />} /> <Route path="/auth/sign-up" element={<Page><SignUpPage /></Page>} />
<Route path="/auth/accept-invite" element={<AcceptInvitePage />} /> <Route path="/auth/accept-invite" element={<Page><AcceptInvitePage /></Page>} />
<Route path="/setup" element={<SetupWizardPage />} /> <Route path="/setup" element={<Page><SetupWizardPage /></Page>} />
{/* Public browsing — approved projects, templates, docs. */} {/* Public browsing — approved projects, templates, docs. */}
{/* Project workspace renders outside the shell — fullscreen canvas, no nav chrome */} {/* Project workspace renders outside the shell — fullscreen canvas, no nav chrome */}
<Route path="/projects/:projectId" element={<ProjectWorkspacePage />} /> <Route path="/projects/:projectId" element={<Page><ProjectWorkspacePage /></Page>} />
<Route path="/projects/:projectId/services/:serviceId" element={<ServiceDetailPage />} /> <Route path="/projects/:projectId/services/:serviceId" element={<Page><ServiceDetailPage /></Page>} />
<Route element={<PlatformShell />}> <Route element={<PlatformShell />}>
<Route path="/projects" element={<ProjectsPage />} /> <Route path="/" element={<Page><DashboardPage /></Page>} />
<Route path="/templates" element={<TemplatesPage />} /> <Route path="/projects" element={<Page><ProjectsPage /></Page>} />
<Route path="/databases" element={<DatabasesPage />} /> <Route path="/templates" element={<Page><TemplatesPage /></Page>} />
<Route path="/ha" element={<HighAvailabilityPage />} /> <Route path="/databases" element={<Page><DatabasesPage /></Page>} />
<Route path="/operations" element={<OperationsPage />} /> <Route path="/ha" element={<Page><HighAvailabilityPage /></Page>} />
<Route path="/activity" element={<ActivityPage />} /> <Route path="/operations" element={<Page><OperationsPage /></Page>} />
<Route path="/security" element={<SecurityPage />} /> <Route path="/activity" element={<Page><ActivityPage /></Page>} />
<Route path="/usage" element={<UsagePage />} /> <Route path="/security" element={<Page><SecurityPage /></Page>} />
<Route path="/docs" element={<DocsPage />} /> <Route path="/usage" element={<Page><UsagePage /></Page>} />
<Route path="/docs" element={<Page><DocsPage /></Page>} />
{/* Authenticated areas */} {/* Authenticated areas */}
<Route element={<AuthRequired />}> <Route element={<AuthRequired />}>
<Route path="/builds" element={<BuildsPage />} /> <Route path="/builds" element={<Page><BuildsPage /></Page>} />
<Route path="/people" element={<PeoplePage />} /> <Route path="/people" element={<Page><PeoplePage /></Page>} />
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<Page><SettingsPage /></Page>} />
<Route path="/git/github-app/callback" element={<GitHubAppCallbackPage />} /> <Route path="/git/github-app/callback" element={<Page><GitHubAppCallbackPage /></Page>} />
<Route path="/settings/audit-logs" element={<AuditLogsPage />} /> <Route path="/settings/audit-logs" element={<Page><AuditLogsPage /></Page>} />
<Route path="/settings/webhooks" element={<WebhooksPage />} /> <Route path="/settings/webhooks" element={<Page><WebhooksPage /></Page>} />
<Route path="/showcase" element={<ComponentShowcase />} /> <Route path="/showcase" element={<Page><ComponentShowcase /></Page>} />
</Route> </Route>
{/* Platform owner only */} {/* Platform owner only */}
<Route element={<AdminRequired />}> <Route element={<AdminRequired />}>
<Route path="/admin" element={<AdminPage />} /> <Route path="/admin" element={<Page><AdminPage /></Page>} />
<Route path="/nodes/:id" element={<NodeDetailPage />} /> <Route path="/nodes/:id" element={<Page><NodeDetailPage /></Page>} />
</Route> </Route>
<Route path="*" element={<NotFoundPage />} /> <Route path="*" element={<NotFoundPage />} />
@@ -179,7 +191,7 @@ export default function App() {
{/* Full-bleed design demo — renders its own chrome, no app shell */} {/* Full-bleed design demo — renders its own chrome, no app shell */}
<Route element={<AuthRequired />}> <Route element={<AuthRequired />}>
<Route path="/metrics-demo" element={<ServiceMetricsDashboard />} /> <Route path="/metrics-demo" element={<Page><ServiceMetricsDashboard /></Page>} />
</Route> </Route>
</Routes> </Routes>
</ErrorBoundary> </ErrorBoundary>
+127 -105
View File
@@ -9,11 +9,13 @@ import {
ChartBar, ChartBar,
CheckCircle2, CheckCircle2,
ChevronsLeft, ChevronsLeft,
ChevronsUpDown,
Clock, Clock,
Container, Container,
Database, Database,
FileText, FileText,
FolderKanban, FolderKanban,
LayoutDashboard,
LayoutTemplate, LayoutTemplate,
LogIn, LogIn,
LogOut, LogOut,
@@ -41,22 +43,26 @@ import {
markAllNotificationsRead, markAllNotificationsRead,
markNotificationRead, markNotificationRead,
pullUpgradeImage, pullUpgradeImage,
listProjects,
} from '@/lib/api-client'; } from '@/lib/api-client';
import { signOutAuthSession } from '@/lib/auth-client'; import { signOutAuthSession } from '@/lib/auth-client';
import { isDemoSearch } from '@/lib/demo-mode'; import { isDemoSearch } from '@/lib/demo-mode';
import { demoProjects } from '@/lib/demo-data';
import { useAuthSession } from '@/lib/use-auth-session'; import { useAuthSession } from '@/lib/use-auth-session';
import { BannerBar, BrandWordmark, useToast } from '@/shared/components'; import { BannerBar, BrandWordmark, CommandPalette, useToast } from '@/shared/components';
type NavItem = { label: string; href: string; icon: typeof FolderKanban; hint: string }; type NavItem = { label: string; href: string; icon: typeof FolderKanban; hint: string };
type NavSection = { title: string; items: NavItem[] }; type NavSection = { title: string; items: NavItem[] };
const navSections: NavSection[] = [ const navSections: NavSection[] = [
{ {
title: 'Build', title: 'Platform',
items: [ items: [
{ label: 'Dashboard', href: '/', icon: LayoutDashboard, hint: 'Fleet overview' },
{ label: 'Projects', href: '/projects', icon: FolderKanban, hint: 'Services & deployments' }, { label: 'Projects', href: '/projects', icon: FolderKanban, hint: 'Services & deployments' },
{ label: 'Templates', href: '/templates', icon: LayoutTemplate, hint: 'Deployable presets' }, { label: 'Templates', href: '/templates', icon: LayoutTemplate, hint: 'Deployable presets' },
{ label: 'Builds', href: '/builds', icon: FileText, hint: 'Build history' }, { label: 'Builds', href: '/builds', icon: FileText, hint: 'Build history' },
{ label: 'Databases', href: '/databases', icon: Database, hint: 'Managed data services' },
], ],
}, },
{ {
@@ -64,7 +70,6 @@ const navSections: NavSection[] = [
items: [ items: [
{ label: 'Operations', href: '/operations', icon: Activity, hint: 'Live jobs & failures' }, { label: 'Operations', href: '/operations', icon: Activity, hint: 'Live jobs & failures' },
{ label: 'Activity', href: '/activity', icon: Clock, hint: 'Platform event feed' }, { label: 'Activity', href: '/activity', icon: Clock, hint: 'Platform event feed' },
{ label: 'Databases', href: '/databases', icon: Database, hint: 'Managed data services' },
{ label: 'High Availability', href: '/ha', icon: ShieldCheck, hint: 'Failover & health' }, { label: 'High Availability', href: '/ha', icon: ShieldCheck, hint: 'Failover & health' },
{ label: 'Security', href: '/security', icon: Shield, hint: 'Scans & compliance' }, { label: 'Security', href: '/security', icon: Shield, hint: 'Scans & compliance' },
{ label: 'Usage', href: '/usage', icon: ChartBar, hint: 'Resource consumption' }, { label: 'Usage', href: '/usage', icon: ChartBar, hint: 'Resource consumption' },
@@ -164,6 +169,18 @@ export function PlatformShell() {
const [theme, setTheme] = useState<ThemeMode>(() => getInitialTheme()); const [theme, setTheme] = useState<ThemeMode>(() => getInitialTheme());
const [sidebarExpanded, setSidebarExpanded] = useState<boolean>(() => getInitialSidebar()); const [sidebarExpanded, setSidebarExpanded] = useState<boolean>(() => getInitialSidebar());
const [notificationsOpen, setNotificationsOpen] = useState(false); const [notificationsOpen, setNotificationsOpen] = useState(false);
const [paletteOpen, setPaletteOpen] = useState(false);
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setPaletteOpen((open) => !open);
}
};
document.addEventListener('keydown', down);
return () => document.removeEventListener('keydown', down);
}, []);
const sessionQuery = useAuthSession({ enabled: !isDemoMode }); const sessionQuery = useAuthSession({ enabled: !isDemoMode });
const signedIn = isDemoMode || Boolean(sessionQuery.data); const signedIn = isDemoMode || Boolean(sessionQuery.data);
@@ -234,8 +251,19 @@ export function PlatformShell() {
} }
}; };
const isActiveRoute = (itemHref: string) => // Longest matching href wins so /settings/audit-logs lights only
location.pathname === itemHref || location.pathname.startsWith(`${itemHref}/`); // "Audit Logs", not "Settings" as well.
const activeHref = location.pathname === '/'
? '/'
: visibleNavSections
.flatMap((section) => section.items.map((item) => item.href))
.filter(
(itemHref) =>
itemHref !== '/' &&
(location.pathname === itemHref || location.pathname.startsWith(`${itemHref}/`)),
)
.sort((a, b) => b.length - a.length)[0] ?? null;
const isActiveRoute = (itemHref: string) => itemHref === activeHref;
const userName = isDemoMode ? 'Demo workspace' : profileQuery.data?.name ?? sessionQuery.data?.user.name ?? 'Account'; const userName = isDemoMode ? 'Demo workspace' : profileQuery.data?.name ?? sessionQuery.data?.user.name ?? 'Account';
const userEmail = isDemoMode ? 'Sample data · read-only' : profileQuery.data?.email ?? sessionQuery.data?.user.email ?? 'Local session'; const userEmail = isDemoMode ? 'Sample data · read-only' : profileQuery.data?.email ?? sessionQuery.data?.user.email ?? 'Local session';
@@ -246,14 +274,20 @@ export function PlatformShell() {
.slice(0, 2) .slice(0, 2)
.toUpperCase() || 'C'; .toUpperCase() || 'C';
const avatar = ( const paletteNavigate = (target: string) => {
<div navigate(href(target));
className="flex h-[30px] w-[30px] shrink-0 items-center justify-center rounded-full text-[11px] font-bold" };
style={{ background: 'linear-gradient(135deg, var(--accent-primary), var(--accent-secondary))', color: 'var(--accent-on)' }}
> // Project deep-links for the palette — only fetched once it opens.
{initials} const paletteProjectsQuery = useQuery({
</div> queryKey: ['palette-projects'],
); queryFn: () => listProjects(),
enabled: paletteOpen && !isDemoMode,
staleTime: 60_000,
});
const paletteProjects = isDemoMode
? demoProjects
: paletteProjectsQuery.data ?? [];
if (!isDemoMode && signedIn && setupQuery.data?.needs_setup) { if (!isDemoMode && signedIn && setupQuery.data?.needs_setup) {
return <Navigate to="/setup" replace />; return <Navigate to="/setup" replace />;
@@ -261,40 +295,46 @@ export function PlatformShell() {
return ( return (
<div className="app-shell min-h-screen"> <div className="app-shell min-h-screen">
<div className="ambient-glow" />
<div className="relative flex min-h-screen"> <div className="relative flex min-h-screen">
{/* ── Sidebar — fixed rail per design/23-sentry ── */}
<aside <aside
className="hidden h-screen shrink-0 flex-col border-r py-4 transition-[width] duration-200 md:flex" className="shell-side hidden h-screen shrink-0 flex-col md:flex"
style={{ style={{ width: sidebarExpanded ? '252px' : '64px' }}
width: sidebarExpanded ? '218px' : '62px',
background: 'var(--bg-base)',
borderRightColor: 'var(--border-subtle)',
}}
> >
<div className={`flex items-center ${sidebarExpanded ? 'justify-between px-3' : 'flex-col items-center gap-2'}`}> <div
<NavLink to={href('/projects')} title="Containr"> className={`flex items-center ${
<img src="/containr.svg" alt="Containr" className="h-[38px] w-[38px] rounded-xl" /> sidebarExpanded ? 'gap-2.5 px-[18px] pb-5 pt-4' : 'flex-col gap-3 px-2 pb-4 pt-4'
}`}
>
<NavLink to={href('/')} title="Containr" className="flex items-center gap-2.5">
<span className="shell-mark grid h-[26px] w-[26px] shrink-0 place-items-center">
<Container size={14} />
</span>
{sidebarExpanded ? (
<span className="font-headline text-[15px] font-semibold tracking-[-0.01em] text-[var(--text-primary)]">
<BrandWordmark />
</span>
) : null}
</NavLink> </NavLink>
<button <button
type="button" type="button"
onClick={() => setSidebarExpanded((v) => !v)} onClick={() => setSidebarExpanded((v) => !v)}
title={sidebarExpanded ? 'Collapse sidebar' : 'Expand sidebar'} title={sidebarExpanded ? 'Collapse sidebar' : 'Expand sidebar'}
className="flex h-7 w-7 items-center justify-center rounded-[var(--radius-sm)] text-[var(--text-tertiary)] transition-colors hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)]" className={`shell-collapse flex items-center justify-center ${
sidebarExpanded ? 'ml-auto h-[22px] w-[22px]' : 'h-7 w-7'
}`}
> >
{sidebarExpanded ? <ChevronsLeft size={15} /> : <PanelLeft size={15} />} {sidebarExpanded ? <ChevronsLeft size={14} /> : <PanelLeft size={15} />}
</button> </button>
</div> </div>
<nav className="mt-3 flex flex-1 flex-col gap-4 overflow-y-auto px-2"> <nav className={`flex flex-1 flex-col overflow-y-auto ${sidebarExpanded ? 'px-3' : 'gap-4 px-2'}`}>
{visibleNavSections.map((section) => ( {visibleNavSections.map((section, si) => (
<div key={section.title}> <div key={section.title} className={si > 0 && sidebarExpanded ? 'mt-[26px]' : ''}>
{sidebarExpanded ? ( {sidebarExpanded ? (
<p className="mb-1 px-2 text-[10px] font-semibold uppercase tracking-[0.14em] text-[var(--text-tertiary)]"> <p className="shell-nav-group">{section.title}</p>
{section.title}
</p>
) : null} ) : null}
<div className={`flex flex-col gap-0.5 ${sidebarExpanded ? '' : 'items-center'}`}> <div className={`flex flex-col ${sidebarExpanded ? '' : 'items-center gap-1'}`}>
{section.items.map((item) => { {section.items.map((item) => {
const Icon = item.icon; const Icon = item.icon;
const isActive = isActiveRoute(item.href); const isActive = isActiveRoute(item.href);
@@ -303,27 +343,12 @@ export function PlatformShell() {
key={item.href} key={item.href}
to={href(item.href)} to={href(item.href)}
title={sidebarExpanded ? item.hint : `${item.label} — ${item.hint}`} title={sidebarExpanded ? item.hint : `${item.label} — ${item.hint}`}
className={`relative flex items-center rounded-[11px] transition-colors ${ className={`shell-ni ${isActive ? 'on' : ''} ${
sidebarExpanded ? 'gap-2.5 px-2 py-2' : 'h-10 w-10 justify-center' sidebarExpanded ? '' : 'h-10 w-10 justify-center !px-0'
} ${
isActive
? 'bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]'
: 'text-[var(--text-tertiary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-secondary)]'
}`} }`}
> >
{isActive ? ( <Icon size={16} className="shrink-0" />
<span {sidebarExpanded ? <span className="truncate">{item.label}</span> : null}
className="absolute -left-2 h-4 w-[2.5px] rounded-full"
style={{ background: 'var(--accent-primary)' }}
/>
) : null}
<Icon size={17} className="shrink-0" />
{sidebarExpanded ? (
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] font-medium leading-tight">{item.label}</span>
<span className="block truncate text-[10px] text-[var(--text-tertiary)]">{item.hint}</span>
</span>
) : null}
</NavLink> </NavLink>
); );
})} })}
@@ -332,23 +357,26 @@ export function PlatformShell() {
))} ))}
</nav> </nav>
<div className={`flex flex-col gap-2 px-2 pt-2 ${sidebarExpanded ? '' : 'items-center'}`}> <div className={`mt-auto ${sidebarExpanded ? 'px-3 pb-3.5' : 'flex justify-center px-2 pb-3'}`}>
{signedIn ? ( {signedIn ? (
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger asChild> <DropdownMenu.Trigger asChild>
<button <button
type="button" type="button"
title={sidebarExpanded ? undefined : userEmail} title={sidebarExpanded ? undefined : userEmail}
className={`flex items-center gap-2.5 rounded-[11px] text-left transition-colors hover:bg-[var(--surface-muted)] ${ className={`shell-user ${
sidebarExpanded ? 'w-full px-2 py-2' : 'h-10 w-10 justify-center' sidebarExpanded ? 'w-full' : 'h-10 w-10 justify-center border-transparent bg-transparent'
}`} }`}
> >
{avatar} <span className="shell-avatar">{initials}</span>
{sidebarExpanded ? ( {sidebarExpanded ? (
<span className="min-w-0 flex-1"> <>
<span className="block truncate text-[12.5px] font-medium text-[var(--text-primary)]">{userName}</span> <span className="min-w-0 flex-1 text-left">
<span className="block truncate text-[13px] font-medium text-[var(--text-primary)]">{userName}</span>
<span className="block truncate text-[10.5px] text-[var(--text-tertiary)]">{userEmail}</span> <span className="block truncate text-[10.5px] text-[var(--text-tertiary)]">{userEmail}</span>
</span> </span>
<ChevronsUpDown size={13} className="shrink-0 text-[var(--text-tertiary)]" />
</>
) : null} ) : null}
</button> </button>
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
@@ -427,12 +455,12 @@ export function PlatformShell() {
<NavLink <NavLink
to="/auth/sign-in" to="/auth/sign-in"
title="Sign in" title="Sign in"
className={`flex items-center gap-2.5 rounded-[11px] text-[var(--text-tertiary)] transition-colors hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] ${ className={`shell-ni ${
sidebarExpanded ? 'w-full px-2 py-2' : 'h-10 w-10 justify-center' sidebarExpanded ? 'w-full' : 'h-10 w-10 justify-center !px-0'
}`} }`}
> >
<LogIn size={16} className="shrink-0" /> <LogIn size={16} className="shrink-0" />
{sidebarExpanded ? <span className="text-[12.5px] font-medium">Sign in</span> : null} {sidebarExpanded ? <span className="text-[13px] font-medium">Sign in</span> : null}
</NavLink> </NavLink>
)} )}
</div> </div>
@@ -440,51 +468,31 @@ export function PlatformShell() {
<div className="relative z-10 flex min-h-screen min-w-0 flex-1 flex-col overflow-hidden"> <div className="relative z-10 flex min-h-screen min-w-0 flex-1 flex-col overflow-hidden">
<BannerBar /> <BannerBar />
<header {/* ── Topbar — search left, utility icons right ── */}
className="hidden h-[46px] shrink-0 items-center border-b px-5 md:flex" <header className="shell-top hidden h-[56px] shrink-0 items-center gap-3.5 md:flex">
style={{
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>
<button <button
type="button" type="button"
onClick={() => setTheme((current) => (current === 'dark' ? 'light' : 'dark'))} onClick={() => setPaletteOpen(true)}
className="flex h-8 w-8 items-center justify-center rounded-[var(--radius-md)] border text-[var(--text-secondary)] hover:text-[var(--text-primary)]" className="shell-search"
style={{ borderColor: 'var(--border-subtle)' }}
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
> >
{theme === 'dark' ? <Sun size={15} /> : <Moon size={15} />} <Search size={13} className="shrink-0" />
Search anything
<kbd>⌘ K</kbd>
</button> </button>
<span className="v-env">{isDemoMode ? 'env:demo' : `env:${import.meta.env.MODE}`}</span>
<div className="flex-1" />
{isAdmin && !isDemoMode ? ( {isAdmin && !isDemoMode ? (
<button <button
type="button" type="button"
onClick={() => pullMutation.mutate()} onClick={() => pullMutation.mutate()}
disabled={pullMutation.isPending || !upgradeQuery.data?.imageRef} 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" className="s-btn-accent"
style={{ background: 'var(--accent-primary)' }}
title={upgradeQuery.data?.message || 'Pull latest configured image'} 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 Upgrade
</button> </button>
) : null} ) : null}
@@ -494,16 +502,11 @@ export function PlatformShell() {
<button <button
type="button" type="button"
onClick={() => setNotificationsOpen((open) => !open)} 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)]" className="shell-tic relative"
style={{ borderColor: 'var(--border-subtle)' }}
aria-label="Notifications" aria-label="Notifications"
> >
<Bell size={15} /> <Bell size={16} />
{unreadCount > 0 ? ( {unreadCount > 0 ? <span className="shell-tic-dot" /> : null}
<span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-[var(--accent-primary)] px-1 text-[10px] font-semibold text-[var(--accent-on)]">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
) : null}
</button> </button>
{notificationsOpen ? ( {notificationsOpen ? (
@@ -566,13 +569,21 @@ export function PlatformShell() {
) : null} ) : null}
</div> </div>
) : null} ) : null}
</div>
<button
type="button"
onClick={() => setTheme((current) => (current === 'dark' ? 'light' : 'dark'))}
className="shell-tic"
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
>
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
</button>
</header> </header>
<header className="md:hidden sticky top-0 z-50 border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/85 backdrop-blur-2xl"> <header className="md:hidden sticky top-0 z-50 border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/85 backdrop-blur-2xl">
<div className="flex items-center justify-between p-4"> <div className="flex items-center justify-between p-4">
<NavLink to={href('/projects')} className="flex items-center gap-3"> <NavLink to={href('/')} className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)]"> <div className="shell-mark flex h-9 w-9 items-center justify-center">
<Container size={16} /> <Container size={16} />
</div> </div>
<span className="font-headline font-semibold text-[var(--text-primary)]">Containr</span> <span className="font-headline font-semibold text-[var(--text-primary)]">Containr</span>
@@ -613,6 +624,17 @@ export function PlatformShell() {
</main> </main>
</div> </div>
</div> </div>
<CommandPalette
open={paletteOpen}
onClose={() => setPaletteOpen(false)}
onNavigate={paletteNavigate}
openTargets={paletteProjects.map((p) => ({
label: p.name,
target: `/projects/${p.id}`,
description: 'Project workspace',
}))}
/>
</div> </div>
); );
} }
@@ -6,6 +6,7 @@ import { type FilterConfig } from '@/lib/dynamic-filter';
import { useDynamicFilter, useFilterState } from '@/lib/use-dynamic-filter'; import { useDynamicFilter, useFilterState } from '@/lib/use-dynamic-filter';
import { formatRelative } from '@/lib/time'; import { formatRelative } from '@/lib/time';
import { DemoRestricted, FilterBar } from '@/shared/components'; import { DemoRestricted, FilterBar } from '@/shared/components';
import { QuietBtn, SCard, SPageHead, SPill } from '@/shared/components/sentry';
import { import {
Activity, AlertCircle, CheckCircle2, Database, GitBranch, Info, Key, Activity, AlertCircle, CheckCircle2, Database, GitBranch, Info, Key,
Loader2, Package, Projector, Rocket, Shield, User, Users, Wrench, Loader2, Package, Projector, Rocket, Shield, User, Users, Wrench,
@@ -24,8 +25,12 @@ const SEVERITY_STYLE: Record<string, { icon: typeof Info; cls: string; dot: stri
info: { icon: Info, cls: 'text-[var(--accent-primary)]', dot: 'bg-[var(--accent-primary)]' }, info: { icon: Info, cls: 'text-[var(--accent-primary)]', dot: 'bg-[var(--accent-primary)]' },
}; };
const SEV_TONE: Record<string, 'err' | 'warn' | 'ok' | 'info'> = {
error: 'err', warning: 'warn', success: 'ok', info: 'info',
};
const SCHEMA: FilterConfig<ActivityEntry>[] = [ const SCHEMA: FilterConfig<ActivityEntry>[] = [
{ key: 'q', label: 'Search', type: 'search', searchFields: ['label', 'action', 'user_email'] }, { key: 'q', label: 'events', type: 'search', searchFields: ['label', 'action', 'user_email'] },
{ {
key: 'severity', label: 'Severity', type: 'select', key: 'severity', label: 'Severity', type: 'select',
options: [ options: [
@@ -58,46 +63,33 @@ export function ActivityPage() {
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<div className="border-b border-[var(--border-subtle)]"> <div className="w-full px-4 pt-6 sm:px-8">
<div className="w-full px-8 py-5"> <SPageHead
<h1 className="v-title">Activity<span className="v-cursor">_</span></h1> title="Activity"
<p className="v-mono mt-1.5 text-[11px] text-[var(--text-tertiary)]"> titleAccent="_"
platform events across your visible projects sub="Platform events across your visible projects"
</p> actions={query.isFetching ? <Loader2 size={14} className="animate-spin text-[var(--text-muted)]" /> : undefined}
</div> />
</div> </div>
<div className="px-8 py-6 space-y-4"> <div className="px-4 sm:px-8 space-y-4">
<div className="flex items-start gap-3"> <SCard icon={<Activity />} title="Event Feed" accent={`${entries.length} events`} pad={false}>
<div className="flex-1"> <div className="px-4 pt-1 pb-3">
<FilterBar {...filter} /> <FilterBar {...filter} />
</div> </div>
{query.isFetching && <Loader2 size={14} className="animate-spin text-[var(--text-muted)] mt-3" />} <div className="divide-y divide-[var(--border-subtle)] border-t border-[var(--border-subtle)]">
</div>
<div className="panel p-0 divide-y divide-[var(--border-subtle)]">
{query.isLoading && <p className="px-5 py-8 text-xs text-[var(--text-muted)]">Loading…</p>} {query.isLoading && <p className="px-5 py-8 text-xs text-[var(--text-muted)]">Loading…</p>}
{!query.isLoading && entries.length === 0 && ( {!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> <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} />)} {entries.map((e) => <Entry key={e.id} entry={e} />)}
</div> </div>
</SCard>
<div className="flex items-center gap-3"> <div className="flex items-center gap-2.5">
<button <QuietBtn disabled={page <= 1} onClick={() => setPage(page - 1)}>← newer</QuietBtn>
disabled={page <= 1} <QuietBtn disabled={!query.data?.has_more} onClick={() => setPage(page + 1)}>older →</QuietBtn>
onClick={() => setPage(page - 1)} <span className="v-mono text-[10.5px] text-[var(--text-muted)]">page {page}</span>
className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--border-subtle)] text-[var(--text-muted)] disabled:opacity-40 hover:text-[var(--text-primary)]"
>
newer
</button>
<button
disabled={!query.data?.has_more}
onClick={() => setPage(page + 1)}
className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--border-subtle)] text-[var(--text-muted)] disabled:opacity-40 hover:text-[var(--text-primary)]"
>
older
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -108,14 +100,14 @@ function Entry({ entry }: { entry: ActivityEntry }) {
const Cat = CATEGORY_ICONS[entry.category] ?? Activity; const Cat = CATEGORY_ICONS[entry.category] ?? Activity;
const sev = SEVERITY_STYLE[entry.severity] ?? SEVERITY_STYLE.info; const sev = SEVERITY_STYLE[entry.severity] ?? SEVERITY_STYLE.info;
return ( return (
<div className="flex items-center gap-3 px-5 py-3"> <div className="flex items-center gap-3 px-4 py-3 hover:bg-[var(--tint-03)] transition-colors">
<div className={`w-8 h-8 rounded-md bg-[var(--bg-elevated)] flex items-center justify-center ${sev.cls}`}> <div className={`s-ibox ${sev.cls}`} style={{ width: 30, height: 30 }}>
<Cat size={15} /> <Cat size={14} />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-[13px] text-[var(--text-primary)] truncate">{entry.label || entry.action}</span> <span className="text-[13px] font-medium text-[var(--text-primary)] truncate">{entry.label || entry.action}</span>
<span className={`v-mono text-[9px] uppercase px-1.5 py-0.5 rounded ${sev.cls} bg-[var(--bg-elevated)]`}>{entry.severity}</span> <SPill tone={SEV_TONE[entry.severity] ?? 'info'}>{entry.severity}</SPill>
</div> </div>
<div className="v-mono text-[10px] text-[var(--text-muted)] mt-0.5"> <div className="v-mono text-[10px] text-[var(--text-muted)] mt-0.5">
{entry.category} · {entry.action}{entry.user_email ? ` · ${entry.user_email}` : ''} {entry.category} · {entry.action}{entry.user_email ? ` · ${entry.user_email}` : ''}
@@ -38,6 +38,7 @@ import {
import { useAuthSession } from '@/lib/use-auth-session'; import { useAuthSession } from '@/lib/use-auth-session';
import { useDemoMode } from '@/lib/demo-mode'; import { useDemoMode } from '@/lib/demo-mode';
import { DemoRestricted, LoadingState, useToast } from '@/shared/components'; import { DemoRestricted, LoadingState, useToast } from '@/shared/components';
import { GhostBtn, QuietBtn, SPageHead, SPill } from '@/shared/components/sentry';
const statLabels: Record<string, string> = { const statLabels: Record<string, string> = {
users: 'Users', users: 'Users',
@@ -199,7 +200,7 @@ export function AdminPage() {
return ( return (
<div className="mx-auto max-w-xl px-5 py-24 text-center"> <div className="mx-auto max-w-xl px-5 py-24 text-center">
<ShieldCheck size={28} className="mx-auto text-[var(--error)]" /> <ShieldCheck size={28} className="mx-auto text-[var(--error)]" />
<h1 className="mt-4 text-lg font-semibold text-[var(--text-primary)]">Admin access required</h1> <h1 className="mt-4 font-headline text-lg font-semibold text-[var(--text-primary)]">Admin access required</h1>
<p className="mt-2 text-sm text-[var(--text-secondary)]"> <p className="mt-2 text-sm text-[var(--text-secondary)]">
This area is restricted to platform administrators. This area is restricted to platform administrators.
</p> </p>
@@ -216,74 +217,74 @@ export function AdminPage() {
const invites = invitesQuery.data ?? []; const invites = invitesQuery.data ?? [];
return ( return (
<div className="mx-auto max-w-6xl px-5 py-8"> <div className="w-full px-4 py-6 sm:px-8">
<div className="mb-8"> <SPageHead
<h1 className="text-[24px] font-bold tracking-tight text-[var(--text-primary)]">Admin console</h1> title="Admin Console"
<p className="mt-1 text-sm text-[var(--text-secondary)]"> titleAccent="_"
Platform overview, project approvals, and user management. sub="Platform overview, project approvals, and user management."
/>
<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> </p>
</div> </div>
<div className="s-stat-foot mt-3">
<div className="grid grid-cols-4 gap-3 max-lg:grid-cols-3 max-sm:grid-cols-2"> <span>total</span>
{Object.entries(statLabels).map(([key, label]) => ( </div>
<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> </div>
))} ))}
</div> </div>
<section className="mt-8"> <section className="s-card !p-0 mt-6 overflow-hidden">
<div className="mb-3 flex items-center gap-2"> <div className="s-cardhead border-b border-[var(--border-subtle)]">
<Clock size={15} className="text-[var(--warning)]" /> <span className="s-ibox"><Clock /></span>
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Pending approvals</h2> <h2 className="s-t">Pending Approvals</h2>
{pending.length > 0 ? ( <span className="s-trail">
<span className="rounded-full bg-[var(--warning-soft)] px-2 py-0.5 text-[10.5px] font-semibold text-[var(--warning)]"> {pending.length > 0 ? <SPill tone="warn">{pending.length}</SPill> : null}
{pending.length}
</span> </span>
) : null}
</div> </div>
{pending.length > 0 ? ( {pending.length > 0 ? (
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)]"> <div>
{pending.map((project) => ( {pending.map((project) => (
<div <div
key={project.id} key={project.id}
className="flex items-center gap-4 border-b border-[var(--border-subtle)] px-4 py-3 last:border-b-0" className="flex items-center gap-4 border-b border-[var(--border-subtle)] px-4 py-3 last:border-b-0"
> >
<FolderKanban size={15} className="shrink-0 text-[var(--text-tertiary)]" /> <span className="s-ibox !h-7 !w-7"><FolderKanban /></span>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<Link to={`/projects/${project.id}`} className="text-[13.5px] font-medium text-[var(--text-primary)] hover:underline"> <Link to={`/projects/${project.id}`} className="text-[13.5px] font-medium text-[var(--text-primary)] hover:underline">
{project.name} {project.name}
</Link> </Link>
<p className="truncate text-[11.5px] text-[var(--text-tertiary)]">{project.description || 'No description'}</p> <p className="truncate text-[11.5px] text-[var(--text-tertiary)]">{project.description || 'No description'}</p>
</div> </div>
<button <GhostBtn
type="button"
onClick={() => approvalMutation.mutate({ id: project.id, approved: true })} onClick={() => approvalMutation.mutate({ id: project.id, approved: true })}
disabled={approvalMutation.isPending} disabled={approvalMutation.isPending}
className="inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-3 py-1.5 text-[12px] font-semibold text-[var(--accent-on)] disabled:opacity-50"
> >
<CheckCircle2 size={13} /> Approve <CheckCircle2 size={13} /> Approve
</button> </GhostBtn>
</div> </div>
))} ))}
</div> </div>
) : ( ) : (
<div className="rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)] px-4 py-6 text-center text-[13px] text-[var(--text-tertiary)]"> <p className="px-4 py-6 text-center text-[13px] text-[var(--text-tertiary)]">
Nothing waiting for approval. Nothing waiting for approval.
</div> </p>
)} )}
</section> </section>
<section className="mt-8"> <section className="s-card !p-0 mt-6 overflow-hidden">
<div className="mb-3 flex items-center gap-2"> <div className="s-cardhead border-b border-[var(--border-subtle)]">
<FolderKanban size={15} className="text-[var(--accent-primary)]" /> <span className="s-ibox"><FolderKanban /></span>
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">All projects</h2> <h2 className="s-t">All Projects</h2>
<span className="s-trail"><span className="s-chip" style={{ cursor: 'default' }}>{allProjects.length}</span></span>
</div> </div>
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)]"> <div>
{allProjects.map((project) => ( {allProjects.map((project) => (
<div <div
key={project.id} key={project.id}
@@ -297,22 +298,16 @@ export function AdminPage() {
{project.stats?.service_count ?? 0} services · {project.stats?.deployment_count ?? 0} deploys {project.stats?.service_count ?? 0} services · {project.stats?.deployment_count ?? 0} deploys
</p> </p>
</div> </div>
<span <SPill tone={project.isApproved ? 'ok' : 'warn'}>
className={`rounded-full px-2.5 py-0.5 text-[10.5px] font-semibold ${
project.isApproved
? 'bg-[var(--success-soft)] text-[var(--success)]'
: 'bg-[var(--warning-soft)] text-[var(--warning)]'
}`}
>
{project.isApproved ? 'public' : 'pending'} {project.isApproved ? 'public' : 'pending'}
</span> </SPill>
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
setEditingProject(project); setEditingProject(project);
setEditForm({ name: project.name, description: project.description ?? '' }); setEditForm({ name: project.name, description: project.description ?? '' });
}} }}
className="inline-flex items-center gap-1 rounded-[var(--radius-md)] border border-[var(--border-default)] px-2.5 py-1.5 text-[12px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]" className="s-icon-btn"
title="Edit project" title="Edit project"
> >
<Pencil size={12} /> <Pencil size={12} />
@@ -325,19 +320,17 @@ export function AdminPage() {
} }
}} }}
disabled={deleteMutation.isPending} disabled={deleteMutation.isPending}
className="inline-flex items-center gap-1 rounded-[var(--radius-md)] border border-[var(--error)] px-2.5 py-1.5 text-[12px] font-medium text-[var(--error)] hover:bg-[var(--error-soft)] disabled:opacity-50" className="s-icon-btn !text-[var(--text-tertiary)] hover:!text-[var(--error)] disabled:opacity-40"
title="Delete project" title="Delete project"
> >
<Trash2 size={12} /> <Trash2 size={12} />
</button> </button>
<button <QuietBtn
type="button"
onClick={() => approvalMutation.mutate({ id: project.id, approved: !project.isApproved })} onClick={() => approvalMutation.mutate({ id: project.id, approved: !project.isApproved })}
disabled={approvalMutation.isPending} disabled={approvalMutation.isPending}
className="rounded-[var(--radius-md)] border border-[var(--border-default)] px-3 py-1.5 text-[12px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:opacity-50"
> >
{project.isApproved ? 'Unapprove' : 'Approve'} {project.isApproved ? 'Unapprove' : 'Approve'}
</button> </QuietBtn>
</div> </div>
))} ))}
{allProjects.length === 0 ? ( {allProjects.length === 0 ? (
@@ -346,12 +339,13 @@ export function AdminPage() {
</div> </div>
</section> </section>
<section className="mt-8"> <section className="s-card !p-0 mt-6 overflow-hidden">
<div className="mb-3 flex items-center gap-2"> <div className="s-cardhead border-b border-[var(--border-subtle)]">
<Megaphone size={15} className="text-[var(--accent-primary)]" /> <span className="s-ibox"><Megaphone /></span>
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Announcements</h2> <h2 className="s-t">Announcements</h2>
<span className="s-trail"><span className="s-chip" style={{ cursor: 'default' }}>{banners.length}</span></span>
</div> </div>
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)]"> <div>
<div className="flex flex-wrap items-center gap-2 border-b border-[var(--border-subtle)] px-4 py-3"> <div className="flex flex-wrap items-center gap-2 border-b border-[var(--border-subtle)] px-4 py-3">
<input <input
value={bannerForm.title} value={bannerForm.title}
@@ -375,14 +369,12 @@ export function AdminPage() {
<option value="warning">warning</option> <option value="warning">warning</option>
<option value="error">error</option> <option value="error">error</option>
</select> </select>
<button <GhostBtn
type="button"
onClick={() => bannerCreateMutation.mutate()} onClick={() => bannerCreateMutation.mutate()}
disabled={bannerCreateMutation.isPending || bannerForm.title.trim().length === 0} disabled={bannerCreateMutation.isPending || bannerForm.title.trim().length === 0}
className="rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-[12px] font-semibold text-[var(--accent-on)] disabled:opacity-50"
> >
{bannerCreateMutation.isPending ? 'Publishing…' : 'Publish'} {bannerCreateMutation.isPending ? 'Publishing…' : 'Publish'}
</button> </GhostBtn>
</div> </div>
{banners.map((banner) => ( {banners.map((banner) => (
<div <div
@@ -396,14 +388,12 @@ export function AdminPage() {
{banner.body ? ` · ${banner.body}` : ''} {banner.body ? ` · ${banner.body}` : ''}
</p> </p>
</div> </div>
<button <QuietBtn
type="button"
onClick={() => bannerToggleMutation.mutate({ id: banner.id, active: !banner.active })} onClick={() => bannerToggleMutation.mutate({ id: banner.id, active: !banner.active })}
disabled={bannerToggleMutation.isPending} disabled={bannerToggleMutation.isPending}
className="rounded-[var(--radius-md)] border border-[var(--border-default)] px-3 py-1.5 text-[12px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:opacity-50"
> >
{banner.active ? 'Deactivate' : 'Activate'} {banner.active ? 'Deactivate' : 'Activate'}
</button> </QuietBtn>
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
@@ -412,7 +402,7 @@ export function AdminPage() {
} }
}} }}
disabled={bannerDeleteMutation.isPending} disabled={bannerDeleteMutation.isPending}
className="inline-flex items-center gap-1 rounded-[var(--radius-md)] border border-[var(--error)] px-2.5 py-1.5 text-[12px] font-medium text-[var(--error)] hover:bg-[var(--error-soft)] disabled:opacity-50" className="s-icon-btn !text-[var(--text-tertiary)] hover:!text-[var(--error)] disabled:opacity-40"
title="Delete banner" title="Delete banner"
> >
<Trash2 size={12} /> <Trash2 size={12} />
@@ -425,12 +415,13 @@ export function AdminPage() {
</div> </div>
</section> </section>
<section className="mt-8"> <section className="s-card !p-0 mt-6 overflow-hidden">
<div className="mb-3 flex items-center gap-2"> <div className="s-cardhead border-b border-[var(--border-subtle)]">
<UserPlus size={15} className="text-[var(--accent-primary)]" /> <span className="s-ibox"><UserPlus /></span>
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Team invites</h2> <h2 className="s-t">Team Invites</h2>
<span className="s-trail"><span className="s-chip" style={{ cursor: 'default' }}>{invites.length}</span></span>
</div> </div>
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)]"> <div>
<div className="flex flex-wrap items-center gap-2 border-b border-[var(--border-subtle)] px-4 py-3"> <div className="flex flex-wrap items-center gap-2 border-b border-[var(--border-subtle)] px-4 py-3">
<input <input
value={inviteEmail} value={inviteEmail}
@@ -439,35 +430,25 @@ export function AdminPage() {
type="email" type="email"
className="h-9 min-w-64 flex-1 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm focus:border-[var(--accent-primary)]" className="h-9 min-w-64 flex-1 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm focus:border-[var(--accent-primary)]"
/> />
<button <GhostBtn
type="button"
onClick={() => inviteCreateMutation.mutate()} onClick={() => inviteCreateMutation.mutate()}
disabled={inviteCreateMutation.isPending} disabled={inviteCreateMutation.isPending}
className="rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-[12px] font-semibold text-[var(--accent-on)] disabled:opacity-50"
> >
{inviteCreateMutation.isPending ? 'Creating…' : 'Create invite link'} {inviteCreateMutation.isPending ? 'Creating…' : 'Create invite link'}
</button> </GhostBtn>
</div> </div>
{inviteLink ? ( {inviteLink ? (
<div className="flex items-center gap-2 border-b border-[var(--border-subtle)] bg-[var(--accent-primary-soft)] px-4 py-2.5"> <div className="flex items-center gap-2 border-b border-[var(--border-subtle)] bg-[var(--accent-primary-soft)] px-4 py-2.5">
<code className="min-w-0 flex-1 truncate text-[12px] text-[var(--accent-primary)]">{inviteLink}</code> <code className="v-mono min-w-0 flex-1 truncate text-[12px] text-[var(--accent-primary)]">{inviteLink}</code>
<button <QuietBtn
type="button"
onClick={() => { onClick={() => {
navigator.clipboard?.writeText(inviteLink); navigator.clipboard?.writeText(inviteLink);
showToast('success', 'Link copied'); showToast('success', 'Link copied');
}} }}
className="inline-flex items-center gap-1 rounded-[var(--radius-md)] border border-[var(--border-default)] px-2.5 py-1 text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
> >
<Copy size={11} /> Copy <Copy size={11} /> Copy
</button> </QuietBtn>
<button <QuietBtn onClick={() => setInviteLink(null)}>Dismiss</QuietBtn>
type="button"
onClick={() => setInviteLink(null)}
className="text-[11px] text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]"
>
Dismiss
</button>
</div> </div>
) : null} ) : null}
{invites.map((invite) => { {invites.map((invite) => {
@@ -486,26 +467,17 @@ export function AdminPage() {
expires {new Date(invite.expires_at).toLocaleString()} expires {new Date(invite.expires_at).toLocaleString()}
</p> </p>
</div> </div>
<span <SPill tone={used ? 'off' : expired ? 'err' : 'ok'}>
className={`rounded-full px-2.5 py-0.5 text-[10.5px] font-semibold ${
used
? 'bg-[var(--surface-muted)] text-[var(--text-tertiary)]'
: expired
? 'bg-[var(--error-soft)] text-[var(--error)]'
: 'bg-[var(--success-soft)] text-[var(--success)]'
}`}
>
{used ? 'used' : expired ? 'expired' : 'open'} {used ? 'used' : expired ? 'expired' : 'open'}
</span> </SPill>
{!used ? ( {!used ? (
<button <QuietBtn
type="button"
onClick={() => inviteRevokeMutation.mutate(invite.id)} onClick={() => inviteRevokeMutation.mutate(invite.id)}
disabled={inviteRevokeMutation.isPending} disabled={inviteRevokeMutation.isPending}
className="rounded-[var(--radius-md)] border border-[var(--error)] px-2.5 py-1.5 text-[12px] font-medium text-[var(--error)] hover:bg-[var(--error-soft)] disabled:opacity-50" className="hover:!text-[var(--error)] hover:!border-[var(--error)]"
> >
Revoke Revoke
</button> </QuietBtn>
) : null} ) : null}
</div> </div>
); );
@@ -516,12 +488,13 @@ export function AdminPage() {
</div> </div>
</section> </section>
<section className="mt-8 pb-10"> <section className="s-card !p-0 mt-6 mb-10 overflow-hidden">
<div className="mb-3 flex items-center gap-2"> <div className="s-cardhead border-b border-[var(--border-subtle)]">
<Users size={15} className="text-[var(--accent-tertiary)]" /> <span className="s-ibox"><Users /></span>
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Users</h2> <h2 className="s-t">Users</h2>
<span className="s-trail"><span className="s-chip" style={{ cursor: 'default' }}>{users.length}</span></span>
</div> </div>
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)]"> <div>
{users.map((user) => ( {users.map((user) => (
<UserRow <UserRow
key={user.id} key={user.id}
@@ -540,30 +513,29 @@ export function AdminPage() {
{impersonation ? ( {impersonation ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
<div className="w-full max-w-lg overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--bg-base)] shadow-2xl"> <div className="w-full max-w-lg s-card !p-0 overflow-hidden">
<div className="border-b border-[var(--border-subtle)] px-5 py-4"> <div className="s-cardhead border-b border-[var(--border-subtle)]">
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]"> <span className="s-ibox"><ShieldCheck /></span>
Impersonating {impersonation.user.email} <h2 className="s-t">Impersonating {impersonation.user.email}</h2>
</h2> <span className="s-trail">
<p className="text-xs text-[var(--text-tertiary)]"> <span className="v-mono text-[10.5px] text-[var(--text-muted)]">
Token valid until {new Date(impersonation.expires_at).toLocaleString()} — audit-logged. expires {new Date(impersonation.expires_at).toLocaleString()}
</p> </span>
</span>
</div> </div>
<div className="space-y-3 px-5 py-4"> <div className="space-y-3 px-5 py-4">
<div className="flex items-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2.5"> <div className="s-inset flex items-center gap-2 px-3 py-2.5">
<code className="min-w-0 flex-1 truncate text-[12px] text-[var(--text-primary)]"> <code className="v-mono min-w-0 flex-1 truncate text-[12px] text-[var(--text-primary)]">
{impersonation.token} {impersonation.token}
</code> </code>
<button <QuietBtn
type="button"
onClick={() => { onClick={() => {
navigator.clipboard?.writeText(impersonation.token); navigator.clipboard?.writeText(impersonation.token);
showToast('success', 'Token copied'); showToast('success', 'Token copied');
}} }}
className="inline-flex shrink-0 items-center gap-1 rounded-[var(--radius-md)] border border-[var(--border-default)] px-2.5 py-1 text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
> >
<Copy size={11} /> Copy <Copy size={11} /> Copy
</button> </QuietBtn>
</div> </div>
<p className="text-[12px] leading-relaxed text-[var(--text-tertiary)]"> <p className="text-[12px] leading-relaxed text-[var(--text-tertiary)]">
Use it as a bearer token — e.g. <code className="text-[var(--text-secondary)]">CONTAINR_TOKEN=&lt;token&gt; Use it as a bearer token — e.g. <code className="text-[var(--text-secondary)]">CONTAINR_TOKEN=&lt;token&gt;
@@ -572,13 +544,7 @@ export function AdminPage() {
</p> </p>
</div> </div>
<div className="flex items-center justify-end gap-2 border-t border-[var(--border-subtle)] px-5 py-3.5"> <div className="flex items-center justify-end gap-2 border-t border-[var(--border-subtle)] px-5 py-3.5">
<button <GhostBtn onClick={() => setImpersonation(null)}>Done</GhostBtn>
type="button"
onClick={() => setImpersonation(null)}
className="rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-xs font-semibold text-[var(--accent-on)]"
>
Done
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -586,10 +552,13 @@ export function AdminPage() {
{editingProject ? ( {editingProject ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
<div className="w-full max-w-md overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--bg-base)] shadow-2xl"> <div className="w-full max-w-md s-card !p-0 overflow-hidden">
<div className="border-b border-[var(--border-subtle)] px-5 py-4"> <div className="s-cardhead border-b border-[var(--border-subtle)]">
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Edit project</h2> <span className="s-ibox"><Pencil /></span>
<p className="text-xs text-[var(--text-tertiary)]">Admin edit — changes apply immediately.</p> <h2 className="s-t">Edit Project</h2>
<span className="s-trail">
<span className="v-mono text-[10.5px] text-[var(--text-muted)]">admin edit</span>
</span>
</div> </div>
<div className="space-y-4 px-5 py-4"> <div className="space-y-4 px-5 py-4">
<div> <div>
@@ -611,21 +580,13 @@ export function AdminPage() {
</div> </div>
</div> </div>
<div className="flex items-center justify-end gap-2 border-t border-[var(--border-subtle)] px-5 py-3.5"> <div className="flex items-center justify-end gap-2 border-t border-[var(--border-subtle)] px-5 py-3.5">
<button <QuietBtn onClick={() => setEditingProject(null)}>Cancel</QuietBtn>
type="button" <GhostBtn
onClick={() => setEditingProject(null)}
className="rounded-[var(--radius-md)] px-3.5 py-2 text-xs font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-muted)]"
>
Cancel
</button>
<button
type="button"
onClick={() => editMutation.mutate()} onClick={() => editMutation.mutate()}
disabled={editMutation.isPending || editForm.name.trim().length < 2} disabled={editMutation.isPending || editForm.name.trim().length < 2}
className="rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-xs font-semibold text-[var(--accent-on)] disabled:opacity-50"
> >
{editMutation.isPending ? 'Saving…' : 'Save'} {editMutation.isPending ? 'Saving…' : 'Save'}
</button> </GhostBtn>
</div> </div>
</div> </div>
</div> </div>
@@ -660,32 +621,22 @@ function UserRow({
<p className="truncate text-[11.5px] text-[var(--text-tertiary)]">{user.email}</p> <p className="truncate text-[11.5px] text-[var(--text-tertiary)]">{user.email}</p>
</div> </div>
{user.is_admin ? ( {user.is_admin ? (
<span className="inline-flex items-center gap-1 rounded-full bg-[var(--accent-primary-soft)] px-2.5 py-0.5 text-[10.5px] font-semibold text-[var(--accent-primary)]"> <SPill tone="info"><ShieldCheck size={10} /> admin</SPill>
<ShieldCheck size={11} /> admin
</span>
) : null} ) : null}
{onImpersonate ? ( {onImpersonate ? (
<button <QuietBtn onClick={onImpersonate}>Impersonate</QuietBtn>
type="button"
onClick={onImpersonate}
className="rounded-[var(--radius-md)] border border-[var(--border-default)] px-3 py-1.5 text-[12px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
>
Impersonate
</button>
) : null} ) : null}
<button <QuietBtn
type="button"
onClick={() => onToggle(!user.is_admin)} onClick={() => onToggle(!user.is_admin)}
disabled={pending || isSelf} disabled={pending || isSelf}
title={isSelf ? 'You cannot change your own admin flag' : undefined} title={isSelf ? 'You cannot change your own admin flag' : undefined}
className="rounded-[var(--radius-md)] border border-[var(--border-default)] px-3 py-1.5 text-[12px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:cursor-not-allowed disabled:opacity-40"
> >
{user.is_admin ? ( {user.is_admin ? (
<span className="inline-flex items-center gap-1"><XCircle size={12} /> Revoke</span> <span className="inline-flex items-center gap-1"><XCircle size={12} /> Revoke</span>
) : ( ) : (
'Make admin' 'Make admin'
)} )}
</button> </QuietBtn>
</div> </div>
); );
} }
@@ -6,7 +6,9 @@ import { type FilterConfig } from '@/lib/dynamic-filter';
import { useDynamicFilter } from '@/lib/use-dynamic-filter'; import { useDynamicFilter } from '@/lib/use-dynamic-filter';
import { formatDate, formatRelative } from '@/lib/time'; import { formatDate, formatRelative } from '@/lib/time';
import { DemoRestricted, FilterBar } from '@/shared/components'; import { DemoRestricted, FilterBar } from '@/shared/components';
import { QuietBtn, SCard, SPageHead, SPill, STable, type SCol } from '@/shared/components/sentry';
import { ScrollText, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'; import { ScrollText, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';
import type { AuditLogEntity } from '@/lib/api-client';
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
@@ -72,109 +74,101 @@ export function AuditLogsPage() {
const hasNext = logs.length === PAGE_SIZE; const hasNext = logs.length === PAGE_SIZE;
const hasFilters = filter.activeCount > 0; const hasFilters = filter.activeCount > 0;
const cols: SCol<AuditLogEntity>[] = [
{
key: 'time', label: 'Time', width: '100px', sortable: false,
render: (log) => (
<span className="v-mono text-[11px] text-[var(--text-tertiary)]" title={log.createdAt ? formatDate(log.createdAt) : undefined}>
{log.createdAt ? formatRelative(log.createdAt) : '—'}
</span>
),
},
{
key: 'actor', label: 'Actor', width: 'minmax(140px,1.1fr)',
sortValue: (log) => log.userEmail ?? log.userId ?? 'system',
render: (log) => (
<span className="text-[12px] text-[var(--text-primary)] truncate">
{log.userEmail || (log.userId ? log.userId.slice(0, 8) : 'system')}
</span>
),
},
{
key: 'resource', label: 'Resource', width: 'minmax(120px,1fr)',
sortValue: (log) => log.resource ?? '',
render: (log) => <span className="v-mono text-[11.5px] text-[var(--text-secondary)]">{log.resource}</span>,
},
{
key: 'action', label: 'Action', width: 'minmax(130px,1fr)',
sortValue: (log) => log.action ?? '',
render: (log) => <SPill tone="info">{log.action}</SPill>,
},
{
key: 'rid', label: 'Resource ID', width: 'minmax(110px,0.8fr)', sortable: false,
render: (log) => (
<span className="v-mono text-[10.5px] text-[var(--text-tertiary)] truncate block" title={log.resourceId}>
{log.resourceId || '—'}
</span>
),
},
{
key: 'ip', label: 'IP', width: '110px', sortable: false,
render: (log) => <span className="v-mono text-[10.5px] text-[var(--text-tertiary)]">{log.ipAddress?.replace(/\/\d+$/, '') || '—'}</span>,
},
{
key: 'details', label: 'Details', width: 'minmax(160px,1.2fr)', sortable: false,
render: (log) => (
<span className="v-mono text-[10.5px] text-[var(--text-tertiary)] truncate block" title={log.details}>
{log.details && log.details !== '{}' ? log.details : '—'}
</span>
),
},
];
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<div className="border-b border-[var(--border-subtle)]"> <div className="w-full px-4 pt-6 sm:px-8">
<div className="w-full px-8 py-5"> <SPageHead
<h1 className="v-title"> title="Audit Logs"
Audit Logs<span className="v-cursor">_</span> titleAccent="_"
</h1> sub="Every authenticated action recorded by the platform"
<p className="v-mono mt-1.5 text-[11px] text-[var(--text-tertiary)]"> />
Every authenticated action recorded by the platform
</p>
</div>
</div> </div>
<div className="w-full px-8 py-6"> <div className="w-full px-4 sm:px-8">
<section className="panel p-6"> <SCard icon={<ScrollText />} title="Event Log" accent={`page ${page}`} pad={false}>
<div className="px-4 pt-1 pb-3">
<FilterBar {...filter} /> <FilterBar {...filter} />
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--border-subtle)] text-left">
<th className="px-3 py-2.5 font-medium uppercase tracking-wider text-xs text-[var(--text-muted)]">Time</th>
<th className="px-3 py-2.5 font-medium uppercase tracking-wider text-xs text-[var(--text-muted)]">Actor</th>
<th className="px-3 py-2.5 font-medium uppercase tracking-wider text-xs text-[var(--text-muted)]">Resource</th>
<th className="px-3 py-2.5 font-medium uppercase tracking-wider text-xs text-[var(--text-muted)]">Action</th>
<th className="px-3 py-2.5 font-medium uppercase tracking-wider text-xs text-[var(--text-muted)]">Resource ID</th>
<th className="px-3 py-2.5 font-medium uppercase tracking-wider text-xs text-[var(--text-muted)]">IP</th>
<th className="px-3 py-2.5 font-medium uppercase tracking-wider text-xs text-[var(--text-muted)]">Details</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border-subtle)]">
{logsQuery.isLoading ? (
<tr>
<td colSpan={7} className="px-4 py-10 text-center text-[var(--text-muted)]">
<Loader2 size={18} className="animate-spin inline-block" />
</td>
</tr>
) : logsQuery.isError ? (
<tr>
<td colSpan={7} className="px-4 py-10 text-center text-[var(--error)]">
Failed to load audit logs.
</td>
</tr>
) : logs.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-10 text-center">
<ScrollText size={20} className="inline-block mb-2 text-[var(--text-muted)]" />
<p className="text-[var(--text-secondary)]">No audit events{hasFilters ? ' match these filters' : ' recorded yet'}.</p>
</td>
</tr>
) : (
logs.map((log) => (
<tr key={log.id} className="hover:bg-[var(--surface-muted)]/50 transition-colors">
<td className="px-3 py-2.5 whitespace-nowrap text-[var(--text-tertiary)]" title={log.createdAt ? formatDate(log.createdAt) : undefined}>
{log.createdAt ? formatRelative(log.createdAt) : '—'}
</td>
<td className="px-3 py-2.5 text-[var(--text-primary)]">
{log.userEmail || (log.userId ? log.userId.slice(0, 8) : 'system')}
</td>
<td className="px-3 py-2.5 mono text-[var(--text-secondary)]">{log.resource}</td>
<td className="px-3 py-2.5">
<span className="px-2 py-0.5 rounded-[var(--radius-sm)] bg-[var(--accent-primary-soft)] text-xs font-medium text-[var(--accent-primary)]">
{log.action}
</span>
</td>
<td className="px-3 py-2.5 mono text-xs text-[var(--text-tertiary)] max-w-[140px] truncate" title={log.resourceId}>
{log.resourceId || '—'}
</td>
<td className="px-3 py-2.5 mono text-xs text-[var(--text-tertiary)]">{log.ipAddress || '—'}</td>
<td className="px-3 py-2.5 mono text-xs text-[var(--text-tertiary)] max-w-[220px] truncate" title={log.details}>
{log.details && log.details !== '{}' ? log.details : '—'}
</td>
</tr>
))
)}
</tbody>
</table>
</div> </div>
<div className="border-t border-[var(--border-subtle)] px-4 overflow-x-auto">
<div className="mt-4 flex items-center justify-between"> {logsQuery.isLoading ? (
<p className="text-xs text-[var(--text-muted)]"> <div className="py-10 text-center text-[var(--text-muted)]">
<Loader2 size={18} className="animate-spin inline-block" />
</div>
) : logsQuery.isError ? (
<div className="py-10 text-center text-[var(--error)]">Failed to load audit logs.</div>
) : logs.length === 0 ? (
<div className="py-10 text-center">
<div className="s-ibox mx-auto mb-2"><ScrollText /></div>
<p className="text-[var(--text-secondary)] text-sm">No audit events{hasFilters ? ' match these filters' : ' recorded yet'}.</p>
</div>
) : (
<STable cols={cols} rows={logs} rowKey={(l) => l.id} />
)}
</div>
<div className="flex items-center justify-between px-4 py-3 border-t border-[var(--border-subtle)]">
<p className="v-mono text-[10.5px] text-[var(--text-muted)]">
Page {page}{logs.length > 0 ? ` — ${logs.length} entries` : ''} Page {page}{logs.length > 0 ? ` — ${logs.length} entries` : ''}
</p> </p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <QuietBtn onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page <= 1}>
onClick={() => setPage((p) => Math.max(1, p - 1))} <ChevronLeft size={12} /> Prev
disabled={page <= 1} </QuietBtn>
className="flex items-center gap-1 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] disabled:opacity-40 transition-colors" <QuietBtn onClick={() => setPage((p) => p + 1)} disabled={!hasNext}>
> Next <ChevronRight size={12} />
<ChevronLeft size={12} /> </QuietBtn>
Prev
</button>
<button
onClick={() => setPage((p) => p + 1)}
disabled={!hasNext}
className="flex items-center gap-1 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] disabled:opacity-40 transition-colors"
>
Next
<ChevronRight size={12} />
</button>
</div> </div>
</div> </div>
</section> </SCard>
</div> </div>
</div> </div>
); );
+27 -46
View File
@@ -13,15 +13,16 @@ import {
} from '@/lib/auth-client'; } from '@/lib/auth-client';
import { acceptInvite, getInvite } from '@/lib/api-client'; import { acceptInvite, getInvite } from '@/lib/api-client';
import { useAuthSession } from '@/lib/use-auth-session'; import { useAuthSession } from '@/lib/use-auth-session';
import { GhostBtn, QuietBtn } from '@/shared/components/sentry';
function sanitizeRedirect(raw: string | null): string { function sanitizeRedirect(raw: string | null): string {
if (!raw) { if (!raw) {
return '/projects'; return '/';
} }
const value = raw.trim(); const value = raw.trim();
if (!value.startsWith('/') || value.startsWith('//')) { if (!value.startsWith('/') || value.startsWith('//')) {
return '/projects'; return '/';
} }
return value; return value;
@@ -54,9 +55,11 @@ function AuthCard({
}) { }) {
return ( return (
<div className="w-full max-w-[400px]"> <div className="w-full max-w-[400px]">
<div className="rounded-[var(--radius-xl)] border border-[var(--border-subtle)] bg-[var(--surface-card)]/95 p-8 shadow-2xl shadow-black/50 backdrop-blur-xl"> <div className="s-card shadow-2xl shadow-black/50">
<h2 className="font-headline text-xl font-semibold text-[var(--text-primary)]">{title}</h2> <div className="s-cardhead mb-1">
<p className="mb-6 mt-1.5 text-sm text-[var(--text-secondary)]">{subtitle}</p> <h2 className="font-headline text-[17px] font-semibold text-[var(--text-primary)]">{title}</h2>
</div>
<p className="mb-6 v-mono text-[11.5px] text-[var(--text-tertiary)]">{subtitle}</p>
{children} {children}
</div> </div>
</div> </div>
@@ -67,19 +70,14 @@ function AuthLayout({ children }: { children: React.ReactNode }) {
return ( return (
<div className="relative min-h-screen overflow-hidden bg-[var(--bg-void)]"> <div className="relative min-h-screen overflow-hidden bg-[var(--bg-void)]">
<div className="subtle-grid absolute inset-0" /> <div className="subtle-grid absolute inset-0" />
<div className="ambient-glow" />
<div
className="absolute left-1/2 top-0 h-[420px] w-[720px] -translate-x-1/2 -translate-y-1/2 rounded-full"
style={{ background: 'radial-gradient(ellipse at center, rgba(180,227,74,0.10) 0%, transparent 65%)' }}
/>
<div className="relative flex min-h-screen flex-col items-center justify-center px-5 py-10"> <div className="relative flex min-h-screen flex-col items-center justify-center px-5 py-10">
<div className="mb-8 flex flex-col items-center"> <div className="mb-8 flex flex-col items-center">
<img src="/containr.svg" alt="Containr" className="h-14 w-14" /> <img src="/containr.svg" alt="Containr" className="h-14 w-14" />
<span className="mt-4 font-headline text-2xl font-bold tracking-tight text-[var(--text-primary)]">Containr</span> <span className="mt-4 font-headline text-2xl font-bold tracking-tight text-[var(--text-primary)]">Containr</span>
<span className="mt-1 text-xs text-[var(--text-muted)]">Self-hosted container platform</span> <span className="mt-1 v-mono text-[11px] uppercase tracking-[0.14em] text-[var(--text-muted)]">Self-hosted container platform</span>
</div> </div>
{children} {children}
<p className="mt-8 text-center text-[11px] text-[var(--text-muted)]"> <p className="mt-8 text-center v-mono text-[10.5px] text-[var(--text-muted)]">
Sessions are cookie-based · first account becomes platform owner Sessions are cookie-based · first account becomes platform owner
</p> </p>
</div> </div>
@@ -89,8 +87,8 @@ function AuthLayout({ children }: { children: React.ReactNode }) {
function AuthErrorNotice({ message }: { message: string }) { function AuthErrorNotice({ message }: { message: string }) {
return ( return (
<div className="mb-4 flex items-start gap-2 rounded-[var(--radius-md)] border border-[var(--error-soft)] bg-[var(--error-soft)] px-3 py-2.5 text-sm text-[var(--error)]"> <div className="mb-4 s-inset flex items-start gap-2 !border-[var(--error)]/30 px-3 py-2.5 text-[12.5px] text-[var(--error)]">
<AlertCircle size={16} className="mt-0.5 shrink-0" /> <AlertCircle size={15} className="mt-0.5 shrink-0" />
<span>{message}</span> <span>{message}</span>
</div> </div>
); );
@@ -98,7 +96,7 @@ function AuthErrorNotice({ message }: { message: string }) {
function AuthInfoNotice({ message }: { message: string }) { function AuthInfoNotice({ message }: { message: string }) {
return ( return (
<div className="mb-4 rounded-[var(--radius-md)] border border-[var(--success-soft)] bg-[var(--success-soft)] px-3 py-2.5 text-sm text-[var(--success)]"> <div className="mb-4 s-inset !border-[var(--success)]/30 px-3 py-2.5 text-[12.5px] text-[var(--success)]">
{message} {message}
</div> </div>
); );
@@ -206,15 +204,10 @@ export function SignInPage() {
/> />
</label> </label>
<button <GhostBtn type="submit" disabled={isSubmitting || sessionQuery.isPending} className="!h-10 w-full justify-center">
type="submit"
disabled={isSubmitting || sessionQuery.isPending}
className="mt-1 inline-flex h-10 w-full items-center justify-center gap-2 rounded-[var(--radius-md)] text-sm font-semibold text-[var(--accent-on)] shadow-lg transition-all disabled:cursor-not-allowed disabled:opacity-60"
style={{ background: 'var(--accent-primary)' }}
>
{isSubmitting ? <Loader2 size={15} className="animate-spin" /> : <Mail size={15} />} {isSubmitting ? <Loader2 size={15} className="animate-spin" /> : <Mail size={15} />}
Continue Continue
</button> </GhostBtn>
</form> </form>
{providers.length > 0 ? ( {providers.length > 0 ? (
@@ -227,24 +220,22 @@ export function SignInPage() {
<div className="grid gap-2 sm:grid-cols-2"> <div className="grid gap-2 sm:grid-cols-2">
{providers.includes('github') ? ( {providers.includes('github') ? (
<button <QuietBtn
type="button"
onClick={() => void signInWithGitHubProvider()} onClick={() => void signInWithGitHubProvider()}
className="inline-flex h-10 items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm font-medium text-[var(--text-primary)] transition-colors hover:border-[var(--border-default)]" className="!h-10 justify-center text-sm"
> >
<Github size={15} /> <Github size={15} />
GitHub GitHub
</button> </QuietBtn>
) : null} ) : null}
{providers.includes('google') ? ( {providers.includes('google') ? (
<button <QuietBtn
type="button"
onClick={() => void signInWithGoogleProvider()} onClick={() => void signInWithGoogleProvider()}
className="inline-flex h-10 items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm font-medium text-[var(--text-primary)] transition-colors hover:border-[var(--border-default)]" className="!h-10 justify-center text-sm"
> >
<GoogleMark /> <GoogleMark />
Google Google
</button> </QuietBtn>
) : null} ) : null}
</div> </div>
</> </>
@@ -280,7 +271,7 @@ export function AcceptInvitePage() {
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
if (sessionQuery.data) { if (sessionQuery.data) {
return <Navigate to="/projects" replace />; return <Navigate to="/" replace />;
} }
const boundEmail = inviteQuery.data?.email ?? undefined; const boundEmail = inviteQuery.data?.email ?? undefined;
@@ -297,7 +288,7 @@ export function AcceptInvitePage() {
// account, sign-in creates the browser session. // account, sign-in creates the browser session.
await signInWithEmail(effectiveEmail.trim(), password); await signInWithEmail(effectiveEmail.trim(), password);
await queryClient.invalidateQueries({ queryKey: ['auth-session'] }); await queryClient.invalidateQueries({ queryKey: ['auth-session'] });
navigate('/projects', { replace: true }); navigate('/', { replace: true });
} catch (exception) { } catch (exception) {
setError(exception instanceof Error ? exception.message : 'Failed to accept invite'); setError(exception instanceof Error ? exception.message : 'Failed to accept invite');
} finally { } finally {
@@ -369,15 +360,10 @@ export function AcceptInvitePage() {
/> />
</label> </label>
<button <GhostBtn type="submit" disabled={isSubmitting} className="!h-10 w-full justify-center">
type="submit"
disabled={isSubmitting}
className="mt-1 inline-flex h-10 w-full items-center justify-center gap-2 rounded-[var(--radius-md)] text-sm font-semibold text-[var(--accent-on)] shadow-lg transition-all disabled:cursor-not-allowed disabled:opacity-60"
style={{ background: 'var(--accent-primary)' }}
>
{isSubmitting ? <Loader2 size={15} className="animate-spin" /> : <User2 size={15} />} {isSubmitting ? <Loader2 size={15} className="animate-spin" /> : <User2 size={15} />}
Accept invite Accept invite
</button> </GhostBtn>
</form> </form>
) : null} ) : null}
@@ -487,15 +473,10 @@ export function SignUpPage() {
/> />
</label> </label>
<button <GhostBtn type="submit" disabled={isSubmitting || sessionQuery.isPending} className="!h-10 w-full justify-center">
type="submit"
disabled={isSubmitting || sessionQuery.isPending}
className="mt-1 inline-flex h-10 w-full items-center justify-center gap-2 rounded-[var(--radius-md)] text-sm font-semibold text-[var(--accent-on)] shadow-lg transition-all disabled:cursor-not-allowed disabled:opacity-60"
style={{ background: 'var(--accent-primary)' }}
>
{isSubmitting ? <Loader2 size={15} className="animate-spin" /> : <User2 size={15} />} {isSubmitting ? <Loader2 size={15} className="animate-spin" /> : <User2 size={15} />}
Create Account Create Account
</button> </GhostBtn>
</form> </form>
<div className="mt-5 flex items-center justify-between text-xs text-[var(--text-secondary)]"> <div className="mt-5 flex items-center justify-between text-xs text-[var(--text-secondary)]">
@@ -14,6 +14,18 @@ import { useDynamicFilter, useFilterState } from '@/lib/use-dynamic-filter';
import { FilterBar } from '@/shared/components'; import { FilterBar } from '@/shared/components';
import { demoBuilds } from '@/lib/demo-data'; import { demoBuilds } from '@/lib/demo-data';
import { formatRelative } from '@/lib/time'; import { formatRelative } from '@/lib/time';
import {
SCard,
SPageHead,
SPill,
SStat,
STable,
IconBtn,
QuietBtn,
Ticks,
type SCol,
} from '@/shared/components/sentry';
import { statusTone } from '@/shared/components/sentry-utils';
import { import {
Check, Check,
X, X,
@@ -24,8 +36,6 @@ import {
Box, Box,
Sparkles, Sparkles,
Filter, Filter,
Circle,
HardDrive,
} from 'lucide-react'; } from 'lucide-react';
const statusOptions: Array<{ value: '' | BuildStatus; label: string }> = [ const statusOptions: Array<{ value: '' | BuildStatus; label: string }> = [
@@ -50,26 +60,11 @@ const BUILD_SCHEMA: FilterConfig<BuildEntity>[] = [
}, },
]; ];
function StatusBadge({ status }: { status: BuildStatus }) { function BuildIcon({ status }: { status: BuildStatus }) {
const config = { if (status === 'success') return <Check size={14} className="text-[var(--success)]" />;
success: { color: 'var(--success)', bg: 'var(--success-soft)', Icon: Check, animate: false }, if (status === 'failed') return <X size={14} className="text-[var(--error)]" />;
failed: { color: 'var(--error)', bg: 'var(--error-soft)', Icon: X, animate: false }, if (status === 'running') return <Loader2 size={14} className="text-[var(--warning)] animate-spin" />;
running: { color: 'var(--warning)', bg: 'var(--warning-soft)', Icon: Loader2, animate: true }, return <Clock size={14} className="text-[var(--text-tertiary)]" />;
pending: { color: 'var(--text-tertiary)', bg: 'var(--surface-muted)', Icon: Clock, animate: false },
cancelled: { color: 'var(--text-tertiary)', bg: 'var(--surface-muted)', Icon: X, animate: false },
}[status] || { color: 'var(--text-tertiary)', bg: 'var(--surface-muted)', Icon: Circle, animate: false };
const { Icon } = config;
return (
<div
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium"
style={{ background: config.bg, color: config.color }}
>
<Icon size={12} className={config.animate ? 'animate-spin' : ''} />
{status}
</div>
);
} }
function bytesToHumanReadable(bytes: number): string { function bytesToHumanReadable(bytes: number): string {
@@ -149,109 +144,113 @@ export function BuildsPage() {
return { running, success, failed, total: builds.length }; return { running, success, failed, total: builds.length };
}, [builds]); }, [builds]);
const buildCols: SCol<BuildEntity>[] = [
{
key: 'build', label: 'Build', width: 'minmax(220px,1.4fr)',
sortValue: (b) => b.id,
render: (b) => (
<span className="flex items-center gap-2.5 min-w-0">
<span className="s-ibox"><BuildIcon status={b.status} /></span>
<span className="min-w-0">
<span className="block v-mono text-[12px] font-medium text-[var(--text-primary)] truncate">{b.id}</span>
<span className="block v-mono text-[10.5px] text-[var(--text-tertiary)] truncate">
{b.imageName || '—'}{b.imageTag ? `:${b.imageTag}` : ''}
</span>
</span>
</span>
),
},
{
key: 'status', label: 'Status', width: '110px',
sortValue: (b) => b.status,
render: (b) => <SPill tone={statusTone(b.status)}>{b.status}</SPill>,
},
{
key: 'progress', label: 'Progress', width: '1fr',
sortValue: (b) => b.progress,
render: (b) =>
b.status === 'running' || b.status === 'pending' ? (
<span className="flex items-center gap-2.5">
<Ticks pct={b.progress} count={40} />
<span className="v-mono text-[10.5px] text-[var(--text-tertiary)]">{b.progress}%</span>
</span>
) : (
<span className="v-mono text-[10.5px] text-[var(--text-muted)]">—</span>
),
},
{
key: 'size', label: 'Size', width: '90px',
sortValue: (b) => b.size,
render: (b) => <span className="v-mono text-[11.5px] text-[var(--text-secondary)]">{bytesToHumanReadable(b.size)}</span>,
},
{
key: 'service', label: 'Service', width: '1fr',
sortValue: (b) => b.serviceId ?? '',
render: (b) => <span className="v-mono text-[11px] text-[var(--text-tertiary)] truncate">{b.serviceId || '—'}</span>,
},
{
key: 'when', label: 'Started', width: '90px',
sortValue: (b) => b.startedAt ?? '',
render: (b) => <span className="v-mono text-[11px] text-[var(--text-tertiary)]">{b.startedAt ? formatRelative(b.startedAt) : '—'}</span>,
},
];
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
{/* Header */} <div className="w-full px-4 pt-6 sm:px-8">
<div className="border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/50 backdrop-blur-sm"> <SPageHead
<div className="w-full px-8 py-4"> title="Build Pipeline"
<div className="flex items-center justify-between gap-4"> titleAccent="_"
<div> sub="Monitor build progress and manage jobs"
<h1 className="v-title">Build Pipeline<span className="v-cursor">_</span></h1> actions={
<p className="text-sm text-[var(--text-secondary)]">Monitor build progress and manage jobs</p> <>
</div> <div className="flex items-center gap-2 text-[11px] v-mono">
<div className={`w-1.5 h-1.5 rounded-full ${liveStatus === 'live' ? 'bg-[var(--success)] animate-pulse' : 'bg-[var(--text-muted)]'}`} />
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 text-sm">
<div className={`w-2 h-2 rounded-full ${liveStatus === 'live' ? 'bg-[var(--success)] animate-pulse' : 'bg-[var(--text-muted)]'}`} />
<span className={liveStatus === 'live' ? 'text-[var(--success)]' : 'text-[var(--text-muted)]'}> <span className={liveStatus === 'live' ? 'text-[var(--success)]' : 'text-[var(--text-muted)]'}>
{liveStatus === 'live' ? 'Live' : liveStatus === 'offline' ? 'Reconnecting...' : 'Polling'} {liveStatus === 'live' ? 'live' : liveStatus === 'offline' ? 'reconnecting' : 'polling'}
</span> </span>
</div> </div>
<button <IconBtn onClick={() => queryClient.invalidateQueries({ queryKey: ['builds-page'] })} title="Refresh builds">
onClick={() => queryClient.invalidateQueries({ queryKey: ['builds-page'] })}
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm font-medium hover:border-[var(--border-default)] transition-colors"
>
<RefreshCw size={14} /> <RefreshCw size={14} />
Refresh </IconBtn>
</button> </>
</div> }
</div> />
</div>
</div> </div>
{/* Demo Mode Banner */} {/* Demo Mode Banner */}
{isDemoMode && ( {isDemoMode && (
<div className="w-full px-8 py-4"> <div className="w-full px-8 py-4">
<div className="px-4 py-3 rounded-[var(--radius-md)] border border-[var(--warning-soft)] bg-[var(--warning-soft)]/50"> <div className="s-inset flex items-center gap-2 text-xs text-[var(--warning)]">
<div className="flex items-center gap-2 text-sm text-[var(--warning)]"> <Sparkles size={13} />
<Sparkles size={16} /> Demo mode active — using sample data
<span>Demo mode active — using sample data</span>
</div>
</div> </div>
</div> </div>
)} )}
{/* Stats Overview */} <div className="w-full px-4 py-6 sm:px-8 space-y-4">
<div className="w-full px-8 py-6"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-3.5">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> <SStat icon={<Box />} label="Total Builds" value={stats.total} foot="all jobs" />
<div className="panel p-4"> <SStat icon={<Loader2 />} label="In Progress" value={stats.running} foot="building now" />
<div className="flex items-center justify-between mb-3"> <SStat icon={<Check />} label="Successful" value={stats.success} foot="completed ok" />
<div className="w-8 h-8 rounded-[var(--radius-sm)] bg-[var(--accent-primary-soft)] flex items-center justify-center"> <SStat icon={<X />} label="Failed" value={stats.failed} foot="need attention" />
<Box size={16} className="text-[var(--accent-primary)]" />
</div>
</div>
<p className="text-2xl font-semibold text-[var(--text-primary)]">{stats.total}</p>
<p className="text-xs text-[var(--text-muted)] mt-1">Total Builds</p>
</div>
<div className="panel p-4">
<div className="flex items-center justify-between mb-3">
<div className="w-8 h-8 rounded-[var(--radius-sm)] bg-[var(--warning-soft)] flex items-center justify-center">
<Loader2 size={16} className="text-[var(--warning)]" />
</div>
</div>
<p className="text-2xl font-semibold text-[var(--warning)]">{stats.running}</p>
<p className="text-xs text-[var(--text-muted)] mt-1">In Progress</p>
</div>
<div className="panel p-4">
<div className="flex items-center justify-between mb-3">
<div className="w-8 h-8 rounded-[var(--radius-sm)] bg-[var(--success-soft)] flex items-center justify-center">
<Check size={16} className="text-[var(--success)]" />
</div>
</div>
<p className="text-2xl font-semibold text-[var(--success)]">{stats.success}</p>
<p className="text-xs text-[var(--text-muted)] mt-1">Successful</p>
</div>
<div className="panel p-4">
<div className="flex items-center justify-between mb-3">
<div className="w-8 h-8 rounded-[var(--radius-sm)] bg-[var(--error-soft)] flex items-center justify-center">
<X size={16} className="text-[var(--error)]" />
</div>
</div>
<p className="text-2xl font-semibold text-[var(--error)]">{stats.failed}</p>
<p className="text-xs text-[var(--text-muted)] mt-1">Failed</p>
</div>
</div>
</div> </div>
{/* Filters */} <SCard icon={<Filter />} title="Filters"
<div className="w-full px-8"> trail={<span className="v-mono text-[10.5px] text-[var(--text-muted)]">{builds.length} jobs shown</span>}
<div className="panel p-4"> >
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-end gap-3">
<Filter size={16} className="text-[var(--text-tertiary)]" /> <div className="flex-1 min-w-[280px]">
<span className="text-sm font-medium text-[var(--text-secondary)]">Filters</span>
</div>
<div className="flex flex-wrap items-end gap-3 mt-3">
<div className="flex-1">
<FilterBar {...filter} /> <FilterBar {...filter} />
</div> </div>
<div> <div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2"> <label className="block text-[10.5px] font-medium uppercase tracking-wider text-[var(--text-muted)] mb-1.5">
Page Size Page Size
</label> </label>
<select <select
value={String(limit)} value={String(limit)}
onChange={(e) => setLimit(Number(e.target.value))} onChange={(e) => setLimit(Number(e.target.value))}
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm focus:border-[var(--accent-primary)] transition-colors" className="s-chip !cursor-pointer appearance-none pr-6"
> >
<option value="20">20</option> <option value="20">20</option>
<option value="50">50</option> <option value="50">50</option>
@@ -259,127 +258,41 @@ export function BuildsPage() {
</select> </select>
</div> </div>
</div> </div>
</div> </SCard>
</div>
{/* Build Table */} <SCard icon={<Box />} title="Build Queue" accent={`${builds.length} jobs`} pad={false}
<div className="w-full px-8 py-6"> trail={
<div className="panel overflow-hidden"> <IconBtn onClick={() => queryClient.invalidateQueries({ queryKey: ['builds-page'] })} title="Refresh">
<RefreshCw size={13} />
</IconBtn>
}
>
{!isDemoMode && buildsQuery.isLoading ? ( {!isDemoMode && buildsQuery.isLoading ? (
<div className="p-12 text-center"> <div className="p-12 text-center">
<Loader2 size={24} className="animate-spin mx-auto text-[var(--text-tertiary)]" /> <Loader2 size={24} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
<p className="mt-3 text-sm text-[var(--text-muted)]">Loading builds...</p> <p className="mt-3 text-sm text-[var(--text-muted)]">Loading builds...</p>
</div> </div>
) : null} ) : !isDemoMode && buildsQuery.isError ? (
<div className="p-10 text-center">
{!isDemoMode && buildsQuery.isError ? (
<div className="p-8 text-center">
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-[var(--error-soft)] flex items-center justify-center">
<X size={24} className="text-[var(--error)]" />
</div>
<p className="text-sm text-[var(--error)]">Failed to load builds</p> <p className="text-sm text-[var(--error)]">Failed to load builds</p>
<p className="text-xs text-[var(--text-muted)] mt-1">{buildsQuery.error instanceof Error ? buildsQuery.error.message : 'Unknown error'}</p> <p className="text-xs text-[var(--text-muted)] mt-1">{buildsQuery.error instanceof Error ? buildsQuery.error.message : 'Unknown error'}</p>
</div> </div>
) : null} ) : builds.length === 0 ? (
{builds.length === 0 && !buildsQuery.isLoading ? (
<div className="p-12 text-center"> <div className="p-12 text-center">
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-[var(--surface-muted)] flex items-center justify-center"> <div className="s-ibox mx-auto mb-3 !w-10 !h-10"><Box /></div>
<Box size={24} className="text-[var(--text-tertiary)]" />
</div>
<p className="text-sm text-[var(--text-muted)]">No builds match current filters</p> <p className="text-sm text-[var(--text-muted)]">No builds match current filters</p>
</div> </div>
) : null} ) : (
<div className="px-4">
{builds.length > 0 ? ( <STable
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 p-4"> cols={buildCols}
{builds.map((build) => ( rows={builds}
<div rowKey={(b) => b.id}
key={build.id} onRowClick={(b) => setSelectedBuild(b)}
className="panel p-4 group hover:border-[var(--accent-primary)]/30 transition-all duration-300 card-lift cursor-pointer"
onClick={() => setSelectedBuild(build)}
>
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-xl flex items-center justify-center ${
build.status === 'success'
? 'bg-[var(--success-soft)]'
: build.status === 'failed'
? 'bg-[var(--error-soft)]'
: build.status === 'running'
? 'bg-[var(--warning-soft)]'
: 'bg-[var(--surface-muted)]'
}`}>
{build.status === 'success' && <Check size={18} className="text-[var(--success)]" />}
{build.status === 'failed' && <X size={18} className="text-[var(--error)]" />}
{build.status === 'running' && <Loader2 size={18} className="text-[var(--warning)] animate-spin" />}
{build.status === 'pending' && <Clock size={18} className="text-[var(--text-tertiary)]" />}
{build.status === 'cancelled' && <X size={18} className="text-[var(--text-tertiary)]" />}
</div>
<div>
<p className="mono text-sm font-medium text-[var(--text-primary)]">{build.id}</p>
<div className="flex items-center gap-2 mt-1">
<StatusBadge status={build.status} />
<span className="text-xs text-[var(--text-tertiary)]">
{build.startedAt ? formatRelative(build.startedAt) : '—'}
</span>
</div>
</div>
</div>
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
<button className="p-2 rounded-lg hover:bg-[var(--surface-muted)] text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-all">
<FileText size={14} />
</button>
</div>
</div>
<div className="space-y-3">
{/* Image info */}
<div className="flex items-center justify-between text-xs">
<span className="text-[var(--text-tertiary)]">Image</span>
<span className="mono text-[var(--text-secondary)]">
{build.imageName || '—'}:{build.imageTag || 'latest'}
</span>
</div>
{/* Progress bar */}
{(build.status === 'running' || build.status === 'pending') && (
<div className="space-y-1.5">
<div className="flex items-center justify-between text-xs">
<span className="text-[var(--text-tertiary)]">Progress</span>
<span className="text-[var(--text-secondary)] font-medium">{build.progress}%</span>
</div>
<div className="h-2 rounded-full bg-[var(--surface-muted)] overflow-hidden">
<div
className="h-full rounded-full transition-all duration-300"
style={{ width: `${Math.max(0, Math.min(100, build.progress))}%`, background: 'var(--accent-primary)' }}
/> />
</div> </div>
</div>
)} )}
</SCard>
{/* 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>
</div> </div>
{/* Error Toast */} {/* Error Toast */}
@@ -393,27 +306,21 @@ export function BuildsPage() {
{selectedBuild && ( {selectedBuild && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-[var(--bg-void)]/80 backdrop-blur-sm" onClick={() => setSelectedBuild(null)} /> <div className="absolute inset-0 bg-[var(--bg-void)]/80 backdrop-blur-sm" onClick={() => setSelectedBuild(null)} />
<div className="relative w-full max-w-3xl panel p-6"> <div className="relative w-full max-w-3xl s-card">
<div className="flex items-center justify-between mb-4"> <div className="s-cardhead">
<div className="flex items-center gap-3"> <span className="s-ibox"><FileText /></span>
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<FileText size={20} className="text-[var(--accent-primary)]" />
</div>
<div> <div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Build Logs</h2> <div className="s-t">Build Logs</div>
<p className="mono text-xs text-[var(--text-tertiary)]">{selectedBuild.id}</p> <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>
</div> </div>
<button <div className="px-4 pb-4">
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="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-void)] p-4 max-h-[400px] overflow-auto"> <div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-void)] p-4 max-h-[400px] overflow-auto">
<pre className="mono text-xs text-[var(--text-secondary)] whitespace-pre-wrap break-all"> <pre className="v-mono text-xs text-[var(--text-secondary)] whitespace-pre-wrap break-all">
{isDemoMode {isDemoMode
? selectedBuild.log || '[demo] No logs available.' ? selectedBuild.log || '[demo] No logs available.'
: logsQuery.isLoading : logsQuery.isLoading
@@ -425,6 +332,7 @@ export function BuildsPage() {
</div> </div>
</div> </div>
</div> </div>
</div>
)} )}
</div> </div>
); );
@@ -0,0 +1,662 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQueries, useQuery } from '@tanstack/react-query';
import {
CartesianGrid,
Line,
LineChart as RechartsLineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import {
AlertTriangle,
Cpu,
FolderKanban,
HardDrive,
MemoryStick,
RefreshCw,
Rocket,
Server,
Settings,
} from 'lucide-react';
import {
getAgentMetrics,
getHostMonitoring,
listActiveAlerts,
listAgents,
listProjects,
listRecentDeployments,
type AgentMetricPoint,
type NodeAgentEntity,
} from '@/lib/api-client';
import { useAuthSession } from '@/lib/use-auth-session';
import { useDemoMode } from '@/lib/demo-mode';
import { demoHostMonitoring, demoProjects } from '@/lib/demo-data';
import {
GhostBtn,
IconBtn,
MiniBars,
SAnomaly,
SCard,
SLegend,
SPageHead,
SPill,
STable,
SStat,
Ticks,
type SCol,
} from '@/shared/components/sentry';
import { statusTone } from '@/shared/components/sentry-utils';
import { QuietBtn } from '@/shared/components/sentry';
/* ------------------------------------------------------------------ */
/* Demo fixtures — deterministic, clearly sample data. */
/* ------------------------------------------------------------------ */
const demoAgents: NodeAgentEntity[] = [
{
id: 'demo-node-1',
name: 'core-01',
hostname: 'core-01.containr.local',
ipAddress: '10.0.1.11',
port: 9090,
status: 'online',
version: '0.9.2',
capabilities: {
containerRuntimes: ['docker'],
supportedArchitectures: ['amd64'],
maxContainers: 64,
storageDriver: 'overlay2',
networkPlugins: ['bridge'],
features: [],
},
resources: {
cpu: { cores: 8, allocation: 8, usage: 2.6 },
memory: { total: 16_662_000_000, allocated: 0, used: 9_832_000_000, available: 6_830_000_000 },
storage: { total: 240_000_000_000, allocated: 0, used: 141_000_000_000, available: 99_000_000_000 },
},
autoPrune: true,
schedulable: true,
tags: ['prod'],
metadata: {},
lastHeartbeat: new Date(Date.now() - 42_000).toISOString(),
},
{
id: 'demo-node-2',
name: 'edge-01',
hostname: 'edge-01.containr.local',
ipAddress: '10.0.2.4',
port: 9090,
status: 'degraded',
version: '0.9.2',
capabilities: {
containerRuntimes: ['docker'],
supportedArchitectures: ['arm64'],
maxContainers: 32,
storageDriver: 'overlay2',
networkPlugins: ['bridge'],
features: [],
},
resources: {
cpu: { cores: 4, allocation: 4, usage: 3.4 },
memory: { total: 8_290_000_000, allocated: 0, used: 7_110_000_000, available: 1_180_000_000 },
storage: { total: 120_000_000_000, allocated: 0, used: 98_000_000_000, available: 22_000_000_000 },
},
autoPrune: false,
schedulable: true,
tags: ['edge'],
metadata: {},
lastHeartbeat: new Date(Date.now() - 610_000).toISOString(),
},
];
/* Deterministic pseudo metric series for demo mode (sine + wobble). */
function demoMetricSeries(seed: number, points = 48): AgentMetricPoint[] {
const now = Date.now();
return Array.from({ length: points }, (_, i) => {
const t = now - (points - 1 - i) * 30 * 60_000;
const wave = Math.sin(i / 5.5 + seed) * 14 + Math.sin(i / 2.3 + seed * 2) * 6;
const cpu = Math.max(4, Math.min(96, 42 + wave + seed * 9));
const mem = Math.max(20, Math.min(95, 55 + wave / 2 + seed * 6));
return {
timestamp: new Date(t).toISOString(),
cpu: { usage: cpu, usage_percent: cpu, cores: 8 },
memory: { usage: mem, usage_percent: mem, limit: 100, available: 100 - mem },
system_load: { load_1m: cpu / 12, load_5m: cpu / 14, load_15m: cpu / 16 },
container_count: 12 + seed * 5,
};
});
}
const demoDeployRows = [
{ id: 'd1', serviceId: 's1', serviceName: 'api-gateway', projectName: 'Core Platform', status: 'deployed', imageName: undefined, startedAt: new Date(Date.now() - 240_000).toISOString(), completedAt: undefined, createdAt: '' },
{ id: 'd2', serviceId: 's2', serviceName: 'web-frontend', projectName: 'Core Platform', status: 'building', imageName: undefined, startedAt: new Date(Date.now() - 390_000).toISOString(), completedAt: undefined, createdAt: '' },
{ id: 'd3', serviceId: 's3', serviceName: 'batch-evaluator', projectName: 'Inference Lab', status: 'failed', imageName: undefined, startedAt: new Date(Date.now() - 3_900_000).toISOString(), completedAt: undefined, createdAt: '' },
{ id: 'd4', serviceId: 's4', serviceName: 'marketing-site', projectName: 'Growth Surface', status: 'deployed', imageName: undefined, startedAt: new Date(Date.now() - 7_400_000).toISOString(), completedAt: undefined, createdAt: '' },
];
/* ------------------------------------------------------------------ */
const SERIES_COLORS = ['#38bdf8', '#1cd8ec', '#a271d9', '#45e896'];
const MAX_CHART_AGENTS = 4;
type RangeKey = '1h' | '24h' | '7d';
function formatRelative(date?: string): string {
if (!date) return '—';
const diff = Date.now() - new Date(date).getTime();
if (diff < 0 || diff > 365 * 86_400_000) return '—';
if (diff < 60_000) return 'just now';
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
return `${Math.floor(diff / 86_400_000)}d ago`;
}
const fmtGB = (v: number) => `${(v / (1024 * 1024 * 1024)).toFixed(0)}G`;
const pct = (used: number, total: number) => (total > 0 ? Math.round((used / total) * 100) : 0);
/* Merge per-agent metric arrays into recharts rows keyed by index. */
function mergeSeries(seriesList: { key: string; points: AgentMetricPoint[] }[]) {
const len = Math.max(...seriesList.map((s) => s.points.length), 0);
return Array.from({ length: len }, (_, i) => {
const row: Record<string, number | string> = {};
seriesList.forEach((s) => {
const p = s.points[Math.min(i, s.points.length - 1)];
if (p) {
row.t = row.t ?? new Date(p.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
row[s.key] = Math.round(p.cpu.usage_percent * 10) / 10;
}
});
return row;
});
}
function ChartTooltip({ active, payload, label }: {
active?: boolean;
payload?: Array<{ name: string; value: number; color: string }>;
label?: string;
}) {
if (!active || !payload?.length) return null;
return (
<div className="s-tip">
<div className="s-tip-t">{label}</div>
{payload.map((p) => (
<div key={p.name} className="s-tip-r">
<span><i style={{ background: p.color }} />{p.name}</span>
<b>{p.value}<small>%</small></b>
</div>
))}
</div>
);
}
export function DashboardPage() {
const navigate = useNavigate();
const isDemoMode = useDemoMode();
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
const signedIn = isDemoMode || Boolean(sessionQuery.data);
const userName = sessionQuery.data?.user.name;
const firstName = isDemoMode ? 'Operator' : userName?.split(' ')[0];
const [range, setRange] = useState<RangeKey>('24h');
const projectsQuery = useQuery({
queryKey: ['projects'],
enabled: !isDemoMode,
queryFn: () => listProjects(),
});
const hostQuery = useQuery({
queryKey: ['host-monitoring'],
enabled: !isDemoMode,
queryFn: getHostMonitoring,
refetchInterval: 30_000,
});
// Agents are admin-scoped; failures collapse to an empty list, never an error state.
const agentsQuery = useQuery({
queryKey: ['agents'],
enabled: !isDemoMode && signedIn,
queryFn: listAgents,
retry: false,
staleTime: 30_000,
});
const deploysQuery = useQuery({
queryKey: ['recent-deploys', 50],
enabled: !isDemoMode,
queryFn: () => listRecentDeployments(50),
refetchInterval: 30_000,
});
const alertsQuery = useQuery({
queryKey: ['ha-alerts'],
enabled: !isDemoMode && signedIn,
queryFn: listActiveAlerts,
retry: false,
});
const projects = isDemoMode ? demoProjects : projectsQuery.data ?? [];
const host = isDemoMode ? demoHostMonitoring : hostQuery.data;
const agents = isDemoMode ? demoAgents : agentsQuery.data ?? [];
const deployments = isDemoMode ? demoDeployRows : deploysQuery.data ?? [];
const alerts = isDemoMode ? [{ id: 'a1', severity: 'warning' as const, message: 'Disk I/O spikes at 03:22' }] : alertsQuery.data ?? [];
const chartAgents = agents.slice(0, MAX_CHART_AGENTS);
const metricQueries = useQueries({
queries: chartAgents.map((a) => ({
queryKey: ['agent-metrics', a.id, range],
queryFn: () => getAgentMetrics(a.id, range),
enabled: !isDemoMode && agents.length > 0,
staleTime: 60_000,
retry: false,
})),
});
/* Series: real per-agent CPU%; demo synth; empty otherwise.
Plain computation — the compiler memoizes it. */
const seriesList = chartAgents.map((a, i) => ({
key: a.name || a.hostname || `node-${i + 1}`,
points: isDemoMode ? demoMetricSeries(i + 1) : metricQueries[i]?.data ?? [],
}));
const chartData = {
rows: mergeSeries(seriesList),
series: seriesList.map((s, i) => ({ key: s.key, color: SERIES_COLORS[i % SERIES_COLORS.length] })),
hasData: seriesList.some((s) => s.points.length > 1),
};
const hostLabel = host?.scope === 'docker-desktop-vm' ? 'VM' : 'Host';
const cpuPct = host && host.cpu.cores > 0
? Math.min(100, Math.round((host.load.load1m / host.cpu.cores) * 100))
: null;
const memPct = host ? Math.round(host.memory.usagePercent) : null;
const diskPct = host ? Math.round(host.storage.usagePercent) : null;
const totalServices = projects.reduce((sum, p) => sum + p.stats.service_count, 0);
const runningServices = projects.reduce((sum, p) => sum + p.stats.running_services, 0);
const onlineAgents = agents.filter((a) => a.status === 'online').length;
const succeeded = deployments.filter((d) => !['failed', 'error'].includes((d.status ?? '').toLowerCase())).length;
const anomalies = [
...(diskPct !== null && diskPct > 85 ? [`Disk usage at ${diskPct}% — prune images or expand volume`] : []),
...(memPct !== null && memPct > 85 ? [`Memory pressure at ${memPct}% — rebalance services`] : []),
...alerts.slice(0, 3).map((a) => a.message).filter((m): m is string => Boolean(m)),
...(deployments.some((d) => (d.status ?? '').toLowerCase() === 'failed')
? [`${deployments.filter((d) => (d.status ?? '').toLowerCase() === 'failed').length} failed deployment${deployments.filter((d) => (d.status ?? '').toLowerCase() === 'failed').length > 1 ? 's' : ''} in the feed`]
: []),
];
const statDefs = [
{
icon: <Server size={13.5} />,
label: 'Active Nodes',
value: agents.length > 0 ? onlineAgents : host?.dockerAvailable ? 1 : '—',
unit: agents.length > 0 ? `of ${agents.length}` : 'local',
ticks: null,
foot: agents.length > 0 ? `${agents.length - onlineAgents} offline or degraded` : 'single-host install',
delta: undefined,
},
{
icon: <Cpu size={13.5} />,
label: `${hostLabel} CPU`,
value: cpuPct ?? '—',
unit: cpuPct !== null ? '%' : undefined,
ticks: cpuPct,
foot: host ? `${host.cpu.cores} cores · load avg` : 'no telemetry',
delta: undefined,
},
{
icon: <MemoryStick size={13.5} />,
label: 'Memory Usage',
value: memPct ?? '—',
unit: memPct !== null ? '%' : undefined,
ticks: memPct,
foot: host ? `${fmtGB(host.memory.used)} / ${fmtGB(host.memory.total)} used` : 'no telemetry',
delta: undefined,
},
{
icon: <Rocket size={13.5} />,
label: 'Deployments',
value: deployments.length,
unit: 'recent',
ticks: null,
foot: `${succeeded} succeeded`,
delta: deployments.length > 0 ? { dir: 'up' as const, text: `+${deployments.length}` } : undefined,
},
];
type NodeRow = {
id: string;
name: string;
address: string;
status: string;
cpu: number | null;
mem: number | null;
disk: number | null;
containers: string;
heartbeat: string;
};
const nodeRows: NodeRow[] = agents.length > 0
? agents.map((a) => ({
id: a.id,
name: a.name || a.hostname,
address: `${a.ipAddress}${a.port ? `:${a.port}` : ''}`,
status: a.status,
cpu: a.resources.cpu.cores > 0 ? Math.round((a.resources.cpu.usage / a.resources.cpu.cores) * 100) : null,
mem: pct(a.resources.memory.used, a.resources.memory.total),
disk: pct(a.resources.storage.used, a.resources.storage.total),
containers: String(a.capabilities.maxContainers ? `≤${a.capabilities.maxContainers}` : '—'),
heartbeat: formatRelative(a.lastHeartbeat),
}))
: host
? [{
id: 'local',
name: host.hostname || 'local host',
address: `${host.os}/${host.architecture}`,
status: host.dockerAvailable ? 'online' : 'degraded',
cpu: cpuPct,
mem: memPct,
disk: diskPct,
containers: host.docker?.containers !== undefined ? String(host.docker.containers) : '—',
heartbeat: 'live',
}]
: [];
const metricCell = (v: number | null) =>
v === null ? <span className="text-[var(--text-muted)]">—</span> : (
<span className="inline-flex items-center gap-2">
<MiniBars tone={v > 85 ? 'var(--error)' : v > 70 ? 'var(--warning)' : 'var(--accent-primary)'} />
<span className="text-[12.5px] font-medium text-[var(--text-primary)]">{v}<small className="text-[var(--text-tertiary)] font-normal">%</small></span>
</span>
);
const nodeCols: SCol<NodeRow>[] = [
{ key: 'name', label: 'Node', width: '1.7fr', sortValue: (r) => r.name,
render: (r) => <span className="text-[var(--text-primary)] font-medium">{r.name}</span> },
{ key: 'address', label: 'Address', width: '1.15fr', sortValue: (r) => r.address,
render: (r) => <span className="v-mono text-[11.5px] text-[var(--text-secondary)]">{r.address}</span> },
{ key: 'status', label: 'Status', width: '0.85fr', sortValue: (r) => r.status,
render: (r) => <SPill tone={statusTone(r.status)}>{r.status}</SPill> },
{ key: 'cpu', label: 'CPU', width: '0.9fr', sortValue: (r) => r.cpu ?? -1, render: (r) => metricCell(r.cpu) },
{ key: 'mem', label: 'Memory', width: '0.9fr', sortValue: (r) => r.mem ?? -1, render: (r) => metricCell(r.mem) },
{ key: 'disk', label: 'Disk', width: '0.9fr', sortValue: (r) => r.disk ?? -1, render: (r) => metricCell(r.disk) },
{ key: 'containers', label: 'Capacity', width: '0.8fr', sortable: false,
render: (r) => <span className="v-mono text-[11.5px] text-[var(--text-secondary)]">{r.containers}</span> },
{ key: 'heartbeat', label: 'Heartbeat', width: '0.8fr', sortable: false,
render: (r) => <span className="v-mono text-[11.5px] text-[var(--text-tertiary)]">{r.heartbeat}</span> },
];
type DeployRow = { id: string; service: string; project: string; status: string; when: string };
const deployRows: DeployRow[] = deployments.slice(0, 7).map((d) => ({
id: d.id,
service: d.serviceName || d.imageName || d.serviceId || d.id,
project: d.projectName || '—',
status: d.status || 'queued',
when: formatRelative(d.startedAt ?? d.completedAt ?? d.createdAt),
}));
const deployCols: SCol<DeployRow>[] = [
{ key: 'service', label: 'Service', width: '1.6fr', sortValue: (r) => r.service,
render: (r) => (
<span className="text-[var(--text-primary)] font-medium">
{r.service}
<span className="v-mono ml-2 text-[10.5px] font-normal text-[var(--text-tertiary)]">{r.project}</span>
</span>
) },
{ key: 'status', label: 'Status', width: '0.9fr', sortValue: (r) => r.status,
render: (r) => <SPill tone={statusTone(r.status)}>{r.status}</SPill> },
{ key: 'when', label: 'When', width: '0.55fr', sortable: false,
render: (r) => <span className="v-mono text-[11.5px] text-[var(--text-tertiary)]">{r.when}</span> },
];
const refetchAll = () => {
projectsQuery.refetch();
hostQuery.refetch();
deploysQuery.refetch();
agentsQuery.refetch();
alertsQuery.refetch();
metricQueries.forEach((q) => q.refetch());
};
return (
<div className="min-h-screen">
<div className="w-full px-4 py-6 sm:px-7">
<SPageHead
title={firstName ? 'Welcome back,' : 'Fleet'}
titleAccent={firstName ?? 'overview'}
sub={
<>
{String(runningServices).padStart(2, '0')} of {String(totalServices).padStart(2, '0')} services online ·{' '}
</>
}
subAccent={agents.length > 0 ? `${onlineAgents}/${agents.length} nodes live` : 'single host'}
actions={
<>
{signedIn && (
<GhostBtn onClick={() => navigate('/projects')}>+ New Project</GhostBtn>
)}
<QuietBtn onClick={() => navigate('/operations')}>
<AlertTriangle size={13} />
View alerts
</QuietBtn>
<IconBtn title="Refresh" onClick={refetchAll}>
<RefreshCw size={14} />
</IconBtn>
<IconBtn title="Settings" onClick={() => navigate('/settings')}>
<Settings size={14} />
</IconBtn>
</>
}
/>
{/* Stat row */}
<div className="mb-4 grid grid-cols-2 gap-3.5 xl:grid-cols-4">
{statDefs.map((s) => (
<SStat
key={s.label}
icon={s.icon}
label={s.label}
value={s.value}
unit={s.unit}
foot={s.foot}
delta={s.delta}
extra={s.ticks !== null && s.ticks !== undefined ? <Ticks pct={s.ticks} count={44} warn={s.ticks > 85} /> : undefined}
/>
))}
</div>
{/* Chart + resource usage */}
<div className="mb-4 grid grid-cols-1 gap-3.5 xl:grid-cols-[2.32fr_1fr]">
<SCard
icon={<Cpu size={13.5} />}
title="Node CPU Load"
accent={`(${range})`}
trail={
<IconBtn title="Refresh" onClick={refetchAll}>
<RefreshCw size={13} />
</IconBtn>
}
pad={false}
>
<SLegend items={chartData.series.map((s) => ({ color: s.color, label: s.key }))} />
<div className="px-3 pb-3">
{chartData.hasData ? (
<ResponsiveContainer width="100%" height={292} initialDimension={{ width: 640, height: 292 }}>
<RechartsLineChart data={chartData.rows} margin={{ top: 14, right: 14, bottom: 4, left: -14 }}>
<CartesianGrid stroke="var(--border-subtle)" vertical={false} />
<XAxis
dataKey="t"
tick={{ fill: 'var(--text-tertiary)', fontSize: 10.5, fontFamily: 'IBM Plex Mono' }}
tickLine={false}
axisLine={{ stroke: 'var(--border-subtle)' }}
minTickGap={48}
/>
<YAxis
domain={[0, 100]}
tick={{ fill: 'var(--text-tertiary)', fontSize: 10, fontFamily: 'IBM Plex Mono' }}
tickLine={false}
axisLine={false}
tickFormatter={(v: number) => `${v}%`}
/>
<Tooltip content={<ChartTooltip />} cursor={{ stroke: 'var(--border-default)' }} />
{chartData.series.map((s, i) => (
<Line
key={s.key}
dataKey={s.key}
stroke={s.color}
strokeWidth={i === 0 ? 1.6 : 1.3}
dot={false}
isAnimationActive={false}
/>
))}
</RechartsLineChart>
</ResponsiveContainer>
) : (
<div className="flex h-[292px] flex-col items-center justify-center gap-2 text-center">
<Server size={22} className="text-[var(--text-tertiary)]" />
<p className="text-[12.5px] text-[var(--text-secondary)]">No node telemetry yet</p>
<p className="max-w-xs text-[11px] text-[var(--text-tertiary)]">
Enroll a Containr agent on a host to chart per-node CPU over time.
</p>
</div>
)}
</div>
{/* range picker rendered as a real control row under the chart */}
<div className="flex items-center gap-1.5 border-t border-[var(--border-subtle)] px-4 py-2">
{(['1h', '24h', '7d'] as RangeKey[]).map((r) => (
<button
key={r}
onClick={() => setRange(r)}
className={`rounded-[5px] px-2 py-1 text-[10.5px] font-medium transition-colors ${
range === r
? 'bg-[var(--nav-active)] text-[var(--text-primary)]'
: 'text-[var(--text-tertiary)] hover:text-[var(--text-primary)]'
}`}
>
{r}
</button>
))}
<span className="v-mono ml-auto text-[10.5px] text-[var(--text-muted)]">
{chartAgents.length > 0 ? `${chartAgents.length} node${chartAgents.length > 1 ? 's' : ''} sampled` : 'awaiting agents'}
</span>
</div>
</SCard>
<SCard
icon={<HardDrive size={13.5} />}
title="Resource Usage"
accent="(host)"
pad={false}
>
<div className="px-4 pt-1">
{[
{ label: 'CPU Load', v: cpuPct },
{ label: 'Memory', v: memPct },
{ label: 'Disk I/O', v: diskPct },
{ label: 'Services online', v: totalServices > 0 ? Math.round((runningServices / totalServices) * 100) : null },
].map((r) => (
<div key={r.label} className="py-2">
<div className="mb-2 flex items-baseline justify-between text-[12px]">
<span className="font-medium text-[var(--text-secondary)]">{r.label}</span>
<b className="font-semibold text-[var(--accent-primary)]">{r.v !== null ? `${r.v}%` : '—'}</b>
</div>
<Ticks pct={r.v ?? 0} count={56} warn={(r.v ?? 0) > 85} />
</div>
))}
</div>
<div className="p-4 pt-2.5">
<SAnomaly
icon={<AlertTriangle size={12.5} className="text-[var(--accent-primary)]" />}
title={
<>
<b className="font-semibold text-[var(--text-primary)]">{anomalies.length}</b>
{anomalies.length === 1 ? ' anomaly' : ' anomalies'} detected
</>
}
items={anomalies.length > 0 ? anomalies : ['All systems nominal']}
/>
</div>
</SCard>
</div>
{/* Node summary */}
<SCard
icon={<Server size={13.5} />}
title="Node Summary"
trail={
<IconBtn title="Refresh" onClick={() => agentsQuery.refetch()}>
<RefreshCw size={13} />
</IconBtn>
}
pad={false}
>
<STable
cols={nodeCols}
rows={nodeRows}
rowKey={(r) => r.id}
selectable
onRowClick={(r) => { if (r.id !== 'local') navigate(`/nodes/${r.id}`); }}
/>
{nodeRows.length === 0 && (
<p className="px-7 pb-5 text-[12px] text-[var(--text-tertiary)]">
No nodes enrolled. The local host appears here once telemetry arrives.
</p>
)}
</SCard>
{/* Deployments + projects */}
<div className="mt-4 grid grid-cols-1 gap-3.5 xl:grid-cols-[1.6fr_1fr]">
<SCard
icon={<Rocket size={13.5} />}
title="Recent Deployments"
accent={deployRows.length > 0 ? `(${deployments.length})` : undefined}
trail={
<IconBtn title="Refresh" onClick={() => deploysQuery.refetch()}>
<RefreshCw size={13} />
</IconBtn>
}
pad={false}
>
<STable cols={deployCols} rows={deployRows} rowKey={(r) => r.id} />
{deployRows.length === 0 && (
<p className="px-7 pb-5 text-[12px] text-[var(--text-tertiary)]">No deployments yet.</p>
)}
</SCard>
<SCard
icon={<FolderKanban size={13.5} />}
title="Projects"
accent={`(${projects.length})`}
pad={false}
>
<div className="px-2 pb-2">
{projects.slice(0, 6).map((p) => {
const running = p.stats.running_services;
const total = p.stats.service_count;
const tone = total === 0 ? 'off' : running === total ? 'ok' : running > 0 ? 'warn' : 'err';
return (
<button
key={p.id}
onClick={() => navigate(isDemoMode ? `/projects/${p.id}?demo=1` : `/projects/${p.id}`)}
className="flex w-full items-center gap-3 rounded-[8px] px-3 py-2.5 text-left transition-colors hover:bg-[var(--surface-muted)]"
>
<span className="s-ibox"><FolderKanban size={13.5} /></span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[12.5px] font-medium text-[var(--text-primary)]">{p.name}</span>
<span className="v-mono block text-[10.5px] text-[var(--text-tertiary)]">
{running}/{total} services · {p.stats.deployment_count} deploys
</span>
</span>
<SPill tone={tone}>{total === 0 ? 'idle' : running === total ? 'healthy' : running > 0 ? 'partial' : 'down'}</SPill>
</button>
);
})}
{projects.length === 0 && (
<p className="px-5 py-4 text-[12px] text-[var(--text-tertiary)]">No projects yet.</p>
)}
</div>
</SCard>
</div>
</div>
</div>
);
}
@@ -15,6 +15,7 @@ function renderMarkdown(markdown: string): string {
function excerpt(markdown: string, length = 140): string { function excerpt(markdown: string, length = 140): string {
const text = markdown const text = markdown
.replace(/^#[^\n]*\n/, ' ')
.replace(/```[\s\S]*?```/g, ' ') .replace(/```[\s\S]*?```/g, ' ')
.replace(/[#>*`\-[\]()]/g, ' ') .replace(/[#>*`\-[\]()]/g, ' ')
.replace(/\s+/g, ' ') .replace(/\s+/g, ' ')
@@ -119,7 +120,7 @@ export function DocsBrowser() {
<div className="min-w-0 flex-1 overflow-y-auto max-md:hidden"> <div className="min-w-0 flex-1 overflow-y-auto max-md:hidden">
{selected ? ( {selected ? (
<article className="docs-article px-8 py-6"> <article className="docs-article px-4 py-6 sm:px-8">
<p className="mb-1 text-[11px] font-medium uppercase tracking-wider text-[var(--text-tertiary)]"> <p className="mb-1 text-[11px] font-medium uppercase tracking-wider text-[var(--text-tertiary)]">
{selected.section} / {selected.path} {selected.section} / {selected.path}
</p> </p>
+154 -86
View File
@@ -1,3 +1,4 @@
import { useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { import {
@@ -8,16 +9,18 @@ import {
GitBranch, GitBranch,
LayoutTemplate, LayoutTemplate,
Rocket, Rocket,
Server,
ShieldCheck, ShieldCheck,
Terminal,
Zap, Zap,
} from 'lucide-react'; } from 'lucide-react';
import { listProjects, listTemplates } from '@/lib/api-client'; import { getHostMonitoring, listProjects, listRecentDeployments, listTemplates } from '@/lib/api-client';
import { DocsBrowser } from '@/features/docs/DocsBrowser'; import { DocsBrowser } from '@/features/docs/DocsBrowser';
import { useAuthSession } from '@/lib/use-auth-session'; import { useAuthSession } from '@/lib/use-auth-session';
import { useDemoMode } from '@/lib/demo-mode'; import { useDemoMode } from '@/lib/demo-mode';
import { useBranding } from '@/lib/use-branding'; import { useBranding } from '@/lib/use-branding';
import { BrandWordmark } from '@/shared/components'; import { BrandWordmark, MiniBars, SPill, Ticks } from '@/shared/components';
import { demoProjects, demoTemplates } from '@/lib/demo-data'; import { demoHostMonitoring, demoProjects, demoTemplates } from '@/lib/demo-data';
const features = [ const features = [
{ icon: Rocket, title: 'Git-push deploys', text: 'Connect a repository and ship. Builds, rollouts, and rollbacks are handled for you.' }, { icon: Rocket, title: 'Git-push deploys', text: 'Connect a repository and ship. Builds, rollouts, and rollbacks are handled for you.' },
@@ -28,7 +31,14 @@ const features = [
{ icon: Zap, title: 'Autoscaling', text: 'Metric-driven scaling policies adapt replicas to real load.' }, { icon: Zap, title: 'Autoscaling', text: 'Metric-driven scaling policies adapt replicas to real load.' },
]; ];
const demoDeploys = [
{ id: 'd1', serviceName: 'api-gateway', status: 'deployed', startedAt: new Date(Date.now() - 240_000).toISOString() },
{ id: 'd2', serviceName: 'web-frontend', status: 'building', startedAt: new Date(Date.now() - 390_000).toISOString() },
{ id: 'd3', serviceName: 'batch-evaluator', status: 'failed', startedAt: new Date(Date.now() - 3_900_000).toISOString() },
];
export function LandingPage() { export function LandingPage() {
const [now] = useState(() => Date.now());
const isDemoMode = useDemoMode(); const isDemoMode = useDemoMode();
const { logoUrl, productName } = useBranding(); const { logoUrl, productName } = useBranding();
const sessionQuery = useAuthSession({ enabled: !isDemoMode }); const sessionQuery = useAuthSession({ enabled: !isDemoMode });
@@ -37,19 +47,35 @@ export function LandingPage() {
const projectsQuery = useQuery({ queryKey: ['landing-projects'], queryFn: () => listProjects(), staleTime: 60_000, enabled: !isDemoMode }); const projectsQuery = useQuery({ queryKey: ['landing-projects'], queryFn: () => listProjects(), staleTime: 60_000, enabled: !isDemoMode });
const templatesQuery = useQuery({ queryKey: ['landing-templates'], queryFn: () => listTemplates(), staleTime: 60_000, enabled: !isDemoMode }); const templatesQuery = useQuery({ queryKey: ['landing-templates'], queryFn: () => listTemplates(), staleTime: 60_000, enabled: !isDemoMode });
const hostQuery = useQuery({ queryKey: ['landing-host'], queryFn: getHostMonitoring, staleTime: 30_000, enabled: !isDemoMode });
const deploysQuery = useQuery({ queryKey: ['landing-deploys'], queryFn: () => listRecentDeployments(4), staleTime: 30_000, enabled: !isDemoMode });
const projects = isDemoMode ? demoProjects : (projectsQuery.data ?? []); const projects = isDemoMode ? demoProjects : (projectsQuery.data ?? []);
const templates = (isDemoMode ? demoTemplates : (templatesQuery.data ?? [])).slice(0, 12); const templates = (isDemoMode ? demoTemplates : (templatesQuery.data ?? [])).slice(0, 12);
const host = isDemoMode ? demoHostMonitoring : hostQuery.data;
const deploys = isDemoMode ? demoDeploys : (deploysQuery.data ?? []).slice(0, 3);
const cpuPct = host ? Math.min(100, Math.round((host.load.load1m / Math.max(1, host.cpu.cores)) * 100)) : null;
const memPct = host ? Math.round(host.memory.usagePercent) : null;
const diskPct = host ? Math.round(host.storage.usagePercent) : null;
const rel = (d?: string) => {
if (!d) return '—';
const s = Math.floor((now - new Date(d).getTime()) / 1000);
if (s < 60) return 'just now';
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
return `${Math.floor(s / 86400)}d ago`;
};
return ( return (
<div className="min-h-screen bg-[var(--bg-void)] text-[var(--text-secondary)]"> <div className="min-h-screen bg-[var(--bg-void)] text-[var(--text-secondary)]">
<div className="ambient-glow" /> {/* ── Top bar ── */}
<header className="sticky top-0 z-40 border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/80 backdrop-blur-xl"> <header className="sticky top-0 z-40 border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/80 backdrop-blur-xl">
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-5"> <div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-5">
<Link to="/" className="flex items-center gap-2.5"> <Link to="/" className="flex items-center gap-2.5">
<img src={logoUrl || '/containr.svg'} alt={productName} className="h-7 w-7 rounded-lg object-contain" /> <img src={logoUrl || '/containr.svg'} alt={productName} className="h-7 w-7 rounded-lg object-contain" />
<span className="text-[15px] font-extrabold tracking-tight text-[var(--text-primary)]"> <span className="font-headline text-[15px] font-semibold tracking-tight text-[var(--text-primary)]">
<BrandWordmark /> <BrandWordmark />
</span> </span>
</Link> </Link>
@@ -57,112 +83,160 @@ export function LandingPage() {
<a href="#features" className="rounded-[var(--radius-md)] px-3 py-1.5 text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] max-sm:hidden">Features</a> <a href="#features" className="rounded-[var(--radius-md)] px-3 py-1.5 text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] max-sm:hidden">Features</a>
<a href="#templates" className="rounded-[var(--radius-md)] px-3 py-1.5 text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] max-sm:hidden">Templates</a> <a href="#templates" className="rounded-[var(--radius-md)] px-3 py-1.5 text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] max-sm:hidden">Templates</a>
<a href="#docs" className="rounded-[var(--radius-md)] px-3 py-1.5 text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] max-sm:hidden">Docs</a> <a href="#docs" className="rounded-[var(--radius-md)] px-3 py-1.5 text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] max-sm:hidden">Docs</a>
<Link <Link to={`/projects${demo}`} className="rounded-[var(--radius-md)] px-3 py-1.5 font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)]">
to={`/projects${demo}`}
className="rounded-[var(--radius-md)] px-3 py-1.5 font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)]"
>
Browse Browse
</Link> </Link>
{signedIn ? ( {signedIn || isDemoMode ? (
<Link <Link to={`/${demo}`} className="ml-1 s-btn-accent">
to="/projects"
className="ml-1 inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-3.5 py-1.5 font-semibold text-[var(--accent-on)]"
>
Console <ArrowRight size={13} /> Console <ArrowRight size={13} />
</Link> </Link>
) : ( ) : (
<Link <Link to="/auth/sign-in" className="ml-1 s-btn-accent">
to={isDemoMode ? `/projects${demo}` : '/auth/sign-in'} Sign in <ArrowRight size={13} />
className="ml-1 inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-3.5 py-1.5 font-semibold text-[var(--accent-on)]"
>
{isDemoMode ? 'Live demo' : 'Sign in'} <ArrowRight size={13} />
</Link> </Link>
)} )}
</nav> </nav>
</div> </div>
</header> </header>
<main className="relative"> <main>
<section className="mx-auto max-w-6xl px-5 pb-16 pt-20 text-center"> {/* ── Hero + live instance window ── */}
<p className="mx-auto mb-5 inline-flex items-center gap-2 rounded-full border border-[var(--border-subtle)] bg-[var(--surface-card)] px-3.5 py-1 text-[11px] font-medium uppercase tracking-[0.16em] text-[var(--text-tertiary)]"> <section className="border-b border-[var(--border-subtle)]">
<Container size={12} className="text-[var(--accent-primary)]" /> <div className="mx-auto grid max-w-6xl gap-10 px-5 pb-16 pt-16 lg:grid-cols-[1.1fr_1fr] lg:items-center">
<div>
<p className="s-chip mb-6 inline-flex">
<Container size={11} />
self-hosted PaaS self-hosted PaaS
</p> </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{' '} 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> </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, Containr turns any Docker host into a full deployment platform — git-push builds,
managed databases, preview environments, HA failover, and a searchable knowledge base. managed databases, preview environments, HA failover, and a searchable knowledge base.
</p> </p>
<div className="mt-8 flex items-center justify-center gap-3"> <div className="mt-8 flex items-center gap-3">
<Link <Link to={`/${demo}`} className="s-btn-accent !px-5 !h-10 text-sm">
to={`/projects${demo}`} {isDemoMode ? 'Explore the live demo' : 'Open the console'} <ArrowRight size={15} />
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} />
</Link> </Link>
<a <a href="#docs" className="s-btn-quiet !px-5 !h-10 text-sm">
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)]"
>
<BookOpen size={15} /> Read the docs <BookOpen size={15} /> Read the docs
</a> </a>
</div> </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 ? ( {/* Live window — real telemetry from this instance */}
<div className="mx-auto mt-12 grid max-w-3xl grid-cols-3 gap-3 text-left max-sm:grid-cols-1"> <div className="s-card !p-0 overflow-hidden">
<Stat label="Public projects" value={projects.length} /> <div className="s-cardhead border-b border-[var(--border-subtle)]">
<Stat <span className="s-ibox"><Server size={13.5} /></span>
label="Running services" <span className="s-t">{host?.hostname ?? 'this instance'} <span className="s-ta">live</span></span>
value={projects.reduce((sum, p) => sum + (p.stats?.running_services ?? 0), 0)} <span className="s-trail">
/> <SPill tone={host?.dockerAvailable ? 'ok' : 'off'}>{host?.dockerAvailable ? 'online' : 'offline'}</SPill>
<Stat </span>
label="Deployments" </div>
value={projects.reduce((sum, p) => sum + (p.stats?.deployment_count ?? 0), 0)} <div className="px-4 pb-2 pt-3">
/> {[
{ label: 'CPU', v: cpuPct },
{ label: 'Memory', v: memPct },
{ label: 'Disk', v: diskPct },
].map((r) => (
<div key={r.label} className="py-1.5">
<div className="mb-1.5 flex items-baseline justify-between text-[11.5px]">
<span className="text-[var(--text-secondary)]">{r.label}</span>
<b className="font-semibold text-[var(--accent-primary)]">{r.v !== null ? `${r.v}%` : '—'}</b>
</div>
<Ticks pct={r.v ?? 0} count={48} warn={(r.v ?? 0) > 85} />
</div>
))}
</div>
<div className="border-t border-[var(--border-subtle)] px-4 py-3">
<p className="v-mono mb-2 text-[10px] uppercase tracking-[0.12em] text-[var(--text-tertiary)]">latest deploys</p>
{deploys.map((d) => (
<div key={d.id} className="flex items-center gap-2.5 py-1.5 text-[12px]">
<MiniBars tone={(d.status ?? '').toLowerCase() === 'failed' ? 'var(--error)' : 'var(--accent-primary)'} />
<span className="min-w-0 flex-1 truncate font-medium text-[var(--text-primary)]">{d.serviceName || d.id}</span>
<span className="v-mono text-[10.5px] text-[var(--text-tertiary)]">{rel(d.startedAt)}</span>
</div>
))}
{deploys.length === 0 && (
<p className="py-1.5 text-[11.5px] text-[var(--text-tertiary)]">No deployments yet.</p>
)}
</div>
</div>
</div> </div>
) : null}
</section> </section>
<section id="features" className="border-t border-[var(--border-subtle)] bg-[var(--bg-base)]/60"> {/* ── Install strip ── */}
<section className="border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/60">
<div className="mx-auto flex max-w-6xl flex-wrap items-center gap-4 px-5 py-5">
<span className="s-ibox"><Terminal size={13.5} /></span>
<code className="v-mono flex-1 text-[12.5px] text-[var(--text-secondary)]">
<span className="text-[var(--accent-primary)]">$</span> docker compose up -d
</code>
<span className="v-mono text-[11px] text-[var(--text-tertiary)]">one host · one command · full platform</span>
</div>
</section>
{/* ── Features ── */}
<section id="features" className="border-b border-[var(--border-subtle)]">
<div className="mx-auto max-w-6xl px-5 py-16"> <div className="mx-auto max-w-6xl px-5 py-16">
<h2 className="text-center text-[26px] font-bold tracking-tight text-[var(--text-primary)]"> <div className="flex items-end justify-between gap-6 max-sm:flex-col max-sm:items-start">
<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 Everything a deployment platform needs
</h2> </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. One install, one dashboard, full control over your own infrastructure.
</p> </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) => ( {features.map((feature) => (
<div <div key={feature.title} className="s-card">
key={feature.title} <div className="s-cardhead !mb-0">
className="rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)] p-5 transition-colors hover:border-[var(--border-default)]" <span className="s-ibox"><feature.icon size={13.5} /></span>
> <h3 className="s-t">{feature.title}</h3>
<feature.icon size={18} className="text-[var(--accent-primary)]" /> </div>
<h3 className="mt-3 text-[14.5px] font-semibold text-[var(--text-primary)]">{feature.title}</h3> <p className="mt-2 text-[12.5px] leading-relaxed text-[var(--text-tertiary)]">{feature.text}</p>
<p className="mt-1.5 text-[12.5px] leading-relaxed text-[var(--text-tertiary)]">{feature.text}</p>
</div> </div>
))} ))}
</div> </div>
</div> </div>
</section> </section>
<section id="templates" className="border-t border-[var(--border-subtle)]"> {/* ── Templates ── */}
<section id="templates" className="border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/60">
<div className="mx-auto max-w-6xl px-5 py-16"> <div className="mx-auto max-w-6xl px-5 py-16">
<div className="flex items-end justify-between"> <div className="flex items-end justify-between gap-6">
<div> <div>
<h2 className="text-[26px] font-bold tracking-tight text-[var(--text-primary)]">Template catalog</h2> <p className="v-mono mb-2 flex items-center gap-2 text-[10.5px] uppercase tracking-[0.1em] text-[var(--text-tertiary)]">
<p className="mt-1 text-sm text-[var(--text-secondary)]"> <i className="inline-block h-[5px] w-[5px] rounded-[1px] bg-[var(--accent-primary)]" />
catalog
</p>
<h2 className="font-headline text-[26px] font-bold tracking-tight text-[var(--text-primary)]">Template catalog</h2>
<p className="mt-1.5 text-[13px] text-[var(--text-secondary)]">
Production-ready presets — deploy in one click from the console. Production-ready presets — deploy in one click from the console.
</p> </p>
</div> </div>
<Link <Link
to={`/templates${demo}`} to={`/templates${demo}`}
className="inline-flex items-center gap-1.5 text-[13px] font-medium text-[var(--accent-primary)] hover:underline" className="s-chip !normal-case !tracking-normal"
> >
Browse all <ArrowRight size={13} /> Browse all <ArrowRight size={11} />
</Link> </Link>
</div> </div>
<div className="mt-8 grid grid-cols-4 gap-3 max-lg:grid-cols-3 max-sm:grid-cols-2"> <div className="mt-8 grid grid-cols-4 gap-3 max-lg:grid-cols-3 max-sm:grid-cols-2">
@@ -170,22 +244,20 @@ export function LandingPage() {
<Link <Link
key={template.id} key={template.id}
to={`/templates?template=${template.id}${isDemoMode ? '&demo=1' : ''}`} to={`/templates?template=${template.id}${isDemoMode ? '&demo=1' : ''}`}
className="group rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)] p-4 transition-colors hover:border-[var(--border-strong)]" className="s-card group"
> >
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
{template.logo ? ( {template.logo ? (
<img src={template.logo} alt="" className="h-7 w-7 rounded-md" /> <img src={template.logo} alt="" className="h-7 w-7 rounded-[6px]" />
) : ( ) : (
<span className="flex h-7 w-7 items-center justify-center rounded-md bg-[var(--surface-muted)] text-[var(--accent-primary)]"> <span className="s-ibox !h-7 !w-7"><LayoutTemplate size={13.5} /></span>
<LayoutTemplate size={14} />
</span>
)} )}
<span className="truncate text-[13px] font-semibold text-[var(--text-primary)]">{template.name}</span> <span className="truncate text-[13px] font-semibold text-[var(--text-primary)]">{template.name}</span>
</div> </div>
<p className="mt-2 line-clamp-2 text-[11.5px] leading-relaxed text-[var(--text-tertiary)]"> <p className="mt-2 line-clamp-2 text-[11.5px] leading-relaxed text-[var(--text-tertiary)]">
{template.description} {template.description}
</p> </p>
<span className="mt-2 inline-block rounded-full bg-[var(--surface-muted)] px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-[var(--text-tertiary)]"> <span className="s-chip mt-2" style={{ cursor: 'default' }}>
{template.category || 'app'} {template.category || 'app'}
</span> </span>
</Link> </Link>
@@ -199,10 +271,15 @@ export function LandingPage() {
</div> </div>
</section> </section>
<section id="docs" className="border-t border-[var(--border-subtle)] bg-[var(--bg-base)]/60"> {/* ── Docs ── */}
<section id="docs" className="border-b border-[var(--border-subtle)]">
<div className="mx-auto max-w-6xl px-5 py-16"> <div className="mx-auto max-w-6xl px-5 py-16">
<h2 className="text-[26px] font-bold tracking-tight text-[var(--text-primary)]">Documentation</h2> <p className="v-mono mb-2 flex items-center gap-2 text-[10.5px] uppercase tracking-[0.1em] text-[var(--text-tertiary)]">
<p className="mt-1 text-sm text-[var(--text-secondary)]"> <i className="inline-block h-[5px] w-[5px] rounded-[1px] bg-[var(--accent-primary)]" />
reference
</p>
<h2 className="font-headline text-[26px] font-bold tracking-tight text-[var(--text-primary)]">Documentation</h2>
<p className="mt-1.5 text-[13px] text-[var(--text-secondary)]">
Guides and references — synced from GitHub, cached locally, always available. Guides and references — synced from GitHub, cached locally, always available.
</p> </p>
<div className="mt-8"> <div className="mt-8">
@@ -213,10 +290,10 @@ export function LandingPage() {
</main> </main>
<footer className="border-t border-[var(--border-subtle)]"> <footer className="border-t border-[var(--border-subtle)]">
<div className="mx-auto flex max-w-6xl items-center justify-between px-5 py-6 text-[12px] text-[var(--text-tertiary)]"> <div className="v-mono mx-auto flex max-w-6xl items-center justify-between px-5 py-6 text-[11.5px] text-[var(--text-tertiary)]">
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<img src="/containr.svg" alt="" className="h-4 w-4 rounded" /> <img src="/containr.svg" alt="" className="h-4 w-4 rounded" />
Containr — self-hosted container platform containr — self-hosted container platform
</span> </span>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Link to={`/projects${demo}`} className="hover:text-[var(--text-secondary)]">Projects</Link> <Link to={`/projects${demo}`} className="hover:text-[var(--text-secondary)]">Projects</Link>
@@ -228,12 +305,3 @@ export function LandingPage() {
</div> </div>
); );
} }
function Stat({ label, value }: { label: string; value: number }) {
return (
<div className="rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)] px-4 py-3.5">
<p className="text-[22px] font-bold tabular-nums text-[var(--text-primary)]">{value}</p>
<p className="mt-0.5 text-[11px] uppercase tracking-wider text-[var(--text-tertiary)]">{label}</p>
</div>
);
}
@@ -19,8 +19,10 @@ import { useAuthSession } from '@/lib/use-auth-session';
import { useDemoMode } from '@/lib/demo-mode'; import { useDemoMode } from '@/lib/demo-mode';
import { formatBytes, formatRelative } from '@/lib/time'; import { formatBytes, formatRelative } from '@/lib/time';
import { DemoRestricted } from '@/shared/components'; import { DemoRestricted } from '@/shared/components';
import { GhostBtn, QuietBtn, SPill } from '@/shared/components/sentry';
import { import {
ArrowLeft, CheckCircle2, ChevronRight, Loader2, Pencil, XCircle, ArrowLeft, CheckCircle2, ChevronRight, Loader2, Pencil, XCircle,
Activity, Cpu, ListOrdered, Server,
} from 'lucide-react'; } from 'lucide-react';
const TIME_RANGES = [ const TIME_RANGES = [
@@ -129,8 +131,7 @@ export function NodeDetailPage() {
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<div className="border-b border-[var(--border-subtle)]"> <div className="w-full px-4 pt-6 sm:px-8">
<div className="w-full px-8 py-5">
<div className="flex items-center gap-2 v-mono text-[11px] text-[var(--text-tertiary)] mb-2"> <div className="flex items-center gap-2 v-mono text-[11px] text-[var(--text-tertiary)] mb-2">
<Link to="/usage" className="hover:text-[var(--text-primary)] flex items-center gap-1"> <Link to="/usage" className="hover:text-[var(--text-primary)] flex items-center gap-1">
<ArrowLeft size={12} /> usage <ArrowLeft size={12} /> usage
@@ -140,7 +141,7 @@ export function NodeDetailPage() {
</div> </div>
<div className="flex items-center justify-between gap-4 flex-wrap"> <div className="flex items-center justify-between gap-4 flex-wrap">
<div> <div>
<h1 className="v-title"> <h1 className="v-title font-headline">
{rename === null ? agent.name : rename} {rename === null ? agent.name : rename}
{isAdmin && rename === null && ( {isAdmin && rename === null && (
<button <button
@@ -163,7 +164,7 @@ export function NodeDetailPage() {
if (e.key === 'Escape') setRename(null); if (e.key === 'Escape') setRename(null);
}} }}
autoFocus autoFocus
className="rounded-md border border-[var(--border-subtle)] bg-[var(--bg-tertiary)] px-2 py-1 text-sm text-[var(--text-primary)]" className="rounded-md border border-[var(--border-subtle)] bg-[var(--bg-elevated)] px-2 py-1 text-sm text-[var(--text-primary)]"
/> />
<button onClick={() => updateMutation.mutate({ name: rename })} className="v-mono text-[11px] text-[var(--accent-primary)] hover:underline">save</button> <button onClick={() => updateMutation.mutate({ name: rename })} className="v-mono text-[11px] text-[var(--accent-primary)] hover:underline">save</button>
<button onClick={() => setRename(null)} className="v-mono text-[11px] text-[var(--text-muted)] hover:underline">cancel</button> <button onClick={() => setRename(null)} className="v-mono text-[11px] text-[var(--text-muted)] hover:underline">cancel</button>
@@ -209,7 +210,7 @@ export function NodeDetailPage() {
}} }}
autoFocus autoFocus
placeholder="eu-west, gpu" placeholder="eu-west, gpu"
className="rounded-md border border-[var(--border-subtle)] bg-[var(--bg-tertiary)] px-2 py-0.5 text-[11px] text-[var(--text-primary)] w-52" className="rounded-md border border-[var(--border-subtle)] bg-[var(--bg-elevated)] px-2 py-0.5 text-[11px] text-[var(--text-primary)] w-52"
/> />
<button <button
onClick={() => updateMutation.mutate({ tags: tagDraft.split(',').map((t) => t.trim()).filter(Boolean) })} onClick={() => updateMutation.mutate({ tags: tagDraft.split(',').map((t) => t.trim()).filter(Boolean) })}
@@ -247,7 +248,7 @@ export function NodeDetailPage() {
}} }}
autoFocus autoFocus
placeholder="apps.node1.example.com — empty clears" placeholder="apps.node1.example.com — empty clears"
className="rounded-md border border-[var(--border-subtle)] bg-[var(--bg-tertiary)] px-2 py-0.5 text-[11px] text-[var(--text-primary)] w-64" className="rounded-md border border-[var(--border-subtle)] bg-[var(--bg-elevated)] px-2 py-0.5 text-[11px] text-[var(--text-primary)] w-64"
/> />
<button <button
onClick={() => updateMutation.mutate({ default_domain: domainDraft.trim().toLowerCase() })} onClick={() => updateMutation.mutate({ default_domain: domainDraft.trim().toLowerCase() })}
@@ -258,17 +259,9 @@ export function NodeDetailPage() {
)} )}
</p> </p>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-2 flex-wrap">
<span className={`v-mono text-[11px] px-2 py-1 rounded-md border ${online <SPill tone={online ? 'ok' : 'err'}>{agent.status}</SPill>
? 'border-[var(--success)]/40 text-[var(--success)]' {!agent.schedulable && <SPill tone="warn">cordoned</SPill>}
: 'border-[var(--error)]/40 text-[var(--error)]'}`}>
{agent.status}
</span>
{!agent.schedulable && (
<span className="v-mono text-[11px] px-2 py-1 rounded-md border border-[var(--warning)]/40 text-[var(--warning)]">
cordoned
</span>
)}
{isAdmin && ( {isAdmin && (
<> <>
<label className="flex items-center gap-1.5 v-mono text-[11px] text-[var(--text-tertiary)] cursor-pointer" title="Daily automatic docker prune"> <label className="flex items-center gap-1.5 v-mono text-[11px] text-[var(--text-tertiary)] cursor-pointer" title="Daily automatic docker prune">
@@ -280,81 +273,79 @@ export function NodeDetailPage() {
/> />
auto-prune auto-prune
</label> </label>
<button <QuietBtn
onClick={() => cordonMutation.mutate(agent.schedulable)} onClick={() => cordonMutation.mutate(agent.schedulable)}
disabled={cordonMutation.isPending} disabled={cordonMutation.isPending}
title={agent.schedulable title={agent.schedulable
? 'Block new placements — running replicas stay' ? 'Block new placements — running replicas stay'
: 'Re-open for placement'} : 'Re-open for placement'}
className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--border-subtle)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:opacity-50"
> >
{agent.schedulable ? 'cordon' : 'uncordon'} {agent.schedulable ? 'cordon' : 'uncordon'}
</button> </QuietBtn>
<button <QuietBtn
onClick={() => setConfirmDrain(true)} onClick={() => setConfirmDrain(true)}
disabled={drainMutation.isPending || !online} disabled={drainMutation.isPending || !online}
title={online title={online
? 'Cordon + remove all service containers + unpin services (they redeploy locally)' ? 'Cordon + remove all service containers + unpin services (they redeploy locally)'
: 'Node must be online to drain'} : 'Node must be online to drain'}
className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--warning)]/40 text-[var(--warning)] hover:bg-[var(--warning)]/10 disabled:opacity-50" className="!text-[var(--warning)] hover:!border-[var(--warning)]/60"
> >
drain drain
</button> </QuietBtn>
<button <GhostBtn
onClick={() => upgradeMutation.mutate()} onClick={() => upgradeMutation.mutate()}
disabled={upgradeMutation.isPending || !online} disabled={upgradeMutation.isPending || !online}
title={online title={online
? `Upgrade agent to the server's bundled binary (server ${upgradeMutation.data?.version ?? 'build'}) — the agent restarts itself` ? `Upgrade agent to the server's bundled binary (server ${upgradeMutation.data?.version ?? 'build'}) — the agent restarts itself`
: 'Node must be online to upgrade'} : 'Node must be online to upgrade'}
className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--border-subtle)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:opacity-50"
> >
{upgradeMutation.isPending ? 'upgrading…' : 'upgrade agent'} {upgradeMutation.isPending ? 'upgrading…' : 'upgrade agent'}
</button> </GhostBtn>
<button <QuietBtn
onClick={() => setConfirmVolumes(true)} onClick={() => setConfirmVolumes(true)}
disabled={pruneMutation.isPending} disabled={pruneMutation.isPending}
className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--border-subtle)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:opacity-50"
> >
prune now prune now
</button> </QuietBtn>
<button <QuietBtn
onClick={() => setConfirmDelete(true)} onClick={() => setConfirmDelete(true)}
className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--error)]/40 text-[var(--error)] hover:bg-[var(--error)]/10" className="!text-[var(--error)] hover:!border-[var(--error)]/60"
> >
remove remove
</button> </QuietBtn>
</> </>
)} )}
</div> </div>
</div> </div>
</div> </div>
</div>
<div className="px-8 py-6 space-y-6"> <div className="px-4 py-6 sm:px-8 space-y-6">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Stat label="cpu" value={latest ? `${latest.cpu.usage_percent.toFixed(0)}%` : '—'} sub={latest ? `${latest.cpu.cores} cores` : ''} /> <Stat label="cpu" icon={<Cpu />} value={latest ? `${latest.cpu.usage_percent.toFixed(0)}%` : '—'} sub={latest ? `${latest.cpu.cores} cores` : ''} />
<Stat label="memory" value={latest ? `${latest.memory.usage_percent.toFixed(0)}%` : '—'} sub={latest ? `${formatBytes(latest.memory.available)} free of ${formatBytes(latest.memory.limit)}` : ''} /> <Stat label="memory" icon={<Server />} value={latest ? `${latest.memory.usage_percent.toFixed(0)}%` : '—'} sub={latest ? `${formatBytes(latest.memory.available)} free of ${formatBytes(latest.memory.limit)}` : ''} />
<Stat label="load 1m" value={latest ? latest.system_load.load_1m.toFixed(2) : '—'} sub={latest ? `5m ${latest.system_load.load_5m.toFixed(2)} · 15m ${latest.system_load.load_15m.toFixed(2)}` : ''} /> <Stat label="load 1m" icon={<Activity />} value={latest ? latest.system_load.load_1m.toFixed(2) : '—'} sub={latest ? `5m ${latest.system_load.load_5m.toFixed(2)} · 15m ${latest.system_load.load_15m.toFixed(2)}` : ''} />
<Stat label="containers" value={latest ? String(latest.container_count) : String(containers.length)} sub="reported by agent" /> <Stat label="containers" icon={<ListOrdered />} value={latest ? String(latest.container_count) : String(containers.length)} sub="reported by agent" />
</div> </div>
<section className="panel p-5"> <section className="s-card !p-0">
<div className="flex items-center justify-between mb-4"> <div className="s-cardhead border-b border-[var(--border-subtle)]">
<h2 className="v-mono text-[11px] uppercase tracking-wider text-[var(--text-tertiary)]">telemetry</h2> <span className="s-ibox"><Activity /></span>
<div className="flex gap-1"> <h2 className="s-t">Telemetry</h2>
<span className="s-trail">
<span className="flex gap-1">
{TIME_RANGES.map((r) => ( {TIME_RANGES.map((r) => (
<button <button
key={r.value} key={r.value}
onClick={() => setTimeRange(r.value)} onClick={() => setTimeRange(r.value)}
className={`v-mono text-[11px] px-2.5 py-1 rounded-md border ${timeRange === r.value className={`s-chip v-mono ${timeRange === r.value ? 'on' : ''}`}
? 'border-[var(--accent-primary)]/50 text-[var(--accent-primary)]'
: 'border-[var(--border-subtle)] text-[var(--text-muted)] hover:text-[var(--text-primary)]'}`}
> >
{r.label} {r.label}
</button> </button>
))} ))}
</span>
</span>
</div> </div>
</div> <div className="p-4">
{metricsQuery.isLoading ? ( {metricsQuery.isLoading ? (
<p className="text-xs text-[var(--text-muted)]">Loading metrics…</p> <p className="text-xs text-[var(--text-muted)]">Loading metrics…</p>
) : metrics.length < 2 ? ( ) : metrics.length < 2 ? (
@@ -365,12 +356,17 @@ export function NodeDetailPage() {
<Sparkline label="memory %" points={metrics} value={(m) => m.memory.usage_percent} /> <Sparkline label="memory %" points={metrics} value={(m) => m.memory.usage_percent} />
</div> </div>
)} )}
</div>
</section> </section>
<div className="grid gap-6 lg:grid-cols-2"> <div className="grid gap-6 lg:grid-cols-2">
<section className="panel p-0"> <section className="s-card !p-0">
<h2 className="v-mono text-[11px] uppercase tracking-wider text-[var(--text-tertiary)] px-5 pt-4 pb-3">containers</h2> <div className="s-cardhead border-b border-[var(--border-subtle)]">
<div className="divide-y divide-[var(--border-subtle)] border-t border-[var(--border-subtle)]"> <span className="s-ibox"><Server /></span>
<h2 className="s-t">Containers</h2>
<span className="s-trail"><span className="s-chip" style={{ cursor: 'default' }}>{containers.length}</span></span>
</div>
<div className="divide-y divide-[var(--border-subtle)]">
{containers.length === 0 && ( {containers.length === 0 && (
<p className="px-5 py-6 text-xs text-[var(--text-muted)]">No containers reported on this node.</p> <p className="px-5 py-6 text-xs text-[var(--text-muted)]">No containers reported on this node.</p>
)} )}
@@ -380,15 +376,24 @@ export function NodeDetailPage() {
<p className="truncate text-sm text-[var(--text-primary)]">{c.name ?? c.id}</p> <p className="truncate text-sm text-[var(--text-primary)]">{c.name ?? c.id}</p>
<p className="truncate v-mono text-[11px] text-[var(--text-tertiary)]">{c.image}</p> <p className="truncate v-mono text-[11px] text-[var(--text-tertiary)]">{c.image}</p>
</div> </div>
<span className="v-mono text-[11px] text-[var(--text-secondary)]">{c.status ?? '—'}</span> {/* Persisted query cache can replay the raw status object — flatten defensively. */}
<span className="v-mono text-[11px] text-[var(--text-secondary)]">
{typeof c.status === 'string'
? c.status
: (c.status as { state?: string } | undefined)?.state ?? '—'}
</span>
</div> </div>
))} ))}
</div> </div>
</section> </section>
<section className="panel p-0"> <section className="s-card !p-0">
<h2 className="v-mono text-[11px] uppercase tracking-wider text-[var(--text-tertiary)] px-5 pt-4 pb-3">command history</h2> <div className="s-cardhead border-b border-[var(--border-subtle)]">
<div className="divide-y divide-[var(--border-subtle)] border-t border-[var(--border-subtle)]"> <span className="s-ibox"><ListOrdered /></span>
<h2 className="s-t">Command History</h2>
<span className="s-trail"><span className="s-chip" style={{ cursor: 'default' }}>{commands.length}</span></span>
</div>
<div className="divide-y divide-[var(--border-subtle)]">
{commands.length === 0 && ( {commands.length === 0 && (
<p className="px-5 py-6 text-xs text-[var(--text-muted)]">No commands sent to this node yet.</p> <p className="px-5 py-6 text-xs text-[var(--text-muted)]">No commands sent to this node yet.</p>
)} )}
@@ -466,12 +471,19 @@ function meshAddresses(agent: { metadata: Record<string, unknown> }): [string, s
.filter((entry): entry is [string, string] => typeof entry[1] === 'string' && entry[1] !== ''); .filter((entry): entry is [string, string] => typeof entry[1] === 'string' && entry[1] !== '');
} }
function Stat({ label, value, sub }: { label: string; value: string; sub?: string }) { function Stat({ label, icon, value, sub }: { label: string; icon: React.ReactNode; value: string; sub?: string }) {
return ( return (
<div className="panel p-4"> <div className="s-stat">
<p className="v-mono text-[10px] uppercase tracking-wider text-[var(--text-tertiary)]">{label}</p> <div className="px-4 pb-1 pt-4">
<p className="mt-1 text-xl font-semibold text-[var(--text-primary)]">{value}</p> <div className="flex items-center gap-2.5">
{sub && <p className="mt-0.5 v-mono text-[11px] text-[var(--text-muted)]">{sub}</p>} <span className="s-ibox">{icon}</span>
<span className="text-[11.5px] font-medium text-[var(--text-secondary)]">{label}</span>
</div>
<p className="mt-3 font-headline text-[24px] font-bold leading-none tabular-nums text-[var(--text-primary)]">{value}</p>
</div>
<div className="s-stat-foot mt-3">
<span>{sub ?? '—'}</span>
</div>
</div> </div>
); );
} }
@@ -510,24 +522,20 @@ function ConfirmModal({ title, body, confirmLabel, danger, extra, onCancel, onCo
pending: boolean; pending: boolean;
}) { }) {
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onCancel}> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm" onClick={onCancel}>
<div className="panel w-full max-w-sm p-5" onClick={(e) => e.stopPropagation()}> <div className="s-card w-full max-w-sm" onClick={(e) => e.stopPropagation()}>
<h3 className="text-sm font-semibold text-[var(--text-primary)]">{title}</h3> <h3 className="s-t">{title}</h3>
<p className="mt-2 text-xs text-[var(--text-secondary)]">{body}</p> <p className="mt-2 text-xs text-[var(--text-secondary)]">{body}</p>
{extra && <div className="mt-3">{extra}</div>} {extra && <div className="mt-3">{extra}</div>}
<div className="mt-5 flex justify-end gap-2"> <div className="mt-5 flex justify-end gap-2">
<button onClick={onCancel} className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--border-subtle)] text-[var(--text-secondary)]"> <QuietBtn onClick={onCancel}>cancel</QuietBtn>
cancel <GhostBtn
</button>
<button
onClick={onConfirm} onClick={onConfirm}
disabled={pending} disabled={pending}
className={`v-mono text-[11px] px-3 py-1.5 rounded-md border disabled:opacity-50 ${danger className={danger ? '!text-[var(--error)] !border-[var(--error)]/40 !bg-[var(--error)]/10' : undefined}
? 'border-[var(--error)]/50 text-[var(--error)] hover:bg-[var(--error)]/10'
: 'border-[var(--accent-primary)]/50 text-[var(--accent-primary)] hover:bg-[var(--accent-primary)]/10'}`}
> >
{pending ? 'working…' : confirmLabel} {pending ? 'working…' : confirmLabel}
</button> </GhostBtn>
</div> </div>
</div> </div>
</div> </div>
@@ -4,28 +4,10 @@ import { getOperations, type OperationsView } from '@/lib/api-client';
import { useDemoMode } from '@/lib/demo-mode'; import { useDemoMode } from '@/lib/demo-mode';
import { formatRelative } from '@/lib/time'; import { formatRelative } from '@/lib/time';
import { DemoRestricted } from '@/shared/components'; import { DemoRestricted } from '@/shared/components';
import { SPageHead, SPill, SStat, Ticks } from '@/shared/components/sentry';
import { statusTone } from '@/shared/components/sentry-utils';
import { Activity, AlertTriangle, Clock, Database, Loader2, Timer } from 'lucide-react'; import { Activity, AlertTriangle, Clock, Database, Loader2, Timer } from 'lucide-react';
function statusClass(status: string): string {
switch (status) {
case 'deployed':
case 'completed':
case 'success':
return 'text-[var(--success)]';
case 'failed':
case 'cancelled':
case 'rolled_back':
return 'text-[var(--error)]';
case 'building':
case 'deploying':
case 'in_progress':
case 'running':
return 'text-[var(--accent-primary)]';
default:
return 'text-[var(--warning)]';
}
}
function Section({ title, icon: Icon, count, children }: { function Section({ title, icon: Icon, count, children }: {
title: string; title: string;
icon: typeof Activity; icon: typeof Activity;
@@ -33,13 +15,15 @@ function Section({ title, icon: Icon, count, children }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<section className="panel p-5"> <section className="s-card overflow-hidden">
<div className="flex items-center gap-2 mb-4"> <div className="s-cardhead border-b border-[var(--border-subtle)]">
<Icon size={16} className="text-[var(--accent-primary)]" /> <span className="s-ibox"><Icon /></span>
<h2 className="text-sm font-semibold text-[var(--text-primary)]">{title}</h2> <h2 className="s-t">{title}</h2>
<span className="text-xs text-[var(--text-muted)] mono">({count})</span> <span className="s-trail">
<span className="s-chip" style={{ cursor: 'default' }}>{count}</span>
</span>
</div> </div>
{children} <div className="p-3">{children}</div>
</section> </section>
); );
} }
@@ -74,13 +58,8 @@ export function OperationsPage() {
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<div className="border-b border-[var(--border-subtle)]"> <div className="w-full px-4 pt-6 sm:px-8">
<div className="w-full px-8 py-5"> <SPageHead title="Operations" titleAccent="_" sub="Live view of deploys, queues, cron runs, and backups" />
<h1 className="v-title">Operations<span className="v-cursor">_</span></h1>
<p className="v-mono mt-1.5 text-[11px] text-[var(--text-tertiary)]">
Live view of deploys, queues, cron runs, and backups
</p>
</div>
</div> </div>
{opsQuery.isLoading ? ( {opsQuery.isLoading ? (
@@ -88,24 +67,12 @@ export function OperationsPage() {
<Loader2 size={20} className="animate-spin text-[var(--text-muted)]" /> <Loader2 size={20} className="animate-spin text-[var(--text-muted)]" />
</div> </div>
) : ( ) : (
<div className="w-full px-8 py-6 space-y-6"> <div className="w-full px-4 py-6 sm:px-8 space-y-6">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> <div className="grid grid-cols-2 gap-3.5 md:grid-cols-4">
<div className="panel-soft p-4"> <SStat icon={<Activity />} label="In Flight" value={active.length} foot="deploying now" />
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">In flight</p> <SStat icon={<Clock />} label="Queued" value={queuedTotal} foot="waiting in queue" />
<p className="mt-1 text-2xl font-semibold text-[var(--accent-primary)]">{active.length}</p> <SStat icon={<AlertTriangle />} label="Failed 24h" value={failed.length} foot="need attention" />
</div> <SStat icon={<Database />} label="Backups 24h" value={backups.length} foot="completed runs" />
<div className="panel-soft p-4">
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Queued</p>
<p className="mt-1 text-2xl font-semibold text-[var(--warning)]">{queuedTotal}</p>
</div>
<div className="panel-soft p-4">
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Failed 24h</p>
<p className="mt-1 text-2xl font-semibold text-[var(--error)]">{failed.length}</p>
</div>
<div className="panel-soft p-4">
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Backups 24h</p>
<p className="mt-1 text-2xl font-semibold text-[var(--text-secondary)]">{backups.length}</p>
</div>
</div> </div>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6"> <div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
@@ -125,7 +92,7 @@ export function OperationsPage() {
<span className="text-sm text-[var(--text-primary)]">{d.service_name}</span> <span className="text-sm text-[var(--text-primary)]">{d.service_name}</span>
<span className="text-xs text-[var(--text-muted)]"> · {d.project_name}</span> <span className="text-xs text-[var(--text-muted)]"> · {d.project_name}</span>
</div> </div>
<span className={`text-xs mono ${statusClass(d.status)}`}>{d.status}</span> <SPill tone={statusTone(d.status)}>{d.status}</SPill>
<span className="text-xs text-[var(--text-muted)]">{formatRelative(d.created_at)}</span> <span className="text-xs text-[var(--text-muted)]">{formatRelative(d.created_at)}</span>
</button> </button>
))} ))}
@@ -152,7 +119,7 @@ export function OperationsPage() {
<p className="text-[11px] text-[var(--error)]/80 truncate">{d.error}</p> <p className="text-[11px] text-[var(--error)]/80 truncate">{d.error}</p>
)} )}
</div> </div>
<span className={`text-xs mono ${statusClass(d.status)}`}>{d.status}</span> <SPill tone={statusTone(d.status)}>{d.status}</SPill>
<span className="text-xs text-[var(--text-muted)]">{formatRelative(d.created_at)}</span> <span className="text-xs text-[var(--text-muted)]">{formatRelative(d.created_at)}</span>
</button> </button>
))} ))}
@@ -167,9 +134,9 @@ export function OperationsPage() {
<div className="space-y-1.5"> <div className="space-y-1.5">
{cron.map((r) => ( {cron.map((r) => (
<div key={r.id} className="flex items-center gap-3 px-3 py-1.5 text-xs"> <div key={r.id} className="flex items-center gap-3 px-3 py-1.5 text-xs">
<span className={`mono ${statusClass(r.status)}`}>{r.status}</span> <SPill tone={statusTone(r.status)}>{r.status}</SPill>
<span className="text-[var(--text-primary)]">{r.job_name}</span> <span className="text-[var(--text-primary)]">{r.job_name}</span>
<span className="mono text-[var(--text-muted)]">{r.schedule}</span> <span className="v-mono text-[var(--text-muted)]">{r.schedule}</span>
{r.error && <span className="text-[var(--error)]/80 truncate">{r.error}</span>} {r.error && <span className="text-[var(--error)]/80 truncate">{r.error}</span>}
<span className="ml-auto text-[var(--text-muted)]">{formatRelative(r.started_at)}</span> <span className="ml-auto text-[var(--text-muted)]">{formatRelative(r.started_at)}</span>
</div> </div>
@@ -185,7 +152,7 @@ export function OperationsPage() {
<div className="space-y-1.5"> <div className="space-y-1.5">
{backups.map((b) => ( {backups.map((b) => (
<div key={b.id} className="flex items-center gap-3 px-3 py-1.5 text-xs"> <div key={b.id} className="flex items-center gap-3 px-3 py-1.5 text-xs">
<span className={`mono ${statusClass(b.status)}`}>{b.status}</span> <SPill tone={statusTone(b.status)}>{b.status}</SPill>
<span className="text-[var(--text-primary)]">{b.database_name}</span> <span className="text-[var(--text-primary)]">{b.database_name}</span>
<span className="text-[var(--text-muted)]">{b.size}</span> <span className="text-[var(--text-muted)]">{b.size}</span>
<span className="ml-auto text-[var(--text-muted)]">{formatRelative(b.created_at)}</span> <span className="ml-auto text-[var(--text-muted)]">{formatRelative(b.created_at)}</span>
@@ -198,14 +165,15 @@ export function OperationsPage() {
{queue.length > 0 && ( {queue.length > 0 && (
<Section title="Deploy queue depth" icon={Clock} count={queue.length}> <Section title="Deploy queue depth" icon={Clock} count={queue.length}>
<div className="space-y-1.5"> <div className="space-y-2">
{queue.map((q) => ( {queue.map((q) => (
<div key={q.service_id} className="flex items-center gap-3 px-3 py-1.5 text-xs mono"> <div key={q.service_id} className="flex items-center gap-3 px-3 py-1.5 text-xs">
<span className="text-[var(--text-secondary)]">{q.service_id.slice(0, 8)}</span> <span className="v-mono text-[var(--text-secondary)]">{q.service_id.slice(0, 8)}</span>
<span className={q.running ? 'text-[var(--accent-primary)]' : 'text-[var(--text-muted)]'}> <SPill tone={q.running ? 'info' : 'off'}>{q.running ? 'running' : 'idle'}</SPill>
{q.running ? 'running' : 'idle'} <span className="flex-1 max-w-[220px]">
<Ticks pct={Math.min(100, q.queued * 25)} count={32} warn={!q.running} />
</span> </span>
<span className="text-[var(--warning)]">{q.queued} queued</span> <span className="v-mono text-[var(--warning)]">{q.queued} queued</span>
</div> </div>
))} ))}
</div> </div>
@@ -13,10 +13,31 @@ import {
Search, Search,
FolderOpen, FolderOpen,
X, X,
Cpu,
MemoryStick,
HardDrive,
Rocket,
RefreshCw,
Server,
} from 'lucide-react'; } from 'lucide-react';
import { useAuthSession } from '@/lib/use-auth-session'; import { useAuthSession } from '@/lib/use-auth-session';
import { useDemoMode } from '@/lib/demo-mode'; import { useDemoMode } from '@/lib/demo-mode';
import { demoProjects } from '@/lib/demo-data'; import { demoProjects } from '@/lib/demo-data';
import {
GhostBtn,
IconBtn,
QuietBtn,
SAnomaly,
SCard,
SPageHead,
SPill,
SSection,
SStat,
STable,
Ticks,
type SCol,
} from '@/shared/components/sentry';
import { statusTone } from '@/shared/components/sentry-utils';
const demoDeploys = [ const demoDeploys = [
{ id: 'd1', name: 'API Gateway', project: 'Core Platform', status: 'DEPLOYED', when: '4m' }, { id: 'd1', name: 'API Gateway', project: 'Core Platform', status: 'DEPLOYED', when: '4m' },
@@ -25,6 +46,8 @@ const demoDeploys = [
{ id: 'd4', name: 'Marketing Site', project: 'Growth Surface', status: 'DEPLOYED', when: '2h' }, { id: 'd4', name: 'Marketing Site', project: 'Growth Surface', status: 'DEPLOYED', when: '2h' },
]; ];
type DeployRow = { id: string; name: string; project?: string; status: string; when: string };
function getHealthStatus(stats: ProjectStats): 'healthy' | 'idle' | 'degraded' | 'critical' { function getHealthStatus(stats: ProjectStats): 'healthy' | 'idle' | 'degraded' | 'critical' {
if (stats.service_count === 0) return 'idle'; if (stats.service_count === 0) return 'idle';
if (stats.running_services === stats.service_count) return 'healthy'; if (stats.running_services === stats.service_count) return 'healthy';
@@ -32,19 +55,6 @@ function getHealthStatus(stats: ProjectStats): 'healthy' | 'idle' | 'degraded' |
return 'critical'; return 'critical';
} }
function healthBadge(health: ReturnType<typeof getHealthStatus>) {
switch (health) {
case 'healthy':
return { label: 'RUNNING', cls: 'v-st-ok' };
case 'idle':
return { label: 'IDLE', cls: '' };
case 'degraded':
return { label: 'DEGRADED', cls: 'v-st-warn' };
case 'critical':
return { label: 'DOWN', cls: 'v-st-fail' };
}
}
function formatRelative(date?: string): string { function formatRelative(date?: string): string {
if (!date) return '—'; if (!date) return '—';
const diff = Date.now() - new Date(date).getTime(); const diff = Date.now() - new Date(date).getTime();
@@ -54,6 +64,7 @@ function formatRelative(date?: string): string {
return `${Math.floor(diff / 86_400_000)}d ago`; return `${Math.floor(diff / 86_400_000)}d ago`;
} }
/* Mini service topology preview inside each project card. */
function MiniCanvas({ project }: { project: ProjectEntity }) { function MiniCanvas({ project }: { project: ProjectEntity }) {
const count = project.stats.service_count; const count = project.stats.service_count;
const running = project.stats.running_services; const running = project.stats.running_services;
@@ -84,40 +95,61 @@ function MiniCanvas({ project }: { project: ProjectEntity }) {
function ProjectCard({ project, href }: { project: ProjectEntity; href: string }) { function ProjectCard({ project, href }: { project: ProjectEntity; href: string }) {
const navigate = useNavigate(); const navigate = useNavigate();
const health = getHealthStatus(project.stats); const health = getHealthStatus(project.stats);
const badge = healthBadge(health); const envLabel =
const envOk = health === 'healthy'; health === 'healthy' ? 'production' : health === 'idle' ? 'idle' : health === 'critical' ? 'down' : 'degraded';
const envLabel = envOk ? 'production' : health === 'idle' ? 'idle' : health === 'critical' ? 'down' : 'degraded';
return ( return (
<article <article
className="group panel p-4 cursor-pointer transition-all duration-200 hover:-translate-y-0.5 hover:border-[var(--border-strong)]" className="s-card group cursor-pointer transition-all duration-200 hover:-translate-y-0.5 hover:border-[var(--border-strong)]"
onClick={() => navigate(href)} onClick={() => navigate(href)}
> >
<div className="mb-3 flex items-center gap-2"> <div className="s-cardhead">
<h3 className="truncate text-[14.5px] font-bold tracking-tight text-[var(--text-primary)]"> <IboxStub />
{project.name} <h3 className="s-t truncate">{project.name}</h3>
</h3> <span className="s-trail">
<span className={`v-st ml-auto ${badge.cls}`}>{badge.label}</span> <SPill
tone={
health === 'healthy' ? 'ok' : health === 'idle' ? 'off' : health === 'critical' ? 'err' : 'warn'
}
>
{health === 'healthy' ? 'Running' : health === 'idle' ? 'Idle' : health === 'critical' ? 'Down' : 'Degraded'}
</SPill>
</span>
</div> </div>
<div className="px-4">
<MiniCanvas project={project} /> <MiniCanvas project={project} />
<div className="v-mono mt-3 flex items-center gap-2 text-[10.5px] text-[var(--text-tertiary)]"> </div>
<div className="s-stat-foot mt-3.5">
<span className="flex items-center gap-2">
<i <i
className="inline-block h-1.5 w-1.5 rounded-full" 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} · {project.stats.running_services}/{project.stats.service_count} online
{envLabel}
</span> </span>
<span>·</span> <span>{formatRelative(project.updatedAt)}</span>
<span>
{project.stats.running_services}/{project.stats.service_count} online
</span>
<span className="ml-auto">{formatRelative(project.updatedAt)}</span>
</div> </div>
</article> </article>
); );
} }
function IboxStub() {
return (
<span className="s-ibox">
<Server size={13.5} />
</span>
);
}
export function ProjectsPage() { export function ProjectsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -181,7 +213,7 @@ export function ProjectsPage() {
const totalServices = projects.reduce((sum, p) => sum + p.stats.service_count, 0); const totalServices = projects.reduce((sum, p) => sum + p.stats.service_count, 0);
const runningServices = projects.reduce((sum, p) => sum + p.stats.running_services, 0); const runningServices = projects.reduce((sum, p) => sum + p.stats.running_services, 0);
const host = hostQuery.data; const host = hostQuery.data;
const hostLabel = host?.scope === 'docker-desktop-vm' ? 'VM' : 'HOST'; const hostLabel = host?.scope === 'docker-desktop-vm' ? 'VM' : 'Host';
const cpuPct = isDemoMode ? 14 : host && host.cpu.cores > 0 const cpuPct = isDemoMode ? 14 : host && host.cpu.cores > 0
? Math.min(100, Math.round((host.load.load1m / host.cpu.cores) * 100)) ? Math.min(100, Math.round((host.load.load1m / host.cpu.cores) * 100))
: null; : null;
@@ -190,9 +222,9 @@ export function ProjectsPage() {
const fmtGB = (v: number) => `${(v / (1024 * 1024 * 1024)).toFixed(0)}G`; const fmtGB = (v: number) => `${(v / (1024 * 1024 * 1024)).toFixed(0)}G`;
const deployments = isDemoMode ? [] : deploysQuery.data ?? []; const deployments = isDemoMode ? [] : deploysQuery.data ?? [];
const deployCount = deployments.length; const deployCount = deployments.length;
const feed = isDemoMode const feed: DeployRow[] = isDemoMode
? demoDeploys ? demoDeploys
: deployments.slice(0, 5).map((d) => ({ : deployments.slice(0, 6).map((d) => ({
id: d.id, id: d.id,
name: d.serviceName || d.imageName || d.serviceId || d.id, name: d.serviceName || d.imageName || d.serviceId || d.id,
project: d.projectName, project: d.projectName,
@@ -200,120 +232,188 @@ export function ProjectsPage() {
when: formatRelative(d.startedAt ?? d.completedAt ?? d.createdAt), when: formatRelative(d.startedAt ?? d.completedAt ?? d.createdAt),
})); }));
const statusClass = (status: string) => const statDefs = [
status === 'DEPLOYED' || status === 'SUCCESS' || status === 'SUCCEEDED' {
? 'v-st-ok' icon: <Cpu size={13.5} />,
: status === 'BUILDING' || status === 'RUNNING' || status === 'QUEUED' label: `${hostLabel} CPU`,
? 'v-st-run' value: cpuPct ?? '—',
: status === 'FAILED' || status === 'CANCELLED' unit: cpuPct !== null ? '%' : undefined,
? 'v-st-fail' ticks: cpuPct,
: 'v-st-off'; foot: isDemoMode || host ? `${host?.cpu.cores ?? 16} cores · load avg` : 'no telemetry',
},
{
icon: <MemoryStick size={13.5} />,
label: `${hostLabel} Memory`,
value: memPct ?? '—',
unit: memPct !== null ? '%' : undefined,
ticks: memPct,
foot: isDemoMode
? '9.8G / 13G used'
: host
? `${fmtGB(host.memory.used)} / ${fmtGB(host.memory.total)} used`
: 'no telemetry',
},
{
icon: <HardDrive size={13.5} />,
label: `${hostLabel} Disk`,
value: diskPct ?? '—',
unit: diskPct !== null ? '%' : undefined,
ticks: diskPct,
foot: isDemoMode
? '338G of 465G'
: host
? `${fmtGB(host.storage.used)} of ${fmtGB(host.storage.total)}`
: 'no telemetry',
},
{
icon: <Rocket size={13.5} />,
label: 'Deployments',
value: isDemoMode ? 18 : deployCount,
unit: 'recent',
ticks: null,
foot: isDemoMode
? '17 succeeded'
: `${deployments.filter((d) => (d.status ?? '').toLowerCase() !== 'failed').length} succeeded`,
},
];
const deployCols: SCol<DeployRow>[] = [
{
key: 'service',
label: 'Service',
width: '2.4fr',
sortValue: (r) => r.name,
render: (r) => (
<span className="text-[var(--text-primary)] font-medium">
{r.name}
{r.project ? (
<span className="v-mono ml-2 text-[10.5px] font-normal text-[var(--text-tertiary)]">{r.project}</span>
) : null}
</span>
),
},
{
key: 'status',
label: 'Status',
width: '1fr',
sortValue: (r) => r.status,
render: (r) => <SPill tone={statusTone(r.status)}>{r.status}</SPill>,
},
{
key: 'when',
label: 'When',
width: '0.6fr',
sortable: false,
render: (r) => <span className="v-mono text-[11.5px] text-[var(--text-tertiary)]">{r.when}</span>,
},
];
const anomalies =
(diskPct ?? 0) > 85 || (memPct ?? 0) > 85
? [
...(diskPct ?? 0) > 85 ? [`Disk usage at ${diskPct}% — prune images or expand volume`] : [],
...(memPct ?? 0) > 85 ? [`Memory pressure at ${memPct}% — consider rebalancing services`] : [],
]
: [];
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<div className="w-full px-8 py-6"> <div className="w-full px-4 py-6 sm:px-7">
{/* Page head */} <SPageHead
<div className="mb-6 flex items-center justify-between gap-5"> title="Projects"
<div> titleAccent="_"
<h1 className="v-title">Projects<span className="v-cursor">_</span></h1> sub={
<p className="v-mono mt-1.5 text-[11px] text-[var(--text-tertiary)]"> <>
<b className="text-[var(--text-secondary)]">{String(projects.length).padStart(2, '0')}</b> projects ·{' '} {String(projects.length).padStart(2, '0')} projects · {String(totalServices).padStart(2, '0')} services ·{' '}
<b className="text-[var(--text-secondary)]">{String(totalServices).padStart(2, '0')}</b> services ·{' '} </>
<b className="text-[var(--text-secondary)]">{runningServices}</b> online }
</p> subAccent={`${runningServices} online`}
</div> actions={
<div className="flex items-center gap-2.5"> <>
<div className="search-box" style={{ width: 'min(240px, 42vw)' }}> <div className="search-box" style={{ width: 'min(230px, 40vw)' }}>
<Search size={13} /> <Search size={13} />
<input <input
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
placeholder="search projects…" placeholder="Search projects"
className="v-mono"
style={{ fontSize: 11.5 }}
/> />
</div> </div>
<IconBtn title="Refresh" onClick={() => { projectsQuery.refetch(); deploysQuery.refetch(); hostQuery.refetch(); }}>
<RefreshCw size={14} />
</IconBtn>
{signedIn && ( {signedIn && (
<button onClick={openCreate} className="v-btn"> <GhostBtn onClick={openCreate}>+ New Project</GhostBtn>
+ new project
</button>
)} )}
</div> </>
</div> }
/>
{/* Host stat row */} {/* Host telemetry */}
<div className="mb-7 grid grid-cols-2 gap-3 lg:grid-cols-4"> <div className="mb-4 grid grid-cols-2 gap-3.5 lg:grid-cols-4">
<div className="v-stat"> {statDefs.map((s) => (
<div className="v-k"><span>{hostLabel}.CPU</span><span className="v-live" /></div> <SStat
<div className="v-v">{cpuPct ?? '—'}<span>%</span></div> key={s.label}
{isDemoMode ? ( icon={s.icon}
<div className="v-spark"> label={s.label}
{[28, 44, 36, 58, 42, 66, 50, 34, 56, 40, 30, 20].map((h, i) => ( value={s.value}
<i key={i} style={{ height: `${h}%` }} /> unit={s.unit}
foot={s.foot}
delta={s.label === 'Deployments' && feed.length > 0 ? { dir: 'up', text: `+${feed.length}` } : undefined}
extra={s.ticks !== null && s.ticks !== undefined ? <Ticks pct={s.ticks} count={44} warn={s.ticks > 85} /> : undefined}
/>
))} ))}
</div> </div>
) : null}
<div className="v-d"> {anomalies.length > 0 && (
{isDemoMode || host ? ( <div className="mb-4">
<><b style={{ color: 'var(--success)' }}>healthy</b> · {host?.cpu.cores ?? 16} cores</> <SAnomaly
) : 'no telemetry'} icon={
</div> <svg width="12.5" height="12.5" viewBox="0 0 16 16" fill="none" stroke="var(--warning)" strokeWidth="1.5">
</div> <path d="M8 2.4 2.2 13h11.6z" />
<div className="v-stat"> <path d="M8 6.6v3M8 11.4v.3" />
<div className="v-k"><span>{hostLabel}.MEM</span><span className="v-live" /></div> </svg>
<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> title={<><b className="text-[var(--text-primary)] font-semibold">{anomalies.length}</b> resource anomaly{anomalies.length > 1 ? 's' : ''} detected</>}
<div className="v-d">{isDemoMode ? '9.8G / 13G' : host ? `${fmtGB(host.memory.used)} / ${fmtGB(host.memory.total)}` : 'no telemetry'}</div> items={anomalies}
</div> />
<div className="v-stat">
<div className="v-k"><span>{hostLabel}.DISK</span><span className="v-live" /></div>
<div className="v-v">{diskPct ?? '—'}<span>%</span></div>
<div className="v-meter"><i className="r" style={{ width: `${diskPct ?? 0}%` }} /></div>
<div className="v-d">{isDemoMode ? '338G of 465G · high' : host ? `${fmtGB(host.storage.used)} of ${fmtGB(host.storage.total)}` : 'no telemetry'}</div>
</div>
<div className="v-stat">
<div className="v-k"><span>DEPLOYS</span></div>
<div className="v-v">{isDemoMode ? 18 : deployCount}</div>
<div className="v-meter"><i style={{ width: '94%' }} /></div>
<div className="v-d"><b style={{ color: 'var(--success)' }}>{isDemoMode ? '17 ok' : `${deployments.filter((d) => (d.status ?? '').toLowerCase() !== 'failed').length} recent`}</b></div>
</div>
</div> </div>
)}
{/* Projects grid */} {/* Projects grid */}
<p className="v-sect">PROJECTS <span className="v-sect-r">{filteredProjects.length} records</span></p> <SSection right={`${filteredProjects.length} records`}>Projects</SSection>
{!isDemoMode && projectsQuery.isLoading && ( {!isDemoMode && projectsQuery.isLoading && (
<div className="grid grid-cols-1 gap-3.5 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4"> <div className="grid grid-cols-1 gap-3.5 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{Array.from({ length: 6 }).map((_, i) => ( {Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="panel h-[210px] animate-pulse" /> <div key={i} className="s-card h-[210px] animate-pulse" />
))} ))}
</div> </div>
)} )}
{!isDemoMode && projectsQuery.isError && ( {!isDemoMode && projectsQuery.isError && (
<div className="panel p-8 text-center"> <div className="s-card p-8 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-[var(--error-soft)]"> <div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-[var(--error-soft)]">
<X size={24} className="text-[var(--error)]" /> <X size={22} className="text-[var(--error)]" />
</div> </div>
<p className="text-lg font-medium text-[var(--text-primary)]">Failed to load projects</p> <p className="text-[15px] font-semibold text-[var(--text-primary)]">Failed to load projects</p>
<p className="mt-2 text-sm text-[var(--text-secondary)]">Check API connectivity and try again</p> <p className="mt-1.5 text-[12px] text-[var(--text-tertiary)]">Check API connectivity and try again</p>
</div> </div>
)} )}
{filteredProjects.length === 0 && !projectsQuery.isLoading && !projectsQuery.isError ? ( {filteredProjects.length === 0 && !projectsQuery.isLoading && !projectsQuery.isError ? (
<div className="panel p-12 text-center"> <div className="s-card p-12 text-center">
<div className="mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-[var(--accent-primary-soft)]"> <div className="mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-xl border border-[var(--border-default)] bg-[var(--bg-overlay)]">
<FolderOpen size={28} className="text-[var(--accent-primary)]" /> <FolderOpen size={24} className="text-[var(--accent-primary)]" />
</div> </div>
<p className="text-xl font-semibold text-[var(--text-primary)]">No projects yet</p> <p className="font-headline text-[17px] font-bold text-[var(--text-primary)]">No projects yet</p>
<p className="mx-auto mt-2 max-w-md text-[var(--text-secondary)]"> <p className="mx-auto mt-2 max-w-md text-[12.5px] text-[var(--text-secondary)]">
{signedIn {signedIn
? 'Create your first project to start deploying services with visual topology management.' ? 'Create your first project to start deploying services with visual topology management.'
: 'No public projects yet. Sign in to create one — an admin approves it before it goes public.'} : 'No public projects yet. Sign in to create one — an admin approves it before it goes public.'}
</p> </p>
<button onClick={openCreate} className="v-btn mt-6"> <GhostBtn onClick={openCreate} className="mt-6">
{signedIn ? '+ create project' : 'sign in to create'} {signedIn ? '+ New Project' : 'Sign in to create'}
</button> </GhostBtn>
</div> </div>
) : null} ) : null}
@@ -326,24 +426,23 @@ export function ProjectsPage() {
)} )}
{/* Deploy feed */} {/* Deploy feed */}
<p className="v-sect mt-7">RECENT DEPLOYMENTS <span className="v-sect-r">latest</span></p> <div className="mt-6">
<div className="v-tbl"> <SCard
<table> icon={<Rocket size={13.5} />}
<thead> title="Recent Deployments"
<tr><th>SERVICE</th><th>STATUS</th><th style={{ textAlign: 'right' }}>WHEN</th></tr> accent={feed.length > 0 ? `(${feed.length})` : undefined}
</thead> trail={
<tbody> <IconBtn title="Refresh" onClick={() => deploysQuery.refetch()}>
{feed.length === 0 ? ( <RefreshCw size={13} />
<tr><td colSpan={3} className="text-center" style={{ color: 'var(--text-tertiary)' }}>no deployments yet</td></tr> </IconBtn>
) : feed.map((d) => ( }
<tr key={d.id}> pad={false}
<td className="v-lead">{d.name}{d.project ? <span className="v-mono ml-2 text-[10.5px] font-normal text-[var(--text-tertiary)]">{d.project}</span> : null}</td> >
<td><span className={`v-st ${statusClass(d.status)}`}>{d.status}</span></td> <STable cols={deployCols} rows={feed} rowKey={(r) => r.id} />
<td style={{ textAlign: 'right' }}>{d.when}</td> {feed.length === 0 && (
</tr> <p className="px-7 pb-5 text-[12px] text-[var(--text-tertiary)]">No deployments yet.</p>
))} )}
</tbody> </SCard>
</table>
</div> </div>
</div> </div>
@@ -351,58 +450,55 @@ export function ProjectsPage() {
{isCreateOpen && ( {isCreateOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-[var(--bg-void)]/80 backdrop-blur-sm" onClick={() => setCreateOpen(false)} /> <div className="absolute inset-0 bg-[var(--bg-void)]/80 backdrop-blur-sm" onClick={() => setCreateOpen(false)} />
<div className="relative w-full max-w-lg panel p-6"> <div className="s-card relative w-full max-w-lg p-6">
<h2 className="text-xl font-semibold text-[var(--text-primary)]">Create new project</h2> <h2 className="font-headline text-[19px] font-bold tracking-tight text-[var(--text-primary)]">New project</h2>
<p className="mt-1 text-sm text-[var(--text-secondary)]"> <p className="mt-1 text-[12.5px] text-[var(--text-secondary)]">
Projects organize your services and provide a visual canvas for topology management. Projects organize your services and provide a visual canvas for topology management.
</p> </p>
<p className="mt-2 rounded-[var(--radius-md)] bg-[var(--warning-soft)] px-3 py-2 text-xs text-[var(--warning)]"> <div className="s-inset mt-3 text-[11.5px] text-[var(--warning)]">
New projects stay private until a platform admin approves them for public listing. New projects stay private until a platform admin approves them for public listing.
</p> </div>
<div className="mt-6 space-y-4"> <div className="mt-5 space-y-4">
<div> <div>
<label className="mb-2 block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)]"> <label className="mb-2 block text-[11px] font-medium uppercase tracking-wider text-[var(--text-tertiary)]">
Project Name Project Name
</label> </label>
<input <input
value={form.name} value={form.name}
onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))} onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))}
placeholder="my-awesome-project" placeholder="my-awesome-project"
className="h-11 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-4 text-[var(--text-primary)] transition-all placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)]" className="h-10 w-full rounded-lg border border-[var(--border-default)] bg-[var(--bg-overlay)] px-3.5 text-[13px] text-[var(--text-primary)] transition-all placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:outline-none"
/> />
</div> </div>
<div> <div>
<label className="mb-2 block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)]"> <label className="mb-2 block text-[11px] font-medium uppercase tracking-wider text-[var(--text-tertiary)]">
Description <span className="normal-case text-[var(--text-muted)]">(optional)</span> Description <span className="normal-case text-[var(--text-muted)]">(optional)</span>
</label> </label>
<textarea <textarea
value={form.description} value={form.description}
onChange={(e) => setForm((p) => ({ ...p, description: e.target.value }))} onChange={(e) => setForm((p) => ({ ...p, description: e.target.value }))}
placeholder="Describe your project..." placeholder="Describe your project…"
rows={3} rows={3}
className="w-full resize-none rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-4 py-3 text-[var(--text-primary)] transition-all placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)]" className="w-full resize-none rounded-lg border border-[var(--border-default)] bg-[var(--bg-overlay)] px-3.5 py-3 text-[13px] text-[var(--text-primary)] transition-all placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:outline-none"
/> />
</div> </div>
</div> </div>
{createProjectMutation.isError && ( {createProjectMutation.isError && (
<div className="mt-4 rounded-[var(--radius-md)] bg-[var(--error-soft)] px-4 py-3 text-sm text-[var(--error)]"> <div className="s-inset mt-4 text-[12px] text-[var(--error)]">
{(createProjectMutation.error as Error).message} {(createProjectMutation.error as Error).message}
</div> </div>
)} )}
<div className="mt-6 flex justify-end gap-3"> <div className="mt-6 flex justify-end gap-2.5">
<button onClick={() => setCreateOpen(false)} className="v-btn-ghost"> <QuietBtn onClick={() => setCreateOpen(false)}>Cancel</QuietBtn>
Cancel <GhostBtn
</button>
<button
onClick={() => createProjectMutation.mutate()} onClick={() => createProjectMutation.mutate()}
disabled={!form.name.trim() || createProjectMutation.isPending} disabled={!form.name.trim() || createProjectMutation.isPending}
className="v-btn"
> >
{createProjectMutation.isPending ? 'Creating...' : 'Create Project'} {createProjectMutation.isPending ? 'Creating…' : 'Create Project'}
</button> </GhostBtn>
</div> </div>
</div> </div>
</div> </div>
+174 -196
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { import {
@@ -104,6 +104,7 @@ import { formatRelative } from '@/lib/time';
import { getAuthBaseUrl, signOutAuthSession } from '@/lib/auth-client'; import { getAuthBaseUrl, signOutAuthSession } from '@/lib/auth-client';
import { useBuildUpdates } from '@/lib/use-build-updates'; import { useBuildUpdates } from '@/lib/use-build-updates';
import { EnhancedMetricCard, LineAreaChart, DonutChart, SegmentedBar, BarChart, DemoRestricted } from '@/shared/components'; import { EnhancedMetricCard, LineAreaChart, DonutChart, SegmentedBar, BarChart, DemoRestricted } from '@/shared/components';
import { SPageHead } from '@/shared/components/sentry';
import { import {
Clock, Clock,
Activity, Activity,
@@ -148,13 +149,10 @@ import {
Send, Send,
} from 'lucide-react'; } from 'lucide-react';
function SecondaryPageHeader({ title, description }: { title: string; description: string }) { function SecondaryPageHeader({ title, description, actions }: { title: string; description: string; actions?: ReactNode }) {
return ( return (
<div className="border-b border-[var(--border-subtle)]"> <div className="w-full px-4 pt-6 sm:px-8">
<div className="w-full px-8 py-5"> <SPageHead title={title} titleAccent="_" sub={description} actions={actions} />
<h1 className="v-title">{title}<span className="v-cursor">_</span></h1>
<p className="v-mono mt-1.5 text-[11px] text-[var(--text-tertiary)]">{description}</p>
</div>
</div> </div>
); );
} }
@@ -180,15 +178,17 @@ function StatCard({
}; };
return ( return (
<div className="panel p-5"> <div className="s-stat">
<div className="flex items-start justify-between mb-3"> <div className="px-4 pb-1 pt-4">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--surface-muted)] flex items-center justify-center"> <div className="flex items-center gap-2.5">
<Icon size={18} className="text-[var(--text-tertiary)]" /> <span className="s-ibox"><Icon /></span>
<span className="text-[11.5px] font-medium text-[var(--text-secondary)]">{title}</span>
</div> </div>
<p className={`font-headline mt-4 text-[30px] font-bold leading-none tracking-tight ${colorClasses[color]}`}>{value}</p>
</div>
<div className="s-stat-foot mt-4">
<span>{description}</span>
</div> </div>
<p className={`text-2xl font-semibold ${colorClasses[color]}`}>{value}</p>
<p className="text-xs font-medium text-[var(--text-muted)] mt-1">{title}</p>
<p className="text-xs text-[var(--text-tertiary)] mt-2">{description}</p>
</div> </div>
); );
} }
@@ -436,7 +436,7 @@ export function UsagePage() {
title="Usage" title="Usage"
description="Platform usage metrics and operational summaries" description="Platform usage metrics and operational summaries"
/> />
<div className="w-full px-8 py-6"> <div className="w-full px-4 py-6 sm:px-8">
<div className="flex items-center gap-2 mb-6"> <div className="flex items-center gap-2 mb-6">
<div className={`w-2 h-2 rounded-full ${liveStatus === 'live' ? 'bg-[var(--success)] animate-pulse' : 'bg-[var(--text-muted)]'}`} /> <div className={`w-2 h-2 rounded-full ${liveStatus === 'live' ? 'bg-[var(--success)] animate-pulse' : 'bg-[var(--text-muted)]'}`} />
<span className={`text-sm ${liveStatus === 'live' ? 'text-[var(--success)]' : 'text-[var(--text-muted)]'}`}> <span className={`text-sm ${liveStatus === 'live' ? 'text-[var(--success)]' : 'text-[var(--text-muted)]'}`}>
@@ -450,14 +450,14 @@ export function UsagePage() {
<p className="mt-3 text-sm text-[var(--text-muted)]">Loading usage data...</p> <p className="mt-3 text-sm text-[var(--text-muted)]">Loading usage data...</p>
</div> </div>
) : !isDemoMode && buildsQuery.isError ? ( ) : !isDemoMode && buildsQuery.isError ? (
<div className="panel p-8 text-center"> <div className="s-card p-8 text-center">
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-[var(--error-soft)] flex items-center justify-center"> <div className="w-12 h-12 mx-auto mb-4 rounded-full bg-[var(--error-soft)] flex items-center justify-center">
<AlertCircle size={24} className="text-[var(--error)]" /> <AlertCircle size={24} className="text-[var(--error)]" />
</div> </div>
<p className="text-sm text-[var(--error)]">Failed to load usage data</p> <p className="text-sm text-[var(--error)]">Failed to load usage data</p>
</div> </div>
) : !signedIn ? ( ) : !signedIn ? (
<div className="panel p-8 text-center"> <div className="s-card p-8 text-center">
<p className="text-sm text-[var(--text-secondary)]">Sign in to view build activity and capacity.</p> <p className="text-sm text-[var(--text-secondary)]">Sign in to view build activity and capacity.</p>
</div> </div>
) : ( ) : (
@@ -490,14 +490,12 @@ export function UsagePage() {
/> />
</div> </div>
<section className="panel p-6"> <section className="s-card p-5">
<div className="mb-5 flex items-center justify-between gap-3"> <div className="mb-5 flex items-center justify-between gap-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)]"> <span className="s-ibox !w-9 !h-9"><Server /></span>
<Server size={18} className="text-[var(--accent-primary)]" />
</div>
<div> <div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Host Monitoring</h2> <h2 className="s-t">Host Monitoring</h2>
<p className="text-xs text-[var(--text-tertiary)]"> <p className="text-xs text-[var(--text-tertiary)]">
{host {host
? `${host.hostname} · ${host.os}/${host.architecture} · uptime ${formatUptime(host.uptimeSeconds)}${host.dockerAvailable ? '' : ' · Docker unavailable'}` ? `${host.hostname} · ${host.os}/${host.architecture} · uptime ${formatUptime(host.uptimeSeconds)}${host.dockerAvailable ? '' : ' · Docker unavailable'}`
@@ -578,13 +576,11 @@ export function UsagePage() {
)} )}
</section> </section>
<section className="panel p-6"> <section className="s-card p-5">
<div className="mb-5 flex items-center gap-3"> <div className="mb-5 flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-[var(--radius-md)] bg-[var(--surface-muted)]"> <span className="s-ibox !w-9 !h-9"><Radio /></span>
<Radio size={18} className="text-[var(--text-tertiary)]" />
</div>
<div> <div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Node Agents</h2> <h2 className="s-t">Node Agents</h2>
<p className="text-xs text-[var(--text-tertiary)]">Remote VPS, VM, and LXC capacity for placement and autoscaling.</p> <p className="text-xs text-[var(--text-tertiary)]">Remote VPS, VM, and LXC capacity for placement and autoscaling.</p>
</div> </div>
</div> </div>
@@ -678,8 +674,8 @@ export function UsagePage() {
<button <button
onClick={() => createTokenMutation.mutate()} onClick={() => createTokenMutation.mutate()}
disabled={createTokenMutation.isPending} disabled={createTokenMutation.isPending}
className="h-8 px-3 rounded-[var(--radius-md)] text-xs font-medium text-[var(--accent-on)] transition-all disabled:opacity-50" className="s-btn-accent h-8 disabled:opacity-50"
style={{ background: 'var(--accent-primary)' }}
> >
{createTokenMutation.isPending ? 'Issuing…' : 'Issue token'} {createTokenMutation.isPending ? 'Issuing…' : 'Issue token'}
</button> </button>
@@ -688,7 +684,7 @@ export function UsagePage() {
{issuedToken && ( {issuedToken && (
<div className="mb-3 rounded-[var(--radius-md)] border border-[var(--success)]/30 bg-[var(--success-soft)] p-3"> <div className="mb-3 rounded-[var(--radius-md)] border border-[var(--success)]/30 bg-[var(--success-soft)] p-3">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<span className="mono text-xs text-[var(--text-primary)] break-all">{issuedToken.token}</span> <span className="v-mono text-xs text-[var(--text-primary)] break-all">{issuedToken.token}</span>
<button <button
onClick={() => void navigator.clipboard.writeText(issuedToken.token ?? '')} onClick={() => void navigator.clipboard.writeText(issuedToken.token ?? '')}
className="flex items-center gap-1 text-xs text-[var(--accent-primary)] hover:underline shrink-0" className="flex items-center gap-1 text-xs text-[var(--accent-primary)] hover:underline shrink-0"
@@ -706,11 +702,11 @@ export function UsagePage() {
</p> </p>
)} )}
<pre className="mono text-xs text-[var(--text-secondary)] whitespace-pre-wrap">{installCommand}</pre> <pre className="v-mono text-xs text-[var(--text-secondary)] whitespace-pre-wrap">{installCommand}</pre>
<pre className="mono text-xs text-[var(--text-secondary)] whitespace-pre-wrap mt-2">{sshCommand}</pre> <pre className="v-mono text-xs text-[var(--text-secondary)] whitespace-pre-wrap mt-2">{sshCommand}</pre>
<div className="mt-4 space-y-2 text-xs text-[var(--text-tertiary)]"> <div className="mt-4 space-y-2 text-xs text-[var(--text-tertiary)]">
<p>Agent endpoint: <span className="mono text-[var(--text-primary)]">{agentEndpoint}</span></p> <p>Agent endpoint: <span className="v-mono text-[var(--text-primary)]">{agentEndpoint}</span></p>
<p>Use an issued token above, or set <span className="mono text-[var(--text-primary)]">CONTAINR_AGENT_AUTH_TOKENS</span> on the backend.</p> <p>Use an issued token above, or set <span className="v-mono text-[var(--text-primary)]">CONTAINR_AGENT_AUTH_TOKENS</span> on the backend.</p>
<p>Agent sends host resources, polls pending Docker commands, and reports command results.</p> <p>Agent sends host resources, polls pending Docker commands, and reports command results.</p>
</div> </div>
@@ -844,7 +840,7 @@ export function PeoplePage() {
title="People" title="People"
description="Team management and access control" description="Team management and access control"
/> />
<div className="w-full px-8 py-6"> <div className="w-full px-4 py-6 sm:px-8">
{profileQuery.isLoading ? ( {profileQuery.isLoading ? (
<div className="py-16 text-center"> <div className="py-16 text-center">
<Loader2 size={24} className="animate-spin mx-auto text-[var(--text-tertiary)]" /> <Loader2 size={24} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
@@ -876,19 +872,17 @@ export function PeoplePage() {
/> />
</div> </div>
<section className="panel p-6"> <section className="s-card p-5">
<div className="mb-5 flex items-center gap-3"> <div className="mb-5 flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)]"> <span className="s-ibox !w-9 !h-9"><UserPlus /></span>
<UserPlus size={18} className="text-[var(--accent-primary)]" />
</div>
<div> <div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Manual User Creation</h2> <h2 className="s-t">Manual User Creation</h2>
<p className="text-xs text-[var(--text-tertiary)]">Public registration is closed after bootstrap.</p> <p className="text-xs text-[var(--text-tertiary)]">Public registration is closed after bootstrap.</p>
</div> </div>
</div> </div>
<form <form
className="grid gap-3 lg:grid-cols-[1fr_1fr_1fr_auto]" className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_auto]"
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault(); event.preventDefault();
createUserMutation.mutate(); createUserMutation.mutate();
@@ -921,8 +915,8 @@ export function PeoplePage() {
<button <button
type="submit" type="submit"
disabled={createUserMutation.isPending} disabled={createUserMutation.isPending}
className="inline-flex h-10 items-center justify-center gap-2 rounded-[var(--radius-md)] px-4 text-sm font-semibold text-[var(--accent-on)] disabled:opacity-60" className="s-btn-accent h-10 justify-center disabled:opacity-60"
style={{ background: 'var(--accent-primary)' }}
> >
{createUserMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : <UserPlus size={14} />} {createUserMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : <UserPlus size={14} />}
Create Create
@@ -1057,14 +1051,12 @@ function GitProvidersSection() {
const selectedType = gitProviderTypes.find((t) => t.value === form.name)!; const selectedType = gitProviderTypes.find((t) => t.value === form.name)!;
return ( return (
<section className="panel p-6"> <section className="s-card p-5">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center"> <span className="s-ibox !w-9 !h-9"><GitBranch /></span>
<GitBranch size={18} className="text-[var(--accent-primary)]" />
</div>
<div> <div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Git Providers</h2> <h2 className="s-t">Git Providers</h2>
<p className="text-xs text-[var(--text-tertiary)]">Connect accounts to deploy services from repositories</p> <p className="text-xs text-[var(--text-tertiary)]">Connect accounts to deploy services from repositories</p>
</div> </div>
</div> </div>
@@ -1073,8 +1065,8 @@ function GitProvidersSection() {
<button <button
onClick={() => provisionMutation.mutate()} onClick={() => provisionMutation.mutate()}
disabled={provisionMutation.isPending} disabled={provisionMutation.isPending}
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-[var(--accent-on)] text-sm font-medium shadow-lg transition-all disabled:opacity-50" className="s-btn-accent h-9 disabled:opacity-50"
style={{ background: 'var(--accent-primary)' }}
> >
<GitBranch size={14} /> <GitBranch size={14} />
{provisionMutation.isPending ? 'Opening GitHub…' : 'Set up GitHub App'} {provisionMutation.isPending ? 'Opening GitHub…' : 'Set up GitHub App'}
@@ -1090,8 +1082,8 @@ function GitProvidersSection() {
)} )}
<button <button
onClick={() => setFormOpen((open) => !open)} onClick={() => setFormOpen((open) => !open)}
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-[var(--accent-on)] text-sm font-medium shadow-lg transition-all" className="s-btn-accent h-9"
style={{ background: 'var(--accent-primary)' }}
> >
<Link2 size={14} /> <Link2 size={14} />
{formOpen ? 'Close' : 'Connect'} {formOpen ? 'Close' : 'Connect'}
@@ -1140,12 +1132,10 @@ function GitProvidersSection() {
{providers.map((provider) => ( {providers.map((provider) => (
<div <div
key={provider.id} key={provider.id}
className="flex items-center justify-between p-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]" className="s-inset flex items-center justify-between"
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-[var(--radius-sm)] bg-[var(--surface-card)] flex items-center justify-center"> <span className="s-ibox"><GitBranch /></span>
<GitBranch size={14} className="text-[var(--text-tertiary)]" />
</div>
<div> <div>
<p className="text-sm font-medium text-[var(--text-primary)]">{provider.display_name}</p> <p className="text-sm font-medium text-[var(--text-primary)]">{provider.display_name}</p>
<p className="text-xs text-[var(--text-muted)] capitalize">{provider.name.replace('_', ' ')}</p> <p className="text-xs text-[var(--text-muted)] capitalize">{provider.name.replace('_', ' ')}</p>
@@ -1201,7 +1191,7 @@ function GitProvidersSection() {
type="password" type="password"
value={form.accessToken} value={form.accessToken}
onChange={(e) => setForm((p) => ({ ...p, accessToken: e.target.value }))} onChange={(e) => setForm((p) => ({ ...p, accessToken: e.target.value }))}
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm mono focus:border-[var(--accent-primary)] transition-colors" className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm v-mono focus:border-[var(--accent-primary)] transition-colors"
placeholder="••••••••••••••••" placeholder="••••••••••••••••"
/> />
<p className="text-xs text-[var(--text-muted)] mt-1.5">{selectedType.tokenHint}</p> <p className="text-xs text-[var(--text-muted)] mt-1.5">{selectedType.tokenHint}</p>
@@ -1214,7 +1204,7 @@ function GitProvidersSection() {
<input <input
value={form.apiUrl} value={form.apiUrl}
onChange={(e) => setForm((p) => ({ ...p, apiUrl: e.target.value }))} onChange={(e) => setForm((p) => ({ ...p, apiUrl: e.target.value }))}
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm mono focus:border-[var(--accent-primary)] transition-colors" className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm v-mono focus:border-[var(--accent-primary)] transition-colors"
placeholder="https://gitea.example.com" placeholder="https://gitea.example.com"
/> />
</div> </div>
@@ -1228,8 +1218,8 @@ function GitProvidersSection() {
<button <button
onClick={() => createMutation.mutate()} onClick={() => createMutation.mutate()}
disabled={!form.accessToken.trim() || createMutation.isPending} disabled={!form.accessToken.trim() || createMutation.isPending}
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-[var(--accent-on)] text-sm font-medium shadow-lg disabled:opacity-50 disabled:cursor-not-allowed transition-all" className="s-btn-accent h-9 disabled:opacity-50 disabled:cursor-not-allowed"
style={{ background: 'var(--accent-primary)' }}
> >
{createMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />} {createMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />}
Connect Provider Connect Provider
@@ -1274,7 +1264,7 @@ export function GitHubAppCallbackPage() {
{!code ? ( {!code ? (
<> <>
<AlertCircle size={28} className="mx-auto text-[var(--error)]" /> <AlertCircle size={28} className="mx-auto text-[var(--error)]" />
<h1 className="mt-4 text-lg font-semibold text-[var(--text-primary)]">Missing code</h1> <h1 className="mt-4 font-headline text-lg font-semibold text-[var(--text-primary)]">Missing code</h1>
<p className="mt-2 text-sm text-[var(--text-tertiary)]"> <p className="mt-2 text-sm text-[var(--text-tertiary)]">
GitHub did not return an app code. Start the setup again from Git providers. GitHub did not return an app code. Start the setup again from Git providers.
</p> </p>
@@ -1282,14 +1272,14 @@ export function GitHubAppCallbackPage() {
) : convertMutation.isError ? ( ) : convertMutation.isError ? (
<> <>
<AlertCircle size={28} className="mx-auto text-[var(--error)]" /> <AlertCircle size={28} className="mx-auto text-[var(--error)]" />
<h1 className="mt-4 text-lg font-semibold text-[var(--text-primary)]">Setup failed</h1> <h1 className="mt-4 font-headline text-lg font-semibold text-[var(--text-primary)]">Setup failed</h1>
<p className="mt-2 text-sm text-[var(--text-tertiary)]"> <p className="mt-2 text-sm text-[var(--text-tertiary)]">
{convertMutation.error instanceof Error ? convertMutation.error.message : 'Could not provision the GitHub App'} {convertMutation.error instanceof Error ? convertMutation.error.message : 'Could not provision the GitHub App'}
</p> </p>
<button <button
type="button" type="button"
onClick={() => navigate('/settings', { replace: true })} onClick={() => navigate('/settings', { replace: true })}
className="mt-6 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-sm font-semibold text-[var(--accent-on)]" className="s-btn-accent mt-6"
> >
Back to settings Back to settings
</button> </button>
@@ -1297,7 +1287,7 @@ export function GitHubAppCallbackPage() {
) : ( ) : (
<> <>
<Loader2 size={28} className="mx-auto animate-spin text-[var(--accent-primary)]" /> <Loader2 size={28} className="mx-auto animate-spin text-[var(--accent-primary)]" />
<h1 className="mt-4 text-lg font-semibold text-[var(--text-primary)]">Setting up GitHub App</h1> <h1 className="mt-4 font-headline text-lg font-semibold text-[var(--text-primary)]">Setting up GitHub App</h1>
<p className="mt-2 text-sm text-[var(--text-tertiary)]">Exchanging the app credentials…</p> <p className="mt-2 text-sm text-[var(--text-tertiary)]">Exchanging the app credentials…</p>
</> </>
)} )}
@@ -1353,13 +1343,11 @@ function PlatformSettingsSection() {
}; };
return ( return (
<section className="panel p-6"> <section className="s-card p-5">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center"> <span className="s-ibox !w-9 !h-9"><Cloud /></span>
<Cloud size={18} className="text-[var(--accent-primary)]" />
</div>
<div> <div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Platform</h2> <h2 className="s-t">Platform</h2>
<p className="text-xs text-[var(--text-tertiary)]">Owner only - values set here override environment variables</p> <p className="text-xs text-[var(--text-tertiary)]">Owner only - values set here override environment variables</p>
</div> </div>
</div> </div>
@@ -1412,8 +1400,8 @@ function PlatformSettingsSection() {
<button <button
onClick={saveToken} onClick={saveToken}
disabled={saveMutation.isPending || !tokenDraft.trim()} disabled={saveMutation.isPending || !tokenDraft.trim()}
className="h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors" className="s-btn-accent h-9 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
style={{ background: 'var(--accent-primary)' }}
> >
Save Save
</button> </button>
@@ -1475,8 +1463,8 @@ function PlatformSettingsSection() {
saveMutation.mutate({ branding: b }); saveMutation.mutate({ branding: b });
}} }}
disabled={saveMutation.isPending || Object.keys(brandDraft).length === 0} disabled={saveMutation.isPending || Object.keys(brandDraft).length === 0}
className="h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors" className="s-btn-accent h-9 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
style={{ background: 'var(--accent-primary)' }}
> >
Save branding Save branding
</button> </button>
@@ -1548,13 +1536,11 @@ function UserTokensSection({ isAdmin }: { isAdmin: boolean }) {
]; ];
return ( return (
<section className="panel p-6"> <section className="s-card p-5">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center"> <span className="s-ibox !w-9 !h-9"><Key /></span>
<Key size={18} className="text-[var(--accent-primary)]" />
</div>
<div> <div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Personal Access Tokens</h2> <h2 className="s-t">Personal Access Tokens</h2>
<p className="text-xs text-[var(--text-tertiary)]">Authenticate the CLI, MCP server, and scripts - same rights as you, bounded by scope</p> <p className="text-xs text-[var(--text-tertiary)]">Authenticate the CLI, MCP server, and scripts - same rights as you, bounded by scope</p>
</div> </div>
</div> </div>
@@ -1588,8 +1574,8 @@ function UserTokensSection({ isAdmin }: { isAdmin: boolean }) {
<button <button
onClick={() => createMutation.mutate()} onClick={() => createMutation.mutate()}
disabled={createMutation.isPending || !tokenName.trim()} disabled={createMutation.isPending || !tokenName.trim()}
className="h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50 disabled:cursor-not-allowed transition-all" className="s-btn-accent h-9 disabled:opacity-50 disabled:cursor-not-allowed"
style={{ background: 'var(--accent-primary)' }}
> >
{createMutation.isPending ? 'Creating…' : 'Create token'} {createMutation.isPending ? 'Creating…' : 'Create token'}
</button> </button>
@@ -1598,7 +1584,7 @@ function UserTokensSection({ isAdmin }: { isAdmin: boolean }) {
{issuedToken ? ( {issuedToken ? (
<div className="rounded-[var(--radius-md)] border border-[var(--success)]/30 bg-[var(--success-soft)] p-3"> <div className="rounded-[var(--radius-md)] border border-[var(--success)]/30 bg-[var(--success-soft)] p-3">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<span className="mono text-xs text-[var(--text-primary)] break-all">{issuedToken.token}</span> <span className="v-mono text-xs text-[var(--text-primary)] break-all">{issuedToken.token}</span>
<button <button
onClick={() => void navigator.clipboard.writeText(issuedToken.token ?? '')} onClick={() => void navigator.clipboard.writeText(issuedToken.token ?? '')}
className="flex items-center gap-1 text-xs text-[var(--accent-primary)] hover:underline shrink-0" className="flex items-center gap-1 text-xs text-[var(--accent-primary)] hover:underline shrink-0"
@@ -1608,7 +1594,7 @@ function UserTokensSection({ isAdmin }: { isAdmin: boolean }) {
</button> </button>
</div> </div>
<p className="mt-1 text-xs text-[var(--text-tertiary)]"> <p className="mt-1 text-xs text-[var(--text-tertiary)]">
Shown once - store it now. Use with <span className="mono">containr auth login --token</span> or as a Bearer token. Shown once - store it now. Use with <span className="v-mono">containr auth login --token</span> or as a Bearer token.
</p> </p>
</div> </div>
) : null} ) : null}
@@ -1630,7 +1616,7 @@ function UserTokensSection({ isAdmin }: { isAdmin: boolean }) {
<div key={token.id} className="flex items-center justify-between gap-2 text-xs"> <div key={token.id} className="flex items-center justify-between gap-2 text-xs">
<div className="min-w-0"> <div className="min-w-0">
<span className="text-[var(--text-primary)]">{token.name}</span> <span className="text-[var(--text-primary)]">{token.name}</span>
<span className="ml-2 mono text-[var(--text-muted)]">{token.key_prefix}…</span> <span className="ml-2 v-mono text-[var(--text-muted)]">{token.key_prefix}…</span>
<span className="ml-2 rounded px-1.5 py-0.5 bg-[var(--surface-muted)] text-[var(--text-secondary)]">{token.scope}</span> <span className="ml-2 rounded px-1.5 py-0.5 bg-[var(--surface-muted)] text-[var(--text-secondary)]">{token.scope}</span>
<span className="ml-2 text-[var(--text-muted)]"> <span className="ml-2 text-[var(--text-muted)]">
{token.last_used_at ? `used ${formatRelative(token.last_used_at)}` : 'unused'} {token.last_used_at ? `used ${formatRelative(token.last_used_at)}` : 'unused'}
@@ -1692,13 +1678,11 @@ function RegistriesSection() {
const registries = registriesQuery.data ?? []; const registries = registriesQuery.data ?? [];
return ( return (
<section className="panel p-6"> <section className="s-card p-5">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center"> <span className="s-ibox !w-9 !h-9"><Archive /></span>
<Archive size={18} className="text-[var(--accent-primary)]" />
</div>
<div> <div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Registry credentials</h2> <h2 className="s-t">Registry credentials</h2>
<p className="text-xs text-[var(--text-tertiary)]">Pull private images on deploy - matched by image host, stored encrypted</p> <p className="text-xs text-[var(--text-tertiary)]">Pull private images on deploy - matched by image host, stored encrypted</p>
</div> </div>
</div> </div>
@@ -1733,8 +1717,8 @@ function RegistriesSection() {
<button <button
onClick={() => createMutation.mutate()} onClick={() => createMutation.mutate()}
disabled={createMutation.isPending || !name.trim() || !host.trim()} disabled={createMutation.isPending || !name.trim() || !host.trim()}
className="h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50 disabled:cursor-not-allowed transition-all" className="s-btn-accent h-9 disabled:opacity-50 disabled:cursor-not-allowed"
style={{ background: 'var(--accent-primary)' }}
> >
{createMutation.isPending ? 'Adding…' : 'Add registry'} {createMutation.isPending ? 'Adding…' : 'Add registry'}
</button> </button>
@@ -1760,7 +1744,7 @@ function RegistriesSection() {
<div key={r.id} className="flex items-center justify-between gap-2 text-xs"> <div key={r.id} className="flex items-center justify-between gap-2 text-xs">
<div className="min-w-0"> <div className="min-w-0">
<span className="text-[var(--text-primary)]">{r.name}</span> <span className="text-[var(--text-primary)]">{r.name}</span>
<span className="ml-2 mono text-[var(--text-muted)]">{r.host}</span> <span className="ml-2 v-mono text-[var(--text-muted)]">{r.host}</span>
{r.username ? <span className="ml-2 text-[var(--text-secondary)]">{r.username}</span> : null} {r.username ? <span className="ml-2 text-[var(--text-secondary)]">{r.username}</span> : null}
<span className="ml-2 rounded px-1.5 py-0.5 bg-[var(--surface-muted)] text-[var(--text-secondary)]"> <span className="ml-2 rounded px-1.5 py-0.5 bg-[var(--surface-muted)] text-[var(--text-secondary)]">
{r.has_password ? 'password set' : 'anonymous'} {r.has_password ? 'password set' : 'anonymous'}
@@ -1828,13 +1812,11 @@ function BackupTargetsSection() {
const fieldCls = 'h-9 flex-1 min-w-[130px] rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all'; const fieldCls = 'h-9 flex-1 min-w-[130px] rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all';
return ( return (
<section className="panel p-6"> <section className="s-card p-5">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center"> <span className="s-ibox !w-9 !h-9"><HardDrive /></span>
<HardDrive size={18} className="text-[var(--accent-primary)]" />
</div>
<div> <div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Backup targets</h2> <h2 className="s-t">Backup targets</h2>
<p className="text-xs text-[var(--text-tertiary)]">S3-compatible destinations for database archives — keys stored encrypted</p> <p className="text-xs text-[var(--text-tertiary)]">S3-compatible destinations for database archives — keys stored encrypted</p>
</div> </div>
</div> </div>
@@ -1853,8 +1835,8 @@ function BackupTargetsSection() {
<button <button
onClick={() => createMutation.mutate()} onClick={() => createMutation.mutate()}
disabled={createMutation.isPending || !form.name.trim() || !form.endpoint.trim() || !form.bucket.trim()} disabled={createMutation.isPending || !form.name.trim() || !form.endpoint.trim() || !form.bucket.trim()}
className="h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50 disabled:cursor-not-allowed transition-all" className="s-btn-accent h-9 disabled:opacity-50 disabled:cursor-not-allowed"
style={{ background: 'var(--accent-primary)' }}
> >
{createMutation.isPending ? 'Checking…' : 'Add target'} {createMutation.isPending ? 'Checking…' : 'Add target'}
</button> </button>
@@ -1880,7 +1862,7 @@ function BackupTargetsSection() {
<div key={t.id} className="flex items-center justify-between gap-2 text-xs"> <div key={t.id} className="flex items-center justify-between gap-2 text-xs">
<div className="min-w-0"> <div className="min-w-0">
<span className="text-[var(--text-primary)]">{t.name}</span> <span className="text-[var(--text-primary)]">{t.name}</span>
<span className="ml-2 mono text-[var(--text-muted)]">{t.endpoint}/{t.bucket}{t.prefix ? `/${t.prefix}` : ''}</span> <span className="ml-2 v-mono text-[var(--text-muted)]">{t.endpoint}/{t.bucket}{t.prefix ? `/${t.prefix}` : ''}</span>
<span className="ml-2 rounded px-1.5 py-0.5 bg-[var(--surface-muted)] text-[var(--text-secondary)]"> <span className="ml-2 rounded px-1.5 py-0.5 bg-[var(--surface-muted)] text-[var(--text-secondary)]">
{t.has_credentials ? 'keys set' : 'anonymous'}{t.use_tls ? '' : ' · no TLS'} {t.has_credentials ? 'keys set' : 'anonymous'}{t.use_tls ? '' : ' · no TLS'}
</span> </span>
@@ -1961,13 +1943,11 @@ function NotificationChannelsSection() {
const channels = channelsQuery.data ?? []; const channels = channelsQuery.data ?? [];
return ( return (
<section className="panel p-6"> <section className="s-card p-5">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center"> <span className="s-ibox !w-9 !h-9"><Bell /></span>
<Bell size={18} className="text-[var(--accent-primary)]" />
</div>
<div> <div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Push notifications</h2> <h2 className="s-t">Push notifications</h2>
<p className="text-xs text-[var(--text-tertiary)]">Relay notifications to a self-hosted ntfy or Gotify server</p> <p className="text-xs text-[var(--text-tertiary)]">Relay notifications to a self-hosted ntfy or Gotify server</p>
</div> </div>
</div> </div>
@@ -1998,8 +1978,8 @@ function NotificationChannelsSection() {
<button <button
onClick={() => createMutation.mutate()} onClick={() => createMutation.mutate()}
disabled={createMutation.isPending || !endpoint.trim()} disabled={createMutation.isPending || !endpoint.trim()}
className="h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50 disabled:cursor-not-allowed transition-all" className="s-btn-accent h-9 disabled:opacity-50 disabled:cursor-not-allowed"
style={{ background: 'var(--accent-primary)' }}
> >
{createMutation.isPending ? 'Adding…' : 'Add channel'} {createMutation.isPending ? 'Adding…' : 'Add channel'}
</button> </button>
@@ -2027,7 +2007,7 @@ function NotificationChannelsSection() {
<span className="rounded px-1.5 py-0.5 bg-[var(--surface-muted)] text-[var(--text-secondary)] uppercase"> <span className="rounded px-1.5 py-0.5 bg-[var(--surface-muted)] text-[var(--text-secondary)] uppercase">
{ch.kind} {ch.kind}
</span> </span>
<span className="ml-2 mono text-[var(--text-primary)] break-all">{ch.endpoint}</span> <span className="ml-2 v-mono text-[var(--text-primary)] break-all">{ch.endpoint}</span>
{testResult[ch.id] === 'ok' ? ( {testResult[ch.id] === 'ok' ? (
<span className="ml-2 text-[var(--success)]">delivered</span> <span className="ml-2 text-[var(--success)]">delivered</span>
) : testResult[ch.id] ? ( ) : testResult[ch.id] ? (
@@ -2145,16 +2125,14 @@ export function SettingsPage() {
title="Settings" title="Settings"
description="Manage account profile and local configuration" description="Manage account profile and local configuration"
/> />
<div className="w-full px-8 py-6"> <div className="w-full px-4 py-6 sm:px-8">
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6"> <div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
{/* Profile Section */} {/* Profile Section */}
<section className="panel p-6"> <section className="s-card p-5">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center"> <span className="s-ibox !w-9 !h-9"><User /></span>
<User size={18} className="text-[var(--accent-primary)]" />
</div>
<div> <div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Profile</h2> <h2 className="s-t">Profile</h2>
<p className="text-xs text-[var(--text-tertiary)]">Backed by GET/PUT /user/profile</p> <p className="text-xs text-[var(--text-tertiary)]">Backed by GET/PUT /user/profile</p>
</div> </div>
</div> </div>
@@ -2196,8 +2174,8 @@ export function SettingsPage() {
<button <button
onClick={() => updateProfileMutation.mutate()} onClick={() => updateProfileMutation.mutate()}
disabled={!hasProfileChanges || updateProfileMutation.isPending} disabled={!hasProfileChanges || updateProfileMutation.isPending}
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-[var(--accent-on)] text-sm font-medium shadow-lg disabled:opacity-50 disabled:cursor-not-allowed transition-all" className="s-btn-accent h-9 disabled:opacity-50 disabled:cursor-not-allowed"
style={{ background: 'var(--accent-primary)' }}
> >
{updateProfileMutation.isPending ? ( {updateProfileMutation.isPending ? (
<> <>
@@ -2248,13 +2226,11 @@ export function SettingsPage() {
<GitProvidersSection /> <GitProvidersSection />
{/* Audit Logs Section */} {/* Audit Logs Section */}
<section className="panel p-6"> <section className="s-card p-5">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center"> <span className="s-ibox !w-9 !h-9"><ScrollText /></span>
<ScrollText size={18} className="text-[var(--accent-primary)]" />
</div>
<div> <div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Audit Logs</h2> <h2 className="s-t">Audit Logs</h2>
<p className="text-xs text-[var(--text-tertiary)]">Every authenticated action recorded by the platform</p> <p className="text-xs text-[var(--text-tertiary)]">Every authenticated action recorded by the platform</p>
</div> </div>
</div> </div>
@@ -2271,13 +2247,11 @@ export function SettingsPage() {
</section> </section>
{/* Runtime & Local State Section */} {/* Runtime & Local State Section */}
<section className="panel p-6"> <section className="s-card p-5">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--surface-muted)] flex items-center justify-center"> <span className="s-ibox !w-9 !h-9"><Settings /></span>
<Settings size={18} className="text-[var(--text-tertiary)]" />
</div>
<div> <div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Runtime & Local State</h2> <h2 className="s-t">Runtime & Local State</h2>
<p className="text-xs text-[var(--text-tertiary)]">API endpoints, cookie-session mode, and storage diagnostics</p> <p className="text-xs text-[var(--text-tertiary)]">API endpoints, cookie-session mode, and storage diagnostics</p>
</div> </div>
</div> </div>
@@ -2288,7 +2262,7 @@ export function SettingsPage() {
<Key size={14} className="text-[var(--text-tertiary)]" /> <Key size={14} className="text-[var(--text-tertiary)]" />
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">API Base</span> <span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">API Base</span>
</div> </div>
<p className="mono text-xs text-[var(--text-primary)] break-all">{getApiBaseUrl()}</p> <p className="v-mono text-xs text-[var(--text-primary)] break-all">{getApiBaseUrl()}</p>
</div> </div>
<div className="p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]"> <div className="p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]">
@@ -2296,7 +2270,7 @@ export function SettingsPage() {
<Key size={14} className="text-[var(--text-tertiary)]" /> <Key size={14} className="text-[var(--text-tertiary)]" />
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Auth Base</span> <span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Auth Base</span>
</div> </div>
<p className="mono text-xs text-[var(--text-primary)] break-all">{getAuthBaseUrl()}</p> <p className="v-mono text-xs text-[var(--text-primary)] break-all">{getAuthBaseUrl()}</p>
</div> </div>
<div className="p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]"> <div className="p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]">
@@ -2350,7 +2324,7 @@ export function SettingsPage() {
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">Cached Keys</p> <p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">Cached Keys</p>
<ul className="space-y-1"> <ul className="space-y-1">
{storageSummary.canvasKeys.slice(0, 5).map((key) => ( {storageSummary.canvasKeys.slice(0, 5).map((key) => (
<li key={key} className="mono text-xs text-[var(--text-tertiary)] truncate"> <li key={key} className="v-mono text-xs text-[var(--text-tertiary)] truncate">
{key} {key}
</li> </li>
))} ))}
@@ -2376,7 +2350,7 @@ export function DocsPage() {
title="Docs" title="Docs"
description="Guides and references — synced from GitHub, cached offline" description="Guides and references — synced from GitHub, cached offline"
/> />
<div className="w-full px-8 py-6"> <div className="w-full px-4 py-6 sm:px-8">
<DocsBrowser /> <DocsBrowser />
</div> </div>
</div> </div>
@@ -2513,7 +2487,7 @@ export function DatabasesPage() {
title="Databases" title="Databases"
description="Managed database services — connection info, runtime actions and backups" description="Managed database services — connection info, runtime actions and backups"
/> />
<div className="w-full px-8 py-6"> <div className="w-full px-4 py-6 sm:px-8">
<div className="mb-5 flex items-center justify-between"> <div className="mb-5 flex items-center justify-between">
<p className="text-xs text-[var(--text-tertiary)]"> <p className="text-xs text-[var(--text-tertiary)]">
{databases.length} {databases.length === 1 ? 'database' : 'databases'} {databases.length} {databases.length === 1 ? 'database' : 'databases'}
@@ -2522,7 +2496,7 @@ export function DatabasesPage() {
<button <button
type="button" type="button"
onClick={() => setCreateOpen(true)} onClick={() => setCreateOpen(true)}
className="inline-flex h-9 items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 text-[12.5px] font-semibold text-[var(--accent-on)]" className="s-btn-accent h-9 text-[12.5px]"
> >
<Database size={13} /> New database <Database size={13} /> New database
</button> </button>
@@ -2540,7 +2514,7 @@ export function DatabasesPage() {
<button <button
type="button" type="button"
onClick={() => setCreateOpen(true)} onClick={() => setCreateOpen(true)}
className="mt-4 inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-[12.5px] font-semibold text-[var(--accent-on)]" className="s-btn-accent mt-4 text-[12.5px]"
> >
<Database size={13} /> Create your first database <Database size={13} /> Create your first database
</button> </button>
@@ -2574,7 +2548,7 @@ export function DatabasesPage() {
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm font-medium text-[var(--text-primary)]">{db.name}</span> <span className="text-sm font-medium text-[var(--text-primary)]">{db.name}</span>
<span className="text-xs px-1.5 py-0.5 rounded bg-[var(--surface-muted)] text-[var(--text-secondary)] mono">{db.type}{db.version ? ` ${db.version}` : ''}</span> <span className="text-xs px-1.5 py-0.5 rounded bg-[var(--surface-muted)] text-[var(--text-secondary)] v-mono">{db.type}{db.version ? ` ${db.version}` : ''}</span>
{db.provider === 'external' && ( {db.provider === 'external' && (
<span className="text-xs px-1.5 py-0.5 rounded bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]">external</span> <span className="text-xs px-1.5 py-0.5 rounded bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]">external</span>
)} )}
@@ -2594,7 +2568,7 @@ export function DatabasesPage() {
<div> <div>
<p className="mb-1.5 text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Connection URL</p> <p className="mb-1.5 text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Connection URL</p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<code className="mono flex-1 truncate rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2 text-xs text-[var(--text-primary)]"> <code className="v-mono flex-1 truncate rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2 text-xs text-[var(--text-primary)]">
{db.connection_url || '—'} {db.connection_url || '—'}
</code> </code>
{db.connection_url && ( {db.connection_url && (
@@ -2617,7 +2591,7 @@ export function DatabasesPage() {
<button <button
onClick={() => testByIdMutation.mutate(db.id ?? '')} onClick={() => testByIdMutation.mutate(db.id ?? '')}
disabled={testByIdMutation.isPending} disabled={testByIdMutation.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] disabled:opacity-40 transition-colors" className="s-btn-quiet"
> >
<RefreshCw size={11} /> Test connection <RefreshCw size={11} /> Test connection
</button> </button>
@@ -2634,21 +2608,21 @@ export function DatabasesPage() {
<button <button
onClick={() => actionMutation.mutate({ id: db.id ?? '', action: 'start' })} onClick={() => actionMutation.mutate({ id: db.id ?? '', action: 'start' })}
disabled={running || actionMutation.isPending} disabled={running || actionMutation.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] disabled:opacity-40 transition-colors" className="s-btn-quiet"
> >
<Play size={11} /> Start <Play size={11} /> Start
</button> </button>
<button <button
onClick={() => actionMutation.mutate({ id: db.id ?? '', action: 'stop' })} onClick={() => actionMutation.mutate({ id: db.id ?? '', action: 'stop' })}
disabled={!running || actionMutation.isPending} disabled={!running || actionMutation.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] disabled:opacity-40 transition-colors" className="s-btn-quiet"
> >
<Square size={11} /> Stop <Square size={11} /> Stop
</button> </button>
<button <button
onClick={() => actionMutation.mutate({ id: db.id ?? '', action: 'restart' })} onClick={() => actionMutation.mutate({ id: db.id ?? '', action: 'restart' })}
disabled={!running || actionMutation.isPending} disabled={!running || actionMutation.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] disabled:opacity-40 transition-colors" className="s-btn-quiet"
> >
<RefreshCw size={11} /> Restart <RefreshCw size={11} /> Restart
</button> </button>
@@ -2665,7 +2639,7 @@ export function DatabasesPage() {
onClick={() => setBindDb(bindDb?.id === db.id ? null : db)} onClick={() => setBindDb(bindDb?.id === db.id ? null : db)}
disabled={!db.connection_url} disabled={!db.connection_url}
title={db.connection_url ? 'Inject connection URL into a service' : 'No connection URL available'} title={db.connection_url ? 'Inject connection URL into a service' : 'No connection URL available'}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] disabled:opacity-40 transition-colors" className="s-btn-quiet"
> >
<Link2 size={11} /> Bind <Link2 size={11} /> Bind
</button> </button>
@@ -2697,7 +2671,7 @@ export function DatabasesPage() {
] as const).map(([label, value]) => ( ] as const).map(([label, value]) => (
<div key={label} className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2"> <div key={label} className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2">
<p className="text-xs text-[var(--text-muted)]">{label}</p> <p className="text-xs text-[var(--text-muted)]">{label}</p>
<p className="mono text-sm text-[var(--text-primary)]">{value}</p> <p className="v-mono text-sm text-[var(--text-primary)]">{value}</p>
</div> </div>
))} ))}
</div> </div>
@@ -2743,8 +2717,8 @@ export function DatabasesPage() {
<button <button
onClick={() => backupMutation.mutate(db.id ?? '')} onClick={() => backupMutation.mutate(db.id ?? '')}
disabled={!running || backupMutation.isPending} disabled={!running || backupMutation.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] text-xs font-medium text-[var(--accent-on)] disabled:opacity-40 transition-all" className="s-btn-accent disabled:opacity-40"
style={{ background: 'var(--accent-primary)' }}
> >
<Archive size={11} /> <Archive size={11} />
{backupMutation.isPending ? 'Starting…' : 'New backup'} {backupMutation.isPending ? 'Starting…' : 'New backup'}
@@ -2764,7 +2738,7 @@ export function DatabasesPage() {
{backups.map((b) => ( {backups.map((b) => (
<div key={b.id} className="flex items-center gap-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2 text-xs"> <div key={b.id} className="flex items-center gap-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2 text-xs">
<span className="text-[var(--text-secondary)]">{formatRelative(b.created_at)}</span> <span className="text-[var(--text-secondary)]">{formatRelative(b.created_at)}</span>
<span className="mono text-[var(--text-muted)]">{b.size}</span> <span className="v-mono text-[var(--text-muted)]">{b.size}</span>
<span className={`${b.status === 'completed' ? 'text-[var(--success)]' : b.status === 'in_progress' ? 'text-[var(--warning)]' : 'text-[var(--error)]'}`}>{b.status}</span> <span className={`${b.status === 'completed' ? 'text-[var(--success)]' : b.status === 'in_progress' ? 'text-[var(--warning)]' : 'text-[var(--error)]'}`}>{b.status}</span>
{b.remote && <span className="rounded bg-[var(--surface-card)] px-1.5 py-0.5 text-[var(--accent-primary)]" title="Also shipped to the configured backup target">offsite</span>} {b.remote && <span className="rounded bg-[var(--surface-card)] px-1.5 py-0.5 text-[var(--accent-primary)]" title="Also shipped to the configured backup target">offsite</span>}
{b.status === 'completed' && ( {b.status === 'completed' && (
@@ -2994,7 +2968,7 @@ export function DatabasesPage() {
(createMode === 'managed' ? createMutation.isPending : registerExternalMutation.isPending) || (createMode === 'managed' ? createMutation.isPending : registerExternalMutation.isPending) ||
(createMode === 'managed' ? createForm.name.trim().length === 0 : externalForm.name.trim().length === 0 || externalForm.host.trim().length === 0) (createMode === 'managed' ? createForm.name.trim().length === 0 : externalForm.name.trim().length === 0 || externalForm.host.trim().length === 0)
} }
className="inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-xs font-semibold text-[var(--accent-on)] disabled:opacity-50" className="s-btn-accent disabled:opacity-50"
> >
{(createMode === 'managed' ? createMutation.isPending : registerExternalMutation.isPending) ? <Loader2 size={13} className="animate-spin" /> : null} {(createMode === 'managed' ? createMutation.isPending : registerExternalMutation.isPending) ? <Loader2 size={13} className="animate-spin" /> : null}
{createMode === 'managed' ? 'Create database' : 'Register database'} {createMode === 'managed' ? 'Create database' : 'Register database'}
@@ -3041,10 +3015,10 @@ function BindDatabasePanel({
const services = servicesQuery.data ?? []; const services = servicesQuery.data ?? [];
return ( return (
<div className="rounded-[var(--radius-md)] border border-[var(--border-default)] bg-[var(--surface-card)] p-4 space-y-3"> <div className="s-card space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-xs font-medium text-[var(--text-primary)]">Bind to service</p> <p className="s-t">Bind to service</p>
<button onClick={onClose} className="text-xs text-[var(--text-muted)] hover:text-[var(--text-primary)]">Close</button> <button onClick={onClose} className="v-mono text-[11px] text-[var(--text-muted)] hover:text-[var(--text-primary)]">close</button>
</div> </div>
<p className="text-xs text-[var(--text-muted)]"> <p className="text-xs text-[var(--text-muted)]">
Injects this database's connection URL as an environment variable on the selected service. Injects this database's connection URL as an environment variable on the selected service.
@@ -3082,13 +3056,13 @@ function BindDatabasePanel({
<input <input
value={varKey} value={varKey}
onChange={(e) => setVarKey(e.target.value)} onChange={(e) => setVarKey(e.target.value)}
className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-2 py-1.5 text-xs mono text-[var(--text-primary)] w-40" className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-2 py-1.5 text-xs v-mono text-[var(--text-primary)] w-40"
/> />
</label> </label>
<button <button
onClick={() => bindMutation.mutate()} onClick={() => bindMutation.mutate()}
disabled={!serviceId || !varKey.trim() || bindMutation.isPending} disabled={!serviceId || !varKey.trim() || bindMutation.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-xs font-medium disabled:opacity-40" className="s-btn-accent disabled:opacity-40"
> >
{bindMutation.isPending ? <Loader2 size={11} className="animate-spin" /> : <Link2 size={11} />} Bind {bindMutation.isPending ? <Loader2 size={11} className="animate-spin" /> : <Link2 size={11} />} Bind
</button> </button>
@@ -3129,7 +3103,7 @@ function BackupScheduleRow({
value={value} value={value}
onChange={(e) => setValue(e.target.value)} onChange={(e) => setValue(e.target.value)}
placeholder="cron schedule, e.g. 0 3 * * *" placeholder="cron schedule, e.g. 0 3 * * *"
className="w-44 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-2 py-1.5 text-xs mono text-[var(--text-primary)]" className="w-44 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-2 py-1.5 text-xs v-mono text-[var(--text-primary)]"
/> />
<button <button
onClick={() => mutation.mutate(value.trim())} onClick={() => mutation.mutate(value.trim())}
@@ -3251,7 +3225,7 @@ export function HighAvailabilityPage() {
title="High availability" title="High availability"
description="Failover manager, policies, and active alerts" description="Failover manager, policies, and active alerts"
/> />
<div className="p-8 space-y-8"> <div className="p-4 space-y-8 sm:p-8">
<div className="flex items-center gap-2 text-xs text-[var(--text-tertiary)]"> <div className="flex items-center gap-2 text-xs text-[var(--text-tertiary)]">
<ShieldCheck size={13} className="text-[var(--accent-primary)]" /> <ShieldCheck size={13} className="text-[var(--accent-primary)]" />
<span>HA state feeds the platform security posture.</span> <span>HA state feeds the platform security posture.</span>
@@ -3297,17 +3271,17 @@ export function HighAvailabilityPage() {
</div> </div>
{isAdmin ? ( {isAdmin ? (
<div className="panel p-6"> <div className="s-card p-5">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<Zap size={18} className="text-[var(--accent-primary)]" /> <Zap size={18} className="text-[var(--accent-primary)]" />
<h2 className="text-base font-semibold text-[var(--text-primary)]">Manager controls</h2> <h2 className="s-t">Manager controls</h2>
</div> </div>
<div className="flex flex-wrap items-end gap-3"> <div className="flex flex-wrap items-end gap-3">
<button <button
type="button" type="button"
disabled={toggleMutation.isPending || statusQuery.isLoading} disabled={toggleMutation.isPending || statusQuery.isLoading}
onClick={() => toggleMutation.mutate(!status?.enabled)} onClick={() => toggleMutation.mutate(!status?.enabled)}
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50" className="s-btn-accent disabled:opacity-50"
> >
{status?.enabled ? 'Disable HA manager' : 'Enable HA manager'} {status?.enabled ? 'Disable HA manager' : 'Enable HA manager'}
</button> </button>
@@ -3338,10 +3312,10 @@ export function HighAvailabilityPage() {
</div> </div>
) : null} ) : null}
<div className="panel p-6"> <div className="s-card p-5">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<AlertCircle size={18} className="text-[var(--accent-primary)]" /> <AlertCircle size={18} className="text-[var(--accent-primary)]" />
<h2 className="text-base font-semibold text-[var(--text-primary)]">Active alerts</h2> <h2 className="s-t">Active alerts</h2>
</div> </div>
{!isDemoMode && alertsQuery.isLoading ? ( {!isDemoMode && alertsQuery.isLoading ? (
<p className="text-sm text-[var(--text-secondary)]">Loading…</p> <p className="text-sm text-[var(--text-secondary)]">Loading…</p>
@@ -3365,7 +3339,7 @@ export function HighAvailabilityPage() {
type="button" type="button"
disabled={resolveMutation.isPending} disabled={resolveMutation.isPending}
onClick={() => alert.id && resolveMutation.mutate(alert.id)} onClick={() => alert.id && resolveMutation.mutate(alert.id)}
className="ml-4 shrink-0 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs text-[var(--text-primary)] disabled:opacity-50" className="s-btn-quiet"
> >
Resolve Resolve
</button> </button>
@@ -3376,17 +3350,17 @@ export function HighAvailabilityPage() {
)} )}
</div> </div>
<div className="panel p-6"> <div className="s-card p-5">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<ShieldCheck size={18} className="text-[var(--accent-primary)]" /> <ShieldCheck size={18} className="text-[var(--accent-primary)]" />
<h2 className="text-base font-semibold text-[var(--text-primary)]">Failover policies</h2> <h2 className="s-t">Failover policies</h2>
</div> </div>
{policyForm === null && isAdmin && ( {policyForm === null && isAdmin && (
<button <button
type="button" type="button"
onClick={() => setPolicyForm({ serviceId: '', strategy: 'active_passive', minHealthyNodes: '1', maxFailures: '3', enabled: true, editing: false })} onClick={() => setPolicyForm({ serviceId: '', strategy: 'active_passive', minHealthyNodes: '1', maxFailures: '3', enabled: true, editing: false })}
className="px-3 py-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-xs font-medium" className="s-btn-accent "
> >
New policy New policy
</button> </button>
@@ -3480,14 +3454,14 @@ export function HighAvailabilityPage() {
type="button" type="button"
disabled={savePolicyMutation.isPending || !policyForm.serviceId} disabled={savePolicyMutation.isPending || !policyForm.serviceId}
onClick={() => savePolicyMutation.mutate()} onClick={() => savePolicyMutation.mutate()}
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50" className="s-btn-accent disabled:opacity-50"
> >
{savePolicyMutation.isPending ? 'Saving…' : 'Save policy'} {savePolicyMutation.isPending ? 'Saving…' : 'Save policy'}
</button> </button>
<button <button
type="button" type="button"
onClick={() => setPolicyForm(null)} onClick={() => setPolicyForm(null)}
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]" className="s-btn-quiet"
> >
Cancel Cancel
</button> </button>
@@ -3526,7 +3500,7 @@ export function HighAvailabilityPage() {
enabled: policy.enabled ?? true, enabled: policy.enabled ?? true,
editing: true, editing: true,
})} })}
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs text-[var(--text-primary)]" className="s-btn-quiet"
> >
Edit Edit
</button> </button>
@@ -3553,10 +3527,10 @@ export function HighAvailabilityPage() {
)} )}
</div> </div>
<div className="panel p-6"> <div className="s-card p-5">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<HeartPulse size={18} className="text-[var(--accent-primary)]" /> <HeartPulse size={18} className="text-[var(--accent-primary)]" />
<h2 className="text-base font-semibold text-[var(--text-primary)]">Health check results</h2> <h2 className="s-t">Health check results</h2>
</div> </div>
{!isDemoMode && healthQuery.isLoading ? ( {!isDemoMode && healthQuery.isLoading ? (
<p className="text-sm text-[var(--text-secondary)]">Loading…</p> <p className="text-sm text-[var(--text-secondary)]">Loading…</p>
@@ -3615,39 +3589,43 @@ export function SecurityPage() {
const projectsQuery = useQuery({ queryKey: ['projects'], queryFn: () => listProjects(), enabled: !isDemoMode }); const projectsQuery = useQuery({ queryKey: ['projects'], queryFn: () => listProjects(), enabled: !isDemoMode });
const projects = isDemoMode ? demoProjects : projectsQuery.data ?? []; const projects = isDemoMode ? demoProjects : projectsQuery.data ?? [];
// Auto-select the first project so the page never renders empty — derived, not
// stored, so clearing the select still shows the empty state explicitly.
const effectiveProjectId = projectId || projects[0]?.id || '';
const servicesQuery = useQuery({ const servicesQuery = useQuery({
queryKey: ['services', projectId], queryKey: ['services', effectiveProjectId],
queryFn: () => listServicesByProject(projectId), queryFn: () => listServicesByProject(effectiveProjectId),
enabled: !isDemoMode && Boolean(projectId), enabled: !isDemoMode && Boolean(effectiveProjectId),
}); });
const services = isDemoMode ? getDemoServicesByProject(projectId) : servicesQuery.data ?? []; const services = isDemoMode ? getDemoServicesByProject(effectiveProjectId) : servicesQuery.data ?? [];
const metricsQuery = useQuery({ const metricsQuery = useQuery({
queryKey: ['security-metrics', projectId], queryKey: ['security-metrics', effectiveProjectId],
queryFn: () => getSecurityMetrics(projectId), queryFn: () => getSecurityMetrics(effectiveProjectId),
enabled: !isDemoMode && Boolean(projectId), enabled: !isDemoMode && Boolean(effectiveProjectId),
refetchInterval: 15_000, refetchInterval: 15_000,
}); });
const historyQuery = useQuery({ const historyQuery = useQuery({
queryKey: ['security-history', projectId], queryKey: ['security-history', effectiveProjectId],
queryFn: () => getSecurityHistory(projectId), queryFn: () => getSecurityHistory(effectiveProjectId),
enabled: !isDemoMode && Boolean(projectId), enabled: !isDemoMode && Boolean(effectiveProjectId),
refetchInterval: 15_000, refetchInterval: 15_000,
}); });
const vulnsQuery = useQuery({ const vulnsQuery = useQuery({
queryKey: ['security-vulns', projectId], queryKey: ['security-vulns', effectiveProjectId],
queryFn: () => listVulnerabilities(projectId), queryFn: () => listVulnerabilities(effectiveProjectId),
enabled: !isDemoMode && Boolean(projectId), enabled: !isDemoMode && Boolean(effectiveProjectId),
}); });
const invalidate = () => { const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ['security-metrics', projectId] }); queryClient.invalidateQueries({ queryKey: ['security-metrics', effectiveProjectId] });
queryClient.invalidateQueries({ queryKey: ['security-history', projectId] }); queryClient.invalidateQueries({ queryKey: ['security-history', effectiveProjectId] });
queryClient.invalidateQueries({ queryKey: ['security-vulns', projectId] }); queryClient.invalidateQueries({ queryKey: ['security-vulns', effectiveProjectId] });
}; };
const scanMutation = useMutation({ const scanMutation = useMutation({
mutationFn: () => startSecurityScan({ mutationFn: () => startSecurityScan({
project_id: projectId, project_id: effectiveProjectId,
service_id: scanServiceId || undefined, service_id: scanServiceId || undefined,
scan_type: scanType as 'dependency' | 'configuration' | 'comprehensive', scan_type: scanType as 'dependency' | 'configuration' | 'comprehensive',
}), }),
@@ -3661,10 +3639,10 @@ export function SecurityPage() {
const metrics = isDemoMode ? demoSecurityMetrics : metricsQuery.data; const metrics = isDemoMode ? demoSecurityMetrics : metricsQuery.data;
const scanHistory = isDemoMode const scanHistory = isDemoMode
? demoSecurityScans.filter((s) => s.project_id === projectId) ? demoSecurityScans.filter((s) => s.project_id === effectiveProjectId)
: historyQuery.data ?? []; : historyQuery.data ?? [];
const vulns = (isDemoMode const vulns = (isDemoMode
? demoVulnerabilities.filter((v) => v.project_id === projectId) ? demoVulnerabilities.filter((v) => v.project_id === effectiveProjectId)
: vulnsQuery.data ?? [] : vulnsQuery.data ?? []
).filter((v) => v.status !== 'resolved'); ).filter((v) => v.status !== 'resolved');
const pageError = const pageError =
@@ -3677,7 +3655,7 @@ export function SecurityPage() {
title="Security" title="Security"
description="Scan findings, security posture, and compliance status" description="Scan findings, security posture, and compliance status"
/> />
<div className="p-8 space-y-8"> <div className="p-4 space-y-8 sm:p-8">
<div className="flex items-center gap-2 text-xs text-[var(--text-tertiary)]"> <div className="flex items-center gap-2 text-xs text-[var(--text-tertiary)]">
<HeartPulse size={13} className="text-[var(--accent-primary)]" /> <HeartPulse size={13} className="text-[var(--accent-primary)]" />
<span>Platform availability and failover live under High availability.</span> <span>Platform availability and failover live under High availability.</span>
@@ -3695,7 +3673,7 @@ export function SecurityPage() {
<div> <div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Project</label> <label className="block text-xs text-[var(--text-tertiary)] mb-1">Project</label>
<select <select
value={projectId} value={effectiveProjectId}
onChange={(e) => { setProjectId(e.target.value); setScanServiceId(''); }} onChange={(e) => { setProjectId(e.target.value); setScanServiceId(''); }}
className="px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)] w-56" className="px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)] w-56"
> >
@@ -3705,7 +3683,7 @@ export function SecurityPage() {
))} ))}
</select> </select>
</div> </div>
{projectId && ( {effectiveProjectId && (
<> <>
<div> <div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Scan type</label> <label className="block text-xs text-[var(--text-tertiary)] mb-1">Scan type</label>
@@ -3737,7 +3715,7 @@ export function SecurityPage() {
type="button" type="button"
disabled={scanMutation.isPending} disabled={scanMutation.isPending}
onClick={() => scanMutation.mutate()} onClick={() => scanMutation.mutate()}
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50" className="s-btn-accent disabled:opacity-50"
> >
{scanMutation.isPending ? 'Starting…' : 'Run scan'} {scanMutation.isPending ? 'Starting…' : 'Run scan'}
</button> </button>
@@ -3748,7 +3726,7 @@ export function SecurityPage() {
)} )}
</div> </div>
{projectId && metrics && ( {effectiveProjectId && metrics && (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard <StatCard
title="Security score" title="Security score"
@@ -3779,11 +3757,11 @@ export function SecurityPage() {
</div> </div>
)} )}
{projectId && ( {effectiveProjectId && (
<div className="panel p-6"> <div className="s-card p-5">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<Shield size={18} className="text-[var(--accent-primary)]" /> <Shield size={18} className="text-[var(--accent-primary)]" />
<h2 className="text-base font-semibold text-[var(--text-primary)]">Findings</h2> <h2 className="s-t">Findings</h2>
</div> </div>
{!isDemoMode && vulnsQuery.isLoading ? ( {!isDemoMode && vulnsQuery.isLoading ? (
<p className="text-sm text-[var(--text-secondary)]">Loading…</p> <p className="text-sm text-[var(--text-secondary)]">Loading…</p>
@@ -3822,7 +3800,7 @@ export function SecurityPage() {
type="button" type="button"
disabled={vulnMutation.isPending} disabled={vulnMutation.isPending}
onClick={() => vulnMutation.mutate({ id: v.id!, status: 'ignored' })} onClick={() => vulnMutation.mutate({ id: v.id!, status: 'ignored' })}
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs text-[var(--text-secondary)] disabled:opacity-50" className="s-btn-quiet"
> >
Ignore Ignore
</button> </button>
@@ -3835,11 +3813,11 @@ export function SecurityPage() {
</div> </div>
)} )}
{projectId && ( {effectiveProjectId && (
<div className="panel p-6"> <div className="s-card p-5">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<Clock size={18} className="text-[var(--accent-primary)]" /> <Clock size={18} className="text-[var(--accent-primary)]" />
<h2 className="text-base font-semibold text-[var(--text-primary)]">Scan history</h2> <h2 className="s-t">Scan history</h2>
</div> </div>
{!isDemoMode && historyQuery.isLoading ? ( {!isDemoMode && historyQuery.isLoading ? (
<p className="text-sm text-[var(--text-secondary)]">Loading…</p> <p className="text-sm text-[var(--text-secondary)]">Loading…</p>
@@ -34,8 +34,7 @@ export function ComponentShowcase() {
}; };
return ( return (
<div className="min-h-screen p-8" style={{ background: '#16171c' }}> <div className="min-h-screen p-8" style={{ background: 'var(--bg-void)' }}>
<div className="ambient-glow" />
<div className="w-full relative z-10"> <div className="w-full relative z-10">
{/* Header */} {/* Header */}
@@ -91,25 +90,25 @@ export function ComponentShowcase() {
Line Charts Line Charts
</h2> </h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="panel p-6"> <div className="s-card p-6">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4"> <h3 className="s-t mb-4">
Basic Line Chart (CPU Style) Basic Line Chart (CPU Style)
</h3> </h3>
<LineChart <LineChart
data={sampleLineData} data={sampleLineData}
color="#b4e34a" color="#38bdf8"
height={120} height={120}
showDots={true} showDots={true}
smooth={true} smooth={true}
/> />
</div> </div>
<div className="panel p-6"> <div className="s-card p-6">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4"> <h3 className="s-t mb-4">
Line Chart (No Dots) Line Chart (No Dots)
</h3> </h3>
<LineChart <LineChart
data={sampleLineData} data={sampleLineData}
color="#7ab8ff" color="var(--accent-primary)"
height={120} height={120}
showDots={false} showDots={false}
smooth={true} smooth={true}
@@ -123,8 +122,8 @@ export function ComponentShowcase() {
Area Charts Area Charts
</h2> </h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="panel p-6"> <div className="s-card p-6">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4"> <h3 className="s-t mb-4">
Area Chart (Active Users Style) Area Chart (Active Users Style)
</h3> </h3>
<LineAreaChart <LineAreaChart
@@ -134,8 +133,8 @@ export function ComponentShowcase() {
height={150} height={150}
/> />
</div> </div>
<div className="panel p-6"> <div className="s-card p-6">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4"> <h3 className="s-t mb-4">
Area Chart (Success Color) Area Chart (Success Color)
</h3> </h3>
<LineAreaChart <LineAreaChart
@@ -153,20 +152,20 @@ export function ComponentShowcase() {
Multi-Line & Bar Charts Multi-Line & Bar Charts
</h2> </h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="panel p-6"> <div className="s-card p-6">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4"> <h3 className="s-t mb-4">
Multi-Line Chart (Performance Style) Multi-Line Chart (Performance Style)
</h3> </h3>
<MultiLineChart <MultiLineChart
datasets={[ datasets={[
{ data: sampleLineData.map(v => v + 10), color: '#7ab8ff', fillOpacity: 0.15 }, { data: sampleLineData.map(v => v + 10), color: 'var(--accent-primary)', fillOpacity: 0.15 },
{ data: sampleLineData, color: '#f2c94c', fillOpacity: 0.15 } { data: sampleLineData, color: 'var(--warning)', fillOpacity: 0.15 }
]} ]}
height={120} height={120}
/> />
</div> </div>
<div className="panel p-6"> <div className="s-card p-6">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4"> <h3 className="s-t mb-4">
Bar Chart (Timeline Style) Bar Chart (Timeline Style)
</h3> </h3>
<BarChart <BarChart
@@ -184,23 +183,23 @@ export function ComponentShowcase() {
Donut Charts Donut Charts
</h2> </h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="panel p-6 flex flex-col items-center"> <div className="s-card p-6 flex flex-col items-center">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4"> <h3 className="s-t mb-4">
RAM Usage (65%) RAM Usage (65%)
</h3> </h3>
<DonutChart percentage={65} color="#f2c94c" size={160} /> <DonutChart percentage={65} color="var(--warning)" size={160} />
</div> </div>
<div className="panel p-6 flex flex-col items-center"> <div className="s-card p-6 flex flex-col items-center">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4"> <h3 className="s-t mb-4">
Disk Usage (82%) Disk Usage (82%)
</h3> </h3>
<DonutChart percentage={82} color="#b4e34a" size={160} /> <DonutChart percentage={82} color="#38bdf8" size={160} />
</div> </div>
<div className="panel p-6 flex flex-col items-center"> <div className="s-card p-6 flex flex-col items-center">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4"> <h3 className="s-t mb-4">
Network (45%) Network (45%)
</h3> </h3>
<DonutChart percentage={45} color="#7ab8ff" size={160} /> <DonutChart percentage={45} color="var(--accent-primary)" size={160} />
</div> </div>
</div> </div>
</section> </section>
@@ -222,7 +221,7 @@ export function ComponentShowcase() {
status="good" status="good"
statusText="Good" statusText="Good"
icon={<Cpu size={16} />} icon={<Cpu size={16} />}
chart={<LineChart data={sampleLineData} color="#b4e34a" height={76} />} chart={<LineChart data={sampleLineData} color="#38bdf8" height={76} />}
onClick={() => handleCardClick('CPU Usage')} onClick={() => handleCardClick('CPU Usage')}
animationDelay={0.04} animationDelay={0.04}
/> />
@@ -233,7 +232,7 @@ export function ComponentShowcase() {
status="average" status="average"
statusText="Average" statusText="Average"
icon={<MemoryStick size={16} />} icon={<MemoryStick size={16} />}
chart={<LineChart data={sampleLineData.map(v => v + 20)} color="#f2c94c" height={76} />} chart={<LineChart data={sampleLineData.map(v => v + 20)} color="var(--warning)" height={76} />}
onClick={() => handleCardClick('Memory')} onClick={() => handleCardClick('Memory')}
animationDelay={0.09} animationDelay={0.09}
/> />
@@ -276,7 +275,7 @@ export function ComponentShowcase() {
value="12.5K" value="12.5K"
subtitle="Queries per minute" subtitle="Queries per minute"
icon={<Database size={16} />} icon={<Database size={16} />}
chart={<LineAreaChart data={sampleAreaData.map(v => v + 10)} color="#7ab8ff" height={130} />} chart={<LineAreaChart data={sampleAreaData.map(v => v + 10)} color="var(--accent-primary)" height={130} />}
details={ details={
<div className="flex items-center gap-3 text-xs"> <div className="flex items-center gap-3 text-xs">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
@@ -313,8 +312,8 @@ export function ComponentShowcase() {
upSpeed={10.4} upSpeed={10.4}
downSpeed={5.2} downSpeed={5.2}
datasets={[ datasets={[
{ data: sampleLineData.map(v => v + 20), color: '#7ab8ff', fillOpacity: 0.15 }, { data: sampleLineData.map(v => v + 20), color: 'var(--accent-primary)', fillOpacity: 0.15 },
{ data: sampleLineData.map(v => v + 10), color: '#f2c94c', fillOpacity: 0.15 } { data: sampleLineData.map(v => v + 10), color: 'var(--warning)', fillOpacity: 0.15 }
]} ]}
onClick={() => handleCardClick('Performance')} onClick={() => handleCardClick('Performance')}
animationDelay={0.09} animationDelay={0.09}
@@ -331,7 +330,7 @@ export function ComponentShowcase() {
<h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4"> <h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4">
Badges & Indicators Badges & Indicators
</h2> </h2>
<div className="panel p-6 space-y-4"> <div className="s-card p-6 space-y-4">
<div className="flex items-center gap-4 flex-wrap"> <div className="flex items-center gap-4 flex-wrap">
<span className="badge-active"> <span className="badge-active">
<span className="live-dot" /> <span className="live-dot" />
@@ -358,7 +357,7 @@ export function ComponentShowcase() {
<h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4"> <h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4">
Buttons Buttons
</h2> </h2>
<div className="panel p-6 space-y-4"> <div className="s-card p-6 space-y-4">
<div className="flex items-center gap-4 flex-wrap"> <div className="flex items-center gap-4 flex-wrap">
<button className="btn-stop"> <button className="btn-stop">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
@@ -387,7 +386,7 @@ export function ComponentShowcase() {
<h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4"> <h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4">
Pills & Tabs Pills & Tabs
</h2> </h2>
<div className="panel p-6 space-y-6"> <div className="s-card p-6 space-y-6">
<div> <div>
<p className="text-sm text-[var(--text-secondary)] mb-3">Pill Group</p> <p className="text-sm text-[var(--text-secondary)] mb-3">Pill Group</p>
<div className="pill-group"> <div className="pill-group">
@@ -420,9 +419,9 @@ export function ComponentShowcase() {
<h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4"> <h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4">
Search & Input Search & Input
</h2> </h2>
<div className="panel p-6 space-y-4"> <div className="s-card p-6 space-y-4">
<div className="search-box"> <div className="search-box">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#6b6e7d" strokeWidth="2" strokeLinecap="round"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2" strokeLinecap="round">
<circle cx="11" cy="11" r="8"/> <circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/> <line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg> </svg>
@@ -435,7 +434,7 @@ export function ComponentShowcase() {
<h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4"> <h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4">
Toast Notifications Toast Notifications
</h2> </h2>
<div className="panel p-6"> <div className="s-card p-6">
<div className="flex gap-3 flex-wrap"> <div className="flex gap-3 flex-wrap">
<button <button
onClick={() => showToast('Operation completed successfully!', 'success')} onClick={() => showToast('Operation completed successfully!', 'success')}
File diff suppressed because it is too large. Load diff
@@ -78,8 +78,8 @@ export function ServiceMetricsDashboard() {
userData, userData,
perf: perfPercent, perf: perfPercent,
perfData: [ perfData: [
{ data: perfData1, color: '#7ab8ff', fillOpacity: 0.15 }, { data: perfData1, color: 'var(--accent-primary)', fillOpacity: 0.15 },
{ data: perfData2, color: '#f2c94c', fillOpacity: 0.15 } { data: perfData2, color: 'var(--warning)', fillOpacity: 0.15 }
], ],
upSpeed: seededRandom(`${serviceId}:upspeed`, 8, 13).toFixed(1), upSpeed: seededRandom(`${serviceId}:upspeed`, 8, 13).toFixed(1),
downSpeed: seededRandom(`${serviceId}:downspeed`, 4, 7).toFixed(1), downSpeed: seededRandom(`${serviceId}:downspeed`, 4, 7).toFixed(1),
@@ -107,9 +107,8 @@ export function ServiceMetricsDashboard() {
}; };
return ( return (
<div className="min-h-screen" style={{ background: '#16171c' }}> <div className="min-h-screen" style={{ background: 'var(--bg-void)' }}>
{/* Ambient glow */} {/* Ambient glow */}
<div className="ambient-glow" />
<div className="flex min-h-screen relative"> <div className="flex min-h-screen relative">
{/* Sidebar - self.html exact match */} {/* Sidebar - self.html exact match */}
@@ -117,8 +116,8 @@ export function ServiceMetricsDashboard() {
className="hidden md:flex shrink-0 flex-col items-center border-r" className="hidden md:flex shrink-0 flex-col items-center border-r"
style={{ style={{
width: '64px', width: '64px',
background: '#111217', background: 'var(--bg-surface)',
borderRight: '1px solid rgba(255,255,255,0.07)', borderRight: '1px solid var(--border-subtle)',
padding: '16px 0', padding: '16px 0',
gap: '5px' gap: '5px'
}} }}
@@ -169,10 +168,10 @@ export function ServiceMetricsDashboard() {
style={{ style={{
width: '34px', width: '34px',
height: '34px', height: '34px',
background: '#22233a', background: 'var(--accent-primary-soft)',
fontSize: '11px', fontSize: '11px',
fontWeight: 700, fontWeight: 700,
color: '#9295a4', color: 'var(--text-tertiary)',
marginTop: '4px', marginTop: '4px',
letterSpacing: '-0.3px' letterSpacing: '-0.3px'
}} }}
@@ -188,8 +187,8 @@ export function ServiceMetricsDashboard() {
className="shrink-0 flex items-center" className="shrink-0 flex items-center"
style={{ style={{
height: '52px', height: '52px',
background: '#111217', background: 'var(--bg-surface)',
borderBottom: '1px solid rgba(255,255,255,0.07)', borderBottom: '1px solid var(--border-subtle)',
padding: '0 22px', padding: '0 22px',
gap: '14px' gap: '14px'
}} }}
@@ -201,22 +200,22 @@ export function ServiceMetricsDashboard() {
<div className="ml-auto flex items-center" style={{ gap: '8px' }}> <div className="ml-auto flex items-center" style={{ gap: '8px' }}>
<button <button
className="rounded-[9px] border bg-transparent text-[#9295a4] font-medium cursor-pointer" className="rounded-[9px] border bg-transparent text-[var(--text-tertiary)] font-medium cursor-pointer"
style={{ style={{
height: '32px', height: '32px',
padding: '0 14px', padding: '0 14px',
border: '1px solid rgba(255,255,255,0.1)', border: '1px solid var(--border-default)',
fontSize: '13px' fontSize: '13px'
}} }}
> >
Support Support
</button> </button>
<button <button
className="rounded-[9px] border-none flex items-center text-[#e8e9f0] font-medium cursor-pointer" className="rounded-[9px] border-none flex items-center text-[var(--text-primary)] font-medium cursor-pointer"
style={{ style={{
height: '32px', height: '32px',
padding: '0 14px', padding: '0 14px',
background: 'rgba(255,255,255,0.08)', background: 'var(--bg-overlay)',
fontSize: '13px', fontSize: '13px',
gap: '6px' gap: '6px'
}} }}
@@ -229,10 +228,10 @@ export function ServiceMetricsDashboard() {
style={{ style={{
width: '32px', width: '32px',
height: '32px', height: '32px',
border: '1px solid rgba(255,255,255,0.1)' border: '1px solid var(--border-default)'
}} }}
> >
<Bell size={15} color="#9295a4" /> <Bell size={15} />
</button> </button>
</div> </div>
</header> </header>
@@ -245,22 +244,22 @@ export function ServiceMetricsDashboard() {
style={{ style={{
gap: '6px', gap: '6px',
padding: '14px 0 10px', padding: '14px 0 10px',
color: '#6b6e7d', color: 'var(--text-tertiary)',
fontSize: '13px' fontSize: '13px'
}} }}
> >
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#6b6e7d" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 18 9 12 15 6"/> <polyline points="15 18 9 12 15 6"/>
</svg> </svg>
<button <button
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
style={{ color: '#6b6e7d' }} style={{ color: 'var(--text-tertiary)' }}
className="hover:text-[#9295a4] transition-colors" className="hover:text-[var(--text-tertiary)] transition-colors"
> >
Servers Servers
</button> </button>
<span style={{ opacity: 0.4 }}>/</span> <span style={{ opacity: 0.4 }}>/</span>
<span style={{ color: '#9295a4' }}>[NuFest] - App Project</span> <span style={{ color: 'var(--text-tertiary)' }}>[NuFest] - App Project</span>
</div> </div>
{/* Project Header */} {/* Project Header */}
@@ -280,7 +279,7 @@ export function ServiceMetricsDashboard() {
</div> </div>
<div> <div>
<div className="flex items-center" style={{ gap: '10px' }}> <div className="flex items-center" style={{ gap: '10px' }}>
<span style={{ fontSize: '20px', fontWeight: 800, letterSpacing: '-0.5px', color: '#e8e9f0' }}> <span style={{ fontSize: '20px', fontWeight: 800, letterSpacing: '-0.5px', color: 'var(--text-primary)' }}>
[NuFest] - App Project [NuFest] - App Project
</span> </span>
<span className={`badge-${isSystemRunning ? 'active' : 'stopped'}`}> <span className={`badge-${isSystemRunning ? 'active' : 'stopped'}`}>
@@ -293,8 +292,8 @@ export function ServiceMetricsDashboard() {
href="https://nufest-dth.app" href="https://nufest-dth.app"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center hover:text-[#9295a4] transition-colors" className="flex items-center hover:text-[var(--text-tertiary)] transition-colors"
style={{ color: '#6b6e7d', fontSize: '12.5px', textDecoration: 'none', gap: '4px' }} style={{ color: 'var(--text-tertiary)', fontSize: '12.5px', textDecoration: 'none', gap: '4px' }}
> >
https://nufest-dth.app https://nufest-dth.app
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"> <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
@@ -304,8 +303,8 @@ export function ServiceMetricsDashboard() {
</svg> </svg>
</a> </a>
<button <button
className="flex items-center hover:text-[#9295a4] transition-colors" className="flex items-center hover:text-[var(--text-tertiary)] transition-colors"
style={{ color: '#6b6e7d', fontSize: '12.5px', gap: '4px' }} style={{ color: 'var(--text-tertiary)', fontSize: '12.5px', gap: '4px' }}
> >
Project Information Project Information
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"> <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
@@ -344,7 +343,7 @@ export function ServiceMetricsDashboard() {
<div <div
className="flex" className="flex"
style={{ style={{
borderBottom: '1px solid rgba(255,255,255,0.07)', borderBottom: '1px solid var(--border-subtle)',
marginBottom: '18px' marginBottom: '18px'
}} }}
> >
@@ -365,16 +364,16 @@ export function ServiceMetricsDashboard() {
{/* Metrics Header */} {/* Metrics Header */}
<div className="flex items-center justify-between" style={{ marginBottom: '16px' }}> <div className="flex items-center justify-between" style={{ marginBottom: '16px' }}>
<span style={{ fontSize: '15px', fontWeight: 700, color: '#e8e9f0' }}>Metrics</span> <span style={{ fontSize: '15px', fontWeight: 700, color: 'var(--text-primary)' }}>Metrics</span>
<div className="flex items-center" style={{ gap: '8px' }}> <div className="flex items-center" style={{ gap: '8px' }}>
<button <button
className="rounded-[9px] border flex items-center font-medium cursor-pointer" className="rounded-[9px] border flex items-center font-medium cursor-pointer"
style={{ style={{
height: '32px', height: '32px',
padding: '0 12px', padding: '0 12px',
border: '1px solid rgba(255,255,255,0.09)', border: '1px solid var(--border-default)',
background: 'rgba(255,255,255,0.04)', background: 'var(--bg-elevated)',
color: '#9295a4', color: 'var(--text-tertiary)',
fontSize: '12.5px', fontSize: '12.5px',
gap: '6px' gap: '6px'
}} }}
@@ -408,7 +407,7 @@ export function ServiceMetricsDashboard() {
status="good" status="good"
statusText="Good" statusText="Good"
icon={<Cpu size={16} />} icon={<Cpu size={16} />}
chart={<LineChart data={metrics.cpuData} color="#b4e34a" height={76} />} chart={<LineChart data={metrics.cpuData} color="#38bdf8" height={76} />}
animationDelay={0.04} animationDelay={0.04}
/> />
@@ -418,23 +417,23 @@ export function ServiceMetricsDashboard() {
<div className="card-icon"> <div className="card-icon">
<MemoryStick size={16} /> <MemoryStick size={16} />
</div> </div>
<span style={{ fontSize: 14, fontWeight: 600, color: '#e8e9f0' }}>RAM Usage</span> <span style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)' }}>RAM Usage</span>
</div> </div>
<div style={{ fontSize: 38, fontWeight: 900, letterSpacing: '-1.5px', lineHeight: 1, color: '#e8e9f0' }}> <div style={{ fontSize: 38, fontWeight: 900, letterSpacing: '-1.5px', lineHeight: 1, color: 'var(--text-primary)' }}>
{metrics.ram}% {metrics.ram}%
</div> </div>
<div style={{ fontSize: 12, color: '#6b6e7d', marginTop: 4 }}> <div style={{ fontSize: 12, color: 'var(--text-tertiary)', marginTop: 4 }}>
<span style={{ color: '#f2c94c', fontWeight: 700 }}>Average</span> Daily usage <span style={{ color: 'var(--warning)', fontWeight: 700 }}>Average</span> Daily usage
</div> </div>
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', margin: '10px 0 4px', position: 'relative' }}> <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', margin: '10px 0 4px', position: 'relative' }}>
<DonutChart percentage={metrics.ram} color="#f2c94c" /> <DonutChart percentage={metrics.ram} color="var(--warning)" />
<div style={{ position: 'absolute', bottom: 14, textAlign: 'center' }}> <div style={{ position: 'absolute', bottom: 14, textAlign: 'center' }}>
<div style={{ fontSize: 10.5, color: '#6b6e7d', marginBottom: 1 }}>Used</div> <div style={{ fontSize: 10.5, color: 'var(--text-tertiary)', marginBottom: 1 }}>Used</div>
<div style={{ fontSize: 12.5, fontWeight: 700, color: '#e8e9f0' }}>{metrics.ramUsedGB} GB / 8GB</div> <div style={{ fontSize: 12.5, fontWeight: 700, color: 'var(--text-primary)' }}>{metrics.ramUsedGB} GB / 8GB</div>
</div> </div>
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingTop: 4 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingTop: 4 }}>
<span style={{ fontSize: 13, color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}>Details</span> <span style={{ fontSize: 13, color: 'var(--text-tertiary)', fontWeight: 500, cursor: 'pointer' }}>Details</span>
<div className="arrow-btn"> <div className="arrow-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round"> <svg viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6" /> <polyline points="9 18 15 12 9 6" />
@@ -9,6 +9,7 @@ import {
getSetupStatus, getSetupStatus,
updatePlatformSettings, updatePlatformSettings,
} from '@/lib/api-client'; } from '@/lib/api-client';
import { GhostBtn, QuietBtn } from '@/shared/components/sentry';
// Marker the GitHub App callback reads so the wizard flow resumes here // Marker the GitHub App callback reads so the wizard flow resumes here
// instead of the settings page. // instead of the settings page.
@@ -39,7 +40,7 @@ export function SetupWizardPage() {
mutationFn: completeSetup, mutationFn: completeSetup,
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['setup-status'] }); queryClient.invalidateQueries({ queryKey: ['setup-status'] });
navigate('/projects', { replace: true }); navigate('/', { replace: true });
}, },
}); });
@@ -53,7 +54,7 @@ export function SetupWizardPage() {
// Wizard is optional once the instance is already configured. // Wizard is optional once the instance is already configured.
if (status && !status.needs_setup && !override) { if (status && !status.needs_setup && !override) {
return <Navigate to="/projects" replace />; return <Navigate to="/" replace />;
} }
const steps: { key: Step; label: string }[] = [ const steps: { key: Step; label: string }[] = [
@@ -92,9 +93,12 @@ export function SetupWizardPage() {
</div> </div>
{step === 'account' ? ( {step === 'account' ? (
<section className="panel p-6"> <section className="s-card">
<h1 className="text-xl font-bold text-[var(--text-primary)]">Welcome to Containr</h1> <div className="s-cardhead mb-1">
<p className="mt-2 text-sm text-[var(--text-secondary)]"> <span className="s-ibox"><Rocket /></span>
<h1 className="s-t">Welcome to Containr</h1>
</div>
<p className="mt-1 text-sm text-[var(--text-secondary)]">
First boot detected. Create the admin account — registration closes to the public First boot detected. Create the admin account — registration closes to the public
right after, so this account owns the instance. right after, so this account owns the instance.
</p> </p>
@@ -105,8 +109,7 @@ export function SetupWizardPage() {
</p> </p>
<Link <Link
to="/auth/sign-in?redirect=/setup" to="/auth/sign-in?redirect=/setup"
className="mt-4 inline-flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)]" className="mt-4 s-btn-accent inline-flex items-center gap-2"
style={{ background: 'var(--accent-primary)' }}
> >
Sign in <ChevronRight size={14} /> Sign in <ChevronRight size={14} />
</Link> </Link>
@@ -114,8 +117,7 @@ export function SetupWizardPage() {
) : !status?.has_users ? ( ) : !status?.has_users ? (
<Link <Link
to="/auth/sign-up?redirect=/setup" to="/auth/sign-up?redirect=/setup"
className="mt-4 inline-flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)]" className="mt-4 s-btn-accent inline-flex items-center gap-2"
style={{ background: 'var(--accent-primary)' }}
> >
Create admin account <ChevronRight size={14} /> Create admin account <ChevronRight size={14} />
</Link> </Link>
@@ -127,9 +129,9 @@ export function SetupWizardPage() {
{step === 'tunnel' ? <TunnelStep onNext={() => setOverride('done')} /> : null} {step === 'tunnel' ? <TunnelStep onNext={() => setOverride('done')} /> : null}
{step === 'done' ? ( {step === 'done' ? (
<section className="panel p-6 text-center"> <section className="s-card text-center">
<Rocket size={28} className="mx-auto text-[var(--accent-primary)]" /> <div className="s-ibox mx-auto !h-10 !w-10"><ShieldCheck /></div>
<h1 className="mt-4 text-xl font-bold text-[var(--text-primary)]">All set</h1> <h1 className="mt-4 font-headline text-lg font-bold text-[var(--text-primary)]">All set</h1>
<p className="mt-2 text-sm text-[var(--text-secondary)]"> <p className="mt-2 text-sm text-[var(--text-secondary)]">
Deploy your first service from a git repo, an image, or a template — the dashboard is Deploy your first service from a git repo, an image, or a template — the dashboard is
ready. ready.
@@ -141,12 +143,10 @@ export function SetupWizardPage() {
: 'Failed to save setup state'} : 'Failed to save setup state'}
</p> </p>
) : null} ) : null}
<button <GhostBtn
type="button"
onClick={() => completeMutation.mutate()} onClick={() => completeMutation.mutate()}
disabled={completeMutation.isPending} disabled={completeMutation.isPending}
className="mt-5 inline-flex items-center gap-2 h-10 px-6 rounded-[var(--radius-md)] text-sm font-semibold text-[var(--accent-on)] disabled:opacity-50" className="mt-5 !h-10 !px-6"
style={{ background: 'var(--accent-primary)' }}
> >
{completeMutation.isPending ? ( {completeMutation.isPending ? (
<Loader2 size={14} className="animate-spin" /> <Loader2 size={14} className="animate-spin" />
@@ -154,7 +154,7 @@ export function SetupWizardPage() {
<ShieldCheck size={15} /> <ShieldCheck size={15} />
)} )}
Enter Containr Enter Containr
</button> </GhostBtn>
</section> </section>
) : null} ) : null}
</div> </div>
@@ -185,12 +185,12 @@ function GitHubStep({ onNext }: { onNext: () => void }) {
}); });
return ( return (
<section className="panel p-6"> <section className="s-card">
<div className="flex items-center gap-3 mb-3"> <div className="s-cardhead mb-1">
<Github size={20} className="text-[var(--text-primary)]" /> <span className="s-ibox"><Github /></span>
<h1 className="text-xl font-bold text-[var(--text-primary)]">Connect a git provider</h1> <h1 className="s-t">Connect a Git Provider</h1>
</div> </div>
<p className="text-sm text-[var(--text-secondary)]"> <p className="mt-1 text-sm text-[var(--text-secondary)]">
Provision the instance GitHub App — repo push events then trigger deployments and preview Provision the instance GitHub App — repo push events then trigger deployments and preview
environments automatically. Self-hosted GitLab/Gitea can be added later in Settings. environments automatically. Self-hosted GitLab/Gitea can be added later in Settings.
</p> </p>
@@ -201,24 +201,15 @@ function GitHubStep({ onNext }: { onNext: () => void }) {
: 'Could not start GitHub App provisioning'} : 'Could not start GitHub App provisioning'}
</p> </p>
) : null} ) : null}
<div className="mt-5 flex items-center gap-3"> <div className="mt-5 flex items-center gap-2">
<button <GhostBtn
type="button"
onClick={() => provisionMutation.mutate()} onClick={() => provisionMutation.mutate()}
disabled={provisionMutation.isPending} disabled={provisionMutation.isPending}
className="inline-flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50"
style={{ background: 'var(--accent-primary)' }}
> >
{provisionMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : null} {provisionMutation.isPending ? <Loader2 size={13} className="animate-spin" /> : null}
Set up GitHub App Set up GitHub App
</button> </GhostBtn>
<button <QuietBtn onClick={onNext}>Skip for now</QuietBtn>
type="button"
onClick={onNext}
className="h-9 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
>
Skip for now
</button>
</div> </div>
</section> </section>
); );
@@ -237,9 +228,12 @@ function TunnelStep({ onNext }: { onNext: () => void }) {
}); });
return ( return (
<section className="panel p-6"> <section className="s-card">
<h1 className="text-xl font-bold text-[var(--text-primary)]">Public access (optional)</h1> <div className="s-cardhead mb-1">
<p className="mt-2 text-sm text-[var(--text-secondary)]"> <span className="s-ibox"><ShieldCheck /></span>
<h1 className="s-t">Public Access (optional)</h1>
</div>
<p className="mt-1 text-sm text-[var(--text-secondary)]">
Paste a Cloudflare Tunnel token to expose this instance on the internet. Without one, Paste a Cloudflare Tunnel token to expose this instance on the internet. Without one,
Containr stays reachable on this network — you can add it later in Settings. Containr stays reachable on this network — you can add it later in Settings.
</p> </p>
@@ -248,31 +242,22 @@ function TunnelStep({ onNext }: { onNext: () => void }) {
value={token} value={token}
onChange={(e) => setToken(e.target.value)} onChange={(e) => setToken(e.target.value)}
placeholder="eyJhIjo… tunnel token" placeholder="eyJhIjo… tunnel token"
className="mt-4 w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-colors" className="mt-4 w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] v-mono text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-colors"
/> />
{saveMutation.isError ? ( {saveMutation.isError ? (
<p className="mt-3 text-xs text-[var(--error)]"> <p className="mt-3 text-xs text-[var(--error)]">
{saveMutation.error instanceof Error ? saveMutation.error.message : 'Failed to save token'} {saveMutation.error instanceof Error ? saveMutation.error.message : 'Failed to save token'}
</p> </p>
) : null} ) : null}
<div className="mt-5 flex items-center gap-3"> <div className="mt-5 flex items-center gap-2">
<button <GhostBtn
type="button"
onClick={() => (token.trim() ? saveMutation.mutate() : onNext())} onClick={() => (token.trim() ? saveMutation.mutate() : onNext())}
disabled={saveMutation.isPending} disabled={saveMutation.isPending}
className="inline-flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50"
style={{ background: 'var(--accent-primary)' }}
> >
{saveMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : null} {saveMutation.isPending ? <Loader2 size={13} className="animate-spin" /> : null}
{token.trim() ? 'Save tunnel token' : 'Continue'} {token.trim() ? 'Save tunnel token' : 'Continue'}
</button> </GhostBtn>
<button <QuietBtn onClick={onNext}>Skip</QuietBtn>
type="button"
onClick={onNext}
className="h-9 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
>
Skip
</button>
</div> </div>
</section> </section>
); );
@@ -17,6 +17,7 @@ import {
} from '@xyflow/react'; } from '@xyflow/react';
import '@xyflow/react/dist/style.css'; import '@xyflow/react/dist/style.css';
import { Database, GitBranch, Package, Timer, Trash2, X } from 'lucide-react'; import { Database, GitBranch, Package, Timer, Trash2, X } from 'lucide-react';
import { GhostBtn, QuietBtn } from '@/shared/components/sentry';
import { import {
canvasFromConfig, canvasFromConfig,
configFromCanvas, configFromCanvas,
@@ -57,7 +58,7 @@ function ServiceBuilderNodeView({ data, selected }: NodeProps<ServiceBuilderNode
</div> </div>
<div className="mt-1 flex items-center justify-between gap-2"> <div className="mt-1 flex items-center justify-between gap-2">
<span className="text-[10.5px] uppercase tracking-wide text-[var(--text-tertiary)]">{meta.label}</span> <span className="text-[10.5px] uppercase tracking-wide text-[var(--text-tertiary)]">{meta.label}</span>
<span className="mono max-w-[120px] truncate text-[10.5px] text-[var(--text-secondary)]"> <span className="v-mono max-w-[120px] truncate text-[10.5px] text-[var(--text-secondary)]">
{data.svc.runtime || data.svc.repo || '—'} {data.svc.runtime || data.svc.repo || '—'}
</span> </span>
</div> </div>
@@ -120,8 +121,8 @@ function BuilderInner({
id: `${dep}->${svc.key}`, id: `${dep}->${svc.key}`,
source: dep, source: dep,
target: svc.key, target: svc.key,
markerEnd: { type: MarkerType.ArrowClosed, width: 14, height: 14, color: 'rgba(180,227,74,0.55)' }, markerEnd: { type: MarkerType.ArrowClosed, width: 14, height: 14, color: 'rgba(56, 189, 248,0.55)' },
style: { stroke: 'rgba(180,227,74,0.42)', strokeWidth: 1.6 }, style: { stroke: 'rgba(56, 189, 248,0.42)', strokeWidth: 1.6 },
className: 'edge-premium', className: 'edge-premium',
})), })),
), ),
@@ -239,7 +240,7 @@ function BuilderInner({
key={t} key={t}
type="button" type="button"
onClick={() => addService(t)} onClick={() => addService(t)}
className="canvas-pill inline-flex items-center gap-1.5 px-2.5 py-1.5 text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]" className="s-chip !py-1.5"
> >
<Icon size={12} className={meta.cls} /> <Icon size={12} className={meta.cls} />
{meta.label} {meta.label}
@@ -263,7 +264,7 @@ function BuilderInner({
deleteKeyCode={['Backspace', 'Delete']} deleteKeyCode={['Backspace', 'Delete']}
proOptions={{ hideAttribution: true }} proOptions={{ hideAttribution: true }}
> >
<Background variant={BackgroundVariant.Dots} gap={22} size={1.2} color="rgba(255,255,255,0.07)" /> <Background variant={BackgroundVariant.Dots} gap={22} size={1.2} color="var(--tint-07)" />
</ReactFlow> </ReactFlow>
<div className="canvas-pill absolute bottom-3 left-3 z-10 px-3 py-1.5 text-[10.5px] text-[var(--text-tertiary)]"> <div className="canvas-pill absolute bottom-3 left-3 z-10 px-3 py-1.5 text-[10.5px] text-[var(--text-tertiary)]">
drag = move · connect handles = depends_on · double-click = add web · Del = remove drag = move · connect handles = depends_on · double-click = add web · Del = remove
@@ -295,21 +296,16 @@ function BuilderInner({
</div> </div>
) : null} ) : null}
<div className="flex gap-2"> <div className="flex gap-2">
<button <QuietBtn onClick={onCancel} className="flex-1 justify-center">
type="button"
onClick={onCancel}
className="flex-1 rounded-[var(--radius-md)] px-3 py-2 text-xs font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-muted)]"
>
Back to JSON Back to JSON
</button> </QuietBtn>
<button <GhostBtn
type="button"
disabled={errors.length > 0 || services.size === 0} disabled={errors.length > 0 || services.size === 0}
onClick={() => onApply(configFromCanvas(builderNodes, config))} onClick={() => onApply(configFromCanvas(builderNodes, config))}
className="flex-1 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-3 py-2 text-xs font-semibold text-[var(--accent-on)] disabled:opacity-50" className="flex-1 justify-center"
> >
Apply to JSON Apply to JSON
</button> </GhostBtn>
</div> </div>
</div> </div>
</div> </div>
@@ -409,7 +405,7 @@ function Inspector({
<label className={`${LABEL_CLS} mt-3`}>Environment (KEY=value, one per line)</label> <label className={`${LABEL_CLS} mt-3`}>Environment (KEY=value, one per line)</label>
<textarea <textarea
className={`${FIELD_CLS} mono min-h-[70px] resize-y`} className={`${FIELD_CLS} v-mono min-h-[70px] resize-y`}
defaultValue={envText} defaultValue={envText}
spellCheck={false} spellCheck={false}
onBlur={(e) => { onBlur={(e) => {
@@ -425,7 +421,7 @@ function Inspector({
<label className={`${LABEL_CLS} mt-3`}>Volumes (source:target[:ro], one per line)</label> <label className={`${LABEL_CLS} mt-3`}>Volumes (source:target[:ro], one per line)</label>
<textarea <textarea
className={`${FIELD_CLS} mono min-h-[50px] resize-y`} className={`${FIELD_CLS} v-mono min-h-[50px] resize-y`}
defaultValue={volText} defaultValue={volText}
spellCheck={false} spellCheck={false}
onBlur={(e) => { onBlur={(e) => {
@@ -18,6 +18,7 @@ import {
import { useAuthSession } from '@/lib/use-auth-session'; import { useAuthSession } from '@/lib/use-auth-session';
import { useDemoMode } from '@/lib/demo-mode'; import { useDemoMode } from '@/lib/demo-mode';
import { demoProjects, demoTemplates, demoTemplateDetails } from '@/lib/demo-data'; import { demoProjects, demoTemplates, demoTemplateDetails } from '@/lib/demo-data';
import { GhostBtn, QuietBtn, SPageHead } from '@/shared/components/sentry';
import { import {
Search, Search,
Filter, Filter,
@@ -489,67 +490,51 @@ export function TemplatesPage() {
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
{/* Header */} <div className="w-full px-4 pt-6 sm:px-8">
<div className="border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/50 backdrop-blur-sm"> <SPageHead
<div className="w-full px-8 py-4"> title="Template Catalog"
<div className="flex items-center justify-between gap-4"> titleAccent="_"
<div> sub={`${filteredTemplates.length} templates · deploy services from pre-configured stacks`}
<h1 className="v-title">Template Catalog<span className="v-cursor">_</span></h1> actions={signedIn && !isDemoMode ? (
<p className="text-sm text-[var(--text-secondary)]">Deploy services from pre-configured templates</p>
</div>
<div className="flex items-center gap-3 text-sm text-[var(--text-tertiary)]">
<Layers size={16} />
<span>{filteredTemplates.length} templates</span>
{signedIn && !isDemoMode ? (
<> <>
<button <QuietBtn
type="button"
onClick={() => { onClick={() => {
setComposeYaml(''); setComposeYaml('');
setComposeWarnings([]); setComposeWarnings([]);
setComposeError(null); setComposeError(null);
setComposeOpen(true); 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 <FileCode size={13} /> Import Compose
</button> </QuietBtn>
<button <GhostBtn onClick={openCreateEditor}>
type="button" <Plus size={13} /> New template
onClick={openCreateEditor} </GhostBtn>
className="inline-flex h-9 items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-3.5 text-[12.5px] font-semibold text-[var(--accent-on)]"
>
<Plus size={14} /> New template
</button>
</> </>
) : null} ) : undefined}
</div> />
</div>
</div>
</div> </div>
{/* Demo Mode Banner */} {/* Demo Mode Banner */}
{isDemoMode && ( {isDemoMode && (
<div className="w-full px-8 py-4"> <div className="w-full px-8 py-4">
<div className="px-4 py-3 rounded-[var(--radius-md)] border border-[var(--warning-soft)] bg-[var(--warning-soft)]/50"> <div className="s-inset flex items-center gap-2 text-xs text-[var(--warning)]">
<div className="flex items-center gap-2 text-sm text-[var(--warning)]"> <Sparkles size={13} />
<Sparkles size={16} /> Demo mode active — using sample data
<span>Demo mode active — using sample data</span>
</div>
</div> </div>
</div> </div>
)} )}
{/* Main Content */} {/* Main Content */}
<div className="w-full px-8 py-6"> <div className="w-full px-4 py-6 sm:px-8">
<div className="grid grid-cols-1 xl:grid-cols-[380px_1fr] gap-6"> <div className="grid grid-cols-1 xl:grid-cols-[380px_1fr] gap-6">
{/* Template List */} {/* Template List */}
<section className="panel overflow-hidden"> <section className="s-card overflow-hidden">
<div className="p-4 border-b border-[var(--border-subtle)]"> <div className="s-cardhead border-b border-[var(--border-subtle)]">
<div className="flex items-center gap-2 mb-4"> <span className="s-ibox"><Filter /></span>
<Filter size={16} className="text-[var(--text-tertiary)]" /> <span className="s-t">Filters</span>
<span className="text-sm font-medium text-[var(--text-secondary)]">Filters</span>
</div> </div>
<div className="p-4 border-b border-[var(--border-subtle)]">
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2"> <label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
@@ -614,21 +599,21 @@ export function TemplatesPage() {
{filteredTemplates.map((template) => { {filteredTemplates.map((template) => {
const selected = template.id === selectedTemplateId; const selected = template.id === selectedTemplateId;
const Icon = categoryIcon(template.category); const Icon = categoryIcon(template.category);
const categoryColor = template.category === 'database' ? '#7ab8ff' : template.category === 'frontend' ? '#f2c94c' : template.category === 'web' ? 'var(--accent-primary)' : '#9295a4'; const categoryColor = template.category === 'database' ? 'var(--info)' : template.category === 'frontend' ? 'var(--warning)' : template.category === 'web' ? 'var(--accent-primary)' : 'var(--text-tertiary)';
return ( return (
<button <button
key={template.id} key={template.id}
onClick={() => selectTemplate(template.id)} onClick={() => selectTemplate(template.id)}
className={`w-full p-4 rounded-[var(--radius-lg)] border text-left transition-all duration-300 group ${ className={`w-full p-4 rounded-[var(--radius-lg)] border text-left transition-all duration-300 group ${
selected selected
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] shadow-lg shadow-[var(--accent-primary-glow)]' ? 'border-[var(--accent-primary)]/60 bg-[var(--accent-primary-soft)]'
: 'border-[var(--border-subtle)] hover:border-[var(--border-default)] hover:bg-[var(--surface-muted)]/50 card-lift' : 'border-[var(--border-subtle)] hover:border-[var(--border-default)] hover:bg-[var(--surface-muted)]/50 card-lift'
}`} }`}
> >
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div <div
className={`w-11 h-11 rounded-xl flex items-center justify-center transition-all duration-300 ${ className={`w-11 h-11 rounded-xl flex items-center justify-center transition-all duration-300 ${
selected ? 'ring-2 ring-white/20' : '' selected ? 'ring-1 ring-[var(--accent-primary)]/40' : ''
}`} }`}
style={{ style={{
background: selected ? categoryColor : `${categoryColor}20`, background: selected ? categoryColor : `${categoryColor}20`,
@@ -691,12 +676,10 @@ export function TemplatesPage() {
</section> </section>
{/* Template Detail */} {/* Template Detail */}
<section className="panel p-6"> <section className="s-card p-5">
{!selectedTemplateId ? ( {!selectedTemplateId ? (
<div className="py-16 text-center"> <div className="py-16 text-center">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-[var(--surface-muted)] flex items-center justify-center"> <div className="s-ibox mx-auto mb-4 !w-14 !h-14"><Layers size={22} /></div>
<Layers size={28} className="text-[var(--text-tertiary)]" />
</div>
<p className="text-sm text-[var(--text-muted)]">Select a template to view details and deploy</p> <p className="text-sm text-[var(--text-muted)]">Select a template to view details and deploy</p>
</div> </div>
) : null} ) : null}
@@ -722,15 +705,15 @@ export function TemplatesPage() {
{/* Header */} {/* Header */}
<div className="flex items-start justify-between gap-4 mb-6"> <div className="flex items-start justify-between gap-4 mb-6">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="w-14 h-14 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center"> <div className="s-ibox !w-14 !h-14 !rounded-xl text-[var(--accent-primary)]">
{(() => { {(() => {
const Icon = categoryIcon(selectedDetail.template.category); const Icon = categoryIcon(selectedDetail.template.category);
return <Icon size={24} className="text-[var(--accent-primary)]" />; return <Icon size={24} />;
})()} })()}
</div> </div>
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h2 className="text-xl font-semibold text-[var(--text-primary)]">{selectedDetail.template.name}</h2> <h2 className="font-headline text-lg font-semibold text-[var(--text-primary)]">{selectedDetail.template.name}</h2>
{selectedDetail.template.isOfficial && ( {selectedDetail.template.isOfficial && (
<span className="flex items-center gap-1 px-2 py-0.5 rounded-full bg-[var(--warning-soft)] text-[var(--warning)] text-xs font-medium"> <span className="flex items-center gap-1 px-2 py-0.5 rounded-full bg-[var(--warning-soft)] text-[var(--warning)] text-xs font-medium">
<Star size={10} className="fill-[var(--warning)]" /> <Star size={10} className="fill-[var(--warning)]" />
@@ -738,7 +721,7 @@ export function TemplatesPage() {
</span> </span>
)} )}
{!selectedDetail.template.isOfficial && selectedDetail.template.isPublic && ( {!selectedDetail.template.isOfficial && selectedDetail.template.isPublic && (
<span className="px-2 py-0.5 rounded-full bg-[var(--info-soft)] text-[var(--info)] text-xs font-medium"> <span className="s-chip !text-[var(--info)] !border-[color-mix(in_srgb,var(--info)_30%,transparent)] !bg-[var(--info-soft)] v-mono !text-[10.5px]">
Public Public
</span> </span>
)} )}
@@ -747,10 +730,10 @@ export function TemplatesPage() {
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="px-3 py-1 rounded-full border border-[var(--border-subtle)] text-xs text-[var(--text-tertiary)]"> <span className="s-chip v-mono !text-[10.5px]">
{categoryLabel(selectedDetail.template.category)} {categoryLabel(selectedDetail.template.category)}
</span> </span>
<span className="px-3 py-1 rounded-full border border-[var(--border-subtle)] text-xs text-[var(--text-tertiary)]"> <span className="s-chip v-mono !text-[10.5px]">
{selectedDetail.config.runtime || 'n/a'} {selectedDetail.config.runtime || 'n/a'}
</span> </span>
{canManageTemplate(selectedDetail.template) ? ( {canManageTemplate(selectedDetail.template) ? (
@@ -777,25 +760,25 @@ export function TemplatesPage() {
{/* Config Grid */} {/* Config Grid */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div className="panel-soft p-4"> <div className="s-inset">
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Type</p> <p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Type</p>
<p className="mono text-sm text-[var(--text-primary)] mt-2">{selectedDetail.config.type || '—'}</p> <p className="v-mono text-sm text-[var(--text-primary)] mt-2">{selectedDetail.config.type || '—'}</p>
</div> </div>
<div className="panel-soft p-4"> <div className="s-inset">
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Port</p> <p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Port</p>
<p className="mono text-sm text-[var(--text-primary)] mt-2">{selectedDetail.config.port || '—'}</p> <p className="v-mono text-sm text-[var(--text-primary)] mt-2">{selectedDetail.config.port || '—'}</p>
</div> </div>
<div className="panel-soft p-4"> <div className="s-inset">
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Build</p> <p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Build</p>
<p className="mono text-xs text-[var(--text-primary)] mt-2 truncate">{selectedDetail.config.buildCommand || '—'}</p> <p className="v-mono text-xs text-[var(--text-primary)] mt-2 truncate">{selectedDetail.config.buildCommand || '—'}</p>
</div> </div>
<div className="panel-soft p-4"> <div className="s-inset">
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Start</p> <p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Start</p>
<p className="mono text-xs text-[var(--text-primary)] mt-2 truncate">{selectedDetail.config.startCommand || '—'}</p> <p className="v-mono text-xs text-[var(--text-primary)] mt-2 truncate">{selectedDetail.config.startCommand || '—'}</p>
</div> </div>
</div> </div>
<div className="panel-soft p-4 mb-6"> <div className="s-inset mb-6">
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">How Templates Work</p> <p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">How Templates Work</p>
<p className="text-sm text-[var(--text-secondary)]"> <p className="text-sm text-[var(--text-secondary)]">
Template defaults are merged with your variable inputs. Creating from template saves a stopped service in the selected project, then you deploy it from the service detail page. Template defaults are merged with your variable inputs. Creating from template saves a stopped service in the selected project, then you deploy it from the service detail page.
@@ -808,7 +791,7 @@ export function TemplatesPage() {
</div> </div>
{isGraphTemplate ? ( {isGraphTemplate ? (
<div className="panel-soft p-4 mb-6"> <div className="s-inset mb-6">
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-3">
<Network size={14} className="text-[var(--accent-primary)]" /> <Network size={14} className="text-[var(--accent-primary)]" />
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]"> <p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
@@ -824,14 +807,14 @@ export function TemplatesPage() {
) : ( ) : (
<Box size={13} className="text-[var(--text-tertiary)] shrink-0" /> <Box size={13} className="text-[var(--text-tertiary)] shrink-0" />
)} )}
<span className="mono text-[var(--text-primary)] truncate">{member.name || member.key}</span> <span className="v-mono text-[var(--text-primary)] truncate">{member.name || member.key}</span>
{member.dependsOn && member.dependsOn.length > 0 ? ( {member.dependsOn && member.dependsOn.length > 0 ? (
<span className="text-[10px] text-[var(--text-tertiary)]"> <span className="text-[10px] text-[var(--text-tertiary)]">
after {member.dependsOn.join(', ')} after {member.dependsOn.join(', ')}
</span> </span>
) : null} ) : null}
</div> </div>
<span className="mono text-[11px] text-[var(--text-tertiary)] truncate"> <span className="v-mono text-[11px] text-[var(--text-tertiary)] truncate">
{member.type === 'database' ? `managed ${member.runtime}` : member.runtime || member.repo || '—'} {member.type === 'database' ? `managed ${member.runtime}` : member.runtime || member.repo || '—'}
</span> </span>
</div> </div>
@@ -845,26 +828,28 @@ export function TemplatesPage() {
{/* Deploy Section */} {/* Deploy Section */}
<div className="border-t border-[var(--border-subtle)] pt-6"> <div className="border-t border-[var(--border-subtle)] pt-6">
<div className="flex items-center gap-3 mb-4"> <div className="s-cardhead !mb-4">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--success-soft)] flex items-center justify-center"> <span className="s-ibox"><Play /></span>
<Play size={18} className="text-[var(--success)]" />
</div>
<div> <div>
<h3 className="text-lg font-semibold text-[var(--text-primary)]">Deploy from Template</h3> <span className="s-t">Deploy from Template</span>
<p className="text-sm text-[var(--text-secondary)]"> <p className="v-mono text-[10.5px] text-[var(--text-tertiary)] mt-0.5">
{isGraphTemplate ? 'Deploy all members into a project' : 'Configure and create a new service'} {isGraphTemplate ? 'Deploy all members into a project' : 'Configure and create a new service'}
</p> </p>
</div> </div>
</div> </div>
{!signedIn ? ( {sessionQuery.isPending && !isDemoMode ? (
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-4 py-5 text-center">
<Loader2 size={16} className="mx-auto animate-spin text-[var(--text-muted)]" />
</div>
) : !signedIn ? (
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-4 py-5 text-center"> <div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-4 py-5 text-center">
<p className="text-sm text-[var(--text-secondary)]"> <p className="text-sm text-[var(--text-secondary)]">
Sign in to deploy this template into one of your projects. Sign in to deploy this template into one of your projects.
</p> </p>
<Link <Link
to="/auth/sign-in" to="/auth/sign-in"
className="mt-3 inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-[12.5px] font-semibold text-[var(--accent-on)]" className="s-btn-accent mt-3 text-[12.5px]"
> >
Sign in <ArrowRight size={13} /> Sign in <ArrowRight size={13} />
</Link> </Link>
@@ -970,7 +955,7 @@ export function TemplatesPage() {
<span> <span>
{lastDeployment.created {lastDeployment.created
? `Stack deployed — ${lastDeployment.created.length} resources` ? `Stack deployed — ${lastDeployment.created.length} resources`
: <>Service <span className="mono font-medium">{lastDeployment.serviceName}</span> created</>} : <>Service <span className="v-mono font-medium">{lastDeployment.serviceName}</span> created</>}
</span> </span>
<Link <Link
to={lastDeployment.created to={lastDeployment.created
@@ -986,7 +971,7 @@ export function TemplatesPage() {
{lastDeployment.created.map((m) => ( {lastDeployment.created.map((m) => (
<div key={m.id} className="flex items-center gap-2 text-xs"> <div key={m.id} className="flex items-center gap-2 text-xs">
{m.kind === 'database' ? <Database size={11} /> : <Box size={11} />} {m.kind === 'database' ? <Database size={11} /> : <Box size={11} />}
<span className="mono">{m.name}</span> <span className="v-mono">{m.name}</span>
<span className="opacity-70">({m.kind})</span> <span className="opacity-70">({m.kind})</span>
</div> </div>
))} ))}
@@ -999,8 +984,8 @@ export function TemplatesPage() {
<button <button
onClick={() => deployMutation.mutate()} onClick={() => deployMutation.mutate()}
disabled={isDeployDisabled} disabled={isDeployDisabled}
className="flex items-center gap-2 h-11 px-6 rounded-[var(--radius-md)] text-[var(--accent-on)] text-sm font-medium shadow-lg disabled:opacity-50 disabled:cursor-not-allowed transition-all" className="s-btn-accent h-11 disabled:opacity-50 disabled:cursor-not-allowed"
style={{ background: 'var(--accent-primary)' }}
> >
{deployMutation.isPending ? ( {deployMutation.isPending ? (
<> <>
@@ -1133,7 +1118,7 @@ export function TemplatesPage() {
value={editorJson} value={editorJson}
onChange={(e) => setEditorJson(e.target.value)} onChange={(e) => setEditorJson(e.target.value)}
spellCheck={false} spellCheck={false}
className="mono min-h-[380px] flex-1 resize-none bg-[var(--bg-void)] p-4 text-[12.5px] leading-relaxed text-[var(--text-secondary)] outline-none" className="v-mono min-h-[380px] flex-1 resize-none bg-[var(--bg-void)] p-4 text-[12.5px] leading-relaxed text-[var(--text-secondary)] outline-none"
/> />
)} )}
{composeWarnings.length > 0 ? ( {composeWarnings.length > 0 ? (
@@ -1171,7 +1156,7 @@ export function TemplatesPage() {
type="button" type="button"
onClick={submitEditor} onClick={submitEditor}
disabled={saveTemplateMutation.isPending} disabled={saveTemplateMutation.isPending}
className="inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-xs font-semibold text-[var(--accent-on)] disabled:opacity-50" className="s-btn-accent disabled:opacity-50"
> >
{saveTemplateMutation.isPending ? <Loader2 size={13} className="animate-spin" /> : null} {saveTemplateMutation.isPending ? <Loader2 size={13} className="animate-spin" /> : null}
{editingTemplate ? 'Save changes' : 'Create template'} {editingTemplate ? 'Save changes' : 'Create template'}
@@ -1223,7 +1208,7 @@ export function TemplatesPage() {
onChange={(e) => setComposeYaml(e.target.value)} onChange={(e) => setComposeYaml(e.target.value)}
spellCheck={false} spellCheck={false}
placeholder={'services:\n db:\n image: postgres:16\n web:\n image: myapp:latest\n depends_on: [db]'} placeholder={'services:\n db:\n image: postgres:16\n web:\n image: myapp:latest\n depends_on: [db]'}
className="mono min-h-[320px] flex-1 resize-none bg-[var(--bg-void)] p-4 text-[12.5px] leading-relaxed text-[var(--text-secondary)] outline-none" className="v-mono min-h-[320px] flex-1 resize-none bg-[var(--bg-void)] p-4 text-[12.5px] leading-relaxed text-[var(--text-secondary)] outline-none"
/> />
) : ( ) : (
<div className="flex min-h-[320px] flex-1 flex-col gap-3 bg-[var(--bg-void)] p-4"> <div className="flex min-h-[320px] flex-1 flex-col gap-3 bg-[var(--bg-void)] p-4">
@@ -1234,7 +1219,7 @@ export function TemplatesPage() {
onChange={(e) => setGitRepo(e.target.value)} onChange={(e) => setGitRepo(e.target.value)}
spellCheck={false} spellCheck={false}
placeholder="owner/repo or https://github.com/owner/repo.git" placeholder="owner/repo or https://github.com/owner/repo.git"
className="mono mt-1 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-base)] px-3 py-2 text-[12.5px] text-[var(--text-secondary)] outline-none focus:border-[var(--accent-primary)]" className="v-mono mt-1 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-base)] px-3 py-2 text-[12.5px] text-[var(--text-secondary)] outline-none focus:border-[var(--accent-primary)]"
/> />
</label> </label>
<label className="text-xs text-[var(--text-tertiary)]"> <label className="text-xs text-[var(--text-tertiary)]">
@@ -1244,7 +1229,7 @@ export function TemplatesPage() {
onChange={(e) => setGitPath(e.target.value)} onChange={(e) => setGitPath(e.target.value)}
spellCheck={false} spellCheck={false}
placeholder="deploy/compose.yml" placeholder="deploy/compose.yml"
className="mono mt-1 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-base)] px-3 py-2 text-[12.5px] text-[var(--text-secondary)] outline-none focus:border-[var(--accent-primary)]" className="v-mono mt-1 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-base)] px-3 py-2 text-[12.5px] text-[var(--text-secondary)] outline-none focus:border-[var(--accent-primary)]"
/> />
</label> </label>
<label className="text-xs text-[var(--text-tertiary)]"> <label className="text-xs text-[var(--text-tertiary)]">
@@ -1254,7 +1239,7 @@ export function TemplatesPage() {
onChange={(e) => setGitRef(e.target.value)} onChange={(e) => setGitRef(e.target.value)}
spellCheck={false} spellCheck={false}
placeholder="main" placeholder="main"
className="mono mt-1 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-base)] px-3 py-2 text-[12.5px] text-[var(--text-secondary)] outline-none focus:border-[var(--accent-primary)]" className="v-mono mt-1 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-base)] px-3 py-2 text-[12.5px] text-[var(--text-secondary)] outline-none focus:border-[var(--accent-primary)]"
/> />
</label> </label>
<p className="text-[11px] text-[var(--text-tertiary)]"> <p className="text-[11px] text-[var(--text-tertiary)]">
@@ -1279,7 +1264,7 @@ export function TemplatesPage() {
type="button" type="button"
onClick={() => composeImportMutation.mutate()} onClick={() => composeImportMutation.mutate()}
disabled={(composeMode === 'paste' ? !composeYaml.trim() : !gitRepo.trim()) || composeImportMutation.isPending} disabled={(composeMode === 'paste' ? !composeYaml.trim() : !gitRepo.trim()) || composeImportMutation.isPending}
className="inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-xs font-semibold text-[var(--accent-on)] disabled:opacity-50" className="s-btn-accent disabled:opacity-50"
> >
{composeImportMutation.isPending ? <Loader2 size={13} className="animate-spin" /> : <FileCode size={13} />} {composeImportMutation.isPending ? <Loader2 size={13} className="animate-spin" /> : <FileCode size={13} />}
Convert to template Convert to template
@@ -8,11 +8,12 @@ import {
testWebhook, testWebhook,
updateWebhook, updateWebhook,
type OutboundWebhook, type OutboundWebhook,
type WebhookDelivery,
} from '@/lib/api-client'; } from '@/lib/api-client';
import { useDemoMode } from '@/lib/demo-mode'; import { useDemoMode } from '@/lib/demo-mode';
import { formatRelative } from '@/lib/time'; import { formatRelative } from '@/lib/time';
import { DemoRestricted, useToast } from '@/shared/components'; import { DemoRestricted, useToast } from '@/shared/components';
import { GhostBtn, QuietBtn, SPageHead, SPill } from '@/shared/components/sentry';
import { statusTone } from '@/shared/components/sentry-utils';
import { import {
Webhook, Webhook,
Plus, Plus,
@@ -30,17 +31,6 @@ const inputClass =
const eventSuggestions = ['*', 'service.*', 'project.*', 'database.*', 'deployment.*', 'template.*']; const eventSuggestions = ['*', 'service.*', 'project.*', 'database.*', 'deployment.*', 'template.*'];
function statusClass(status: WebhookDelivery['status']): string {
switch (status) {
case 'success':
return 'text-[var(--success)]';
case 'failed':
return 'text-[var(--error)]';
default:
return 'text-[var(--warning)]';
}
}
function WebhookRow({ function WebhookRow({
webhook, webhook,
onDelete, onDelete,
@@ -63,59 +53,50 @@ function WebhookRow({
}); });
return ( return (
<div className="panel p-4"> <div className="s-card !p-0">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3 px-4 py-3">
<button onClick={() => setExpanded((v) => !v)} className="text-[var(--text-tertiary)]"> <span className="s-ibox shrink-0"><Webhook /></span>
{expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</button>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm font-semibold text-[var(--text-primary)]">{webhook.name}</span> <span className="text-sm font-semibold text-[var(--text-primary)]">{webhook.name}</span>
{!webhook.enabled && ( {!webhook.enabled && <SPill tone="off">disabled</SPill>}
<span className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--surface-muted)] text-[var(--text-muted)]">
disabled
</span>
)}
</div> </div>
<p className="text-xs text-[var(--text-muted)] mono truncate">{webhook.url}</p> <p className="v-mono text-[10.5px] text-[var(--text-muted)] truncate">{webhook.url}</p>
</div> </div>
<div className="hidden md:flex gap-1"> <div className="hidden md:flex gap-1.5">
{webhook.events.map((ev) => ( {webhook.events.map((ev) => (
<span <span key={ev} className="s-chip">{ev}</span>
key={ev}
className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--accent-primary-soft)] text-[var(--accent-primary)] mono"
>
{ev}
</span>
))} ))}
</div> </div>
<div className="flex items-center gap-1 shrink-0"> <div className="flex items-center gap-1 shrink-0">
<button
onClick={() => setExpanded((v) => !v)}
className="s-icon-btn"
title={expanded ? 'Collapse' : 'Deliveries'}
>
{expanded ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
</button>
<button <button
onClick={onTest} onClick={onTest}
disabled={testing || !webhook.enabled} disabled={testing || !webhook.enabled}
className="p-2 rounded-[var(--radius-md)] text-[var(--text-tertiary)] hover:text-[var(--accent-primary)] disabled:opacity-40 transition-colors" className="s-icon-btn disabled:opacity-40"
title="Send test ping" title="Send test ping"
> >
{testing ? <Loader2 size={15} className="animate-spin" /> : <Send size={15} />} {testing ? <Loader2 size={13} className="animate-spin" /> : <Send size={13} />}
</button>
<button
onClick={onToggle}
className="p-2 rounded-[var(--radius-md)] text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-colors text-xs"
>
{webhook.enabled ? 'Disable' : 'Enable'}
</button> </button>
<QuietBtn onClick={onToggle}>{webhook.enabled ? 'Disable' : 'Enable'}</QuietBtn>
<button <button
onClick={onDelete} onClick={onDelete}
className="p-2 rounded-[var(--radius-md)] text-[var(--text-tertiary)] hover:text-[var(--error)] transition-colors" className="s-icon-btn !text-[var(--text-tertiary)] hover:!text-[var(--error)]"
title="Delete webhook" title="Delete webhook"
> >
<Trash2 size={15} /> <Trash2 size={13} />
</button> </button>
</div> </div>
</div> </div>
{expanded && ( {expanded && (
<div className="mt-3 border-t border-[var(--border-subtle)] pt-3"> <div className="border-t border-[var(--border-subtle)] bg-[var(--surface-muted)]/40 px-4 py-3">
<p className="text-[11px] uppercase tracking-wider text-[var(--text-muted)] mb-2"> <p className="v-mono text-[10px] uppercase tracking-[0.12em] text-[var(--text-muted)] mb-2">
Recent deliveries Recent deliveries
</p> </p>
{deliveriesQuery.isLoading ? ( {deliveriesQuery.isLoading ? (
@@ -123,10 +104,10 @@ function WebhookRow({
) : (deliveriesQuery.data ?? []).length === 0 ? ( ) : (deliveriesQuery.data ?? []).length === 0 ? (
<p className="text-xs text-[var(--text-muted)]">No deliveries yet.</p> <p className="text-xs text-[var(--text-muted)]">No deliveries yet.</p>
) : ( ) : (
<div className="space-y-1"> <div className="space-y-1.5">
{(deliveriesQuery.data ?? []).slice(0, 20).map((d) => ( {(deliveriesQuery.data ?? []).slice(0, 20).map((d) => (
<div key={d.id} className="flex items-center gap-3 text-xs mono"> <div key={d.id} className="flex items-center gap-3 text-[11px] v-mono">
<span className={statusClass(d.status)}>{d.status}</span> <SPill tone={statusTone(d.status)}>{d.status}</SPill>
<span className="text-[var(--text-secondary)]">{d.event}</span> <span className="text-[var(--text-secondary)]">{d.event}</span>
{d.response_status != null && ( {d.response_status != null && (
<span className="text-[var(--text-muted)]">HTTP {d.response_status}</span> <span className="text-[var(--text-muted)]">HTTP {d.response_status}</span>
@@ -207,46 +188,41 @@ export function WebhooksPage() {
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<div className="border-b border-[var(--border-subtle)]"> <div className="w-full px-4 pt-6 sm:px-8">
<div className="w-full px-8 py-5 flex items-center justify-between"> <SPageHead
<div> title="Webhooks"
<h1 className="v-title">Webhooks<span className="v-cursor">_</span></h1> titleAccent="_"
<p className="v-mono mt-1.5 text-[11px] text-[var(--text-tertiary)]"> sub="Signed HTTP delivery for every platform event — deploys, restarts, deletes"
Signed HTTP delivery for every platform event — deploys, restarts, deletes actions={
</p> <GhostBtn onClick={() => setCreating(true)}>
</div> <Plus size={13} /> New webhook
<button </GhostBtn>
onClick={() => setCreating(true)} }
className="flex items-center gap-1.5 h-9 px-4 rounded-[var(--radius-md)] text-sm font-semibold text-[var(--accent-on)]" />
style={{ background: 'var(--accent-primary)' }}
>
<Plus size={15} />
New webhook
</button>
</div>
</div> </div>
<div className="w-full px-8 py-6 space-y-4 max-w-4xl"> <div className="w-full px-4 sm:px-8 space-y-4 max-w-4xl">
{secret && ( {secret && (
<div className="panel-soft p-4 flex items-center gap-3"> <div className="s-inset p-4 flex items-center gap-3">
<span className="s-ibox shrink-0"><Webhook /></span>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm font-medium text-[var(--text-primary)]">Signing secret — shown once</p> <p className="text-sm font-medium text-[var(--text-primary)]">Signing secret — shown once</p>
<p className="text-xs text-[var(--text-muted)]"> <p className="v-mono text-[11px] text-[var(--text-muted)]">
Verify deliveries with <span className="mono">X-Containr-Signature</span> Verify deliveries with <span className="v-mono">X-Containr-Signature</span>
</p> </p>
<code className="block mt-2 text-xs mono text-[var(--accent-primary)] break-all">{secret}</code> <code className="block mt-2 v-mono text-xs text-[var(--accent-primary)] break-all">{secret}</code>
</div> </div>
<button <button
onClick={() => { onClick={() => {
navigator.clipboard?.writeText(secret); navigator.clipboard?.writeText(secret);
toast.showToast('Secret copied', 'success'); toast.showToast('Secret copied', 'success');
}} }}
className="p-2 text-[var(--text-tertiary)] hover:text-[var(--text-primary)]" className="s-icon-btn"
> >
<Copy size={15} /> <Copy size={13} />
</button> </button>
<button onClick={() => setSecret(null)} className="p-2 text-[var(--text-tertiary)]"> <button onClick={() => setSecret(null)} className="s-icon-btn">
<X size={15} /> <X size={13} />
</button> </button>
</div> </div>
)} )}
@@ -254,12 +230,12 @@ export function WebhooksPage() {
{webhooksQuery.isLoading ? ( {webhooksQuery.isLoading ? (
<Loader2 size={18} className="animate-spin text-[var(--text-muted)]" /> <Loader2 size={18} className="animate-spin text-[var(--text-muted)]" />
) : (webhooksQuery.data ?? []).length === 0 ? ( ) : (webhooksQuery.data ?? []).length === 0 ? (
<div className="panel p-8 text-center"> <div className="s-card py-10 text-center">
<Webhook size={28} className="mx-auto text-[var(--text-muted)] mb-3" /> <div className="s-ibox mx-auto mb-3"><Webhook /></div>
<p className="text-sm text-[var(--text-secondary)]">No webhooks yet.</p> <p className="text-sm text-[var(--text-secondary)]">No webhooks yet.</p>
<p className="text-xs text-[var(--text-muted)] mt-1"> <p className="v-mono text-[11px] text-[var(--text-muted)] mt-1">
Subscribe an HTTP endpoint to platform events like <span className="mono">service.*</span> or{' '} Subscribe an HTTP endpoint to platform events like <span className="v-mono">service.*</span> or{' '}
<span className="mono">deployment.fail</span>. <span className="v-mono">deployment.fail</span>.
</p> </p>
</div> </div>
) : ( ) : (
@@ -283,8 +259,11 @@ export function WebhooksPage() {
{creating && ( {creating && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-[var(--bg-void)]/80 backdrop-blur-sm" onClick={() => setCreating(false)} /> <div className="absolute inset-0 bg-[var(--bg-void)]/80 backdrop-blur-sm" onClick={() => setCreating(false)} />
<div className="relative w-full max-w-md panel p-6"> <div className="relative w-full max-w-md s-card">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">New webhook</h3> <div className="s-cardhead mb-4">
<span className="s-ibox"><Webhook /></span>
<h3 className="s-t">New Webhook</h3>
</div>
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2"> <label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
@@ -304,7 +283,7 @@ export function WebhooksPage() {
<input <input
value={form.url} value={form.url}
onChange={(e) => setForm((f) => ({ ...f, url: e.target.value }))} onChange={(e) => setForm((f) => ({ ...f, url: e.target.value }))}
className={`${inputClass} w-full mono`} className={`${inputClass} w-full v-mono`}
placeholder="https://example.com/hooks/containr" placeholder="https://example.com/hooks/containr"
/> />
</div> </div>
@@ -317,30 +296,21 @@ export function WebhooksPage() {
<button <button
key={ev} key={ev}
onClick={() => toggleEvent(ev)} onClick={() => toggleEvent(ev)}
className={`px-2.5 py-1 rounded-[var(--radius-md)] border text-xs mono transition-colors ${ className={`s-chip v-mono ${form.events.includes(ev) ? 'on' : ''}`}
form.events.includes(ev)
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]'
: 'border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-tertiary)]'
}`}
> >
{ev} {ev}
</button> </button>
))} ))}
</div> </div>
<p className="mt-2 text-[11px] text-[var(--text-muted)]"> <p className="mt-2 v-mono text-[10.5px] text-[var(--text-muted)]">
Events are <span className="mono">resource.action</span> — wildcards like{' '} Events are <span className="v-mono">resource.action</span> — wildcards like{' '}
<span className="mono">service.*</span> match the whole resource. <span className="v-mono">service.*</span> match the whole resource.
</p> </p>
</div> </div>
</div> </div>
<div className="mt-6 flex justify-end gap-2"> <div className="mt-6 flex justify-end gap-2">
<button <QuietBtn onClick={() => setCreating(false)}>Cancel</QuietBtn>
onClick={() => setCreating(false)} <GhostBtn
className="h-10 px-4 rounded-[var(--radius-md)] text-sm text-[var(--text-secondary)]"
>
Cancel
</button>
<button
onClick={() => createMutation.mutate()} onClick={() => createMutation.mutate()}
disabled={ disabled={
createMutation.isPending || createMutation.isPending ||
@@ -348,12 +318,10 @@ export function WebhooksPage() {
!form.url.trim() || !form.url.trim() ||
form.events.length === 0 form.events.length === 0
} }
className="h-10 px-5 rounded-[var(--radius-md)] text-sm font-semibold text-[var(--accent-on)] disabled:opacity-50 flex items-center gap-2"
style={{ background: 'var(--accent-primary)' }}
> >
{createMutation.isPending && <Loader2 size={14} className="animate-spin" />} {createMutation.isPending && <Loader2 size={13} className="animate-spin" />}
Create Create
</button> </GhostBtn>
</div> </div>
</div> </div>
</div> </div>
@@ -167,12 +167,12 @@ function toFlowEdges(
}, },
markerEnd: { markerEnd: {
type: MarkerType.ArrowClosed, type: MarkerType.ArrowClosed,
color: 'rgba(180, 227, 74, 0.55)', color: 'color-mix(in srgb, var(--accent-primary) 55%, transparent)',
width: 13, width: 13,
height: 13, height: 13,
}, },
style: { style: {
stroke: 'rgba(180, 227, 74, 0.42)', stroke: 'color-mix(in srgb, var(--accent-primary) 42%, transparent)',
strokeWidth: 1.6, strokeWidth: 1.6,
}, },
className: 'edge-premium', className: 'edge-premium',
@@ -198,7 +198,7 @@ function ZoomControls({ onTidy }: { onTidy: () => void }) {
<button <button
type="button" type="button"
onClick={() => setViewport({ x: 0, y: 0, zoom: 1 }, { duration: 180 })} onClick={() => setViewport({ x: 0, y: 0, zoom: 1 }, { duration: 180 })}
className="canvas-pill-btn mono w-12 text-[11px]" className="canvas-pill-btn v-mono w-12 text-[11px]"
title="Reset zoom (0)" title="Reset zoom (0)"
> >
{Math.round(zoom * 100)}% {Math.round(zoom * 100)}%
@@ -873,7 +873,7 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
const minimapNodeColor = useCallback( const minimapNodeColor = useCallback(
(node: CanvasNode) => { (node: CanvasNode) => {
if (node.type === 'groupNode') { if (node.type === 'groupNode') {
return 'rgba(255,255,255,0.08)'; return 'var(--tint-08)';
} }
return serviceAccent((node.data as ServiceNodeData).service); return serviceAccent((node.data as ServiceNodeData).service);
}, },
@@ -922,14 +922,14 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
selectionKeyCode="Shift" selectionKeyCode="Shift"
proOptions={{ hideAttribution: true }} proOptions={{ hideAttribution: true }}
> >
<Background variant={BackgroundVariant.Dots} gap={26} size={1.2} color="rgba(255,255,255,0.055)" /> <Background variant={BackgroundVariant.Dots} gap={26} size={1.2} color="var(--tint-05)" />
<MiniMap <MiniMap
position="bottom-right" position="bottom-right"
pannable pannable
zoomable zoomable
nodeColor={minimapNodeColor} nodeColor={minimapNodeColor}
bgColor="rgba(14, 15, 18, 0.92)" bgColor="var(--surface-glass)"
maskColor="rgba(12, 13, 15, 0.72)" maskColor="var(--surface-glass)"
className="canvas-minimap hidden md:block" className="canvas-minimap hidden md:block"
/> />
</ReactFlow> </ReactFlow>
@@ -937,21 +937,19 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
{/* Floating: env filter + group (top-left) */} {/* Floating: env filter + group (top-left) */}
<div className="absolute left-4 top-4 flex items-center gap-2"> <div className="absolute left-4 top-4 flex items-center gap-2">
{environments.length > 1 && ( {environments.length > 1 && (
<div className="canvas-pill flex items-center gap-0.5 p-1"> <div className="canvas-pill !rounded-[9px] p-1">
<span className="s-seg">
{['all', ...environments].map((env) => ( {['all', ...environments].map((env) => (
<button <button
key={env} key={env}
type="button" type="button"
onClick={() => setEnvFilter(env)} onClick={() => setEnvFilter(env)}
className={`px-2.5 py-1 rounded-full text-[11px] font-medium capitalize transition-all ${ className={`!w-auto !h-auto px-2.5 py-1 capitalize v-mono text-[10.5px] ${envFilter === env ? 'on' : ''}`}
envFilter === env
? 'bg-[var(--surface-card)] text-[var(--text-primary)] shadow-sm'
: 'text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]'
}`}
> >
{env} {env}
</button> </button>
))} ))}
</span>
</div> </div>
)} )}
{!readOnly && ( {!readOnly && (
@@ -1018,12 +1016,12 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
{/* Empty state */} {/* Empty state */}
{services.length === 0 && ( {services.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none"> <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="text-center pointer-events-auto"> <div className="text-center pointer-events-auto s-card !py-8 !px-10">
<div className="w-14 h-14 mx-auto mb-4 rounded-2xl bg-[var(--surface-card)] border border-[var(--border-subtle)] flex items-center justify-center"> <div className="s-ibox mx-auto mb-4 !h-11 !w-11">
<Rocket size={24} className="text-[var(--accent-primary)]" /> <Rocket />
</div> </div>
<h3 className="text-base font-semibold text-[var(--text-primary)]">Deploy your first service</h3> <h3 className="font-headline text-base font-semibold text-[var(--text-primary)]">Deploy your first service</h3>
<p className="text-sm text-[var(--text-tertiary)] mt-1 mb-4 max-w-xs"> <p className="v-mono text-[11.5px] text-[var(--text-tertiary)] mt-1.5 mb-5 max-w-xs">
Add a service from a git repo or Docker image - it runs on a private network with the rest of this Add a service from a git repo or Docker image - it runs on a private network with the rest of this
project. project.
</p> </p>
@@ -1031,10 +1029,9 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
<button <button
type="button" type="button"
onClick={onAddService} onClick={onAddService}
className="inline-flex items-center gap-2 h-9 px-4 rounded-lg text-[var(--accent-on)] text-sm font-medium shadow-lg" className="s-btn-accent inline-flex items-center gap-2"
style={{ background: 'var(--accent-primary)' }}
> >
<Plus size={15} /> <Plus size={13} />
Add Service Add Service
</button> </button>
)} )}
@@ -1,6 +1,7 @@
import type { ServiceEntity } from '@/lib/api-client'; import type { ServiceEntity } from '@/lib/api-client';
import { serviceStatusClass } from '@/lib/api-client';
import type { ServiceVariable } from '../auto-connections'; import type { ServiceVariable } from '../auto-connections';
import { GhostBtn, QuietBtn, SPill } from '@/shared/components/sentry';
import { statusTone } from '@/shared/components/sentry-utils';
import { ServiceIcon } from './ServiceIcon'; import { ServiceIcon } from './ServiceIcon';
import { serviceAccent } from './service-visuals'; import { serviceAccent } from './service-visuals';
import { formatRelative } from '@/lib/time'; import { formatRelative } from '@/lib/time';
@@ -63,28 +64,11 @@ function statusLabel(status: string): string {
} }
} }
function statusColor(status: string): string {
switch (status) {
case 'running':
case 'deployed':
return 'var(--success)';
case 'failed':
return 'var(--error)';
case 'building':
case 'deploying':
case 'pending':
case 'rolling_back':
return 'var(--warning)';
default:
return 'var(--text-tertiary)';
}
}
function Section({ title, children, action }: { title: string; children: React.ReactNode; action?: React.ReactNode }) { function Section({ title, children, action }: { title: string; children: React.ReactNode; action?: React.ReactNode }) {
return ( return (
<div> <div>
<div className="flex items-center justify-between mb-1.5"> <div className="flex items-center justify-between mb-1.5">
<p className="text-[10px] uppercase tracking-wider text-[var(--text-muted)]">{title}</p> <p className="v-mono text-[10px] uppercase tracking-[0.14em] text-[var(--text-muted)]">{title}</p>
{action} {action}
</div> </div>
{children} {children}
@@ -163,48 +147,49 @@ export function ServiceInspector({
return ( return (
<aside className="canvas-inspector absolute right-4 top-4 bottom-4 w-[312px] panel-glass flex flex-col overflow-hidden"> <aside className="canvas-inspector absolute right-4 top-4 bottom-4 w-[312px] panel-glass flex flex-col overflow-hidden">
<div className="flex items-center gap-3 px-4 pt-4 pb-3 border-b border-[var(--border-subtle)]"> <div className="s-cardhead border-b border-[var(--border-subtle)]">
<div <span
className="w-9 h-9 rounded-[var(--radius-md)] flex items-center justify-center flex-shrink-0" className="s-ibox flex-shrink-0"
style={{ background: `${accent}1c`, color: accent }} style={{ background: `${accent}14`, color: accent, borderColor: `${accent}33` }}
> >
<ServiceIcon service={service} size={16} /> <ServiceIcon service={service} size={15} />
</div> </span>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<h3 className="text-sm font-semibold text-[var(--text-primary)] truncate">{service.name}</h3> <h3 className="text-[13px] font-semibold text-[var(--text-primary)] truncate leading-tight">{service.name}</h3>
<div className="flex items-center gap-1.5 text-[11px] text-[var(--text-tertiary)]"> <div className="flex items-center gap-1.5 mt-1">
<span className={`status-dot ${serviceStatusClass(service.status)}`} /> <SPill tone={statusTone(service.status)}>{statusLabel(service.status)}</SPill>
<span style={{ color: statusColor(service.status) }}>{statusLabel(service.status)}</span> <span className="v-mono text-[10px] text-[var(--text-tertiary)]">
<span>· {service.type}</span> {service.type}
{service.environment && service.environment !== 'production' && <span>· {service.environment}</span>} {service.environment && service.environment !== 'production' ? ` · ${service.environment}` : ''}
</span>
</div> </div>
</div> </div>
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
className="w-7 h-7 rounded-md flex items-center justify-center text-[var(--text-tertiary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-muted)]" className="s-icon-btn"
title="Close (Esc)" title="Close (Esc)"
> >
<X size={14} /> <X size={12} />
</button> </button>
</div> </div>
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-4"> <div className="flex-1 overflow-y-auto px-4 py-3 space-y-4">
{(publicUrl || service.port) && ( {(publicUrl || service.port) && (
<Section title="Address"> <Section title="Address">
<div className="flex items-center gap-1.5 group"> <div className="s-inset flex items-center gap-1.5 px-2.5 py-2 group">
<Globe size={11} className="text-[var(--text-tertiary)] flex-shrink-0" /> <Globe size={11} className="text-[var(--text-tertiary)] flex-shrink-0" />
{publicUrl ? ( {publicUrl ? (
<a <a
href={publicUrl} href={publicUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="mono text-[11px] text-[var(--accent-primary)] hover:underline break-all" className="v-mono text-[11px] text-[var(--accent-primary)] hover:underline break-all"
> >
{publicUrl.replace(/^https?:\/\//, '')} {publicUrl.replace(/^https?:\/\//, '')}
</a> </a>
) : ( ) : (
<span className="mono text-[11px] text-[var(--text-secondary)]"> <span className="v-mono text-[11px] text-[var(--text-secondary)]">
{service.name}:{service.port} {service.name}:{service.port}
</span> </span>
)} )}
@@ -218,7 +203,7 @@ export function ServiceInspector({
</button> </button>
</div> </div>
{publicUrl && service.port ? ( {publicUrl && service.port ? (
<p className="mono text-[10.5px] text-[var(--text-tertiary)] mt-0.5 pl-4"> <p className="v-mono text-[10.5px] text-[var(--text-tertiary)] mt-1 pl-1">
{service.name}:{service.port} {service.name}:{service.port}
</p> </p>
) : null} ) : null}
@@ -266,13 +251,13 @@ export function ServiceInspector({
{variables.slice(0, 6).map((variable) => ( {variables.slice(0, 6).map((variable) => (
<div <div
key={variable.key} key={variable.key}
className="flex items-center gap-2 px-2 py-1.5 rounded-[var(--radius-sm)] bg-[var(--surface-muted)]/50" className="s-inset !rounded-[6px] flex items-center gap-2 px-2 py-1.5"
> >
<KeyRound size={9} className="text-[var(--text-tertiary)] flex-shrink-0" /> <KeyRound size={9} className="text-[var(--text-tertiary)] flex-shrink-0" />
<span className="mono text-[10px] text-[var(--text-primary)] truncate font-medium"> <span className="v-mono text-[10px] text-[var(--text-primary)] truncate font-medium">
{variable.key} {variable.key}
</span> </span>
<span className="mono text-[10px] text-[var(--text-tertiary)] truncate ml-auto max-w-[130px]" title={variable.isSecret ? 'Secret' : variable.value}> <span className="v-mono text-[10px] text-[var(--text-tertiary)] truncate ml-auto max-w-[130px]" title={variable.isSecret ? 'Secret' : variable.value}>
{variable.isSecret ? '••••••••' : variable.value} {variable.isSecret ? '••••••••' : variable.value}
</span> </span>
</div> </div>
@@ -299,7 +284,7 @@ export function ServiceInspector({
{visibleConfig.map(([label, value]) => ( {visibleConfig.map(([label, value]) => (
<div key={label} className="flex items-baseline gap-2"> <div key={label} className="flex items-baseline gap-2">
<dt className="text-[10px] text-[var(--text-muted)] w-[70px] flex-shrink-0">{label}</dt> <dt className="text-[10px] text-[var(--text-muted)] w-[70px] flex-shrink-0">{label}</dt>
<dd className="mono text-[10.5px] text-[var(--text-secondary)] truncate" title={value}> <dd className="v-mono text-[10.5px] text-[var(--text-secondary)] truncate" title={value}>
{value} {value}
</dd> </dd>
</div> </div>
@@ -307,7 +292,7 @@ export function ServiceInspector({
{(service.replicas ?? 0) > 0 && ( {(service.replicas ?? 0) > 0 && (
<div className="flex items-baseline gap-2"> <div className="flex items-baseline gap-2">
<dt className="text-[10px] text-[var(--text-muted)] w-[70px] flex-shrink-0">Replicas</dt> <dt className="text-[10px] text-[var(--text-muted)] w-[70px] flex-shrink-0">Replicas</dt>
<dd className="mono text-[10.5px] text-[var(--text-secondary)]">{service.replicas}</dd> <dd className="v-mono text-[10.5px] text-[var(--text-secondary)]">{service.replicas}</dd>
</div> </div>
)} )}
</dl> </dl>
@@ -318,7 +303,7 @@ export function ServiceInspector({
{connections.length === 0 ? ( {connections.length === 0 ? (
<p className="text-[11px] text-[var(--text-muted)]"> <p className="text-[11px] text-[var(--text-muted)]">
No inferred connections. Reference another service via{' '} No inferred connections. Reference another service via{' '}
<span className="mono">{'{{variable}}'}</span> placeholders in env vars. <span className="v-mono">{'{{variable}}'}</span> placeholders in env vars.
</p> </p>
) : ( ) : (
<div className="space-y-1.5"> <div className="space-y-1.5">
@@ -327,13 +312,13 @@ export function ServiceInspector({
key={conn.id} key={conn.id}
type="button" type="button"
onClick={() => onSelectPeer(conn.peerId)} onClick={() => onSelectPeer(conn.peerId)}
className="w-full text-left px-2.5 py-2 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]/40 hover:bg-[var(--surface-muted)] transition-colors" className="s-inset !rounded-[6px] w-full text-left px-2.5 py-2 hover:border-[var(--border-default)] transition-colors"
> >
<div className="flex items-center gap-1.5 text-[11px] text-[var(--text-primary)]"> <div className="flex items-center gap-1.5 text-[11px] text-[var(--text-primary)]">
<Link2 size={10} className="text-[var(--accent-primary)] flex-shrink-0" /> <Link2 size={10} className="text-[var(--accent-primary)] flex-shrink-0" />
<span className="truncate font-medium">{conn.outbound ? `→ ${conn.peer}` : `← ${conn.peer}`}</span> <span className="truncate font-medium">{conn.outbound ? `→ ${conn.peer}` : `← ${conn.peer}`}</span>
</div> </div>
<p className="mono text-[9.5px] text-[var(--text-tertiary)] truncate mt-0.5 pl-4"> <p className="v-mono text-[9.5px] text-[var(--text-tertiary)] truncate mt-0.5 pl-4">
{conn.reasons.join(' · ')} {conn.reasons.join(' · ')}
</p> </p>
</button> </button>
@@ -350,65 +335,58 @@ export function ServiceInspector({
</div> </div>
<div className="border-t border-[var(--border-subtle)] px-4 py-3 space-y-2"> <div className="border-t border-[var(--border-subtle)] px-4 py-3 space-y-2">
<button <GhostBtn
type="button"
onClick={() => onOpenService(service.id)} onClick={() => onOpenService(service.id)}
className="w-full flex items-center justify-center gap-2 h-9 rounded-[var(--radius-md)] text-[var(--accent-on)] text-[13px] font-medium" className="w-full justify-center !h-9"
style={{ background: 'var(--accent-primary)' }}
> >
<ArrowUpRight size={14} /> <ArrowUpRight size={14} />
Open service Open service
</button> </GhostBtn>
<div className="grid grid-cols-3 gap-1.5"> <div className="grid grid-cols-3 gap-1.5">
<button <QuietBtn
type="button"
disabled={actions.pending || readOnly} disabled={actions.pending || readOnly}
onClick={actions.deploy} onClick={actions.deploy}
className="flex items-center justify-center gap-1 h-8 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors disabled:opacity-40" className="justify-center"
> >
{actions.deployPending ? <Loader2 size={11} className="animate-spin" /> : <Rocket size={11} />} {actions.deployPending ? <Loader2 size={11} className="animate-spin" /> : <Rocket size={11} />}
Deploy Deploy
</button> </QuietBtn>
{service.status !== 'running' ? ( {service.status !== 'running' ? (
<button <QuietBtn
type="button"
disabled={actions.pending || readOnly} disabled={actions.pending || readOnly}
onClick={actions.start} onClick={actions.start}
className="flex items-center justify-center gap-1 h-8 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors disabled:opacity-40" className="justify-center"
> >
<Play size={11} /> <Play size={11} />
Start Start
</button> </QuietBtn>
) : ( ) : (
<button <QuietBtn
type="button"
disabled={actions.pending || readOnly} disabled={actions.pending || readOnly}
onClick={actions.restart} onClick={actions.restart}
className="flex items-center justify-center gap-1 h-8 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors disabled:opacity-40" className="justify-center"
> >
{actions.restartPending ? <Loader2 size={11} className="animate-spin" /> : <Play size={11} />} {actions.restartPending ? <Loader2 size={11} className="animate-spin" /> : <Play size={11} />}
Restart Restart
</button> </QuietBtn>
)} )}
<button <QuietBtn
type="button"
disabled={actions.pending || readOnly || service.status !== 'running'} disabled={actions.pending || readOnly || service.status !== 'running'}
onClick={actions.stop} onClick={actions.stop}
className="flex items-center justify-center gap-1 h-8 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors disabled:opacity-40" className="justify-center"
> >
<Square size={11} /> <Square size={11} />
Stop Stop
</button> </QuietBtn>
</div> </div>
<button <QuietBtn
type="button"
disabled={actions.pending || readOnly} disabled={actions.pending || readOnly}
onClick={actions.remove} onClick={actions.remove}
className="w-full flex items-center justify-center gap-1.5 h-8 rounded-[var(--radius-sm)] text-[11px] font-medium text-[var(--error)] hover:bg-[var(--error-soft)] transition-colors disabled:opacity-40" className="w-full justify-center !text-[var(--error)] hover:!border-[var(--error)]/50"
> >
<Trash2 size={11} /> <Trash2 size={11} />
Delete service Delete service
</button> </QuietBtn>
</div> </div>
</aside> </aside>
); );
@@ -1,10 +1,11 @@
import { Handle, Position, type Node, type NodeProps } from '@xyflow/react'; import { Handle, Position, type Node, type NodeProps } from '@xyflow/react';
import type { ServiceEntity } from '@/lib/api-client'; import type { ServiceEntity } from '@/lib/api-client';
import { serviceStatusClass } from '@/lib/api-client';
import { Box, ExternalLink, Copy, Layers, Loader2 } from 'lucide-react'; import { Box, ExternalLink, Copy, Layers, Loader2 } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { serviceIcon, serviceAccent } from './service-visuals'; import { serviceIcon, serviceAccent } from './service-visuals';
import { ServiceIcon } from './ServiceIcon'; import { ServiceIcon } from './ServiceIcon';
import { SPill } from '@/shared/components/sentry';
import { statusTone } from '@/shared/components/sentry-utils';
export type ServiceNodeData = { export type ServiceNodeData = {
service: ServiceEntity; service: ServiceEntity;
@@ -65,10 +66,10 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
return ( return (
<div <div
className={`w-full rounded-[var(--radius-lg)] border transition-[border-color,box-shadow,transform] duration-150 group relative overflow-hidden ${ className={`w-full rounded-[10px] border transition-[border-color,box-shadow] duration-150 group relative overflow-hidden ${
selected selected
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] shadow-[0_0_0_1px_var(--accent-primary),0_12px_36px_rgba(0,0,0,0.5)]' ? 'border-[var(--accent-primary)] bg-[var(--surface-card)] shadow-[0_0_0_1px_var(--accent-primary),0_12px_36px_rgba(0,0,0,0.5)]'
: 'border-[var(--border-subtle)] bg-[var(--surface-card)] hover:border-[var(--border-default)] hover:shadow-[0_12px_32px_rgba(0,0,0,0.45)] hover:-translate-y-px' : 'border-[var(--border-subtle)] bg-[var(--surface-card)] hover:border-[var(--border-default)]'
}`} }`}
> >
<div <div
@@ -86,39 +87,39 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
style={{ background: stateColor }} style={{ background: stateColor }}
/> />
<div className="relative px-3.5 pt-3.5 pb-3"> <div className="relative">
<div className="flex items-center gap-3 mb-2.5"> <div className="flex items-center gap-2.5 px-3.5 pt-3 pb-2.5">
<div <div
className="w-9 h-9 rounded-[var(--radius-md)] flex items-center justify-center flex-shrink-0 transition-all duration-200" className="w-8 h-8 rounded-[7px] border border-[var(--border-subtle)] flex items-center justify-center flex-shrink-0 transition-all duration-200"
style={{ style={{
background: selected && !brand ? typeColor : `${typeColor}1c`, background: selected && !brand ? typeColor : `${typeColor}14`,
color: selected && !brand ? 'var(--bg-void)' : typeColor, color: selected && !brand ? 'var(--bg-void)' : typeColor,
}} }}
> >
{isTransient ? ( {isTransient ? (
<Loader2 size={16} className="animate-spin" /> <Loader2 size={14} className="animate-spin" />
) : ( ) : (
<ServiceIcon service={service} size={17} /> <ServiceIcon service={service} size={15} />
)} )}
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<h4 className="font-semibold text-[13px] leading-tight text-[var(--text-primary)] truncate tracking-tight"> <h4 className="font-semibold text-[12.5px] leading-tight text-[var(--text-primary)] truncate tracking-tight">
{service.name} {service.name}
</h4> </h4>
<p className="text-[10.5px] text-[var(--text-tertiary)] truncate mt-0.5"> <p className="v-mono text-[10px] text-[var(--text-tertiary)] truncate mt-0.5">
{service.type} {service.type}
{service.environment && service.environment !== 'production' ? ` · ${service.environment}` : ''} {service.environment && service.environment !== 'production' ? ` · ${service.environment}` : ''}
</p> </p>
</div> </div>
{replicas > 1 && ( {replicas > 1 && (
<span className="flex items-center gap-1 px-1.5 py-0.5 rounded bg-[var(--surface-muted)] text-[10px] font-semibold text-[var(--text-secondary)]"> <span className="s-chip">
<Layers size={9} /> <Layers size={9} />
{replicas} {replicas}
</span> </span>
)} )}
</div> </div>
<div className="flex items-center gap-2 mb-2.5 px-2.5 py-1.5 rounded-[var(--radius-sm)] bg-[var(--surface-muted)]/60 border border-[var(--border-subtle)]/60 group/addr"> <div className="mx-3 mb-2.5 s-inset !rounded-[6px] flex items-center gap-2 px-2.5 py-1.5 group/addr">
<ExternalLink size={10} className="text-[var(--text-tertiary)] flex-shrink-0" /> <ExternalLink size={10} className="text-[var(--text-tertiary)] flex-shrink-0" />
{publicUrl ? ( {publicUrl ? (
<a <a
@@ -126,12 +127,12 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className="text-[10.5px] text-[var(--accent-primary)] truncate mono hover:underline" className="v-mono text-[10.5px] text-[var(--accent-primary)] truncate hover:underline"
> >
{publicUrl.replace(/^https?:\/\//, '')} {publicUrl.replace(/^https?:\/\//, '')}
</a> </a>
) : ( ) : (
<span className="text-[10.5px] text-[var(--text-secondary)] truncate mono" title="Internal network address"> <span className="v-mono text-[10.5px] text-[var(--text-secondary)] truncate" title="Internal network address">
{internalAddr} {internalAddr}
</span> </span>
)} )}
@@ -148,15 +149,10 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
</button> </button>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between px-3.5 py-2 border-t border-[var(--border-subtle)]">
<div className="flex items-center gap-1.5"> <SPill tone={statusTone(service.status)}>{statusLabel(service.status)}</SPill>
<span className={`status-dot ${serviceStatusClass(service.status)}`} />
<span className="text-[10px] font-semibold tracking-[0.08em] uppercase" style={{ color: stateColor }}>
{statusLabel(service.status)}
</span>
</div>
{service.image && ( {service.image && (
<span className="text-[9.5px] text-[var(--text-muted)] truncate mono max-w-[110px]" title={service.image}> <span className="v-mono text-[9.5px] text-[var(--text-muted)] truncate max-w-[110px]" title={service.image}>
{service.image} {service.image}
</span> </span>
)} )}
@@ -191,25 +187,25 @@ export function GroupNode({ id, data }: NodeProps<GroupNodeType>) {
return ( return (
<div <div
className="h-full w-full rounded-[var(--radius-xl)] border-2 border-dashed transition-all duration-200 relative" className="h-full w-full rounded-[12px] border border-dashed transition-all duration-200 relative"
style={{ style={{
borderColor: isHovered ? 'var(--border-strong)' : 'var(--border-default)', borderColor: isHovered ? 'var(--border-strong)' : 'var(--border-default)',
background: isHovered ? 'var(--accent-primary-soft)' : 'rgba(255,255,255,0.02)', background: isHovered ? 'var(--accent-primary-soft)' : 'var(--tint-03)',
}} }}
onMouseEnter={() => setIsHovered(true)} onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)} onMouseLeave={() => setIsHovered(false)}
> >
<div className="relative p-4"> <div className="relative p-4">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<div <span
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors duration-200" className="s-ibox !h-7 !w-7 transition-colors duration-200"
style={{ style={{
background: isHovered ? 'var(--accent-primary-soft)' : 'var(--surface-card)', background: isHovered ? 'var(--accent-primary-soft)' : undefined,
color: isHovered ? 'var(--accent-primary)' : 'var(--text-tertiary)', color: isHovered ? 'var(--accent-primary)' : undefined,
}} }}
> >
<Box size={14} /> <Box />
</div> </span>
{editing ? ( {editing ? (
<input <input
autoFocus autoFocus
@@ -227,7 +223,7 @@ export function GroupNode({ id, data }: NodeProps<GroupNodeType>) {
/> />
) : ( ) : (
<h3 <h3
className="text-sm font-semibold text-[var(--text-primary)] tracking-tight" className="font-headline text-sm font-semibold text-[var(--text-primary)] tracking-tight"
onDoubleClick={(e) => { onDoubleClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setDraft(data.title); setDraft(data.title);
@@ -241,7 +237,7 @@ export function GroupNode({ id, data }: NodeProps<GroupNodeType>) {
</div> </div>
<p <p
className="text-[10px] text-[var(--text-tertiary)] mt-2 transition-opacity duration-200" className="v-mono text-[10px] uppercase tracking-[0.12em] text-[var(--text-tertiary)] mt-2 transition-opacity duration-200"
style={{ opacity: isHovered ? 1 : 0 }} style={{ opacity: isHovered ? 1 : 0 }}
> >
Drag services here to group them Drag services here to group them
@@ -18,15 +18,15 @@ export function serviceTypeIcon(type: string, size = 18) {
export function serviceTypeColor(type: string): string { export function serviceTypeColor(type: string): string {
switch (type) { switch (type) {
case 'web': case 'web':
return '#7ab8ff'; return 'var(--info)';
case 'database': case 'database':
return '#b4e34a'; return '#38bdf8';
case 'worker': case 'worker':
return '#f2c94c'; return 'var(--warning)';
case 'cron': case 'cron':
return '#f2994a'; return 'var(--accent-secondary)';
default: default:
return '#9295a4'; return 'var(--text-tertiary)';
} }
} }
@@ -1,306 +1,198 @@
import { useEffect, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { import { useQueries } from '@tanstack/react-query';
LineMetricChart, import { Cpu, HardDrive, ArrowDownUp, Boxes } from 'lucide-react';
DualLineChart, import { LineMetricChart, DonutChart } from '@/shared/components';
DonutChart, import { getServiceMetrics, type ServiceMetrics } from '@/lib/api-client';
} from '@/shared/components'; import { formatBytes } from '@/lib/time';
interface MetricData {
cpu: number[];
ram: number;
ramUsed: string;
cache: number;
cacheBreakdown: { cache: number; nonCache: number; total: number };
users: number[];
performance: number[];
performanceAlt: number[];
upSpeed: number;
downSpeed: number;
}
interface MetricsDashboardProps { interface MetricsDashboardProps {
projectId?: string; services: { id: string; name: string; status: string }[];
isRunning?: boolean; isDemoMode?: boolean;
onStop?: () => void;
onRestart?: () => void;
} }
function getRandom(min: number, max: number) { function healthLabel(pct: number): { text: string; color: string } {
return Math.floor(Math.random() * (max - min + 1)) + min; if (pct < 50) return { text: 'Good', color: 'var(--success)' };
if (pct < 80) return { text: 'Average', color: 'var(--warning)' };
return { text: 'High', color: 'var(--error)' };
} }
function generateInitialData(): MetricData { function Tile({ icon: Icon, label, children }: {
return { icon: React.ElementType;
cpu: Array.from({ length: 24 }, () => getRandom(10, 45)), label: string;
ram: getRandom(50, 85), children: React.ReactNode;
ramUsed: '5.4 GB', }) {
cache: 352,
cacheBreakdown: { cache: 212, nonCache: 85.5, total: 1750 },
users: Array.from({ length: 15 }, () => getRandom(40, 110)),
performance: Array.from({ length: 12 }, () => getRandom(70, 95)),
performanceAlt: Array.from({ length: 12 }, () => getRandom(60, 85)),
upSpeed: 10.4,
downSpeed: 5.2,
};
}
export function MetricsDashboard({
isRunning = true,
}: MetricsDashboardProps) {
const [data, setData] = useState<MetricData>(generateInitialData);
const [timeRange, setTimeRange] = useState<'day' | 'month' | 'year'>('day');
useEffect(() => {
if (!isRunning) return;
const interval = setInterval(() => {
setData((prev) => {
const newCpu = [...prev.cpu.slice(1), getRandom(10, 45)];
const newUsers = [...prev.users.slice(1), getRandom(60, 110)];
const newPerf = [...prev.performance.slice(1), getRandom(82, 99)];
const newPerfAlt = [...prev.performanceAlt.slice(1), getRandom(60, 85)];
return {
...prev,
cpu: newCpu,
ram: getRandom(50, 85),
ramUsed: `${(8 * (prev.ram / 100)).toFixed(1)} GB`,
users: newUsers,
performance: newPerf,
performanceAlt: newPerfAlt,
upSpeed: parseFloat((Math.random() * 5 + 8).toFixed(1)),
downSpeed: parseFloat((Math.random() * 3 + 4).toFixed(1)),
};
});
}, 2000);
return () => clearInterval(interval);
}, [isRunning]);
const currentCpu = data.cpu[data.cpu.length - 1];
const currentUsers = data.users[data.users.length - 1];
const currentPerf = data.performance[data.performance.length - 1];
return ( return (
<div> <div className="s-card !p-0 overflow-hidden">
{/* Metrics Header - self.html exact match */} <div className="s-cardhead">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '16px' }}> <span className="s-ibox"><Icon /></span>
<span style={{ fontSize: '15px', fontWeight: 700, color: '#e8e9f0' }}>Metrics</span> <span className="s-t">{label}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<button style={{
height: '32px',
padding: '0 12px',
borderRadius: '9px',
border: '1px solid rgba(255,255,255,0.09)',
background: 'rgba(255,255,255,0.04)',
color: '#9295a4',
fontSize: '12.5px',
fontWeight: 500,
fontFamily: 'inherit',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '6px',
}}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" />
</svg>
Filter
</button>
<div className="pill-group">
{(['day', 'month', 'year'] as const).map((range) => (
<div
key={range}
className={`pill ${timeRange === range ? 'active' : ''}`}
onClick={() => setTimeRange(range)}
>
{range.charAt(0).toUpperCase() + range.slice(1)}
</div>
))}
</div>
</div>
</div>
{/* Row 1: CPU, RAM, Cache - self.html exact: 13px gap */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1.95fr', gap: '13px', marginBottom: '13px' }}>
{/* CPU Card */}
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '12px' }}>
<div className="card-icon"><svg viewBox="0 0 24 24"><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><line x1="9" y1="1" x2="9" y2="4"/><line x1="15" y1="1" x2="15" y2="4"/><line x1="9" y1="20" x2="9" y2="23"/><line x1="15" y1="20" x2="15" y2="23"/><line x1="20" y1="9" x2="23" y2="9"/><line x1="20" y1="14" x2="23" y2="14"/><line x1="1" y1="9" x2="4" y2="9"/><line x1="1" y1="14" x2="4" y2="14"/></svg></div>
<span style={{ fontSize: '14px', fontWeight: 600, color: '#e8e9f0' }}>CPU Usage</span>
</div>
<div style={{ fontSize: '38px', fontWeight: 900, letterSpacing: '-1.5px', lineHeight: 1, color: '#e8e9f0' }}>{currentCpu}%</div>
<div style={{ fontSize: '12px', color: '#6b6e7d', marginTop: '4px' }}>
<span style={{ color: currentCpu < 50 ? '#5ee6a0' : currentCpu < 75 ? '#f2c94c' : '#b4e34a', fontWeight: 700 }}>
{currentCpu < 50 ? 'Good' : currentCpu < 75 ? 'Average' : 'High'}
</span>{' '}
Daily usage
</div>
<div style={{ height: 76, margin: '12px 0 6px' }}>
<LineMetricChart data={data.cpu} color="#b4e34a" height={76} />
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingTop: '4px' }}>
<span style={{ fontSize: '13px', color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}>Details</span>
<div className="arrow-btn">
<svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg>
</div>
</div>
</div>
{/* RAM Card */}
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '12px' }}>
<div className="card-icon"><svg viewBox="0 0 24 24"><rect x="2" y="8" width="20" height="8" rx="2"/><path d="M6 8V6M10 8V6M14 8V6M18 8V6M6 16v2M18 16v2"/></svg></div>
<span style={{ fontSize: '14px', fontWeight: 600, color: '#e8e9f0' }}>RAM Usage</span>
</div>
<div style={{ fontSize: '38px', fontWeight: 900, letterSpacing: '-1.5px', lineHeight: 1, color: '#e8e9f0' }}>{data.ram}%</div>
<div style={{ fontSize: '12px', color: '#6b6e7d', marginTop: '4px' }}>
<span style={{ color: data.ram < 60 ? '#5ee6a0' : '#f2c94c', fontWeight: 700 }}>
{data.ram < 60 ? 'Good' : 'Average'}
</span>{' '}
Daily usage
</div>
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', margin: '10px 0 4px', position: 'relative' }}>
<DonutChart percent={data.ram} color="#f2c94c" size={160} thickness={16} />
<div style={{ position: 'absolute', bottom: 14, textAlign: 'center' }}>
<div style={{ fontSize: '10.5px', color: '#6b6e7d', marginBottom: 1 }}>Used</div>
<div style={{ fontSize: '12.5px', fontWeight: 700, color: '#e8e9f0' }}>{data.ramUsed} / 8GB</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingTop: '4px' }}>
<span style={{ fontSize: '13px', color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}>Details</span>
<div className="arrow-btn">
<svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg>
</div>
</div>
</div>
{/* Cache Card */}
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '12px' }}>
<div className="card-icon"><svg viewBox="0 0 24 24"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg></div>
<span style={{ fontSize: '14px', fontWeight: 600, color: '#e8e9f0' }}>Cache</span>
</div>
<div style={{ fontSize: '38px', fontWeight: 900, letterSpacing: '-1.5px', lineHeight: 1, color: '#e8e9f0' }}>{data.cache} MB</div>
<div style={{ fontSize: '12px', color: '#6b6e7d', marginTop: '4px' }}>
<span style={{ color: '#f2c94c', fontWeight: 700 }}>220MB Average</span>{' '}
cached images and files
</div>
{/* Segmented Bar */}
<div style={{ display: 'flex', alignItems: 'center', gap: '5px', margin: '16px 0 15px', height: '32px' }}>
<div className="cache-seg" style={{ width: '43%', background: '#ff6b5b', borderRadius: '10px 4px 4px 10px' }} />
<div className="cache-seg" style={{ width: '13%', background: '#8c6ef0', borderRadius: '5px' }} />
<div className="cache-seg" style={{ flex: 1, background: 'rgba(255,255,255,0.07)', borderRadius: '4px 10px 10px 4px' }} />
</div>
{/* Stats row */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto 1fr auto 1fr', gap: 0, alignItems: 'stretch' }}>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '5px', fontSize: '11px', color: '#6b6e7d', marginBottom: '5px' }}>
<div className="stat-dot" style={{ background: '#ff6b5b' }} />
Cache
</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: '4px' }}>
<span style={{ fontSize: '15px', fontWeight: 800, color: '#e8e9f0' }}>212 MB</span>
<span style={{ fontSize: '11px', color: '#6b6e7d' }}>12%</span>
</div>
</div>
<div style={{ width: '1px', background: 'rgba(255,255,255,0.08)', margin: '0 16px' }} />
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '5px', fontSize: '11px', color: '#6b6e7d', marginBottom: '5px' }}>
<div className="stat-dot" style={{ background: '#8c6ef0' }} />
Non-Cache
</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: '4px' }}>
<span style={{ fontSize: '15px', fontWeight: 800, color: '#e8e9f0' }}>85.5 MB</span>
<span style={{ fontSize: '11px', color: '#6b6e7d' }}>4%</span>
</div>
</div>
<div style={{ width: '1px', background: 'rgba(255,255,255,0.08)', margin: '0 16px' }} />
<div>
<div style={{ fontSize: '11px', color: '#6b6e7d', marginBottom: '5px' }}>Total</div>
<div style={{ fontSize: '15px', fontWeight: 800, color: '#e8e9f0' }}>1.75 GB</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingTop: '16px' }}>
<span style={{ fontSize: '13px', color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}>Details</span>
<div className="arrow-btn">
<svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg>
</div>
</div>
</div>
</div>
{/* Row 2: Active Users, Performance - self.html exact: 13px gap */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '13px' }}>
{/* Active Users - horizontal layout */}
<div className="card" style={{ flexDirection: 'row', padding: 0, overflow: 'hidden' }}>
<div style={{ flex: 1, padding: '20px 18px 18px 20px', display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '12px' }}>
<div className="card-icon"><svg viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/></svg></div>
<span style={{ fontSize: '14px', fontWeight: 600, color: '#e8e9f0' }}>Active User</span>
</div>
<div style={{ fontSize: '36px', fontWeight: 900, letterSpacing: '-1.5px', lineHeight: 1, color: '#e8e9f0' }}>{currentUsers} K</div>
<div style={{ fontSize: '12px', color: '#6b6e7d', marginTop: '4px' }}>User active right now</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '5px', marginTop: '12px', flexWrap: 'wrap' }}>
{['🇨🇳', '🇮🇩', '🇲🇲', '🇲🇾', '🇯🇵', '🇮🇳', '🇰🇷', '🇵🇭'].map((flag, i) => (
<span key={i} className="flag">{flag}</span>
))}
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: 'auto', paddingTop: '14px' }}>
<span style={{ fontSize: '13px', color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}>Details</span>
<div className="arrow-btn">
<svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg>
</div>
</div>
</div>
<div style={{ width: '50%', padding: '16px 14px 46px 0', display: 'flex', alignItems: 'flex-end' }}>
<LineMetricChart data={data.users} color="var(--accent-primary)" fillOpacity={0.15} showArea height={130} />
</div>
</div>
{/* Performance Card */}
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '12px' }}>
<div className="card-icon"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></div>
<span style={{ fontSize: '14px', fontWeight: 600, color: '#e8e9f0' }}>Performance</span>
</div>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '16px', flex: 1 }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '36px', fontWeight: 900, letterSpacing: '-1.5px', lineHeight: 1, color: '#e8e9f0' }}>{currentPerf}%</div>
<div style={{ fontSize: '12px', color: '#6b6e7d', marginTop: '4px' }}>
<span style={{ color: currentPerf > 85 ? '#5ee6a0' : '#f2c94c', fontWeight: 700 }}>
{currentPerf > 85 ? 'Good' : 'Average'}
</span>{' '}
Last scan
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: '8px' }}>
<div style={{ width: 134, height: 58 }}>
<DualLineChart data1={data.performance} data2={data.performanceAlt} height={58} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px', alignItems: 'flex-end' }}>
<div className="speed-row" style={{ color: '#7ab8ff' }}>
<svg viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 19 19 12"/></svg>
<span>{data.upSpeed}</span> Mbps
</div>
<div className="speed-row" style={{ color: 'var(--accent-primary)' }}>
<svg viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 5 5 12"/></svg>
<span>{data.downSpeed}</span> Mbps
</div>
</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingTop: '14px' }}>
<span style={{ fontSize: '13px', color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}>Check Speed</span>
<div className="arrow-btn">
<svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg>
</div>
</div>
</div>
</div> </div>
<div className="px-4 pb-4 pt-3">{children}</div>
</div>
);
}
export function MetricsDashboard({ services, isDemoMode = false }: MetricsDashboardProps) {
const liveServices = services.filter((s) => s.status === 'running');
const metricsQueries = useQueries({
queries: liveServices.map((service) => ({
queryKey: ['service-metrics', service.id],
queryFn: () => getServiceMetrics(service.id),
enabled: !isDemoMode,
refetchInterval: 5000,
retry: false,
})),
});
const samples = metricsQueries
.map((q) => q.data)
.filter((m): m is ServiceMetrics => Boolean(m) && m!.status === 'ok');
const totalCpu = samples.reduce((sum, m) => sum + m.cpu_percent, 0);
const avgCpu = samples.length > 0 ? totalCpu / samples.length : 0;
const memUsed = samples.reduce((sum, m) => sum + m.memory_usage_bytes, 0);
const memLimit = samples.reduce((sum, m) => sum + m.memory_limit_bytes, 0);
const memPct = memLimit > 0 ? (memUsed / memLimit) * 100 : 0;
const netRx = samples.reduce((sum, m) => sum + m.network_rx_bytes, 0);
const netTx = samples.reduce((sum, m) => sum + m.network_tx_bytes, 0);
const instanceCount = samples.reduce((sum, m) => sum + m.instances.length, 0);
// Rolling history for the sparklines — accumulated from real samples.
const [cpuHistory, setCpuHistory] = useState<number[]>([]);
const [netHistory, setNetHistory] = useState<{ rx: number[]; tx: number[] }>({ rx: [], tx: [] });
const lastNetRef = useRef<{ rx: number; tx: number; at: number } | null>(null);
const samplesKey = samples.map((s) => `${s.service_id}:${s.collected_at}`).join('|');
const avgCpuRef = avgCpu;
const netRxRef = netRx;
const netTxRef = netTx;
useEffect(() => {
if (!samplesKey) return;
const now = Date.now();
const prev = lastNetRef.current;
const elapsed = prev ? Math.max(1, (now - prev.at) / 1000) : 0;
const rxRate = prev && elapsed > 0 ? Math.max(0, (netRxRef - prev.rx) / elapsed) : 0;
const txRate = prev && elapsed > 0 ? Math.max(0, (netTxRef - prev.tx) / elapsed) : 0;
lastNetRef.current = { rx: netRxRef, tx: netTxRef, at: now };
setCpuHistory((h) => [...h, Math.min(100, avgCpuRef)].slice(-24));
setNetHistory((h) => ({ rx: [...h.rx, rxRate].slice(-24), tx: [...h.tx, txRate].slice(-24) }));
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the samples' collected_at signature
}, [samplesKey]);
const cpuHealth = healthLabel(avgCpu);
const memHealth = healthLabel(memPct);
const reporting = samples.length;
const noData = !isDemoMode && liveServices.length > 0 && reporting === 0;
return (
<div>
<div className="flex items-center justify-between mb-4">
<span className="v-mono text-[10.5px] uppercase tracking-[0.1em] text-[var(--text-tertiary)]">Metrics</span>
<span className="v-mono text-[10.5px] text-[var(--text-muted)]">
{isDemoMode
? 'sample data'
: reporting > 0
? `${reporting} service${reporting === 1 ? '' : 's'} reporting · every 5s`
: 'no running services'}
</span>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
{/* CPU */}
<Tile icon={Cpu} label="CPU Usage">
<div className="font-headline text-[30px] font-bold leading-none tracking-[-0.02em] text-[var(--text-primary)]">
{avgCpu.toFixed(1)}<span className="text-[13px] font-medium text-[var(--text-tertiary)]">%</span>
</div>
<div className="mt-1.5 v-mono text-[10.5px] text-[var(--text-tertiary)]">
<span style={{ color: cpuHealth.color }}>{cpuHealth.text}</span>
{' '}· avg across running services
</div>
<div style={{ height: 56, marginTop: 12 }}>
{cpuHistory.length > 1 ? (
<LineMetricChart data={cpuHistory} color="var(--accent-primary)" fillOpacity={0.15} showArea height={56} />
) : (
<div className="h-full rounded bg-[var(--surface-muted)] opacity-40" />
)}
</div>
</Tile>
{/* Memory */}
<Tile icon={HardDrive} label="Memory">
<div className="font-headline text-[30px] font-bold leading-none tracking-[-0.02em] text-[var(--text-primary)]">
{memPct.toFixed(0)}<span className="text-[13px] font-medium text-[var(--text-tertiary)]">%</span>
</div>
<div className="mt-1.5 v-mono text-[10.5px] text-[var(--text-tertiary)]">
<span style={{ color: memHealth.color }}>{memHealth.text}</span>
{' '}· {formatBytes(memUsed)} used
</div>
<div className="mt-2 flex justify-center">
<DonutChart percentage={memPct} size={100} thickness={11} />
</div>
<div className="mt-1 text-center v-mono text-[10.5px] text-[var(--text-tertiary)]">
{formatBytes(memUsed)} / {memLimit > 0 ? formatBytes(memLimit) : '∞'}
</div>
</Tile>
{/* Network */}
<Tile icon={ArrowDownUp} label="Network">
<div className="font-headline text-[30px] font-bold leading-none tracking-[-0.02em] text-[var(--text-primary)]">
{formatBytes(netRx)}
</div>
<div className="mt-1.5 v-mono text-[10.5px] text-[var(--text-tertiary)]">
<span className="text-[var(--accent-primary)]">RX</span>
{' '}total · TX {formatBytes(netTx)}
</div>
<div style={{ height: 56, marginTop: 12 }}>
{netHistory.rx.length > 1 ? (
<LineMetricChart data={netHistory.rx} color="var(--accent-primary)" fillOpacity={0.12} showArea height={56} />
) : (
<div className="h-full rounded bg-[var(--surface-muted)] opacity-40" />
)}
</div>
<div className="mt-2 v-mono text-[10.5px] text-[var(--text-muted)]">
{netHistory.rx.length > 0
? `↓ ${formatBytes(netHistory.rx[netHistory.rx.length - 1])}/s · ↑ ${formatBytes(netHistory.tx[netHistory.tx.length - 1])}/s`
: 'rate accumulating…'}
</div>
</Tile>
{/* Instances */}
<Tile icon={Boxes} label="Instances">
<div className="font-headline text-[30px] font-bold leading-none tracking-[-0.02em] text-[var(--text-primary)]">
{isDemoMode ? liveServices.length : instanceCount}
</div>
<div className="mt-1.5 v-mono text-[10.5px] text-[var(--text-tertiary)]">
<span className="text-[var(--success)]">Running</span>
{' '}across {liveServices.length} service{liveServices.length === 1 ? '' : 's'}
</div>
<ul className="mt-3 space-y-1.5">
{liveServices.slice(0, 4).map((service, i) => {
const m = samples.find((s) => s.service_id === service.id);
return (
<li key={service.id} className="flex items-center justify-between text-[11.5px]">
<span className="truncate text-[var(--text-secondary)]">
<span className="mr-1.5 inline-block h-1.5 w-1.5 rounded-full bg-[var(--success)] align-middle" />
{service.name}
</span>
<span className="v-mono text-[10.5px] text-[var(--text-muted)]">
{m ? `${m.cpu_percent.toFixed(1)}%` : i < 3 ? '—' : ''}
</span>
</li>
);
})}
{liveServices.length > 4 && (
<li className="v-mono text-[10.5px] text-[var(--text-muted)]">+{liveServices.length - 4} more</li>
)}
</ul>
</Tile>
</div>
{noData && (
<p className="mt-3 v-mono text-[11px] text-[var(--text-muted)]">
Metrics collector is not reporting yet — check that the node agent is online.
</p>
)}
</div> </div>
); );
} }
@@ -2,6 +2,7 @@ import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { listProjectVariables, updateProjectVariables } from '@/lib/api-client'; import { listProjectVariables, updateProjectVariables } from '@/lib/api-client';
import { Loader2, Plus, Save, Trash2, KeyRound } from 'lucide-react'; import { Loader2, Plus, Save, Trash2, KeyRound } from 'lucide-react';
import { QuietBtn } from '@/shared/components/sentry';
type Row = { key: string; value: string; isSecret: boolean }; type Row = { key: string; value: string; isSecret: boolean };
@@ -42,26 +43,23 @@ export function SharedVariablesSection({ projectId }: { projectId: string }) {
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]"> <div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<div> <div>
<h3 className="text-sm font-medium text-[var(--text-secondary)]">Shared Variables</h3> <h3 className="s-t">Shared Variables</h3>
<p className="mt-1 text-xs text-[var(--text-muted)]"> <p className="mt-1 text-xs text-[var(--text-muted)]">
Reference from any service as{' '} Reference from any service as{' '}
<code className="mono text-[var(--accent-primary)]">{'${{shared.KEY}}'}</code>. Applied on next <code className="v-mono text-[var(--accent-primary)]">{'${{shared.KEY}}'}</code>. Applied on next
deploy/redeploy — not live-updated. deploy/redeploy — not live-updated.
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <QuietBtn onClick={() => setDrafts([...rows, { key: '', value: '', isSecret: false }])}>
onClick={() => setDrafts([...rows, { key: '', value: '', isSecret: false }])}
className="flex items-center gap-2 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] transition-colors"
>
<Plus size={12} /> <Plus size={12} />
Add Add
</button> </QuietBtn>
<button <button
onClick={() => saveMutation.mutate(rows)} onClick={() => saveMutation.mutate(rows)}
disabled={!dirty || saveMutation.isPending} disabled={!dirty || saveMutation.isPending}
className="flex items-center gap-2 px-3 py-1.5 rounded-[var(--radius-md)] text-xs font-medium text-[var(--accent-on)] disabled:opacity-50 transition-all" className="s-btn-accent disabled:opacity-50"
style={{ background: 'var(--accent-primary)' }}
> >
{saveMutation.isPending ? <Loader2 size={12} className="animate-spin" /> : <Save size={12} />} {saveMutation.isPending ? <Loader2 size={12} className="animate-spin" /> : <Save size={12} />}
Save Save
@@ -78,12 +76,12 @@ export function SharedVariablesSection({ projectId }: { projectId: string }) {
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{rows.map((row, i) => ( {rows.map((row, i) => (
<div key={i} className="grid grid-cols-[1fr_1fr_auto_auto] gap-2 items-center"> <div key={i} className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto_auto] gap-2 items-center">
<input <input
value={row.key} value={row.key}
onChange={(e) => updateRow(i, { key: e.target.value })} onChange={(e) => updateRow(i, { key: e.target.value })}
placeholder="KEY" placeholder="KEY"
className="h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] mono text-xs text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all" className="h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] v-mono text-xs text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all"
/> />
<input <input
value={row.value} value={row.value}
@@ -91,13 +89,13 @@ export function SharedVariablesSection({ projectId }: { projectId: string }) {
type={row.isSecret ? 'password' : 'text'} type={row.isSecret ? 'password' : 'text'}
placeholder="value" placeholder="value"
autoComplete="off" autoComplete="off"
className="h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] mono text-xs text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all" className="h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] v-mono text-xs text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all"
/> />
<button <button
type="button" type="button"
title={row.isSecret ? 'Secret — masked on save' : 'Mark as secret'} title={row.isSecret ? 'Secret — masked on save' : 'Mark as secret'}
onClick={() => updateRow(i, { isSecret: !row.isSecret })} onClick={() => updateRow(i, { isSecret: !row.isSecret })}
className={`w-9 h-9 rounded-[var(--radius-md)] border flex items-center justify-center transition-colors ${ className={`w-9 h-9 rounded-lg border flex items-center justify-center transition-colors ${
row.isSecret row.isSecret
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]' ? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]'
: 'border-[var(--border-subtle)] text-[var(--text-muted)] hover:border-[var(--border-default)]' : 'border-[var(--border-subtle)] text-[var(--text-muted)] hover:border-[var(--border-default)]'
@@ -108,7 +106,7 @@ export function SharedVariablesSection({ projectId }: { projectId: string }) {
<button <button
type="button" type="button"
onClick={() => setDrafts(rows.filter((_, idx) => idx !== i))} onClick={() => setDrafts(rows.filter((_, idx) => idx !== i))}
className="w-9 h-9 rounded-[var(--radius-md)] border border-[var(--border-subtle)] flex items-center justify-center text-[var(--text-muted)] hover:border-[var(--error)] hover:text-[var(--error)] transition-colors" className="w-9 h-9 rounded-lg border border-[var(--border-subtle)] flex items-center justify-center text-[var(--text-muted)] hover:border-[var(--error)] hover:text-[var(--error)] transition-colors"
> >
<Trash2 size={12} /> <Trash2 size={12} />
</button> </button>
@@ -50,7 +50,9 @@ import {
GitBranch, GitBranch,
Container, Container,
FileUp, FileUp,
RefreshCw,
} from 'lucide-react'; } from 'lucide-react';
import { GhostBtn, QuietBtn } from '@/shared/components/sentry';
type WorkspaceView = 'canvas' | 'observability' | 'logs' | 'activity' | 'settings'; type WorkspaceView = 'canvas' | 'observability' | 'logs' | 'activity' | 'settings';
@@ -146,12 +148,10 @@ function ServiceCreateDialog(props: {
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-[var(--bg-void)]/80 backdrop-blur-sm" onClick={props.onClose} /> <div className="absolute inset-0 bg-[var(--bg-void)]/80 backdrop-blur-sm" onClick={props.onClose} />
<div className="relative w-full max-w-lg panel p-6"> <div className="relative w-full max-w-lg s-card !p-6">
<div className="flex items-center gap-3 mb-1"> <div className="flex items-center gap-2.5 mb-1">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center"> <span className="s-ibox !w-9 !h-9"><Plus /></span>
<Plus size={20} className="text-[var(--accent-primary)]" /> <h3 className="font-headline text-lg font-semibold text-[var(--text-primary)]">Add Service</h3>
</div>
<h3 className="text-xl font-semibold text-[var(--text-primary)]">Add Service</h3>
</div> </div>
<p className="text-sm text-[var(--text-secondary)]"> <p className="text-sm text-[var(--text-secondary)]">
Deploy a new service to this project. Configure runtime settings after creation. Deploy a new service to this project. Configure runtime settings after creation.
@@ -202,7 +202,7 @@ function ServiceCreateDialog(props: {
</div> </div>
{isDatabase ? ( {isDatabase ? (
<div className="space-y-3 p-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]/50"> <div className="s-inset space-y-3">
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2"> <label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
@@ -287,12 +287,12 @@ function ServiceCreateDialog(props: {
<input <input
value={form.image ?? ''} value={form.image ?? ''}
onChange={(e) => setForm((p) => ({ ...p, image: e.target.value }))} onChange={(e) => setForm((p) => ({ ...p, image: e.target.value }))}
className="w-full h-11 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all mono text-sm" className="w-full h-11 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all v-mono text-sm"
placeholder="ghcr.io/org/app:latest" placeholder="ghcr.io/org/app:latest"
/> />
</div> </div>
) : ( ) : (
<div className="space-y-3 p-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]/50"> <div className="s-inset space-y-3">
<div> <div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2"> <label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
Provider Provider
@@ -326,7 +326,7 @@ function ServiceCreateDialog(props: {
<input <input
value={repoSearch} value={repoSearch}
onChange={(e) => setRepoSearch(e.target.value)} onChange={(e) => setRepoSearch(e.target.value)}
className="w-full h-9 px-3 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm mono mb-2" className="w-full h-9 px-3 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm v-mono mb-2"
placeholder="Search repositories..." placeholder="Search repositories..."
/> />
{repositoriesQuery.isLoading ? ( {repositoriesQuery.isLoading ? (
@@ -357,7 +357,7 @@ function ServiceCreateDialog(props: {
: 'text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)]' : 'text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)]'
}`} }`}
> >
<span className="mono text-xs truncate">{repo.full_name}</span> <span className="v-mono text-xs truncate">{repo.full_name}</span>
<span className="text-[10px] text-[var(--text-muted)] ml-2 shrink-0">{repo.default_branch}</span> <span className="text-[10px] text-[var(--text-muted)] ml-2 shrink-0">{repo.default_branch}</span>
</button> </button>
))} ))}
@@ -380,7 +380,7 @@ function ServiceCreateDialog(props: {
<select <select
value={form.git_branch ?? ''} value={form.git_branch ?? ''}
onChange={(e) => setForm((p) => ({ ...p, git_branch: e.target.value }))} onChange={(e) => setForm((p) => ({ ...p, git_branch: e.target.value }))}
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm mono" className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm v-mono"
> >
<option value="">Select branch</option> <option value="">Select branch</option>
{branches.map((branch) => ( {branches.map((branch) => (
@@ -391,7 +391,7 @@ function ServiceCreateDialog(props: {
<input <input
value={form.git_branch ?? ''} value={form.git_branch ?? ''}
onChange={(e) => setForm((p) => ({ ...p, git_branch: e.target.value }))} onChange={(e) => setForm((p) => ({ ...p, git_branch: e.target.value }))}
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm mono" className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm v-mono"
placeholder={selectedRepo?.default_branch ?? 'main'} placeholder={selectedRepo?.default_branch ?? 'main'}
/> />
)} )}
@@ -403,7 +403,7 @@ function ServiceCreateDialog(props: {
<input <input
value={form.build_path ?? ''} value={form.build_path ?? ''}
onChange={(e) => setForm((p) => ({ ...p, build_path: e.target.value }))} onChange={(e) => setForm((p) => ({ ...p, build_path: e.target.value }))}
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm mono" className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm v-mono"
placeholder="/ (repo root)" placeholder="/ (repo root)"
/> />
</div> </div>
@@ -419,7 +419,7 @@ function ServiceCreateDialog(props: {
<input <input
value={form.command ?? ''} value={form.command ?? ''}
onChange={(e) => setForm((p) => ({ ...p, command: e.target.value }))} onChange={(e) => setForm((p) => ({ ...p, command: e.target.value }))}
className="w-full h-11 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all mono text-sm" className="w-full h-11 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all v-mono text-sm"
placeholder="npm run start" placeholder="npm run start"
/> />
</div> </div>
@@ -434,7 +434,7 @@ function ServiceCreateDialog(props: {
<div className="mt-6 flex justify-end gap-3"> <div className="mt-6 flex justify-end gap-3">
<button <button
onClick={props.onClose} onClick={props.onClose}
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-[var(--text-secondary)] text-sm font-medium hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors" className="s-btn-quiet"
> >
Cancel Cancel
</button> </button>
@@ -454,8 +454,8 @@ function ServiceCreateDialog(props: {
database: isDatabase ? { engine: dbEngine, plan: dbPlan } : undefined, database: isDatabase ? { engine: dbEngine, plan: dbPlan } : undefined,
}) })
} }
className="px-5 py-2 rounded-[var(--radius-md)] text-[var(--accent-on)] text-sm font-medium shadow-lg disabled:opacity-50 disabled:cursor-not-allowed transition-all" className="s-btn-accent disabled:opacity-50 disabled:cursor-not-allowed"
style={{ background: 'var(--accent-primary)' }}
> >
{props.loading ? 'Creating...' : isDatabase ? 'Create Database' : 'Create Service'} {props.loading ? 'Creating...' : isDatabase ? 'Create Database' : 'Create Service'}
</button> </button>
@@ -689,17 +689,17 @@ export function ProjectWorkspacePage() {
if ((!isDemoMode && projectQuery.isError) || !project) { if ((!isDemoMode && projectQuery.isError) || !project) {
return ( return (
<div className="min-h-screen flex items-center justify-center p-8"> <div className="min-h-screen flex items-center justify-center p-8">
<div className="panel p-8 text-center max-w-md"> <div className="s-card p-8 text-center max-w-md">
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-[var(--error-soft)] flex items-center justify-center"> <div className="w-12 h-12 mx-auto mb-4 rounded-full bg-[var(--error-soft)] flex items-center justify-center">
<X size={24} className="text-[var(--error)]" /> <X size={24} className="text-[var(--error)]" />
</div> </div>
<p className="text-lg font-medium text-[var(--text-primary)]">Project not found</p> <p className="font-headline text-lg font-medium text-[var(--text-primary)]">Project not found</p>
<p className="mt-2 text-sm text-[var(--text-secondary)]"> <p className="mt-2 text-sm text-[var(--text-secondary)]">
This project may have been deleted or you don't have access. This project may have been deleted or you don't have access.
</p> </p>
<button <button
onClick={() => navigate(isDemoMode ? '/projects?demo=1' : '/projects')} onClick={() => navigate(isDemoMode ? '/projects?demo=1' : '/projects')}
className="mt-6 px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm font-medium hover:border-[var(--border-default)] transition-colors" className="s-btn-quiet"
> >
Back to Projects Back to Projects
</button> </button>
@@ -724,13 +724,13 @@ export function ProjectWorkspacePage() {
<span className="hidden sm:inline">Projects</span> <span className="hidden sm:inline">Projects</span>
</button> </button>
<div className="w-px h-4 bg-[var(--border-subtle)] shrink-0" /> <div className="w-px h-4 bg-[var(--border-subtle)] shrink-0" />
<h1 className="v-title text-sm shrink-0 min-w-0 truncate">{project.name}<span className="v-cursor">_</span></h1> <h1 className="v-title font-headline text-sm shrink-0 min-w-0 truncate">{project.name}<span className="v-cursor">_</span></h1>
<span className="hidden lg:flex items-center gap-1.5 text-[11px] text-[var(--text-tertiary)] shrink-0"> <span className="hidden lg:flex items-center gap-1.5 text-[11px] text-[var(--text-tertiary)] shrink-0">
{services.length} service{services.length !== 1 ? 's' : ''} · {runningServices} running {services.length} service{services.length !== 1 ? 's' : ''} · {runningServices} running
</span> </span>
{/* View tabs */} {/* View tabs */}
<nav className="flex items-center gap-0.5 rounded-full bg-[var(--surface-muted)] border border-[var(--border-subtle)] p-0.5 mx-2 overflow-x-auto min-w-0"> <nav className="flex items-center gap-0.5 mx-2 overflow-x-auto min-w-0 self-stretch [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{viewItems.filter((item) => signedIn || !item.authOnly).map((item) => { {viewItems.filter((item) => signedIn || !item.authOnly).map((item) => {
const active = activeView === item.key; const active = activeView === item.key;
const Icon = item.icon; const Icon = item.icon;
@@ -742,11 +742,7 @@ export function ProjectWorkspacePage() {
next.set('view', item.key); next.set('view', item.key);
return next; return next;
})} })}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-all whitespace-nowrap ${ className={`tab ${active ? 'active' : ''} !py-0 h-full !text-[11.5px]`}
active
? 'bg-[var(--surface-card)] text-[var(--text-primary)] shadow-sm'
: 'text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]'
}`}
> >
<Icon size={13} /> <Icon size={13} />
<span className="hidden md:inline">{item.label}</span> <span className="hidden md:inline">{item.label}</span>
@@ -765,29 +761,21 @@ export function ProjectWorkspacePage() {
</span> </span>
<button <button
onClick={() => setCommandPaletteOpen(true)} onClick={() => setCommandPaletteOpen(true)}
className="hidden md:flex items-center gap-2 h-8 px-2.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-muted)] text-xs hover:border-[var(--border-default)] hover:text-[var(--text-secondary)] transition-colors" className="s-chip hidden lg:flex"
> >
<Search size={12} /> <Search size={12} />
<kbd className="px-1 py-0.5 rounded bg-[var(--surface-card)] text-[10px] font-mono">⌘K</kbd> <kbd className="px-1 py-px rounded border border-[var(--border-subtle)] bg-[var(--bg-surface)] text-[10px] font-mono">⌘K</kbd>
</button> </button>
{!isDemoMode && signedIn && ( {!isDemoMode && signedIn && (
<> <>
<button <QuietBtn onClick={() => setImportOpen(true)} title="Deploy a docker-compose file into this project">
onClick={() => setImportOpen(true)} <FileUp size={13} />
className="flex items-center gap-1.5 h-8 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] hover:text-[var(--text-primary)] transition-colors"
title="Deploy a docker-compose file into this project"
>
<FileUp size={14} />
<span className="hidden sm:inline">Compose</span> <span className="hidden sm:inline">Compose</span>
</button> </QuietBtn>
<button <GhostBtn onClick={openAddService}>
onClick={openAddService} <Plus size={13} />
className="flex items-center gap-1.5 h-8 px-3 rounded-[var(--radius-md)] text-[var(--accent-on)] text-xs font-semibold shadow-lg hover:shadow-xl transition-all"
style={{ background: 'var(--accent-primary)' }}
>
<Plus size={14} />
<span className="hidden sm:inline">Add Service</span> <span className="hidden sm:inline">Add Service</span>
</button> </GhostBtn>
</> </>
)} )}
</div> </div>
@@ -818,67 +806,48 @@ export function ProjectWorkspacePage() {
<div className="flex-1 min-h-0 overflow-y-auto"> <div className="flex-1 min-h-0 overflow-y-auto">
{isDemoMode && ( {isDemoMode && (
<div className="w-full px-8 pt-6"> <div className="w-full px-8 pt-6">
<div className="px-4 py-3 rounded-[var(--radius-md)] border border-[var(--warning-soft)] bg-[var(--warning-soft)]/50"> <div className="s-inset flex items-center gap-2 text-xs text-[var(--warning)]">
<div className="flex items-center gap-2 text-sm text-[var(--warning)]"> <Sparkles size={13} />
<Sparkles size={16} /> Demo mode active — using sample data for preview
<span>Demo mode active - using sample data for preview</span>
</div>
</div> </div>
</div> </div>
)} )}
<div className="w-full px-8 py-6"> <div className="w-full px-4 py-6 sm:px-8">
<section className="min-w-0"> <section className="min-w-0">
{activeView === 'observability' && ( {activeView === 'observability' && (
<div className="space-y-6"> <div className="space-y-6">
{/* Health Summary */} {/* Health Summary */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-3.5">
<div className="panel-soft p-4"> {([
<div className="flex items-center gap-2"> { label: 'Healthy', color: 'var(--success)', value: runningServices, pulse: true },
<div className="w-2 h-2 rounded-full bg-[var(--success)] live-pulse" /> { label: 'Failed', color: 'var(--error)', value: services.filter((s) => s.status === 'failed').length, pulse: false },
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Healthy</p> { label: 'Building', color: 'var(--warning)', value: services.filter((s) => s.status === 'building').length, pulse: true },
]).map((s) => (
<div key={s.label} className="s-card">
<div className="s-cardhead !py-2.5">
<span className={`w-1.5 h-1.5 rounded-full ${s.pulse ? 'animate-pulse' : ''}`} style={{ background: s.color }} />
<span className="text-[11px] text-[var(--text-tertiary)]">{s.label}</span>
</div> </div>
<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>
<div className="panel-soft p-4">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-[var(--error)]" />
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Failed</p>
</div>
<p className="mt-2 text-3xl font-semibold text-[var(--error)]">
{services.filter((s) => s.status === 'failed').length}
</p>
</div>
<div className="panel-soft p-4">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-[var(--warning)] animate-pulse" />
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Building</p>
</div>
<p className="mt-2 text-3xl font-semibold text-[var(--warning)]">
{services.filter((s) => s.status === 'building').length}
</p>
</div> </div>
))}
</div> </div>
{/* Metrics Dashboard */} {/* Metrics Dashboard */}
<MetricsDashboard <MetricsDashboard services={visibleServices} isDemoMode={isDemoMode} />
isRunning={runningServices > 0}
onStop={() => toast.showToast('Services stopped', 'warning')}
onRestart={() => toast.showToast('Services restarted', 'success')}
/>
{/* Service List */} {/* Service List */}
<div className="panel p-6"> <div className="s-card">
<div className="flex items-center justify-between mb-3"> <div className="s-cardhead">
<h3 className="text-sm font-medium text-[var(--text-secondary)]">Services</h3> <span className="s-ibox"><Layers /></span>
<span className="s-t">Services</span>
{!isDemoMode && environments.length > 0 && ( {!isDemoMode && environments.length > 0 && (
<div className="flex items-center gap-1.5 flex-wrap"> <div className="s-trail flex-wrap">
<button <button
onClick={() => setEnvFilter(null)} onClick={() => setEnvFilter(null)}
className={`px-2.5 py-1 rounded-full text-xs font-medium transition-colors ${ className={`s-chip !py-1 !px-2.5 ${envFilter === null ? '!text-[var(--accent-primary)] !border-[var(--accent-primary)]/40' : ''}`}
envFilter === null
? 'bg-[var(--accent-primary)] text-white'
: 'bg-[var(--surface-muted)] text-[var(--text-secondary)] hover:bg-[var(--surface-card)]'
}`}
> >
All All
</button> </button>
@@ -886,11 +855,7 @@ export function ProjectWorkspacePage() {
<button <button
key={env.id} key={env.id}
onClick={() => setEnvFilter(envFilter === env.name ? null : env.name)} onClick={() => setEnvFilter(envFilter === env.name ? null : env.name)}
className={`px-2.5 py-1 rounded-full text-xs font-medium transition-colors ${ className={`s-chip !py-1 !px-2.5 ${envFilter === env.name ? '!text-[var(--accent-primary)] !border-[var(--accent-primary)]/40' : ''}`}
envFilter === env.name
? 'bg-[var(--accent-primary)] text-white'
: 'bg-[var(--surface-muted)] text-[var(--text-secondary)] hover:bg-[var(--surface-card)]'
}`}
> >
{env.name} ({env.service_count}) {env.name} ({env.service_count})
</button> </button>
@@ -899,6 +864,7 @@ export function ProjectWorkspacePage() {
</div> </div>
)} )}
</div> </div>
<div className="px-4 pb-4">
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] overflow-hidden divide-y divide-[var(--border-subtle)]"> <div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] overflow-hidden divide-y divide-[var(--border-subtle)]">
{visibleServices.length === 0 ? ( {visibleServices.length === 0 ? (
<div className="p-8 text-center"> <div className="p-8 text-center">
@@ -918,9 +884,7 @@ export function ProjectWorkspacePage() {
className="w-full flex items-center justify-between px-4 py-3 hover:bg-[var(--surface-muted)]/50 transition-colors text-left" className="w-full flex items-center justify-between px-4 py-3 hover:bg-[var(--surface-muted)]/50 transition-colors text-left"
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-[var(--radius-sm)] bg-[var(--surface-card)] border border-[var(--border-subtle)] flex items-center justify-center"> <span className="s-ibox"><Box /></span>
<Box size={14} className="text-[var(--text-tertiary)]" />
</div>
<div> <div>
<p className="text-sm font-medium text-[var(--text-primary)]">{service.name}</p> <p className="text-sm font-medium text-[var(--text-primary)]">{service.name}</p>
<p className="text-xs text-[var(--text-tertiary)]">{service.type}</p> <p className="text-xs text-[var(--text-tertiary)]">{service.type}</p>
@@ -933,31 +897,29 @@ export function ProjectWorkspacePage() {
</div> </div>
</div> </div>
</div> </div>
</div>
)} )}
{activeView === 'logs' && ( {activeView === 'logs' && (
<div className="panel p-6"> <div className="s-card">
<div className="flex items-center justify-between mb-4"> <div className="s-cardhead">
<div className="flex items-center gap-3"> <span className="s-ibox"><FileText /></span>
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<FileText size={20} className="text-[var(--accent-primary)]" />
</div>
<div> <div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Logs</h2> <div className="s-t">Logs</div>
<p className="text-sm text-[var(--text-secondary)]">Aggregated service output</p> <div className="mt-0.5 text-[11px] text-[var(--text-tertiary)]">Aggregated service output</div>
</div>
</div> </div>
{!isDemoMode && ( {!isDemoMode && (
<button <div className="s-trail">
onClick={() => workspaceLogsQuery.refetch()} <QuietBtn onClick={() => workspaceLogsQuery.refetch()}>
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors" <RefreshCw size={13} />
>
Refresh Refresh
</button> </QuietBtn>
</div>
)} )}
</div> </div>
<div className="px-4 pb-4">
<div className="mono rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-void)] p-4 text-xs text-[var(--text-secondary)] overflow-x-auto max-h-[600px] overflow-y-auto"> <div className="v-mono rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-void)] p-4 text-xs text-[var(--text-secondary)] overflow-x-auto max-h-[600px] overflow-y-auto">
{services.length === 0 ? ( {services.length === 0 ? (
<p className="text-[var(--text-muted)]">No services available.</p> <p className="text-[var(--text-muted)]">No services available.</p>
) : isDemoMode ? ( ) : isDemoMode ? (
@@ -999,19 +961,19 @@ export function ProjectWorkspacePage() {
)} )}
</div> </div>
</div> </div>
</div>
)} )}
{activeView === 'activity' && ( {activeView === 'activity' && (
<div className="panel p-6"> <div className="s-card">
<div className="flex items-center gap-3 mb-4"> <div className="s-cardhead">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center"> <span className="s-ibox"><Clock /></span>
<Clock size={20} className="text-[var(--accent-primary)]" />
</div>
<div> <div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Activity</h2> <div className="s-t">Activity</div>
<p className="text-sm text-[var(--text-secondary)]">Project timeline — deploys, config, membership</p> <div className="mt-0.5 text-[11px] text-[var(--text-tertiary)]">Project timeline — deploys, config, membership</div>
</div> </div>
</div> </div>
<div className="px-4 pb-4">
<div className="divide-y divide-[var(--border-subtle)] border border-[var(--border-subtle)] rounded-[var(--radius-md)]"> <div className="divide-y divide-[var(--border-subtle)] border border-[var(--border-subtle)] rounded-[var(--radius-md)]">
{activityQuery.isLoading ? ( {activityQuery.isLoading ? (
<p className="px-4 py-6 text-xs text-[var(--text-muted)]">Loading…</p> <p className="px-4 py-6 text-xs text-[var(--text-muted)]">Loading…</p>
@@ -1035,39 +997,39 @@ export function ProjectWorkspacePage() {
)} )}
</div> </div>
</div> </div>
</div>
)} )}
{activeView === 'settings' && ( {activeView === 'settings' && (
<div className="panel p-6"> <div className="s-card">
<div className="flex items-center gap-3 mb-6"> <div className="s-cardhead">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center"> <span className="s-ibox"><Settings /></span>
<Settings size={20} className="text-[var(--accent-primary)]" />
</div>
<div> <div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Settings</h2> <div className="s-t">Settings</div>
<p className="text-sm text-[var(--text-secondary)]">Project configuration</p> <div className="mt-0.5 text-[11px] text-[var(--text-tertiary)]">Project configuration</div>
</div> </div>
</div> </div>
<div className="px-4 pb-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="panel-soft p-4"> <div className="s-inset">
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Project ID</p> <p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Project ID</p>
<p className="mono mt-2 text-sm text-[var(--text-primary)] break-all">{project.id}</p> <p className="v-mono mt-2 text-sm text-[var(--text-primary)] break-all">{project.id}</p>
</div> </div>
<div className="panel-soft p-4"> <div className="s-inset">
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Created</p> <p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Created</p>
<p className="mt-2 text-sm text-[var(--text-primary)]">{formatDate(project.createdAt)}</p> <p className="mt-2 text-sm text-[var(--text-primary)]">{formatDate(project.createdAt)}</p>
</div> </div>
<div className="panel-soft p-4 md:col-span-2"> <div className="s-inset md:col-span-2">
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Canvas Storage Key</p> <p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Canvas Storage Key</p>
<p className="mono mt-2 text-xs text-[var(--text-secondary)] break-all">{canvasStorageKey(project.id)}</p> <p className="v-mono mt-2 text-xs text-[var(--text-secondary)] break-all">{canvasStorageKey(project.id)}</p>
</div> </div>
</div> </div>
{!isDemoMode && signedIn && <SharedVariablesSection projectId={project.id} />} {!isDemoMode && signedIn && <SharedVariablesSection projectId={project.id} />}
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]"> <div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-3">Local Data</h3> <h3 className="s-t mb-3">Local Data</h3>
<button <button
onClick={() => { onClick={() => {
clearCanvasMetadata(project.id); clearCanvasMetadata(project.id);
@@ -1080,6 +1042,7 @@ export function ProjectWorkspacePage() {
</button> </button>
</div> </div>
</div> </div>
</div>
)} )}
</section> </section>
</div> </div>
@@ -1105,12 +1068,10 @@ export function ProjectWorkspacePage() {
{importOpen && ( {importOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-[var(--bg-void)]/80 backdrop-blur-sm" onClick={() => setImportOpen(false)} /> <div className="absolute inset-0 bg-[var(--bg-void)]/80 backdrop-blur-sm" onClick={() => setImportOpen(false)} />
<div className="relative w-full max-w-2xl panel p-6"> <div className="relative w-full max-w-2xl s-card !p-6">
<div className="flex items-center gap-3 mb-1"> <div className="flex items-center gap-2.5 mb-1">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center"> <span className="s-ibox !w-9 !h-9"><FileUp /></span>
<FileUp size={20} className="text-[var(--accent-primary)]" /> <h3 className="font-headline text-lg font-semibold text-[var(--text-primary)]">Import docker-compose</h3>
</div>
<h3 className="text-xl font-semibold text-[var(--text-primary)]">Import docker-compose</h3>
</div> </div>
<p className="text-sm text-[var(--text-secondary)]"> <p className="text-sm text-[var(--text-secondary)]">
Paste a docker-compose.yml. Services deploy in dependency order; postgres, mysql, redis, mongo, Paste a docker-compose.yml. Services deploy in dependency order; postgres, mysql, redis, mongo,
@@ -1120,7 +1081,7 @@ export function ProjectWorkspacePage() {
value={composeYaml} value={composeYaml}
onChange={(e) => setComposeYaml(e.target.value)} onChange={(e) => setComposeYaml(e.target.value)}
spellCheck={false} spellCheck={false}
className="mt-5 w-full h-72 px-4 py-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all mono text-sm resize-none" className="mt-5 w-full h-72 px-4 py-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all v-mono text-sm resize-none"
placeholder={'services:\n web:\n image: nginx:alpine\n ports:\n - "80"\n db:\n image: postgres:16-alpine\n environment:\n POSTGRES_PASSWORD: secret'} placeholder={'services:\n web:\n image: nginx:alpine\n ports:\n - "80"\n db:\n image: postgres:16-alpine\n environment:\n POSTGRES_PASSWORD: secret'}
/> />
<div className="mt-5 flex justify-end gap-2"> <div className="mt-5 flex justify-end gap-2">
@@ -1133,8 +1094,8 @@ export function ProjectWorkspacePage() {
<button <button
onClick={() => composeImportMutation.mutate(composeYaml)} onClick={() => composeImportMutation.mutate(composeYaml)}
disabled={composeImportMutation.isPending || composeYaml.trim().length === 0} disabled={composeImportMutation.isPending || composeYaml.trim().length === 0}
className="h-10 px-5 rounded-[var(--radius-md)] text-sm font-semibold text-[var(--accent-on)] disabled:opacity-50 transition-all flex items-center gap-2" className="s-btn-accent h-10 disabled:opacity-50"
style={{ background: 'var(--accent-primary)' }}
> >
{composeImportMutation.isPending && <Loader2 size={14} className="animate-spin" />} {composeImportMutation.isPending && <Loader2 size={14} className="animate-spin" />}
Deploy stack Deploy stack
@@ -1163,6 +1124,9 @@ export function ProjectWorkspacePage() {
return next; return next;
}); });
}} }}
navTargets={viewItems
.filter((item) => signedIn || !item.authOnly)
.map((item) => ({ label: `Go to ${item.label}`, target: item.key }))}
/> />
{/* Error Banner */} {/* Error Banner */}
+715 -194
View File
File diff suppressed because it is too large. Load diff
+12 -3
View File
@@ -492,11 +492,12 @@ export function getApiBaseUrl(): string {
// pingServer hits the public /health route — any HTTP response proves // pingServer hits the public /health route — any HTTP response proves
// reachability, and no auth means no 401 noise in the connectivity probe. // reachability, and no auth means no 401 noise in the connectivity probe.
export async function pingServer(): Promise<void> { export async function pingServer(): Promise<boolean> {
const res = await fetch(`${API_ROOT}/health`); const res = await fetch(`${API_ROOT}/health`);
if (!res.ok) { if (!res.ok) {
throw new Error(`Server returned ${res.status}`); throw new Error(`Server returned ${res.status}`);
} }
return true;
} }
export type SetupStatus = { export type SetupStatus = {
@@ -1238,9 +1239,17 @@ export type AgentContainer = {
created_at?: string; created_at?: string;
}; };
type RawAgentContainer = Omit<AgentContainer, 'status'> & {
status?: string | { state?: string; health?: string };
};
export async function listAgentContainers(id: string): Promise<AgentContainer[]> { export async function listAgentContainers(id: string): Promise<AgentContainer[]> {
const payload = await requestJson<{ containers?: AgentContainer[] }>(`/agents/${id}/containers`); const payload = await requestJson<{ containers?: RawAgentContainer[] }>(`/agents/${id}/containers`);
return payload.containers ?? []; // The wire sends status as a Docker state object — flatten to its state string.
return (payload.containers ?? []).map((c) => ({
...c,
status: typeof c.status === 'string' ? c.status : c.status?.state || '—',
}));
} }
export type AgentCommand = { export type AgentCommand = {
+5
View File
@@ -13,6 +13,11 @@ export function formatRelative(iso?: string): string {
const hour = 60 * minute; const hour = 60 * minute;
const day = 24 * hour; const day = 24 * hour;
// Future-dated or year-plus-old timestamps are effectively meaningless —
// surface them as absent rather than "739898d ago".
if (diffMs < 0 || diffMs > 365 * day) {
return 'n/a';
}
if (diffMs < minute) { if (diffMs < minute) {
return 'just now'; return 'just now';
} }
+1
View File
@@ -23,6 +23,7 @@ const queryClient = new QueryClient({
// High-churn or bulky keys never hit localStorage — a stale log tail is // High-churn or bulky keys never hit localStorage — a stale log tail is
// worse than none. // worse than none.
const NEVER_PERSIST = new Set([ const NEVER_PERSIST = new Set([
'auth-session',
'shell-notifications', 'shell-notifications',
'notifications', 'notifications',
'service-logs', 'service-logs',
+2 -2
View File
@@ -77,12 +77,12 @@ export function CardHeader({ title, description, icon, action, className = '', .
<div className={`flex items-start justify-between gap-4 ${className}`} {...props}> <div className={`flex items-start justify-between gap-4 ${className}`} {...props}>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
{icon && ( {icon && (
<div className="w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 ring-1 ring-white/5" style={{ background: 'rgba(255,255,255,0.07)' }}> <div className="w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 ring-1 ring-[var(--tint-05)]" style={{ background: 'var(--tint-07)' }}>
{icon} {icon}
</div> </div>
)} )}
<div> <div>
<h3 className="text-lg font-semibold text-[var(--text-primary)] tracking-tight">{title}</h3> <h3 className="font-headline text-base font-semibold text-[var(--text-primary)] tracking-tight">{title}</h3>
{description && ( {description && (
<p className="text-sm text-[var(--text-secondary)] mt-0.5">{description}</p> <p className="text-sm text-[var(--text-secondary)] mt-0.5">{description}</p>
)} )}
@@ -68,7 +68,7 @@ function gradientId(prefix: string, color: string, index = 0) {
export function LineChart({ export function LineChart({
data, data,
color = '#b4e34a', color = '#38bdf8',
fillColor = 'transparent', fillColor = 'transparent',
height = 76, height = 76,
showDots = true, showDots = true,
@@ -181,7 +181,7 @@ export function MultiLineChart({
export function LineMetricChart({ export function LineMetricChart({
data, data,
color = '#b4e34a', color = '#38bdf8',
fillOpacity = 0.15, fillOpacity = 0.15,
height = 76, height = 76,
showArea = false, showArea = false,
@@ -204,8 +204,8 @@ export function DualLineChart({
data1, data1,
data2, data2,
height = 58, height = 58,
color1 = '#7ab8ff', color1 = 'var(--accent-primary)',
color2 = '#f2c94c', color2 = 'var(--warning)',
fillOpacity1 = 0.15, fillOpacity1 = 0.15,
fillOpacity2 = 0.15, fillOpacity2 = 0.15,
}: DualLineChartProps) { }: DualLineChartProps) {
Loaded 100 of 111 files, more files were not shown because too many files have changed in this diff. Show more