Compare commits

...
Author SHA1 Message Date
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
69 changed files with 4727 additions and 1196 deletions

No files matched your search

+3
View File
@@ -124,3 +124,6 @@ ref/
.playwright-mcp/
canvas-*.png
.vercel
# Local design previews (not shipped)
design/
+14 -4
View File
@@ -1,6 +1,6 @@
# Containr — Roadmap 2: The dflow Convergence
Last verified: **2026-10-08** — deep pass over the full dflow codebase
Last verified: **2026-10-10** — deep pass over the full dflow codebase
(`~/Desktop/PROG+HTML/dflow`, community fork of dflow-sh/dflow, frozen for
preservation) against Containr `main` (`15b5b0e`).
@@ -337,8 +337,9 @@ and wired refs, one click or one CLI call.
and local + tags are rejected, empty array clears. Surfaces:
`--placement-tags` on services create/update, `--tags` on
`nodes update`, MCP args, OpenAPI, node-detail tag editor +
service-detail placement-tags input. Remaining: build-on-node,
remote sleep/wake, spread weighting by real capacity.
service-detail placement-tags input. Build-on-node and remote
sleep/wake both shipped (see below). Remaining: spread weighting
by real capacity.
- [x] **Agent self-upgrade** — `POST /agents/:id/upgrade` enqueues a
`self_upgrade` command carrying platform + sha256 + server version;
the agent downloads its replacement from `GET /agents/download/
@@ -537,9 +538,18 @@ and wired refs, one click or one CLI call.
workspace, CLI `environments` + MCP parity. Per-env vars/domains ride the
service's own scope (one env per service); promote ships via preview
promote.
- [ ] **sqlc port** — ~265 raw `database/sql` sites → `sqlc/queries/`. Done in tranches (dynamic queries stay raw). Tranches merged: metrics storage + service domains (#75), service clone/move ops (#76), preview environments (#77), deployments + rollback (#78). Remaining: runtime, compliance, cron, git, security, auth, sleeper, scanner, gateway, and smaller files.
- [x] **sqlc port** — all static `database/sql` call sites → `sqlc/queries/` + generated `sqlcdb`. Merged: metrics + domains (#75), service clone/move (#76), preview environments (#77), deployments + rollback (#78), runtime ops (#79), cron (#80), sleep/wake sweeper (#81), git providers/repos/webhooks (#82), auth + security (#83), operations/settings/audit/activity/builds/misc + apwhy gateway (#84). Raw SQL remains only for runtime-assembled statements (filtered lists, sparse updates) and Better Auth `auth_users` probes.
- [ ] **Tests** — canvas/wizard frontend coverage, deployment/template/
webhook table tests, Playwright e2e golden path.
- [x] **UI/UX audit** — browser-driven pass over the whole app (PR #85):
fixed the stuck connectivity banner (`pingServer` returned
`undefined`), stale persisted sessions, fabricated observability
metrics (now real `getServiceMetrics` aggregation), mobile overflow,
Security blank state, deep-linkable service tabs, and a dozen
polish items.
- [x] **Design previews** — gitignored `/design` holds three on-brand
HTML concepts (console, service detail, landing) on the product's
token system with a live white-label accent switcher.
- [x] **First-run wizard** — public `GET /setup/status` + authed
`POST /setup/complete`; `/setup` page steps admin account → GitHub App
manifest flow (callback returns to the wizard via a session marker) →
+21 -30
View File
@@ -12,6 +12,7 @@ import (
"time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
@@ -180,13 +181,13 @@ func handleGetProjectActivity(c *gin.Context) {
}
// Access check: approved / owner / member / admin.
var visible bool
if err := db.QueryRow(
`SELECT p.is_approved OR p.owner_id = $2::uuid OR $3::bool
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $2::uuid)
FROM projects p WHERE p.id = $1`,
projectID, userID, contextIsAdmin(c),
).Scan(&visible); err != nil || !visible {
userUUID, _ := uuid.Parse(userID)
visible, err := sqlcdb.New(db.DB).CheckSecurityProjectAccess(context.Background(), sqlcdb.CheckSecurityProjectAccessParams{
ID: uuid.MustParse(projectID),
OwnerID: userUUID,
Column3: contextIsAdmin(c),
})
if err != nil || !visible {
respondError(c, http.StatusNotFound, "NOT_FOUND", "project not found")
return
}
@@ -237,12 +238,12 @@ func StartAuditRetention(ctx context.Context, db *database.DB) {
}
prune := func() {
res, err := db.Exec(`DELETE FROM audit_logs WHERE created_at < NOW() - ($1::int * INTERVAL '1 day')`, days)
n, err := sqlcdb.New(db.DB).PruneAuditLogs(context.Background(), int32(days))
if err != nil {
log.Printf("audit retention prune failed: %v", err)
return
}
if n, _ := res.RowsAffected(); n > 0 {
if n > 0 {
log.Printf("audit retention: pruned %d rows older than %d days", n, days)
}
}
@@ -265,34 +266,24 @@ func StartAuditRetention(ctx context.Context, db *database.DB) {
// backfillAuditMetadata classifies rows written before severity/category/label
// existed so filters and the feed see uniform metadata.
func backfillAuditMetadata(db *database.DB) {
cursor := uuid.Nil.String()
q := sqlcdb.New(db.DB)
cursor := uuid.Nil
for {
rows, err := db.Query(
`SELECT id::text, resource, action FROM audit_logs
WHERE label = '' AND id > $1::uuid ORDER BY id LIMIT 500`, cursor)
rows, err := q.ListAuditBackfillBatch(context.Background(), cursor)
if err != nil {
log.Printf("audit backfill scan failed: %v", err)
return
}
type row struct{ id, resource, action string }
var batch []row
for rows.Next() {
var r row
if rows.Scan(&r.id, &r.resource, &r.action) == nil {
batch = append(batch, r)
}
}
rows.Close()
if len(batch) == 0 {
if len(rows) == 0 {
return
}
for _, r := range batch {
cursor = r.id
sev, cat, label := classifyAuditEvent(r.resource, r.action)
if _, err := db.Exec(
`UPDATE audit_logs SET severity = $1, category = $2, label = $3 WHERE id = $4::uuid`,
sev, cat, label, r.id,
); err != nil {
for _, r := range rows {
id, _ := uuid.Parse(r.ID)
cursor = id
sev, cat, label := classifyAuditEvent(r.Resource, r.Action)
if err := q.SetAuditLogMetadata(context.Background(), sqlcdb.SetAuditLogMetadataParams{
Severity: sev, Category: cat, Label: label, Column4: id,
}); err != nil {
log.Printf("audit backfill update failed: %v", err)
}
}
+23 -34
View File
@@ -52,15 +52,11 @@ func EnsureAdminAccount(ctx context.Context, db *database.DB, cfg *config.Config
return
}
var userID string
err = db.QueryRowContext(ctx, `
INSERT INTO users (email, password_hash, name, is_admin)
VALUES ($1, $2, $3, true)
ON CONFLICT (email) DO UPDATE
SET is_admin = true, updated_at = NOW()
RETURNING id
`, email, string(hashed), name).Scan(&userID)
if err != nil {
if _, err := sqlcdb.New(db.DB).UpsertAdminUser(ctx, sqlcdb.UpsertAdminUserParams{
Email: email,
PasswordHash: string(hashed),
Name: name,
}); err != nil {
log.Printf("Admin bootstrap failed to upsert user %s: %v", email, err)
return
}
@@ -185,24 +181,18 @@ func handleAdminOverview(c *gin.Context) {
func handleAdminListUsers(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
rows, err := db.QueryContext(c.Request.Context(), `
SELECT id, email, name, COALESCE(avatar_url, ''), is_admin, COALESCE(to_char(created_at, 'YYYY-MM-DD"T"HH24:MI:SS"Z"'), '')
FROM users
ORDER BY created_at ASC
`)
rows, err := sqlcdb.New(db.DB).ListAdminUsers(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list users"})
return
}
defer rows.Close()
users := make([]adminUserRow, 0)
for rows.Next() {
var u adminUserRow
if err := rows.Scan(&u.ID, &u.Email, &u.Name, &u.AvatarURL, &u.IsAdmin, &u.CreatedAt); err != nil {
continue
}
users = append(users, u)
users := make([]adminUserRow, 0, len(rows))
for _, r := range rows {
users = append(users, adminUserRow{
ID: r.ID.String(), Email: r.Email, Name: r.Name,
AvatarURL: r.AvatarUrl, IsAdmin: r.IsAdmin, CreatedAt: r.CreatedAt,
})
}
c.JSON(http.StatusOK, gin.H{"users": users})
}
@@ -229,15 +219,16 @@ func handleAdminSetUserAdmin(c *gin.Context) {
return
}
result, err := db.ExecContext(c.Request.Context(),
`UPDATE users SET is_admin = $1, updated_at = NOW() WHERE id = $2`,
req.IsAdmin, targetID,
)
targetUUID, _ := uuid.Parse(targetID)
n, err := sqlcdb.New(db.DB).SetUserAdmin(c.Request.Context(), sqlcdb.SetUserAdminParams{
IsAdmin: req.IsAdmin,
ID: targetUUID,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user"})
return
}
if n, _ := result.RowsAffected(); n == 0 {
if n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
@@ -258,15 +249,13 @@ func handleAdminImpersonateUser(c *gin.Context) {
return
}
var email, name string
var isAdmin bool
err := db.QueryRowContext(c.Request.Context(),
`SELECT email, COALESCE(name, ''), is_admin FROM users WHERE id = $1`, targetID).
Scan(&email, &name, &isAdmin)
userUUID, _ := uuid.Parse(targetID)
info, err := sqlcdb.New(db.DB).GetAdminUserInfo(c.Request.Context(), userUUID)
if err != nil {
respondError(c, http.StatusNotFound, "NOT_FOUND", "user not found")
return
}
email, name, isAdmin := info.Email, info.Name, info.IsAdmin
if isAdmin {
respondError(c, http.StatusForbidden, "FORBIDDEN", "admin accounts cannot be impersonated")
return
@@ -286,9 +275,9 @@ func handleAdminImpersonateUser(c *gin.Context) {
}
LogAuditWithRequest(c, "user", targetID, "impersonate", map[string]interface{}{
"actor_id": actorID,
"actor_id": actorID,
"target_email": email,
"expires_at": expiresAt.Format(time.RFC3339),
"expires_at": expiresAt.Format(time.RFC3339),
})
c.JSON(http.StatusOK, gin.H{
"token": token,
+121 -189
View File
@@ -2,6 +2,7 @@ package api
import (
"containr/internal/database"
"containr/internal/database/sqlcdb"
"context"
"crypto/rand"
"database/sql"
@@ -14,6 +15,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
"github.com/google/uuid"
"github.com/lib/pq"
"golang.org/x/crypto/bcrypt"
)
@@ -160,21 +162,22 @@ func handleAPwhyServiceValidate(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
ctx := c.Request.Context()
var upstreamURL, healthPath string
var authHeader, authValue sql.NullString
var timeoutMs sql.NullInt64
err := db.QueryRowContext(ctx, `
SELECT upstream_url, health_path, upstream_auth_header, upstream_auth_value, request_timeout_ms
FROM api_services WHERE id = $1
`, serviceID).Scan(&upstreamURL, &healthPath, &authHeader, &authValue, &timeoutMs)
if err != nil {
svcUUID, parseErr := uuid.Parse(serviceID)
var svc sqlcdb.GetAPServiceForValidateRow
var err error
if parseErr == nil {
svc, err = sqlcdb.New(db.DB).GetAPServiceForValidate(ctx, svcUUID)
}
if parseErr != nil || err != nil {
sendErrorResponse(c, http.StatusNotFound, "SERVICE_NOT_FOUND", "Service not found", nil)
return
}
upstreamURL, healthPath := svc.UpstreamUrl, svc.HealthPath
authHeader, authValue := svc.UpstreamAuthHeader, svc.UpstreamAuthValue
timeout := 8000
if timeoutMs.Valid && timeoutMs.Int64 > 0 {
timeout = int(timeoutMs.Int64)
if svc.RequestTimeoutMs.Valid && svc.RequestTimeoutMs.Int32 > 0 {
timeout = int(svc.RequestTimeoutMs.Int32)
}
target := strings.TrimRight(upstreamURL, "/") + "/" + strings.TrimLeft(healthPath, "/")
@@ -203,17 +206,19 @@ func handleAPwhyServiceValidate(c *gin.Context) {
validationStatus = "healthy"
}
_, _ = db.ExecContext(ctx, `
UPDATE api_services
SET last_validation_at = NOW(), last_validation_status = $1, last_validation_message = $2, updated_at = NOW()
WHERE id = $3
`, validationStatus, message, serviceID)
q := sqlcdb.New(db.DB)
_ = q.SetAPServiceValidation(ctx, sqlcdb.SetAPServiceValidationParams{
LastValidationStatus: sql.NullString{String: validationStatus, Valid: true},
LastValidationMessage: sql.NullString{String: message, Valid: true},
ID: svcUUID,
})
if !ok {
_, _ = db.ExecContext(ctx, `
INSERT INTO incident_events (service_id, code, message, severity, http_status, occurred_at)
VALUES ($1, 'SERVICE_VALIDATION_FAILED', $2, 'medium', $3, NOW())
`, serviceID, message, status)
_ = q.InsertServiceIncident(ctx, sqlcdb.InsertServiceIncidentParams{
ServiceID: uuid.NullUUID{UUID: svcUUID, Valid: true},
Message: message,
HttpStatus: sql.NullInt32{Int32: int32(status), Valid: true},
})
}
sendSuccessResponse(c, http.StatusOK, map[string]interface{}{
@@ -231,36 +236,24 @@ func handleAPwhyServicesList(c *gin.Context) {
ctx := context.Background()
// Query services from database
rows, err := db.QueryContext(ctx, `
SELECT id, name, slug, upstream_url, route_prefix, enabled, created_at, updated_at
FROM api_services
ORDER BY created_at DESC
`)
rows, err := sqlcdb.New(db.DB).ListAPServices(ctx)
if err != nil {
sendErrorResponse(c, http.StatusInternalServerError, "DATABASE_ERROR",
"Failed to query services", err.Error())
return
}
defer rows.Close()
services := make([]map[string]interface{}, 0)
for rows.Next() {
var id, name, slug, upstreamURL, routePrefix, createdAt, updatedAt string
var enabled int
err := rows.Scan(&id, &name, &slug, &upstreamURL, &routePrefix, &enabled, &createdAt, &updatedAt)
if err != nil {
continue // Skip malformed rows
}
services := make([]map[string]interface{}, 0, len(rows))
for _, r := range rows {
services = append(services, map[string]interface{}{
"id": id,
"name": name,
"slug": slug,
"upstreamUrl": upstreamURL,
"routePrefix": routePrefix,
"enabled": enabled != 0,
"createdAt": createdAt,
"updatedAt": updatedAt,
"id": r.ID.String(),
"name": r.Name,
"slug": r.Slug,
"upstreamUrl": r.UpstreamUrl,
"routePrefix": r.RoutePrefix,
"enabled": r.Enabled != 0,
"createdAt": r.CreatedAt,
"updatedAt": r.UpdatedAt,
})
}
@@ -299,14 +292,14 @@ func handleAPwhyServicesCreate(c *gin.Context) {
}
// id defaults to gen_random_uuid(); enabled defaults to 1.
var id string
err := db.QueryRowContext(ctx, `
INSERT INTO api_services (
name, slug, upstream_url, route_prefix, health_path,
rpm_limit, monthly_quota, created_at, updated_at
) VALUES ($1, $2, $3, $4, '/health', $5, $6, NOW(), NOW())
RETURNING id
`, req.Name, slug, req.UpstreamURL, req.RoutePrefix, rpmLimit, monthlyQuota).Scan(&id)
svcID, err := sqlcdb.New(db.DB).InsertAPService(ctx, sqlcdb.InsertAPServiceParams{
Name: req.Name,
Slug: slug,
UpstreamUrl: req.UpstreamURL,
RoutePrefix: req.RoutePrefix,
RpmLimit: sql.NullInt32{Int32: int32(rpmLimit), Valid: true},
MonthlyQuota: sql.NullInt32{Int32: int32(monthlyQuota), Valid: true},
})
if err != nil {
if isUniqueViolation(err) {
@@ -320,7 +313,7 @@ func handleAPwhyServicesCreate(c *gin.Context) {
}
serviceData := map[string]interface{}{
"id": id,
"id": svcID.String(),
"name": req.Name,
"slug": slug,
"upstreamUrl": req.UpstreamURL,
@@ -356,13 +349,15 @@ func handleAPwhyServicesPatch(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
if input.Enabled != nil {
enabled := 0
enabled := int32(0)
if *input.Enabled {
enabled = 1
}
_, err := db.ExecContext(context.Background(),
"UPDATE api_services SET enabled = $1, updated_at = NOW() WHERE id = $2",
enabled, serviceID)
svcUUID, _ := uuid.Parse(serviceID)
err := sqlcdb.New(db.DB).SetAPServiceEnabled(context.Background(), sqlcdb.SetAPServiceEnabledParams{
Enabled: enabled,
ID: svcUUID,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
@@ -383,11 +378,7 @@ func handleAPwhyServicesPatch(c *gin.Context) {
func handleAPwhyKeysList(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
rows, err := db.QueryContext(context.Background(), `
SELECT id, name, key_prefix, plan, enabled, rpm_limit, monthly_quota, created_at, updated_at
FROM api_keys
ORDER BY created_at DESC
`)
rows, err := sqlcdb.New(db.DB).ListAPKeys(context.Background())
if err != nil {
c.JSON(http.StatusOK, gin.H{
"ok": true,
@@ -395,34 +386,24 @@ func handleAPwhyKeysList(c *gin.Context) {
})
return
}
defer rows.Close()
keys := make([]map[string]interface{}, 0)
for rows.Next() {
var id, name, keyPrefix, plan, createdAt, updatedAt string
var enabled int
var rpmLimit, monthlyQuota sql.NullInt64
err := rows.Scan(&id, &name, &keyPrefix, &plan, &enabled, &rpmLimit, &monthlyQuota, &createdAt, &updatedAt)
if err != nil {
continue
}
keys := make([]map[string]interface{}, 0, len(rows))
for _, r := range rows {
key := map[string]interface{}{
"id": id,
"name": name,
"keyPrefix": keyPrefix,
"plan": plan,
"enabled": enabled != 0,
"createdAt": createdAt,
"updatedAt": updatedAt,
"id": r.ID.String(),
"name": r.Name,
"keyPrefix": r.KeyPrefix,
"plan": r.Plan,
"enabled": r.Enabled != 0,
"createdAt": r.CreatedAt,
"updatedAt": r.UpdatedAt,
}
if rpmLimit.Valid {
key["rpmLimit"] = rpmLimit.Int64
if r.RpmLimit.Valid {
key["rpmLimit"] = int64(r.RpmLimit.Int32)
}
if monthlyQuota.Valid {
key["monthlyQuota"] = monthlyQuota.Int64
if r.MonthlyQuota.Valid {
key["monthlyQuota"] = int64(r.MonthlyQuota.Int32)
}
keys = append(keys, key)
@@ -481,14 +462,14 @@ func handleAPwhyKeysCreate(c *gin.Context) {
}
// id defaults to gen_random_uuid(); enabled defaults to 1.
var id string
err = db.QueryRowContext(context.Background(), `
INSERT INTO api_keys (
name, key_hash, key_prefix, plan, allowed_service_ids,
rpm_limit, monthly_quota, created_at, updated_at
) VALUES ($1, $2, $3, $4, '[]', $5, $6, NOW(), NOW())
RETURNING id
`, input.Name, keyHash, keyPrefix, plan, rpmLimit, monthlyQuota).Scan(&id)
keyID, err := sqlcdb.New(db.DB).InsertAPKey(context.Background(), sqlcdb.InsertAPKeyParams{
Name: input.Name,
KeyHash: keyHash,
KeyPrefix: keyPrefix,
Plan: plan,
RpmLimit: sql.NullInt32{Int32: int32(rpmLimit), Valid: true},
MonthlyQuota: sql.NullInt32{Int32: int32(monthlyQuota), Valid: true},
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
@@ -501,7 +482,7 @@ func handleAPwhyKeysCreate(c *gin.Context) {
c.JSON(http.StatusCreated, gin.H{
"ok": true,
"data": gin.H{
"id": id,
"id": keyID.String(),
"name": input.Name,
"plan": plan,
"key": apiKey, // Only return the actual key once
@@ -532,13 +513,15 @@ func handleAPwhyKeysPatch(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
if input.Enabled != nil {
enabled := 0
enabled := int32(0)
if *input.Enabled {
enabled = 1
}
_, err := db.ExecContext(context.Background(),
"UPDATE api_keys SET enabled = $1, updated_at = NOW() WHERE id = $2",
enabled, keyID)
keyUUID, _ := uuid.Parse(keyID)
err := sqlcdb.New(db.DB).SetAPKeyEnabled(context.Background(), sqlcdb.SetAPKeyEnabledParams{
Enabled: enabled,
ID: keyUUID,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
@@ -560,23 +543,27 @@ func handleAPwhyAnalyticsOps(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
// Get counts from database
var totalServices, totalKeys, totalUsers int
q := sqlcdb.New(db.DB)
ctx := context.Background()
if err := db.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM api_services").Scan(&totalServices); err != nil {
totalServices, err := q.CountAPServices(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"ok": false,
"error": "Failed to count services: " + err.Error(),
})
return
}
if err := db.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM api_keys").Scan(&totalKeys); err != nil {
totalKeys, err := q.CountAPKeys(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"ok": false,
"error": "Failed to count api keys: " + err.Error(),
})
return
}
if err := db.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM users").Scan(&totalUsers); err != nil {
totalUsers, err := q.CountUsers(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"ok": false,
"error": "Failed to count users: " + err.Error(),
@@ -584,8 +571,8 @@ func handleAPwhyAnalyticsOps(c *gin.Context) {
return
}
var totalRequests int
if err := db.QueryRowContext(context.Background(), "SELECT COALESCE(SUM(request_count), 0) FROM usage_counters").Scan(&totalRequests); err != nil {
totalRequests, err := q.SumUsageCounters(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"ok": false,
"error": "Failed to aggregate request counters: " + err.Error(),
@@ -593,12 +580,8 @@ func handleAPwhyAnalyticsOps(c *gin.Context) {
return
}
var requestsToday int
if err := db.QueryRowContext(context.Background(), `
SELECT COALESCE(SUM(value), 0)::int
FROM metrics_timeseries
WHERE metric = 'request_total' AND occurred_at >= DATE_TRUNC('day', NOW())
`).Scan(&requestsToday); err != nil {
requestsToday, err := q.SumRequestMetricSince(ctx, "day")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"ok": false,
"error": "Failed to aggregate today's requests: " + err.Error(),
@@ -606,12 +589,8 @@ func handleAPwhyAnalyticsOps(c *gin.Context) {
return
}
var requestsThisMonth int
if err := db.QueryRowContext(context.Background(), `
SELECT COALESCE(SUM(value), 0)::int
FROM metrics_timeseries
WHERE metric = 'request_total' AND occurred_at >= DATE_TRUNC('month', NOW())
`).Scan(&requestsThisMonth); err != nil {
requestsThisMonth, err := q.SumRequestMetricSince(ctx, "month")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"ok": false,
"error": "Failed to aggregate monthly requests: " + err.Error(),
@@ -636,94 +615,47 @@ func handleAPwhyAnalyticsOps(c *gin.Context) {
func handleAPwhyAnalyticsTraffic(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
q := sqlcdb.New(db.DB)
ctx := context.Background()
topServices := make([]map[string]interface{}, 0)
serviceRows, err := db.QueryContext(context.Background(), `
SELECT s.id, s.name, COALESCE(SUM(u.request_count), 0) AS total_requests
FROM api_services s
LEFT JOIN usage_counters u ON u.service_id = s.id
GROUP BY s.id, s.name
ORDER BY total_requests DESC, s.name ASC
LIMIT 10
`)
if err == nil {
defer serviceRows.Close()
for serviceRows.Next() {
var serviceID, serviceName string
var totalRequests int
if scanErr := serviceRows.Scan(&serviceID, &serviceName, &totalRequests); scanErr == nil {
topServices = append(topServices, map[string]interface{}{
"service_id": serviceID,
"name": serviceName,
"requests": totalRequests,
})
}
if serviceRows, err := q.ListTopAPServices(ctx); err == nil {
for _, r := range serviceRows {
topServices = append(topServices, map[string]interface{}{
"service_id": r.ID.String(),
"name": r.Name,
"requests": r.TotalRequests,
})
}
}
requestsByDay := make([]map[string]interface{}, 0)
trafficRows, err := db.QueryContext(context.Background(), `
SELECT TO_CHAR(DATE_TRUNC('day', occurred_at), 'YYYY-MM-DD') AS day_bucket, COUNT(*) AS total
FROM metrics_timeseries
WHERE metric = 'request_total' AND occurred_at >= NOW() - INTERVAL '7 days'
GROUP BY DATE_TRUNC('day', occurred_at)
ORDER BY DATE_TRUNC('day', occurred_at) ASC
`)
if err == nil {
defer trafficRows.Close()
for trafficRows.Next() {
var day string
var count int
if scanErr := trafficRows.Scan(&day, &count); scanErr == nil {
requestsByDay = append(requestsByDay, map[string]interface{}{
"day": day,
"requests": count,
})
}
if trafficRows, err := q.ListRequestsByDay(ctx); err == nil {
for _, r := range trafficRows {
requestsByDay = append(requestsByDay, map[string]interface{}{
"day": r.DayBucket,
"requests": r.Total,
})
}
}
statusCodes := make([]map[string]interface{}, 0)
statusRows, err := db.QueryContext(context.Background(), `
SELECT COALESCE(http_status, 0) AS status_code, COALESCE(SUM(count), 0) AS total
FROM incident_events
WHERE occurred_at >= NOW() - INTERVAL '7 days'
GROUP BY COALESCE(http_status, 0)
ORDER BY total DESC, status_code ASC
`)
if err == nil {
defer statusRows.Close()
for statusRows.Next() {
var code int
var total int
if scanErr := statusRows.Scan(&code, &total); scanErr == nil {
statusCodes = append(statusCodes, map[string]interface{}{
"status_code": code,
"count": total,
})
}
if statusRows, err := q.ListIncidentStatusCodes(ctx); err == nil {
for _, r := range statusRows {
statusCodes = append(statusCodes, map[string]interface{}{
"status_code": r.StatusCode,
"count": r.Total,
})
}
}
clientEvents := make([]map[string]interface{}, 0)
eventRows, err := db.QueryContext(context.Background(), `
SELECT COALESCE((labels_json::jsonb ->> 'path'), 'unknown') AS path, COUNT(*) AS total
FROM metrics_timeseries
WHERE metric = 'client_event' AND occurred_at >= NOW() - INTERVAL '7 days'
GROUP BY COALESCE((labels_json::jsonb ->> 'path'), 'unknown')
ORDER BY total DESC, path ASC
LIMIT 20
`)
if err == nil {
defer eventRows.Close()
for eventRows.Next() {
var path string
var total int
if scanErr := eventRows.Scan(&path, &total); scanErr == nil {
clientEvents = append(clientEvents, map[string]interface{}{
"path": path,
"count": total,
})
}
if eventRows, err := q.ListClientEventPaths(ctx); err == nil {
for _, r := range eventRows {
clientEvents = append(clientEvents, map[string]interface{}{
"path": r.Path,
"count": r.Total,
})
}
}
+54 -34
View File
@@ -2,6 +2,9 @@ package api
import (
"containr/internal/database"
"containr/internal/database/sqlcdb"
"context"
"database/sql"
"encoding/json"
"fmt"
"net/http"
@@ -11,6 +14,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/sqlc-dev/pqtype"
)
type AuditLog struct {
@@ -98,16 +102,20 @@ func LogAudit(userID, resource, resourceID, action string, details map[string]in
}
detailsJSON, _ := json.Marshal(details)
resourceUUID := parseUUIDOrNil(resourceID)
userUUID := parseUUIDOrNil(userID)
severity, category, label := classifyAuditEvent(resource, action)
auditID := uuid.New().String()
_, err := db.Exec(
`INSERT INTO audit_logs (id, user_id, resource, resource_id, action, details, severity, category, label, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
auditID, userUUID, resource, resourceUUID, action, string(detailsJSON), severity, category, label, time.Now().UTC(),
)
err := sqlcdb.New(db.DB).InsertAuditLog(context.Background(), sqlcdb.InsertAuditLogParams{
ID: uuid.New(),
UserID: nullUUIDOrNil(userID),
Resource: resource,
ResourceID: nullUUIDOrNil(resourceID),
Action: action,
Details: pqtype.NullRawMessage{RawMessage: detailsJSON, Valid: true},
Severity: severity,
Category: category,
Label: label,
CreatedAt: time.Now().UTC(),
})
if err != nil {
}
@@ -131,16 +139,24 @@ func LogAuditWithRequest(c *gin.Context, resource, resourceID, action string, de
if uid, ok := userID.(string); ok {
userIDStr = uid
}
userUUID := parseUUIDOrNil(userIDStr)
resourceUUID := parseUUIDOrNil(resourceID)
severity, category, label := classifyAuditEvent(resource, action)
auditID := uuid.New().String()
_, err := db.Exec(
`INSERT INTO audit_logs (id, user_id, resource, resource_id, action, details, ip_address, user_agent, severity, category, label, created_at)
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9, $10, $11, $12)`,
auditID, userUUID, resource, resourceUUID, action, string(detailsJSON), c.ClientIP(), c.GetHeader("User-Agent"), severity, category, label, time.Now().UTC(),
)
var inet pqtype.Inet
_ = inet.Scan(c.ClientIP())
err := sqlcdb.New(db.DB).InsertAuditLogWithRequest(context.Background(), sqlcdb.InsertAuditLogWithRequestParams{
ID: uuid.New(),
UserID: nullUUIDOrNil(userIDStr),
Resource: resource,
ResourceID: nullUUIDOrNil(resourceID),
Action: action,
Column6: json.RawMessage(detailsJSON),
Column7: inet,
UserAgent: sql.NullString{String: c.GetHeader("User-Agent"), Valid: true},
Severity: severity,
Category: category,
Label: label,
CreatedAt: time.Now().UTC(),
})
if err != nil {
}
@@ -255,30 +271,29 @@ func handleGetResourceAuditLogs(c *gin.Context) {
resource := c.Param("resource")
resourceID := c.Param("id")
rows, err := db.Query(
`SELECT id, COALESCE(user_id::text, ''), resource, COALESCE(resource_id::text, ''), action, COALESCE(details::text, '{}'),
COALESCE(ip_address::text, ''), COALESCE(user_agent, ''), created_at
FROM audit_logs
WHERE user_id::text = $1 AND resource = $2 AND resource_id::text = $3
ORDER BY created_at DESC
LIMIT 100`,
userID, resource, resourceID,
)
rows, err := sqlcdb.New(db.DB).ListResourceAuditLogs(context.Background(), sqlcdb.ListResourceAuditLogsParams{
UserID: nullUUIDOrNil(userID),
Resource: resource,
ResourceID: nullUUIDOrNil(resourceID),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch audit logs"})
return
}
defer rows.Close()
var logs []AuditLog
for rows.Next() {
var log AuditLog
err := rows.Scan(&log.ID, &log.UserID, &log.Resource, &log.ResourceID, &log.Action, &log.Details, &log.IPAddress, &log.UserAgent, &log.CreatedAt)
if err != nil {
continue
}
logs = append(logs, log)
for _, row := range rows {
logs = append(logs, AuditLog{
ID: row.ID.String(),
UserID: row.UserID,
Resource: row.Resource,
ResourceID: row.ResourceID,
Action: row.Action,
Details: row.Details,
IPAddress: row.IpAddress,
UserAgent: row.UserAgent,
CreatedAt: row.CreatedAt,
})
}
c.JSON(http.StatusOK, gin.H{"audit_logs": logs})
@@ -302,3 +317,8 @@ func parseUUIDOrNil(raw string) interface{} {
}
return trimmed
}
func nullUUIDOrNil(raw string) uuid.NullUUID {
parsed, err := uuid.Parse(strings.TrimSpace(raw))
return uuid.NullUUID{UUID: parsed, Valid: err == nil}
}
+78 -46
View File
@@ -3,6 +3,8 @@ package api
import (
"bytes"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"context"
"database/sql"
"encoding/json"
"errors"
@@ -63,11 +65,16 @@ func handleLogin(c *gin.Context) {
// Find user by email
var user User
var hashedPassword string
err := db.QueryRow(`
SELECT id, email, password_hash, name, COALESCE(avatar_url, ''), is_admin, created_at
FROM users
WHERE email = $1
`, req.Email).Scan(&user.ID, &user.Email, &hashedPassword, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt)
urow, err := sqlcdb.New(db.DB).GetUserByEmailForAuth(context.Background(), req.Email)
user = User{
ID: urow.ID.String(),
Email: urow.Email,
Name: urow.Name,
AvatarURL: urow.AvatarUrl,
IsAdmin: urow.IsAdmin,
CreatedAt: urow.CreatedAt.Time.String(),
}
hashedPassword = urow.PasswordHash
if err == sql.ErrNoRows {
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.
var total int
if err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&total); err != nil {
q := sqlcdb.New(db.DB)
total64, err := q.CountUsers(context.Background())
if err != nil {
return User{}, err
}
var user User
err = db.QueryRow(`
INSERT INTO users (email, password_hash, name, avatar_url, is_admin)
VALUES ($1, $2, $3, NULLIF($4, ''), $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, ''), 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)
urow, err := q.UpsertLocalUser(context.Background(), sqlcdb.UpsertLocalUserParams{
Email: email,
PasswordHash: string(hashedPassword),
Name: name,
Column4: strings.TrimSpace(authUser.Image),
IsAdmin: total64 == 0,
})
if err != nil {
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
}
@@ -302,8 +314,8 @@ func handleRegister(c *gin.Context) {
return
}
var existing int
err = db.QueryRow("SELECT COUNT(*) FROM users WHERE email = $1", strings.ToLower(strings.TrimSpace(req.Email))).Scan(&existing)
q := sqlcdb.New(db.DB)
existing, err := q.CountUsersByEmail(context.Background(), strings.ToLower(strings.TrimSpace(req.Email)))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
@@ -322,12 +334,20 @@ func handleRegister(c *gin.Context) {
}
// First account owns the platform.
var user User
err = db.QueryRow(`
INSERT INTO users (email, password_hash, name, is_admin)
VALUES ($1, $2, $3, $4)
RETURNING id, email, name, COALESCE(avatar_url, ''), is_admin, created_at
`, req.Email, string(hashedPassword), req.Name, total == 0).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt)
urow, err := q.InsertUserAdmin(context.Background(), sqlcdb.InsertUserAdminParams{
Email: req.Email,
PasswordHash: string(hashedPassword),
Name: req.Name,
IsAdmin: total == 0,
})
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 {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
@@ -365,8 +385,8 @@ func handleCreateUser(c *gin.Context) {
return
}
var existing int
if err := db.QueryRow("SELECT COUNT(*) FROM users WHERE email = $1", req.Email).Scan(&existing); err != nil {
existing, err := sqlcdb.New(db.DB).CountUsersByEmail(context.Background(), req.Email)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
@@ -390,12 +410,19 @@ func handleCreateUser(c *gin.Context) {
return
}
var user User
err = db.QueryRow(`
INSERT INTO users (email, password_hash, name)
VALUES ($1, $2, $3)
RETURNING id, email, name, COALESCE(avatar_url, ''), is_admin, created_at
`, req.Email, string(hashedPassword), req.Name).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt)
urow, err := sqlcdb.New(db.DB).InsertUser(context.Background(), sqlcdb.InsertUserParams{
Email: req.Email,
PasswordHash: string(hashedPassword),
Name: req.Name,
})
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 {
c.JSON(http.StatusConflict, gin.H{"error": "User already exists"})
return
@@ -458,10 +485,11 @@ func createBetterAuthUser(c *gin.Context, name string, email string, password st
}
func countLocalUsers(db *database.DB) (int, error) {
var count int
if err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count); err != nil {
count64, err := sqlcdb.New(db.DB).CountUsers(context.Background())
if err != nil {
return 0, err
}
count := int(count64)
// 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
// 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)
var user User
err := db.QueryRow(`
SELECT id, email, name, COALESCE(avatar_url, ''), is_admin, created_at
FROM users
WHERE id = $1
`, userID).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt)
urow, err := sqlcdb.New(db.DB).GetUserByID(context.Background(), uuid.MustParse(userID))
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 == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
@@ -521,11 +553,11 @@ func handleUpdateProfile(c *gin.Context) {
}
// Update user profile
_, err := db.Exec(`
UPDATE users
SET name = COALESCE($1, name), avatar_url = COALESCE($2, avatar_url)
WHERE id = $3
`, req.Name, req.AvatarURL, userID)
err := sqlcdb.New(db.DB).UpdateUserProfile(context.Background(), sqlcdb.UpdateUserProfileParams{
Column1: req.Name,
Column2: req.AvatarURL,
ID: uuid.MustParse(userID),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update profile"})
+55 -65
View File
@@ -16,6 +16,7 @@ import (
"containr/internal/build"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/docker"
"containr/internal/types"
@@ -669,39 +670,22 @@ func (h *BuildHandler) upsertBuild(status *BuildStatusResponse) error {
}
now := time.Now().UTC()
_, err := h.db.Exec(
`INSERT INTO builds
(id, project_id, service_id, status, progress, started_at, completed_at, image_name, image_tag, size, error, log, metadata, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb, $14, $14)
ON CONFLICT (id) DO UPDATE SET
project_id = EXCLUDED.project_id,
service_id = EXCLUDED.service_id,
status = EXCLUDED.status,
progress = EXCLUDED.progress,
started_at = EXCLUDED.started_at,
completed_at = EXCLUDED.completed_at,
image_name = EXCLUDED.image_name,
image_tag = EXCLUDED.image_tag,
size = EXCLUDED.size,
error = EXCLUDED.error,
log = EXCLUDED.log,
metadata = EXCLUDED.metadata,
updated_at = EXCLUDED.updated_at`,
status.ID,
nullIfEmptyString(status.ProjectID),
nullIfEmptyString(status.ServiceID),
status.Status,
status.Progress,
status.StartedAt,
status.CompletedAt,
status.ImageName,
status.ImageTag,
status.Size,
nullIfEmptyString(status.Error),
status.Log,
jsonOrEmptyObject(metadataRaw),
now,
)
err := sqlcdb.New(h.db.DB).UpsertBuild(context.Background(), sqlcdb.UpsertBuildParams{
ID: status.ID,
ProjectID: nsNullIfEmpty(status.ProjectID),
ServiceID: nsNullIfEmpty(status.ServiceID),
Status: status.Status,
Progress: int32(status.Progress),
StartedAt: status.StartedAt,
CompletedAt: ntPtr(status.CompletedAt),
ImageName: status.ImageName,
ImageTag: status.ImageTag,
Size: status.Size,
Error: nsNullIfEmpty(status.Error),
Log: status.Log,
Column13: json.RawMessage(jsonOrEmptyObject(metadataRaw)),
CreatedAt: now,
})
if err != nil && h.isMissingBuildsTable(err) {
return nil
}
@@ -713,18 +697,37 @@ func (h *BuildHandler) getBuildFromDB(buildID string) (BuildStatusResponse, bool
return BuildStatusResponse{}, false, nil
}
row := h.db.QueryRow(
`SELECT id, project_id, service_id, status, progress, started_at, completed_at, image_name, image_tag, size, error, log, metadata
FROM builds
WHERE id = $1`,
buildID,
)
build, found, err := scanBuildRow(row.Scan)
if err != nil && h.isMissingBuildsTable(err) {
return BuildStatusResponse{}, false, nil
row, err := sqlcdb.New(h.db.DB).GetBuild(context.Background(), buildID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) || h.isMissingBuildsTable(err) {
return BuildStatusResponse{}, false, nil
}
return BuildStatusResponse{}, false, err
}
return build, found, err
build := BuildStatusResponse{
ID: row.ID,
ProjectID: row.ProjectID.String,
ServiceID: row.ServiceID.String,
Status: row.Status,
Progress: int(row.Progress),
StartedAt: row.StartedAt.UTC(),
ImageName: row.ImageName,
ImageTag: row.ImageTag,
Size: row.Size,
Error: row.Error.String,
Log: row.Log,
}
if t := timePtrNT(row.CompletedAt); t != nil {
u := t.UTC()
build.CompletedAt = &u
}
if len(row.Metadata) > 0 {
metadata := map[string]string{}
if err := json.Unmarshal(row.Metadata, &metadata); err == nil {
build.Metadata = metadata
}
}
return build, true, nil
}
func (h *BuildHandler) listBuildsFromDB(projectID, serviceID, status string, page, limit int) ([]BuildStatusResponse, int, error) {
@@ -808,19 +811,11 @@ func (h *BuildHandler) cancelBuildInDB(buildID string) bool {
return false
}
now := time.Now().UTC()
result, err := h.db.Exec(
`UPDATE builds
SET status = 'cancelled',
progress = 100,
completed_at = $1,
log = COALESCE(log, '') || $2,
updated_at = $1
WHERE id = $3
AND status NOT IN ('success', 'failed', 'cancelled')`,
now,
h.formatLogLine("Cancellation requested")+"\n",
buildID,
)
affected, err := sqlcdb.New(h.db.DB).CancelBuild(context.Background(), sqlcdb.CancelBuildParams{
CompletedAt: sql.NullTime{Time: now, Valid: true},
Log: h.formatLogLine("Cancellation requested") + "\n",
ID: buildID,
})
if err != nil {
if h.isMissingBuildsTable(err) {
return false
@@ -828,9 +823,7 @@ func (h *BuildHandler) cancelBuildInDB(buildID string) bool {
log.Printf("failed to cancel build %s in database: %v", buildID, err)
return false
}
affected, err := result.RowsAffected()
if err != nil || affected == 0 {
if affected == 0 {
return false
}
@@ -909,12 +902,9 @@ func scanBuildRow(scan func(dest ...interface{}) error) (BuildStatusResponse, bo
return parsed, true, nil
}
func nullIfEmptyString(value string) interface{} {
func nsNullIfEmpty(value string) sql.NullString {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return nil
}
return trimmed
return sql.NullString{String: trimmed, Valid: trimmed != ""}
}
func jsonOrEmptyObject(raw []byte) string {
+18 -23
View File
@@ -1,9 +1,11 @@
package api
import (
"context"
"net/http"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
@@ -57,15 +59,12 @@ func contextIsAdmin(c *gin.Context) bool {
// public; unapproved ones are visible to the owner, project members, and
// platform admins only.
func projectReadAccess(c *gin.Context, db *database.DB, projectID uuid.UUID) (bool, bool) {
var isApproved bool
var ownerID string
err := db.QueryRow(
`SELECT is_approved, owner_id::text FROM projects WHERE id = $1`,
projectID,
).Scan(&isApproved, &ownerID)
q := sqlcdb.New(db.DB)
row, err := q.GetProjectReadAccess(context.Background(), projectID)
if err != nil {
return false, false
}
isApproved, ownerID := row.IsApproved, row.OwnerID
if isApproved || contextIsAdmin(c) {
return true, true
}
@@ -76,11 +75,11 @@ func projectReadAccess(c *gin.Context, db *database.DB, projectID uuid.UUID) (bo
if ownerID == userID {
return true, true
}
var member bool
if err := db.QueryRow(
`SELECT EXISTS(SELECT 1 FROM project_members WHERE project_id = $1 AND user_id = $2)`,
projectID, userID,
).Scan(&member); err == nil && member {
userUUID, _ := uuid.Parse(userID)
if member, err := q.ProjectMemberExists(context.Background(), sqlcdb.ProjectMemberExistsParams{
ProjectID: projectID,
UserID: userUUID,
}); err == nil && member {
return true, true
}
return true, false
@@ -91,11 +90,8 @@ func projectReadAccess(c *gin.Context, db *database.DB, projectID uuid.UUID) (bo
// project members, and platform admins may reach secret-adjacent data
// such as variables and runtime logs.
func projectManageAccess(c *gin.Context, db *database.DB, projectID uuid.UUID) (bool, bool) {
var ownerID string
err := db.QueryRow(
`SELECT owner_id::text FROM projects WHERE id = $1`,
projectID,
).Scan(&ownerID)
q := sqlcdb.New(db.DB)
ownerID, err := q.GetProjectOwnerText(context.Background(), projectID)
if err != nil {
return false, false
}
@@ -109,11 +105,11 @@ func projectManageAccess(c *gin.Context, db *database.DB, projectID uuid.UUID) (
if ownerID == userID {
return true, true
}
var member bool
if err := db.QueryRow(
`SELECT EXISTS(SELECT 1 FROM project_members WHERE project_id = $1 AND user_id = $2)`,
projectID, userID,
).Scan(&member); err == nil && member {
userUUID, _ := uuid.Parse(userID)
if member, err := q.ProjectMemberExists(context.Background(), sqlcdb.ProjectMemberExistsParams{
ProjectID: projectID,
UserID: userUUID,
}); err == nil && member {
return true, true
}
return true, false
@@ -121,8 +117,7 @@ func projectManageAccess(c *gin.Context, db *database.DB, projectID uuid.UUID) (
// serviceProjectID resolves the project that owns a service.
func serviceProjectID(db *database.DB, serviceID uuid.UUID) (uuid.UUID, bool) {
var projectID uuid.UUID
err := db.QueryRow(`SELECT project_id FROM services WHERE id = $1`, serviceID).Scan(&projectID)
projectID, err := sqlcdb.New(db.DB).GetServiceProjectID(context.Background(), serviceID)
if err != nil {
return uuid.Nil, false
}
+1 -1
View File
@@ -116,7 +116,7 @@ func handleDeleteEnvironment(c *gin.Context) {
return
}
var projectID uuid.UUID
if err := db.QueryRow(`SELECT project_id FROM environments WHERE id = $1`, envID).Scan(&projectID); err != nil {
if projectID, err = sqlcdb.New(db.DB).GetEnvironmentProjectID(context.Background(), envID); err != nil {
respondError(c, http.StatusNotFound, "NOT_FOUND", "Environment not found")
return
}
+65 -52
View File
@@ -17,9 +17,11 @@ import (
"time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/gateway"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
@@ -107,23 +109,28 @@ func extractGatewayKey(c *gin.Context) string {
// lookupGatewayService resolves the slug to an enabled service row.
func lookupGatewayService(ctx context.Context, db *database.DB, slug string) (*gatewayService, error) {
var s gatewayService
var enabled int
err := db.QueryRowContext(ctx, `
SELECT id, upstream_url, route_prefix, upstream_auth_header,
upstream_auth_value, enabled, rpm_limit, monthly_quota,
request_timeout_ms
FROM api_services WHERE slug = $1
`, slug).Scan(&s.ID, &s.UpstreamURL, &s.RoutePrefix,
&s.UpstreamAuthHeader, &s.UpstreamAuthValue, &enabled,
&s.RPMLimit, &s.MonthlyQuota, &s.RequestTimeoutMS)
r, err := sqlcdb.New(db.DB).GetAPServiceBySlug(ctx, slug)
if err != nil {
return nil, err
}
s.Enabled = enabled != 0
s := gatewayService{
ID: r.ID.String(),
UpstreamURL: r.UpstreamUrl,
RoutePrefix: r.RoutePrefix,
UpstreamAuthHeader: r.UpstreamAuthHeader,
UpstreamAuthValue: r.UpstreamAuthValue,
Enabled: r.Enabled != 0,
RPMLimit: nullInt32To64(r.RpmLimit),
MonthlyQuota: nullInt32To64(r.MonthlyQuota),
RequestTimeoutMS: int(r.RequestTimeoutMs.Int32),
}
return &s, nil
}
func nullInt32To64(v sql.NullInt32) sql.NullInt64 {
return sql.NullInt64{Int64: int64(v.Int32), Valid: v.Valid}
}
// authenticateGatewayKey resolves a presented key by its indexed prefix and
// bcrypt-verifies candidates. Returns nil when nothing matches.
func authenticateGatewayKey(ctx context.Context, db *database.DB, presented string) (*gatewayKey, error) {
@@ -131,26 +138,24 @@ func authenticateGatewayKey(ctx context.Context, db *database.DB, presented stri
if len(prefix) > 8 {
prefix = prefix[:8]
}
rows, err := db.QueryContext(ctx, `
SELECT id, key_hash, enabled, rpm_limit, monthly_quota, allowed_service_ids
FROM api_keys WHERE key_prefix = $1
`, prefix)
rows, err := sqlcdb.New(db.DB).ListAPKeysByPrefix(ctx, prefix)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var k gatewayKey
var enabled int
if err := rows.Scan(&k.ID, &k.Hash, &enabled, &k.RPMLimit, &k.MonthlyQuota, &k.AllowedServiceIDs); err != nil {
return nil, err
for _, r := range rows {
k := gatewayKey{
ID: r.ID.String(),
Hash: r.KeyHash,
Enabled: r.Enabled != 0,
RPMLimit: nullInt32To64(r.RpmLimit),
MonthlyQuota: nullInt32To64(r.MonthlyQuota),
AllowedServiceIDs: r.AllowedServiceIds,
}
k.Enabled = enabled != 0
if bcrypt.CompareHashAndPassword([]byte(k.Hash), []byte(presented)) == nil {
return &k, nil
}
}
return nil, rows.Err()
return nil, nil
}
// keyAllowsService applies the allowed_service_ids JSON allowlist. An empty
@@ -168,48 +173,56 @@ func keyAllowsService(k *gatewayKey, serviceID string) bool {
return false
}
// monthlyUsage sums a counter dimension for the current calendar month.
func monthlyUsage(ctx context.Context, db *database.DB, column, id, period string) (int, error) {
var total int
err := db.QueryRowContext(ctx, `
SELECT COALESCE(SUM(request_count), 0) FROM usage_counters
WHERE `+column+` = $1 AND period_month = $2
`, id, period).Scan(&total)
return total, err
// monthlyKeyUsage / monthlyServiceUsage sum a counter dimension for the
// current calendar month.
func monthlyKeyUsage(ctx context.Context, db *database.DB, id, period string) (int64, error) {
keyUUID, _ := uuid.Parse(id)
return sqlcdb.New(db.DB).SumUsageByKey(ctx, sqlcdb.SumUsageByKeyParams{
ApiKeyID: keyUUID, PeriodMonth: period,
})
}
func monthlyServiceUsage(ctx context.Context, db *database.DB, id, period string) (int64, error) {
svcUUID, _ := uuid.Parse(id)
return sqlcdb.New(db.DB).SumUsageByService(ctx, sqlcdb.SumUsageByServiceParams{
ServiceID: svcUUID, PeriodMonth: period,
})
}
// recordGatewayRequest persists usage, metrics, and failure incidents for a
// proxied call. Runs in the request path but is three small writes.
func recordGatewayRequest(ctx context.Context, db *database.DB, svc *gatewayService, k *gatewayKey, status int, period string) {
_, _ = db.ExecContext(ctx, `
INSERT INTO usage_counters (api_key_id, service_id, period_month, request_count, updated_at)
VALUES ($1, $2, $3, 1, NOW())
ON CONFLICT (api_key_id, service_id, period_month)
DO UPDATE SET request_count = usage_counters.request_count + 1, updated_at = NOW()
`, k.ID, svc.ID, period)
_, _ = db.ExecContext(ctx, `
UPDATE api_keys SET last_used_at = NOW() WHERE id = $1
`, k.ID)
q := sqlcdb.New(db.DB)
keyUUID, _ := uuid.Parse(k.ID)
svcUUID, _ := uuid.Parse(svc.ID)
_ = q.UpsertUsageCounter(ctx, sqlcdb.UpsertUsageCounterParams{
ApiKeyID: keyUUID, ServiceID: svcUUID, PeriodMonth: period,
})
_ = q.TouchAPKeyLastUsed(ctx, keyUUID)
labels, _ := json.Marshal(map[string]interface{}{
"service_id": svc.ID,
"key_id": k.ID,
"status": status,
})
_, _ = db.ExecContext(ctx, `
INSERT INTO metrics_timeseries (metric, value, labels_json, occurred_at)
VALUES ('gateway_request', 1, $1, NOW())
`, string(labels))
_ = q.InsertMetricPoint(ctx, sql.NullString{String: string(labels), Valid: true})
}
func recordGatewayIncident(ctx context.Context, db *database.DB, svcID string, keyID *string, code, msg, severity string, status int) {
var kid interface{}
var kid uuid.NullUUID
if keyID != nil {
kid = *keyID
if u, err := uuid.Parse(*keyID); err == nil {
kid = uuid.NullUUID{UUID: u, Valid: true}
}
}
_, _ = db.ExecContext(ctx, `
INSERT INTO incident_events (service_id, api_key_id, code, message, severity, http_status, occurred_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW())
`, svcID, kid, code, msg, severity, status)
svcUUID, _ := uuid.Parse(svcID)
_ = sqlcdb.New(db.DB).InsertGatewayIncident(ctx, sqlcdb.InsertGatewayIncidentParams{
ServiceID: uuid.NullUUID{UUID: svcUUID, Valid: svcUUID != uuid.Nil},
ApiKeyID: kid,
Code: code,
Message: msg,
Severity: severity,
HttpStatus: sql.NullInt32{Int32: int32(status), Valid: true},
})
}
// handleGatewayProxy is mounted at /g/:slug and /g/:slug/*path on the root
@@ -267,13 +280,13 @@ func handleGatewayProxy(c *gin.Context) {
period := now.Format("2006-01")
if key.MonthlyQuota.Valid && key.MonthlyQuota.Int64 > 0 {
if used, err := monthlyUsage(ctx, db, "api_key_id", key.ID, period); err == nil && int64(used) >= key.MonthlyQuota.Int64 {
if used, err := monthlyKeyUsage(ctx, db, key.ID, period); err == nil && used >= key.MonthlyQuota.Int64 {
respondError(c, http.StatusTooManyRequests, "QUOTA_EXCEEDED", "Monthly request quota exceeded")
return
}
}
if svc.MonthlyQuota.Valid && svc.MonthlyQuota.Int64 > 0 {
if used, err := monthlyUsage(ctx, db, "service_id", svc.ID, period); err == nil && int64(used) >= svc.MonthlyQuota.Int64 {
if used, err := monthlyServiceUsage(ctx, db, svc.ID, period); err == nil && used >= svc.MonthlyQuota.Int64 {
respondError(c, http.StatusTooManyRequests, "QUOTA_EXCEEDED", "Service monthly quota exceeded")
return
}
+27 -18
View File
@@ -1,12 +1,16 @@
package api
import (
"context"
"fmt"
"strconv"
"strings"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/source"
"github.com/google/uuid"
)
// resolveGitCloneSpec turns a service's git_repo (clone URL, ssh remote, or
@@ -22,26 +26,31 @@ func resolveGitCloneSpec(db *database.DB, ownerID, gitRepo, branch, commit, buil
}
spec := source.Spec{Branch: branch, Commit: commit, BuildPath: buildPath}
q := sqlcdb.New(db.DB)
ctx := context.Background()
var providerID string
ownerUUID, _ := uuid.Parse(ownerID)
var providerID uuid.UUID
if !strings.Contains(cloneURL, "://") {
// Bare owner/repo — the connected repository row knows the host.
err = db.QueryRow(
`SELECT clone_url, provider_id FROM git_repositories
WHERE full_name = $1 AND user_id = $2 LIMIT 1`,
cloneURL, ownerID,
).Scan(&cloneURL, &providerID)
row, err := q.GetGitRepoCloneByFullName(ctx, sqlcdb.GetGitRepoCloneByFullNameParams{
FullName: cloneURL,
UserID: ownerUUID,
})
if err != nil {
return source.Spec{}, fmt.Errorf("repository %s is not connected — connect it under Git settings or store a clone URL", raw)
}
cloneURL = row.CloneUrl
providerID = row.ProviderID
} else {
// URL form — match a connected repo for its provider when possible;
// an unmatched public URL still clones, just without credentials.
_ = db.QueryRow(
`SELECT provider_id FROM git_repositories
WHERE clone_url = $1 AND user_id = $2 LIMIT 1`,
cloneURL, ownerID,
).Scan(&providerID)
if pid, err := q.GetGitRepoProviderByCloneURL(ctx, sqlcdb.GetGitRepoProviderByCloneURLParams{
CloneUrl: cloneURL,
UserID: ownerUUID,
}); err == nil {
providerID = pid
}
}
if err := source.ValidateRemoteURL(cloneURL); err != nil {
@@ -49,17 +58,17 @@ func resolveGitCloneSpec(db *database.DB, ownerID, gitRepo, branch, commit, buil
}
spec.CloneURL = cloneURL
if providerID == "" {
if providerID == uuid.Nil {
return spec, nil
}
var name, apiURL, token string
if err := db.QueryRow(
`SELECT name, api_url, access_token FROM git_providers
WHERE id = $1 AND user_id = $2`,
providerID, ownerID,
).Scan(&name, &apiURL, &token); err != nil {
prov, err := q.GetGitProviderForFetch(ctx, sqlcdb.GetGitProviderForFetchParams{
ID: providerID,
UserID: ownerUUID,
})
if err != nil {
return spec, nil // provider row gone — anonymous clone still works for public repos
}
name, apiURL, token := prov.Name, prov.ApiUrl, prov.AccessToken
switch strings.ToLower(name) {
case "github_app":
+4 -4
View File
@@ -15,6 +15,7 @@ import (
"time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/deployment"
"github.com/gin-gonic/gin"
@@ -88,8 +89,8 @@ func handleAdminGitHubAppManifest(c *gin.Context) {
callbackURL := base + "/git/github-app/callback"
manifest, err := json.Marshal(map[string]interface{}{
"name": name,
"url": base,
"name": name,
"url": base,
"hook_attributes": map[string]interface{}{
"url": base + "/api/git/github-app/webhook",
"active": true,
@@ -206,9 +207,8 @@ func handleAdminGitHubAppConvert(c *gin.Context) {
// GET /admin/git/github-app — provisioning status for the settings UI.
func handleAdminGetGitHubApp(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
var stored string
source := "none"
if err := db.QueryRow(`SELECT value FROM app_settings WHERE key = $1`, settingGitHubAppID).Scan(&stored); err == nil && strings.TrimSpace(stored) != "" {
if v, err := sqlcdb.New(db.DB).GetAppSetting(c.Request.Context(), settingGitHubAppID); err == nil && strings.TrimSpace(v) != "" {
source = "settings"
} else if strings.TrimSpace(os.Getenv("GITHUB_APP_ID")) != "" {
source = "env"
+15 -9
View File
@@ -193,8 +193,8 @@ func handleAcceptInvite(c *gin.Context) {
return
}
var existing int
if err := db.QueryRow(`SELECT COUNT(*) FROM users WHERE email = $1`, req.Email).Scan(&existing); err != nil {
existing, err := q.CountUsersByEmail(c.Request.Context(), req.Email)
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "database error")
return
}
@@ -214,17 +214,23 @@ func handleAcceptInvite(c *gin.Context) {
return
}
var user User
err = db.QueryRow(`
INSERT INTO users (email, password_hash, name)
VALUES ($1, $2, $3)
RETURNING id, email, name, COALESCE(avatar_url, ''), is_admin, created_at
`, req.Email, string(hashedPassword), strings.TrimSpace(req.Name)).
Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt)
row, err := q.InsertUser(c.Request.Context(), sqlcdb.InsertUserParams{
Email: req.Email,
PasswordHash: string(hashedPassword),
Name: strings.TrimSpace(req.Name),
})
if err != nil {
respondError(c, http.StatusConflict, "CONFLICT", "user already exists")
return
}
user := User{
ID: row.ID.String(),
Email: row.Email,
Name: row.Name,
AvatarURL: row.AvatarUrl,
IsAdmin: row.IsAdmin,
CreatedAt: row.CreatedAt.Time.String(),
}
userUUID, _ := uuid.Parse(user.ID)
if err := q.MarkUserInviteUsed(c.Request.Context(), sqlcdb.MarkUserInviteUsedParams{
+3 -7
View File
@@ -9,6 +9,7 @@ import (
"time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/docker"
containertypes "github.com/docker/docker/api/types/container"
@@ -60,17 +61,12 @@ func handleGetServiceMetrics(c *gin.Context) {
return
}
var projectID uuid.UUID
var status string
err = db.QueryRow(
`SELECT s.project_id, COALESCE(s.status, '')
FROM services s WHERE s.id = $1`,
serviceID,
).Scan(&projectID, &status)
svc, err := sqlcdb.New(db.DB).GetServiceProjectAndStatus(context.Background(), serviceID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
}
projectID := svc.ProjectID
if _, allowed := projectReadAccess(c, db, projectID); !allowed {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
+3 -4
View File
@@ -315,10 +315,9 @@ func (r *agentNodeRunner) replicaPayload(spec deployment.RuntimeSpec, agentID st
// pullCredentials resolves the project owner's registry auth for the image
// host so private images pull on remote nodes. Empty map → anonymous pull.
func (r *agentNodeRunner) pullCredentials(projectID, imageRef string) map[string]interface{} {
var ownerID string
if err := r.db.QueryRow(
`SELECT owner_id FROM projects WHERE id = $1`, projectID,
).Scan(&ownerID); err != nil {
pid, _ := uuid.Parse(projectID)
ownerID, err := sqlcdb.New(r.db.DB).GetProjectOwnerText(context.Background(), pid)
if err != nil {
return map[string]interface{}{}
}
auth := registryAuthFor(r.db, ownerID, imageRef)
@@ -9,6 +9,7 @@ import (
"containr/internal/database"
"containr/internal/database/sqlcdb"
"context"
"database/sql"
"encoding/json"
"fmt"
"log"
@@ -29,15 +30,13 @@ func notifyAdmins(db *database.DB, kind, title, body, resourceType, resourceID s
if db == nil {
return
}
rows, err := db.Query(`SELECT id FROM users WHERE is_admin`)
adminIDs, err := sqlcdb.New(db.DB).ListAdminUserIDs(context.Background())
if err != nil {
log.Printf("containr: notify admins lookup failed: %v", err)
return
}
defer rows.Close()
for rows.Next() {
var id uuid.UUID
if err := rows.Scan(&id); err == nil {
for _, id := range adminIDs {
{
insertUserNotification(db, id.String(), kind, title, body, resourceType, resourceID)
}
}
@@ -73,34 +72,32 @@ type agentHealthRow struct {
func reconcileAgentHealth(db *database.DB, staleAfter time.Duration) {
cutoff := time.Now().Add(-staleAfter)
var agents []agentHealthRow
rows, err := db.Query(
`SELECT id, COALESCE(name, hostname, id::text), COALESCE(status, ''),
COALESCE(last_heartbeat < $1, TRUE), auto_prune
FROM node_agents`, cutoff)
q := sqlcdb.New(db.DB)
agentRows, err := q.ListAgentHealthRows(context.Background(), sql.NullTime{Time: cutoff, Valid: true})
if err != nil {
log.Printf("containr: agent health sweep: %v", err)
return
}
for rows.Next() {
var a agentHealthRow
if err := rows.Scan(&a.id, &a.name, &a.status, &a.stale, &a.autoPrune); err == nil {
agents = append(agents, a)
}
for _, r := range agentRows {
agents = append(agents, agentHealthRow{
id: r.ID, name: r.Name, status: r.Status, stale: r.Stale, autoPrune: r.AutoPrune,
})
}
rows.Close()
for _, a := range agents {
switch {
case a.stale && a.status != "offline":
if _, err := db.Exec(
`UPDATE node_agents SET status = 'offline', updated_at = NOW() WHERE id = $1`, a.id); err != nil {
if err := q.SetAgentStatus(context.Background(), sqlcdb.SetAgentStatusParams{
Status: sql.NullString{String: "offline", Valid: true}, ID: a.id,
}); err != nil {
continue
}
notifyAdmins(db, "agent", "Node agent offline",
fmt.Sprintf("Agent %s missed its heartbeat for over %s.", a.name, staleAfter), "agent", a.id)
case !a.stale && a.status == "offline":
if _, err := db.Exec(
`UPDATE node_agents SET status = 'online', updated_at = NOW() WHERE id = $1`, a.id); err != nil {
if err := q.SetAgentStatus(context.Background(), sqlcdb.SetAgentStatusParams{
Status: sql.NullString{String: "online", Valid: true}, ID: a.id,
}); err != nil {
continue
}
notifyAdmins(db, "agent", "Node agent back online",
@@ -186,17 +183,17 @@ func checkForUpgrade(db *database.DB, currentVersion string) {
return
}
var lastNotified string
_ = db.QueryRow(`SELECT value FROM app_settings WHERE key = 'upgrade_notified_tag'`).Scan(&lastNotified)
lastNotified, _ := sqlcdb.New(db.DB).GetAppSetting(context.Background(), "upgrade_notified_tag")
if lastNotified == release.TagName {
return
}
notifyAdmins(db, "upgrade", "Containr update available",
fmt.Sprintf("Release %s is available (running %s). %s", release.TagName, currentVersion, release.HTMLURL),
"release", release.TagName)
_, _ = db.Exec(
`INSERT INTO app_settings (key, value, updated_at) VALUES ('upgrade_notified_tag', $1, NOW())
ON CONFLICT (key) DO UPDATE SET value = $1, updated_at = NOW()`, release.TagName)
_ = sqlcdb.New(db.DB).UpsertAppSettingValue(context.Background(), sqlcdb.UpsertAppSettingValueParams{
Key: "upgrade_notified_tag",
Value: release.TagName,
})
}
// semverNewer reports whether candidate is a higher semver than current.
+40 -69
View File
@@ -1,13 +1,16 @@
package api
import (
"context"
"net/http"
"time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/deployqueue"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// GET /operations — a single cross-cutting view of platform work: active
@@ -55,91 +58,59 @@ func handleGetOperations(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
isAdmin := contextIsAdmin(c)
// Same visibility rule as the deployment list endpoint.
visibility := `p.is_approved OR p.owner_id = $1 OR $2::bool
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1)`
userUUID, _ := uuid.Parse(userID)
q := sqlcdb.New(db.DB)
visibility := sqlcdb.ListActiveOperationDeploymentsParams{OwnerID: userUUID, Column2: isAdmin}
active := []operationDeployment{}
rows, err := db.Query(
`SELECT d.id, d.service_id, s.name, p.name, d.status,
COALESCE(d.image_name, ''), d.error, d.started_at, d.completed_at, d.created_at
FROM deployments d
JOIN services s ON s.id = d.service_id
JOIN projects p ON p.id = s.project_id
WHERE d.status IN ('queued','pending','building','deploying','rolling_back')
AND (`+visibility+`)
ORDER BY d.created_at ASC`, userID, isAdmin)
if err == nil {
defer rows.Close()
for rows.Next() {
var d operationDeployment
var sid string
_ = rows.Scan(&d.ID, &sid, &d.ServiceName, &d.ProjectName, &d.Status, &d.Image, &d.Error, &d.StartedAt, &d.CompletedAt, &d.CreatedAt)
d.ServiceID = sid
active = append(active, d)
if rows, err := q.ListActiveOperationDeployments(context.Background(), visibility); err == nil {
for _, r := range rows {
active = append(active, operationDeployment{
ID: r.ID.String(), ServiceID: r.ServiceID.String(), ServiceName: r.ServiceName,
ProjectName: r.ProjectName, Status: r.Status.String, Image: r.Image,
Error: strPtrNS(r.Error), StartedAt: timePtrNT(r.StartedAt),
CompletedAt: timePtrNT(r.CompletedAt), CreatedAt: r.CreatedAt.Time,
})
}
}
recentFailed := []operationDeployment{}
rows2, err2 := db.Query(
`SELECT d.id, d.service_id, s.name, p.name, d.status,
COALESCE(d.image_name, ''), d.error, d.started_at, d.completed_at, d.created_at
FROM deployments d
JOIN services s ON s.id = d.service_id
JOIN projects p ON p.id = s.project_id
WHERE d.status IN ('failed','cancelled','rolled_back')
AND d.updated_at > NOW() - INTERVAL '24 hours'
AND (`+visibility+`)
ORDER BY d.updated_at DESC
LIMIT 20`, userID, isAdmin)
if err2 == nil {
defer rows2.Close()
for rows2.Next() {
var d operationDeployment
var sid string
_ = rows2.Scan(&d.ID, &sid, &d.ServiceName, &d.ProjectName, &d.Status, &d.Image, &d.Error, &d.StartedAt, &d.CompletedAt, &d.CreatedAt)
d.ServiceID = sid
recentFailed = append(recentFailed, d)
if rows, err := q.ListRecentFailedDeployments(context.Background(), sqlcdb.ListRecentFailedDeploymentsParams{
OwnerID: userUUID, Column2: isAdmin,
}); err == nil {
for _, r := range rows {
recentFailed = append(recentFailed, operationDeployment{
ID: r.ID.String(), ServiceID: r.ServiceID.String(), ServiceName: r.ServiceName,
ProjectName: r.ProjectName, Status: r.Status.String, Image: r.Image,
Error: strPtrNS(r.Error), StartedAt: timePtrNT(r.StartedAt),
CompletedAt: timePtrNT(r.CompletedAt), CreatedAt: r.CreatedAt.Time,
})
}
}
cronRuns := []operationCronRun{}
rows3, err3 := db.Query(
`SELECT e.id, j.name, j.schedule, e.status, e.started_at, e.finished_at, e.error
FROM cron_executions e
JOIN cron_jobs j ON j.id = e.cron_job_id
JOIN projects p ON p.id = j.project_id
WHERE e.started_at > NOW() - INTERVAL '24 hours'
AND (`+visibility+`)
ORDER BY e.started_at DESC
LIMIT 30`, userID, isAdmin)
if err3 == nil {
defer rows3.Close()
for rows3.Next() {
var r operationCronRun
_ = rows3.Scan(&r.ID, &r.JobName, &r.Schedule, &r.Status, &r.StartedAt, &r.FinishedAt, &r.Error)
cronRuns = append(cronRuns, r)
if rows, err := q.ListRecentCronRuns(context.Background(), sqlcdb.ListRecentCronRunsParams{
OwnerID: userUUID, Column2: isAdmin,
}); err == nil {
for _, r := range rows {
cronRuns = append(cronRuns, operationCronRun{
ID: r.ID.String(), JobName: r.JobName, Schedule: r.Schedule, Status: r.Status.String,
StartedAt: r.StartedAt, FinishedAt: timePtrNT(r.FinishedAt), Error: strPtrNS(r.Error),
})
}
}
backups := []operationBackup{}
rows4, err4 := db.Query(
`SELECT b.id, b.database_id, ds.name, b.status, b.size, b.created_at, b.completed_at
FROM database_backups b
JOIN database_services ds ON ds.id = b.database_id
WHERE b.created_at > NOW() - INTERVAL '24 hours'
AND (ds.user_id = $1 OR $2::bool)
ORDER BY b.created_at DESC
LIMIT 20`, userID, isAdmin)
if err4 == nil {
defer rows4.Close()
for rows4.Next() {
var b operationBackup
_ = rows4.Scan(&b.ID, &b.DatabaseID, &b.DBName, &b.Status, &b.Size, &b.CreatedAt, &b.Completed)
backups = append(backups, b)
if rows, err := q.ListRecentOperationBackups(context.Background(), sqlcdb.ListRecentOperationBackupsParams{
UserID: userID, Column2: isAdmin,
}); err == nil {
for _, r := range rows {
backups = append(backups, operationBackup{
ID: r.ID, DatabaseID: r.DatabaseID, DBName: r.DbName, Status: r.Status,
Size: r.Size, CreatedAt: r.CreatedAt.Time, Completed: timePtrNT(r.CompletedAt),
})
}
}
var queue []deployqueue.ServiceQueueState
if queueValue, exists := c.Get("deploy_queue"); exists && queueValue != nil {
queue = queueValue.(*deployqueue.Queue).Snapshot()
+17 -11
View File
@@ -1,10 +1,12 @@
package api
import (
"context"
"net/http"
"time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/secrets"
"github.com/gin-gonic/gin"
@@ -112,7 +114,8 @@ func handleUpdateProjectVariables(c *gin.Context) {
}
defer tx.Rollback()
if _, err = tx.Exec(`DELETE FROM project_variables WHERE project_id = $1`, projectID); err != nil {
txq := sqlcdb.New(tx)
if err = txq.DeleteProjectVariables(c.Request.Context(), projectID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clear existing variables"})
return
}
@@ -127,11 +130,15 @@ func handleUpdateProjectVariables(c *gin.Context) {
if v.IsSecret {
value = secrets.Encrypt(value)
}
if _, err = tx.Exec(
`INSERT INTO project_variables (id, project_id, key, value, is_secret, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
uuid.New(), projectID, v.Key, value, v.IsSecret, now, now,
); err != nil {
if err = txq.InsertProjectVariable(c.Request.Context(), sqlcdb.InsertProjectVariableParams{
ID: uuid.New(),
ProjectID: projectID,
Key: v.Key,
Value: value,
IsSecret: v.IsSecret,
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to insert variable: " + v.Key})
return
}
@@ -168,11 +175,10 @@ func handleUpdateProjectVariables(c *gin.Context) {
// resolveSharedVar returns the decrypted value of a project variable.
func resolveSharedVar(db *database.DB, projectID uuid.UUID, key string) (string, bool) {
var value string
err := db.QueryRow(
`SELECT value FROM project_variables WHERE project_id = $1 AND key = $2`,
projectID, key,
).Scan(&value)
value, err := sqlcdb.New(db.DB).GetProjectVariableValue(context.Background(), sqlcdb.GetProjectVariableValueParams{
ProjectID: projectID,
Key: key,
})
if err != nil {
return "", false
}
+9 -7
View File
@@ -1,11 +1,13 @@
package api
import (
"context"
"net/http"
"strings"
"time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/secrets"
"github.com/docker/docker/api/types/registry"
@@ -177,17 +179,17 @@ func handleDeleteRegistry(c *gin.Context) {
// bare image names (nginx:latest).
func registryAuthFor(db *database.DB, ownerID, imageRef string) registry.AuthConfig {
host := registryHost(imageRef)
var username, password string
err := db.QueryRow(
`SELECT username, password FROM registries WHERE owner_id = $1 AND host = $2`,
ownerID, host,
).Scan(&username, &password)
ownerUUID, _ := uuid.Parse(ownerID)
row, err := sqlcdb.New(db.DB).GetRegistryAuth(context.Background(), sqlcdb.GetRegistryAuthParams{
OwnerID: ownerUUID,
Host: host,
})
if err != nil {
return registry.AuthConfig{}
}
return registry.AuthConfig{
Username: username,
Password: secrets.Decrypt(password),
Username: row.Username,
Password: secrets.Decrypt(row.Password),
ServerAddress: host,
}
}
+23 -28
View File
@@ -11,6 +11,7 @@ import (
"containr/internal/build"
"containr/internal/config"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/deployment"
"containr/internal/deployqueue"
"containr/internal/docker"
@@ -21,6 +22,7 @@ import (
"containr/internal/secrets"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg *config.Config) {
@@ -83,19 +85,18 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
// 127.0.0.1 fallback stays for node-bound checks.
docker := dockerClient
haManager.WithServiceResolver(func(ctx context.Context, serviceID string) (string, int, error) {
var name, projectID string
var port int
err := db.QueryRowContext(ctx,
`SELECT name, project_id::text, COALESCE(port, 0) FROM services WHERE id = $1`,
serviceID,
).Scan(&name, &projectID, &port)
sid, err := uuid.Parse(serviceID)
if err != nil {
return "", 0, err
}
if err := docker.ConnectSelfToNetwork(ctx, deployment.ProjectNetworkName(projectID)); err != nil {
svc, err := sqlcdb.New(db.DB).GetServiceNameProjectPort(ctx, sid)
if err != nil {
return "", 0, err
}
return name, port, nil
if err := docker.ConnectSelfToNetwork(ctx, deployment.ProjectNetworkName(svc.ProjectID)); err != nil {
return "", 0, err
}
return svc.Name, int(svc.Port), nil
})
}
haAPIManager := NewHAManager(haManager)
@@ -137,31 +138,25 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
// Boot sweep: in-memory engine state dies on restart, so anything that
// looked in-flight is actually orphaned — mark it failed rather than
// leaving phantom 'building'/'queued' rows.
_, _ = db.Exec(
`UPDATE deployments SET status = 'failed', error = 'Interrupted by server restart', completed_at = NOW(), updated_at = NOW()
WHERE status IN ('queued', 'pending', 'building', 'deploying', 'rolling_back')`,
)
bootQ := sqlcdb.New(db.DB)
_ = bootQ.FailInterruptedDeployments(context.Background())
// Boot sweep: encrypt any plaintext secret values left over from before
// encryption-at-rest landed. Transparent and idempotent.
if encRows, err := db.Query(`SELECT id, value FROM environment_variables WHERE is_secret = TRUE`); err == nil {
type encRow struct {
id string
value string
}
var toEncrypt []encRow
for encRows.Next() {
var r encRow
if err := encRows.Scan(&r.id, &r.value); err == nil && !secrets.IsEncrypted(r.value) {
toEncrypt = append(toEncrypt, r)
if encRows, err := bootQ.ListSecretEnvVars(context.Background()); err == nil {
toEncrypt := 0
for _, r := range encRows {
if secrets.IsEncrypted(r.Value) {
continue
}
if err := bootQ.UpdateEnvVarValue(context.Background(), sqlcdb.UpdateEnvVarValueParams{
Value: secrets.Encrypt(r.Value), ID: r.ID,
}); err == nil {
toEncrypt++
}
}
encRows.Close()
for _, r := range toEncrypt {
_, _ = db.Exec(`UPDATE environment_variables SET value = $1, updated_at = NOW() WHERE id = $2`, secrets.Encrypt(r.value), r.id)
}
if len(toEncrypt) > 0 {
log.Printf("secrets: encrypted %d plaintext secret variable(s)", len(toEncrypt))
if toEncrypt > 0 {
log.Printf("secrets: encrypted %d plaintext secret variable(s)", toEncrypt)
}
}
+70 -109
View File
@@ -2,7 +2,9 @@ package api
import (
"containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/security"
"context"
"database/sql"
"encoding/json"
"fmt"
@@ -33,8 +35,8 @@ func NewSecurityHandler(db *database.DB, encryptionKey string) *SecurityHandler
// Notify the project owner when a background scan completes — criticals
// earn a distinct title so they stand out in the notification list.
scanner.OnScanComplete = func(scan *security.SecurityScan) {
var ownerID string
if err := db.QueryRow(`SELECT owner_id::text FROM projects WHERE id = $1`, scan.ProjectID).Scan(&ownerID); err != nil || ownerID == "" {
ownerID, err := sqlcdb.New(db.DB).GetProjectOwnerText(context.Background(), uuid.MustParse(scan.ProjectID))
if err != nil || ownerID == "" {
return
}
title := "Security scan completed"
@@ -81,14 +83,10 @@ func (sh *SecurityHandler) StartSecurityScan(c *gin.Context) {
return
}
var serviceExists bool
err := sh.db.QueryRow(
`SELECT EXISTS(
SELECT 1 FROM services WHERE id = $1 AND project_id = $2
)`,
req.ServiceID,
req.ProjectID,
).Scan(&serviceExists)
serviceExists, err := sqlcdb.New(sh.db.DB).ServiceExistsInProject(context.Background(), sqlcdb.ServiceExistsInProjectParams{
ID: uuid.MustParse(req.ServiceID),
ProjectID: uuid.MustParse(req.ProjectID),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to validate service"})
return
@@ -164,40 +162,30 @@ func (sh *SecurityHandler) GetVulnerabilities(c *gin.Context) {
}
// Query vulnerabilities
rows, err := sh.db.Query(`
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)
rows, err := sqlcdb.New(sh.db.DB).ListVulnerabilities(context.Background(), uuid.MustParse(projectID))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get vulnerabilities"})
return
}
defer rows.Close()
var vulnerabilities []security.Vulnerability
for rows.Next() {
var vuln security.Vulnerability
var resolvedAt *time.Time
err := rows.Scan(&vuln.ID, &vuln.Type, &vuln.Severity, &vuln.Title, &vuln.Description,
&vuln.ServiceID, &vuln.Status, &vuln.FoundAt, &resolvedAt)
if err != nil {
for _, row := range rows {
// NULL description/service_id failed the original scan and dropped
// the row — keep the same filter.
if !row.Description.Valid || !row.ServiceID.Valid {
continue
}
vuln.ResolvedAt = resolvedAt
vulnerabilities = append(vulnerabilities, vuln)
vulnerabilities = append(vulnerabilities, security.Vulnerability{
ID: row.ID.String(),
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})
@@ -227,11 +215,11 @@ func (sh *SecurityHandler) UpdateVulnerability(c *gin.Context) {
resolvedAt = &now
}
_, err := sh.db.Exec(`
UPDATE vulnerabilities
SET status = $1, resolved_at = $2
WHERE id = $3
`, req.Status, resolvedAt, vulnID)
err := sqlcdb.New(sh.db.DB).UpdateVulnerabilityStatus(context.Background(), sqlcdb.UpdateVulnerabilityStatusParams{
Status: req.Status,
ResolvedAt: ntPtr(resolvedAt),
ID: uuid.MustParse(vulnID),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update vulnerability"})
@@ -271,13 +259,7 @@ func (sh *SecurityHandler) StartComplianceAssessment(c *gin.Context) {
return
}
var frameworkExists bool
err := sh.db.QueryRow(
`SELECT EXISTS(
SELECT 1 FROM compliance_frameworks WHERE id = $1
)`,
req.FrameworkID,
).Scan(&frameworkExists)
frameworkExists, err := sqlcdb.New(sh.db.DB).ComplianceFrameworkExists(context.Background(), uuid.MustParse(req.FrameworkID))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to validate framework"})
return
@@ -321,30 +303,25 @@ func (sh *SecurityHandler) GetComplianceReport(c *gin.Context) {
// GetComplianceFrameworks retrieves available compliance frameworks
func (sh *SecurityHandler) GetComplianceFrameworks(c *gin.Context) {
rows, err := sh.db.Query(`
SELECT id, name, description, version, enabled, created_at
FROM compliance_frameworks
WHERE enabled = true
ORDER BY name
`)
rows, err := sqlcdb.New(sh.db.DB).ListComplianceFrameworks(context.Background())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get compliance frameworks"})
return
}
defer rows.Close()
var frameworks []security.ComplianceFramework
for rows.Next() {
var framework security.ComplianceFramework
err := rows.Scan(&framework.ID, &framework.Name, &framework.Description,
&framework.Version, &framework.Enabled, &framework.CreatedAt)
if err != nil {
for _, row := range rows {
if !row.Description.Valid {
continue
}
frameworks = append(frameworks, framework)
frameworks = append(frameworks, security.ComplianceFramework{
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})
@@ -385,23 +362,19 @@ func (sh *SecurityHandler) GetSecurityMetrics(c *gin.Context) {
Resolved int `json:"resolved"`
}
err := sh.db.QueryRow(`
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
`, projectID).Scan(&vulnMetrics.Total, &vulnMetrics.Critical, &vulnMetrics.High,
&vulnMetrics.Medium, &vulnMetrics.Low, &vulnMetrics.Open, &vulnMetrics.Resolved)
q := sqlcdb.New(sh.db.DB)
mrow, err := q.GetVulnerabilityMetrics(context.Background(), uuid.MustParse(projectID))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get vulnerability metrics"})
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
var latestScan struct {
@@ -411,13 +384,11 @@ func (sh *SecurityHandler) GetSecurityMetrics(c *gin.Context) {
Status string `json:"status"`
}
err = sh.db.QueryRow(`
SELECT id, COALESCE((summary->>'score')::int, 0), started_at as scanned_at, status
FROM security_scans
WHERE project_id = $1
ORDER BY started_at DESC
LIMIT 1
`, projectID).Scan(&latestScan.ID, &latestScan.Score, &latestScan.ScannedAt, &latestScan.Status)
srow, err := q.GetLatestSecurityScan(context.Background(), uuid.MustParse(projectID))
latestScan.ID = srow.ID.String()
latestScan.Score = int(srow.Score)
latestScan.ScannedAt = srow.ScannedAt
latestScan.Status = srow.Status
if err == sql.ErrNoRows {
latestScan = struct {
@@ -438,13 +409,10 @@ func (sh *SecurityHandler) GetSecurityMetrics(c *gin.Context) {
LastAssessed *time.Time `json:"last_assessed"`
}
err = sh.db.QueryRow(`
SELECT overall_status, score, assessment_date
FROM compliance_reports
WHERE project_id = $1
ORDER BY assessment_date DESC
LIMIT 1
`, projectID).Scan(&complianceStatus.OverallStatus, &complianceStatus.Score, &complianceStatus.LastAssessed)
crow, err := q.GetLatestComplianceReport(context.Background(), uuid.MustParse(projectID))
complianceStatus.OverallStatus = crow.OverallStatus
complianceStatus.Score = int(crow.Score)
complianceStatus.LastAssessed = &crow.AssessmentDate
if err == sql.ErrNoRows {
complianceStatus = struct {
@@ -614,21 +582,11 @@ func (sh *SecurityHandler) requireProjectAccess(c *gin.Context, projectID string
return "", false
}
var hasAccess bool
err := sh.db.QueryRow(
`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
))
)`,
projectID, optionalUserUUID(c), contextIsAdmin(c),
).Scan(&hasAccess)
hasAccess, err := sqlcdb.New(sh.db.DB).CheckSecurityProjectAccess(context.Background(), sqlcdb.CheckSecurityProjectAccessParams{
ID: uuid.MustParse(projectID),
OwnerID: optionalUserUUID(c),
Column3: contextIsAdmin(c),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to verify project access"})
return "", false
@@ -649,7 +607,8 @@ func (sh *SecurityHandler) requireSecurityScanAccess(c *gin.Context, scanID stri
}
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 {
c.JSON(http.StatusNotFound, gin.H{"error": "Security scan not found"})
return false
@@ -670,7 +629,8 @@ func (sh *SecurityHandler) requireComplianceReportAccess(c *gin.Context, reportI
}
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 {
c.JSON(http.StatusNotFound, gin.H{"error": "Compliance report not found"})
return false
@@ -691,7 +651,8 @@ func (sh *SecurityHandler) requireVulnerabilityAccess(c *gin.Context, vulnID str
}
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 {
c.JSON(http.StatusNotFound, gin.H{"error": "Vulnerability not found"})
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 (
"containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/deployment"
"containr/internal/deployqueue"
"context"
@@ -70,20 +71,18 @@ type Service struct {
// loadServiceSleep fills the sleep-mode columns, which live outside the
// wide service SELECTs. Missing columns (pre-migration) degrade to off.
func loadServiceSleep(db *database.DB, s *Service) {
var idle int
if err := db.QueryRow(
`SELECT COALESCE(sleep_enabled, false), COALESCE(sleep_idle_minutes, 15) FROM services WHERE id = $1`,
s.ID,
).Scan(&s.SleepEnabled, &idle); err == nil {
s.SleepIdleMinutes = idle
row, err := sqlcdb.New(db.DB).GetServiceSleep(context.Background(), s.ID)
if err == nil {
s.SleepEnabled = row.SleepEnabled
s.SleepIdleMinutes = int(row.SleepIdleMinutes)
}
}
// serviceNodeID reads the pin column directly — kept out of the wide service
// SELECTs so pre-migration schemas keep working.
func serviceNodeID(db *database.DB, serviceID uuid.UUID) string {
var id sql.NullString
if err := db.QueryRow(`SELECT node_id FROM services WHERE id = $1`, serviceID).Scan(&id); err == nil {
id, err := sqlcdb.New(db.DB).GetServiceNodeID(context.Background(), serviceID)
if err == nil {
return id.String
}
return ""
@@ -91,17 +90,14 @@ func serviceNodeID(db *database.DB, serviceID uuid.UUID) string {
// serviceSpread reads the spread column with the same lazy pattern.
func serviceSpread(db *database.DB, serviceID uuid.UUID) bool {
var spread bool
if err := db.QueryRow(`SELECT COALESCE(spread, false) FROM services WHERE id = $1`, serviceID).Scan(&spread); err == nil {
return spread
}
return false
spread, err := sqlcdb.New(db.DB).GetServiceSpread(context.Background(), serviceID)
return err == nil && spread
}
// servicePlacementTags reads the placement_tags column with the lazy pattern.
func servicePlacementTags(db *database.DB, serviceID uuid.UUID) []string {
var raw []byte
if err := db.QueryRow(`SELECT placement_tags FROM services WHERE id = $1`, serviceID).Scan(&raw); err != nil {
raw, err := sqlcdb.New(db.DB).GetServicePlacementTags(context.Background(), serviceID)
if err != nil {
return nil
}
var tags []string
@@ -179,8 +175,8 @@ func normalizeTraefikLabels(raw map[string]string) (map[string]string, error) {
// serviceTraefikLabels lazy-reads the traefik_labels column.
func serviceTraefikLabels(db *database.DB, serviceID uuid.UUID) map[string]string {
var raw []byte
if err := db.QueryRow(`SELECT traefik_labels FROM services WHERE id = $1`, serviceID).Scan(&raw); err != nil {
raw, err := sqlcdb.New(db.DB).GetServiceTraefikLabels(context.Background(), serviceID)
if err != nil {
return nil
}
var labels map[string]string
@@ -211,7 +207,6 @@ func tagsJSON(tags []string) []byte {
// loadServiceNode fills the pin + spread + placement tags + the agent's
// display name for responses.
func loadServiceNode(db *database.DB, s *Service) {
var name sql.NullString
s.NodeID = serviceNodeID(db, s.ID)
s.Spread = serviceSpread(db, s.ID)
s.PlacementTags = servicePlacementTags(db, s.ID)
@@ -219,8 +214,8 @@ func loadServiceNode(db *database.DB, s *Service) {
if s.NodeID == "" {
return
}
if err := db.QueryRow(`SELECT name FROM node_agents WHERE id = $1`, s.NodeID).Scan(&name); err == nil {
s.NodeName = name.String
if name, err := sqlcdb.New(db.DB).GetNodeAgentName(context.Background(), s.NodeID); err == nil {
s.NodeName = name
}
}
@@ -235,8 +230,8 @@ type ServiceVolume struct {
// loadServiceVolumes reads the JSONB column; absent/invalid data degrades
// to no mounts rather than failing the request.
func loadServiceVolumes(db *database.DB, serviceID uuid.UUID) []deployment.VolumeMount {
var raw []byte
if err := db.QueryRow(`SELECT volumes FROM services WHERE id = $1`, serviceID).Scan(&raw); err != nil {
raw, err := sqlcdb.New(db.DB).GetServiceVolumes(context.Background(), serviceID)
if err != nil {
return nil
}
var stored []ServiceVolume
@@ -703,13 +698,11 @@ func handleCreateService(c *gin.Context) {
}
func getProjectEnvironmentID(db *database.DB, projectID uuid.UUID, environment string) (uuid.UUID, error) {
var environmentID uuid.UUID
err := db.QueryRow(
"SELECT id FROM environments WHERE project_id = $1 AND name = $2",
projectID,
environment,
).Scan(&environmentID)
return environmentID, err
row, err := sqlcdb.New(db.DB).GetProjectEnvironmentByName(context.Background(), sqlcdb.GetProjectEnvironmentByNameParams{
ProjectID: projectID,
Name: environment,
})
return row.ID, err
}
func inferServiceSourceType(service Service) string {
+14 -16
View File
@@ -10,11 +10,13 @@ import (
"time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/docker"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/api/types/registry"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// Platform setting keys. Values stored in app_settings take precedence over
@@ -70,8 +72,7 @@ func handleGetBranding(c *gin.Context) {
// then the caller's default.
func settingValue(db *database.DB, key, envKey, fallback string) string {
if db != nil && db.DB != nil {
var value string
err := db.QueryRow(`SELECT value FROM app_settings WHERE key = $1`, key).Scan(&value)
value, err := sqlcdb.New(db.DB).GetAppSetting(context.Background(), key)
if err == nil {
return value
}
@@ -88,18 +89,15 @@ func settingValue(db *database.DB, key, envKey, fallback string) string {
}
func setSetting(db *database.DB, key, value string, secret bool) error {
_, err := db.Exec(`
INSERT INTO app_settings (key, value, is_secret, updated_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (key) DO UPDATE
SET value = EXCLUDED.value, is_secret = EXCLUDED.is_secret, updated_at = NOW()
`, key, value, secret)
return err
return sqlcdb.New(db.DB).UpsertAppSetting(context.Background(), sqlcdb.UpsertAppSettingParams{
Key: key,
Value: value,
IsSecret: secret,
})
}
func deleteSetting(db *database.DB, key string) error {
_, err := db.Exec(`DELETE FROM app_settings WHERE key = $1`, key)
return err
return sqlcdb.New(db.DB).DeleteAppSetting(context.Background(), key)
}
// signupEnabled controls whether public registration stays open after the
@@ -110,11 +108,12 @@ func signupEnabled(db *database.DB) bool {
}
func isAdminUser(db *database.DB, userID string) bool {
var isAdmin bool
if err := db.QueryRow(`SELECT is_admin FROM users WHERE id = $1`, userID).Scan(&isAdmin); err != nil {
parsed, err := uuid.Parse(userID)
if err != nil {
return false
}
return isAdmin
isAdmin, err := sqlcdb.New(db.DB).GetUserIsAdmin(context.Background(), parsed)
return err == nil && isAdmin
}
func requireAdmin(c *gin.Context) (*database.DB, bool) {
@@ -242,8 +241,7 @@ func handleUpdateSettings(c *gin.Context) {
// resolvedTunnelToken prefers the in-app value and falls back to the env.
func resolvedTunnelToken(db *database.DB) (string, string) {
if db != nil && db.DB != nil {
var value string
if err := db.QueryRow(`SELECT value FROM app_settings WHERE key = $1`, settingCloudflareTunnelToken).Scan(&value); err == nil && strings.TrimSpace(value) != "" {
if value, err := sqlcdb.New(db.DB).GetAppSetting(context.Background(), settingCloudflareTunnelToken); err == nil && strings.TrimSpace(value) != "" {
return strings.TrimSpace(value), "app"
}
}
+3 -2
View File
@@ -5,10 +5,12 @@ package api
// lives in app_settings so it survives restarts.
import (
"context"
"net/http"
"strings"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"github.com/gin-gonic/gin"
)
@@ -25,8 +27,7 @@ func setupComplete(db *database.DB, hasUsers bool) bool {
if !hasUsers {
return false
}
var projects int
if err := db.QueryRow(`SELECT COUNT(*) FROM projects`).Scan(&projects); err == nil && projects > 0 {
if projects, err := sqlcdb.New(db.DB).CountProjects(context.Background()); err == nil && projects > 0 {
return true
}
return false
+33 -18
View File
@@ -640,19 +640,20 @@ func deployTemplateGraph(
createdServiceIDs := []uuid.UUID{}
createdDatabaseIDs := []string{}
cleanup := func() {
q := sqlcdb.New(db.DB)
for _, sid := range createdServiceIDs {
if engine != nil {
removeServiceRuntime(context.Background(), db, engine, sid)
}
_, _ = db.Exec(`DELETE FROM environment_variables WHERE service_id = $1`, sid)
_, _ = db.Exec(`DELETE FROM services WHERE id = $1`, sid)
_ = q.DeleteEnvVarsForService(context.Background(), sid)
_ = q.DeleteServiceByID(context.Background(), sid)
}
for _, did := range createdDatabaseIDs {
if dbHandler != nil && dbHandler.dockerClient != nil {
_ = dbHandler.dockerClient.RemoveContainer(context.Background(), managedDatabaseContainerName(did), true)
_ = dbHandler.dockerClient.RemoveVolume(context.Background(), managedDatabaseVolumeName(did), true)
}
_, _ = db.Exec(`DELETE FROM database_services WHERE id = $1`, did)
_ = q.DeleteDatabaseServiceByID(context.Background(), did)
}
}
@@ -772,14 +773,19 @@ func deployTemplateGraph(
if builder == "" {
builder = "auto"
}
if _, err = tx.ExecContext(ctx,
`UPDATE services SET port = $1, healthcheck_path = $2, volumes = COALESCE($3::jsonb, '[]'::jsonb),
git_repo = NULLIF($4, ''), git_branch = NULLIF($5, ''), builder = $6
WHERE id = $7`,
m.spec.Port, strings.TrimSpace(m.spec.HealthCheck), volumesJSON,
strings.TrimSpace(m.spec.Repo), strings.TrimSpace(m.spec.Branch), builder,
serviceID,
); err != nil {
var volumesRaw json.RawMessage
if s, ok := volumesJSON.(string); ok {
volumesRaw = json.RawMessage(s)
}
if err = sqlcdb.New(tx).UpdateServiceDeployMeta(ctx, sqlcdb.UpdateServiceDeployMetaParams{
Port: int32(m.spec.Port),
HealthcheckPath: strings.TrimSpace(m.spec.HealthCheck),
Column3: volumesRaw,
Column4: strings.TrimSpace(m.spec.Repo),
Column5: strings.TrimSpace(m.spec.Branch),
Builder: builder,
ID: serviceID,
}); err != nil {
tx.Rollback()
cleanup()
return nil, &gin.H{"error": "Failed to configure service"}
@@ -861,15 +867,24 @@ func queueTemplateDeployment(c *gin.Context, db *database.DB, engine *deployment
CreatedAt: now,
UpdatedAt: now,
}
if _, err := db.Exec(
`INSERT INTO deployments
(id, service_id, version, commit_hash, status, image_name, image_tag, created_at, updated_at)
VALUES ($1, $2, $3, NULL, $4, '', '', $5, $6)`,
dep.ID, dep.ServiceID, fmt.Sprintf("v%d", now.Unix()), dep.Status, dep.CreatedAt, dep.UpdatedAt,
); err != nil {
q := sqlcdb.New(db.DB)
if err := q.InsertDeployment(context.Background(), sqlcdb.InsertDeploymentParams{
ID: dep.ID,
ServiceID: dep.ServiceID,
Version: fmt.Sprintf("v%d", now.Unix()),
Status: sql.NullString{String: dep.Status, Valid: true},
ImageName: sql.NullString{String: "", Valid: true},
ImageTag: sql.NullString{String: "", Valid: true},
CreatedAt: sql.NullTime{Time: dep.CreatedAt, Valid: true},
UpdatedAt: sql.NullTime{Time: dep.UpdatedAt, Valid: true},
}); err != nil {
return uuid.Nil
}
_, _ = db.Exec(`UPDATE services SET status = 'queued', updated_at = $1 WHERE id = $2`, time.Now(), service.ID)
_ = q.SetServiceStatus(context.Background(), sqlcdb.SetServiceStatusParams{
Status: sql.NullString{String: "queued", Valid: true},
UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true},
ID: service.ID,
})
getDeployQueue(c).Enqueue(service.ID, deployqueue.Job{
DeploymentID: dep.ID,
+5 -6
View File
@@ -411,12 +411,11 @@ func handleCreateFromTemplate(c *gin.Context) {
return
}
// sqlc's schema predates the runtime columns; port and healthcheck_path are
// written via raw SQL like every other service mutation path.
if _, err = tx.ExecContext(ctx,
`UPDATE services SET port = $1, healthcheck_path = $2 WHERE id = $3`,
config.Port, strings.TrimSpace(config.HealthCheck), serviceID,
); err != nil {
if err = sqlcdb.New(tx).UpdateServiceRuntimeMeta(ctx, sqlcdb.UpdateServiceRuntimeMetaParams{
Port: int32(config.Port),
HealthcheckPath: strings.TrimSpace(config.HealthCheck),
ID: serviceID,
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create service from template"})
return
}
+16 -9
View File
@@ -1,11 +1,14 @@
package api
import (
"containr/internal/database"
"containr/internal/secrets"
"database/sql"
"net/http"
"time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/secrets"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
@@ -147,7 +150,8 @@ func handleUpdateVariables(c *gin.Context) {
}
defer tx.Rollback()
_, err = tx.Exec("DELETE FROM environment_variables WHERE service_id = $1", serviceID)
txq := sqlcdb.New(tx)
err = txq.DeleteEnvVarsForService(c.Request.Context(), serviceID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clear existing variables"})
return
@@ -164,12 +168,15 @@ func handleUpdateVariables(c *gin.Context) {
if v.IsSecret {
value = secrets.Encrypt(value)
}
varID := uuid.New()
_, err = tx.Exec(
`INSERT INTO environment_variables (id, service_id, key, value, is_secret, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
varID, serviceID, v.Key, value, v.IsSecret, now, now,
)
err = txq.InsertEnvVar(c.Request.Context(), sqlcdb.InsertEnvVarParams{
ID: uuid.New(),
ServiceID: serviceID,
Key: v.Key,
Value: value,
IsSecret: sql.NullBool{Bool: v.IsSecret, Valid: true},
CreatedAt: sql.NullTime{Time: now, Valid: true},
UpdatedAt: sql.NullTime{Time: now, Valid: true},
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to insert variable: " + v.Key})
return
@@ -0,0 +1,636 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: apwhy.sql
package sqlcdb
import (
"context"
"database/sql"
"github.com/google/uuid"
)
const countAPKeys = `-- name: CountAPKeys :one
SELECT COUNT(*) FROM api_keys
`
func (q *Queries) CountAPKeys(ctx context.Context) (int64, error) {
row := q.db.QueryRowContext(ctx, countAPKeys)
var count int64
err := row.Scan(&count)
return count, err
}
const countAPServices = `-- name: CountAPServices :one
SELECT COUNT(*) FROM api_services
`
func (q *Queries) CountAPServices(ctx context.Context) (int64, error) {
row := q.db.QueryRowContext(ctx, countAPServices)
var count int64
err := row.Scan(&count)
return count, err
}
const getAPServiceBySlug = `-- name: GetAPServiceBySlug :one
SELECT id, upstream_url, route_prefix, upstream_auth_header,
upstream_auth_value, enabled, rpm_limit, monthly_quota,
request_timeout_ms
FROM api_services WHERE slug = $1
`
type GetAPServiceBySlugRow struct {
ID uuid.UUID `json:"id"`
UpstreamUrl string `json:"upstream_url"`
RoutePrefix string `json:"route_prefix"`
UpstreamAuthHeader sql.NullString `json:"upstream_auth_header"`
UpstreamAuthValue sql.NullString `json:"upstream_auth_value"`
Enabled int32 `json:"enabled"`
RpmLimit sql.NullInt32 `json:"rpm_limit"`
MonthlyQuota sql.NullInt32 `json:"monthly_quota"`
RequestTimeoutMs sql.NullInt32 `json:"request_timeout_ms"`
}
func (q *Queries) GetAPServiceBySlug(ctx context.Context, slug string) (GetAPServiceBySlugRow, error) {
row := q.db.QueryRowContext(ctx, getAPServiceBySlug, slug)
var i GetAPServiceBySlugRow
err := row.Scan(
&i.ID,
&i.UpstreamUrl,
&i.RoutePrefix,
&i.UpstreamAuthHeader,
&i.UpstreamAuthValue,
&i.Enabled,
&i.RpmLimit,
&i.MonthlyQuota,
&i.RequestTimeoutMs,
)
return i, err
}
const getAPServiceForValidate = `-- name: GetAPServiceForValidate :one
SELECT upstream_url, health_path, upstream_auth_header, upstream_auth_value, request_timeout_ms
FROM api_services WHERE id = $1
`
type GetAPServiceForValidateRow struct {
UpstreamUrl string `json:"upstream_url"`
HealthPath string `json:"health_path"`
UpstreamAuthHeader sql.NullString `json:"upstream_auth_header"`
UpstreamAuthValue sql.NullString `json:"upstream_auth_value"`
RequestTimeoutMs sql.NullInt32 `json:"request_timeout_ms"`
}
func (q *Queries) GetAPServiceForValidate(ctx context.Context, id uuid.UUID) (GetAPServiceForValidateRow, error) {
row := q.db.QueryRowContext(ctx, getAPServiceForValidate, id)
var i GetAPServiceForValidateRow
err := row.Scan(
&i.UpstreamUrl,
&i.HealthPath,
&i.UpstreamAuthHeader,
&i.UpstreamAuthValue,
&i.RequestTimeoutMs,
)
return i, err
}
const insertAPKey = `-- name: InsertAPKey :one
INSERT INTO api_keys (
name, key_hash, key_prefix, plan, allowed_service_ids,
rpm_limit, monthly_quota, created_at, updated_at
) VALUES ($1, $2, $3, $4, '[]', $5, $6, NOW(), NOW())
RETURNING id
`
type InsertAPKeyParams struct {
Name string `json:"name"`
KeyHash string `json:"key_hash"`
KeyPrefix string `json:"key_prefix"`
Plan string `json:"plan"`
RpmLimit sql.NullInt32 `json:"rpm_limit"`
MonthlyQuota sql.NullInt32 `json:"monthly_quota"`
}
func (q *Queries) InsertAPKey(ctx context.Context, arg InsertAPKeyParams) (uuid.UUID, error) {
row := q.db.QueryRowContext(ctx, insertAPKey,
arg.Name,
arg.KeyHash,
arg.KeyPrefix,
arg.Plan,
arg.RpmLimit,
arg.MonthlyQuota,
)
var id uuid.UUID
err := row.Scan(&id)
return id, err
}
const insertAPService = `-- name: InsertAPService :one
INSERT INTO api_services (
name, slug, upstream_url, route_prefix, health_path,
rpm_limit, monthly_quota, created_at, updated_at
) VALUES ($1, $2, $3, $4, '/health', $5, $6, NOW(), NOW())
RETURNING id
`
type InsertAPServiceParams struct {
Name string `json:"name"`
Slug string `json:"slug"`
UpstreamUrl string `json:"upstream_url"`
RoutePrefix string `json:"route_prefix"`
RpmLimit sql.NullInt32 `json:"rpm_limit"`
MonthlyQuota sql.NullInt32 `json:"monthly_quota"`
}
func (q *Queries) InsertAPService(ctx context.Context, arg InsertAPServiceParams) (uuid.UUID, error) {
row := q.db.QueryRowContext(ctx, insertAPService,
arg.Name,
arg.Slug,
arg.UpstreamUrl,
arg.RoutePrefix,
arg.RpmLimit,
arg.MonthlyQuota,
)
var id uuid.UUID
err := row.Scan(&id)
return id, err
}
const insertGatewayIncident = `-- name: InsertGatewayIncident :exec
INSERT INTO incident_events (service_id, api_key_id, code, message, severity, http_status, occurred_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW())
`
type InsertGatewayIncidentParams struct {
ServiceID uuid.NullUUID `json:"service_id"`
ApiKeyID uuid.NullUUID `json:"api_key_id"`
Code string `json:"code"`
Message string `json:"message"`
Severity string `json:"severity"`
HttpStatus sql.NullInt32 `json:"http_status"`
}
func (q *Queries) InsertGatewayIncident(ctx context.Context, arg InsertGatewayIncidentParams) error {
_, err := q.db.ExecContext(ctx, insertGatewayIncident,
arg.ServiceID,
arg.ApiKeyID,
arg.Code,
arg.Message,
arg.Severity,
arg.HttpStatus,
)
return err
}
const insertMetricPoint = `-- name: InsertMetricPoint :exec
INSERT INTO metrics_timeseries (metric, value, labels_json, occurred_at)
VALUES ('gateway_request', 1, $1, NOW())
`
func (q *Queries) InsertMetricPoint(ctx context.Context, labelsJson sql.NullString) error {
_, err := q.db.ExecContext(ctx, insertMetricPoint, labelsJson)
return err
}
const insertServiceIncident = `-- name: InsertServiceIncident :exec
INSERT INTO incident_events (service_id, code, message, severity, http_status, occurred_at)
VALUES ($1, 'SERVICE_VALIDATION_FAILED', $2, 'medium', $3, NOW())
`
type InsertServiceIncidentParams struct {
ServiceID uuid.NullUUID `json:"service_id"`
Message string `json:"message"`
HttpStatus sql.NullInt32 `json:"http_status"`
}
func (q *Queries) InsertServiceIncident(ctx context.Context, arg InsertServiceIncidentParams) error {
_, err := q.db.ExecContext(ctx, insertServiceIncident, arg.ServiceID, arg.Message, arg.HttpStatus)
return err
}
const listAPKeys = `-- name: ListAPKeys :many
SELECT id, name, key_prefix, plan, enabled, rpm_limit, monthly_quota,
to_char(created_at, 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS created_at,
to_char(updated_at, 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS updated_at
FROM api_keys
ORDER BY created_at DESC
`
type ListAPKeysRow struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
KeyPrefix string `json:"key_prefix"`
Plan string `json:"plan"`
Enabled int32 `json:"enabled"`
RpmLimit sql.NullInt32 `json:"rpm_limit"`
MonthlyQuota sql.NullInt32 `json:"monthly_quota"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func (q *Queries) ListAPKeys(ctx context.Context) ([]ListAPKeysRow, error) {
rows, err := q.db.QueryContext(ctx, listAPKeys)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListAPKeysRow{}
for rows.Next() {
var i ListAPKeysRow
if err := rows.Scan(
&i.ID,
&i.Name,
&i.KeyPrefix,
&i.Plan,
&i.Enabled,
&i.RpmLimit,
&i.MonthlyQuota,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listAPKeysByPrefix = `-- name: ListAPKeysByPrefix :many
SELECT id, key_hash, enabled, rpm_limit, monthly_quota, allowed_service_ids
FROM api_keys WHERE key_prefix = $1
`
type ListAPKeysByPrefixRow struct {
ID uuid.UUID `json:"id"`
KeyHash string `json:"key_hash"`
Enabled int32 `json:"enabled"`
RpmLimit sql.NullInt32 `json:"rpm_limit"`
MonthlyQuota sql.NullInt32 `json:"monthly_quota"`
AllowedServiceIds string `json:"allowed_service_ids"`
}
func (q *Queries) ListAPKeysByPrefix(ctx context.Context, keyPrefix string) ([]ListAPKeysByPrefixRow, error) {
rows, err := q.db.QueryContext(ctx, listAPKeysByPrefix, keyPrefix)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListAPKeysByPrefixRow{}
for rows.Next() {
var i ListAPKeysByPrefixRow
if err := rows.Scan(
&i.ID,
&i.KeyHash,
&i.Enabled,
&i.RpmLimit,
&i.MonthlyQuota,
&i.AllowedServiceIds,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listAPServices = `-- name: ListAPServices :many
SELECT id, name, slug, upstream_url, route_prefix, enabled,
to_char(created_at, 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS created_at,
to_char(updated_at, 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS updated_at
FROM api_services
ORDER BY created_at DESC
`
type ListAPServicesRow struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
UpstreamUrl string `json:"upstream_url"`
RoutePrefix string `json:"route_prefix"`
Enabled int32 `json:"enabled"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func (q *Queries) ListAPServices(ctx context.Context) ([]ListAPServicesRow, error) {
rows, err := q.db.QueryContext(ctx, listAPServices)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListAPServicesRow{}
for rows.Next() {
var i ListAPServicesRow
if err := rows.Scan(
&i.ID,
&i.Name,
&i.Slug,
&i.UpstreamUrl,
&i.RoutePrefix,
&i.Enabled,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listClientEventPaths = `-- name: ListClientEventPaths :many
SELECT COALESCE((labels_json::jsonb ->> 'path'), 'unknown') AS path, COUNT(*) AS total
FROM metrics_timeseries
WHERE metric = 'client_event' AND occurred_at >= NOW() - INTERVAL '7 days'
GROUP BY COALESCE((labels_json::jsonb ->> 'path'), 'unknown')
ORDER BY total DESC, path ASC
LIMIT 20
`
type ListClientEventPathsRow struct {
Path interface{} `json:"path"`
Total int64 `json:"total"`
}
func (q *Queries) ListClientEventPaths(ctx context.Context) ([]ListClientEventPathsRow, error) {
rows, err := q.db.QueryContext(ctx, listClientEventPaths)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListClientEventPathsRow{}
for rows.Next() {
var i ListClientEventPathsRow
if err := rows.Scan(&i.Path, &i.Total); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listIncidentStatusCodes = `-- name: ListIncidentStatusCodes :many
SELECT COALESCE(http_status, 0) AS status_code, COALESCE(SUM(count), 0) AS total
FROM incident_events
WHERE occurred_at >= NOW() - INTERVAL '7 days'
GROUP BY COALESCE(http_status, 0)
ORDER BY total DESC, status_code ASC
`
type ListIncidentStatusCodesRow struct {
StatusCode int32 `json:"status_code"`
Total interface{} `json:"total"`
}
func (q *Queries) ListIncidentStatusCodes(ctx context.Context) ([]ListIncidentStatusCodesRow, error) {
rows, err := q.db.QueryContext(ctx, listIncidentStatusCodes)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListIncidentStatusCodesRow{}
for rows.Next() {
var i ListIncidentStatusCodesRow
if err := rows.Scan(&i.StatusCode, &i.Total); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listRequestsByDay = `-- name: ListRequestsByDay :many
SELECT TO_CHAR(DATE_TRUNC('day', occurred_at), 'YYYY-MM-DD') AS day_bucket, COUNT(*) AS total
FROM metrics_timeseries
WHERE metric = 'request_total' AND occurred_at >= NOW() - INTERVAL '7 days'
GROUP BY DATE_TRUNC('day', occurred_at)
ORDER BY DATE_TRUNC('day', occurred_at) ASC
`
type ListRequestsByDayRow struct {
DayBucket string `json:"day_bucket"`
Total int64 `json:"total"`
}
func (q *Queries) ListRequestsByDay(ctx context.Context) ([]ListRequestsByDayRow, error) {
rows, err := q.db.QueryContext(ctx, listRequestsByDay)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListRequestsByDayRow{}
for rows.Next() {
var i ListRequestsByDayRow
if err := rows.Scan(&i.DayBucket, &i.Total); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listTopAPServices = `-- name: ListTopAPServices :many
SELECT s.id, s.name, COALESCE(SUM(u.request_count), 0) AS total_requests
FROM api_services s
LEFT JOIN usage_counters u ON u.service_id = s.id
GROUP BY s.id, s.name
ORDER BY total_requests DESC, s.name ASC
LIMIT 10
`
type ListTopAPServicesRow struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
TotalRequests interface{} `json:"total_requests"`
}
func (q *Queries) ListTopAPServices(ctx context.Context) ([]ListTopAPServicesRow, error) {
rows, err := q.db.QueryContext(ctx, listTopAPServices)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListTopAPServicesRow{}
for rows.Next() {
var i ListTopAPServicesRow
if err := rows.Scan(&i.ID, &i.Name, &i.TotalRequests); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const setAPKeyEnabled = `-- name: SetAPKeyEnabled :exec
UPDATE api_keys SET enabled = $1, updated_at = NOW() WHERE id = $2
`
type SetAPKeyEnabledParams struct {
Enabled int32 `json:"enabled"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) SetAPKeyEnabled(ctx context.Context, arg SetAPKeyEnabledParams) error {
_, err := q.db.ExecContext(ctx, setAPKeyEnabled, arg.Enabled, arg.ID)
return err
}
const setAPServiceEnabled = `-- name: SetAPServiceEnabled :exec
UPDATE api_services SET enabled = $1, updated_at = NOW() WHERE id = $2
`
type SetAPServiceEnabledParams struct {
Enabled int32 `json:"enabled"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) SetAPServiceEnabled(ctx context.Context, arg SetAPServiceEnabledParams) error {
_, err := q.db.ExecContext(ctx, setAPServiceEnabled, arg.Enabled, arg.ID)
return err
}
const setAPServiceValidation = `-- name: SetAPServiceValidation :exec
UPDATE api_services
SET last_validation_at = NOW(), last_validation_status = $1, last_validation_message = $2, updated_at = NOW()
WHERE id = $3
`
type SetAPServiceValidationParams struct {
LastValidationStatus sql.NullString `json:"last_validation_status"`
LastValidationMessage sql.NullString `json:"last_validation_message"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) SetAPServiceValidation(ctx context.Context, arg SetAPServiceValidationParams) error {
_, err := q.db.ExecContext(ctx, setAPServiceValidation, arg.LastValidationStatus, arg.LastValidationMessage, arg.ID)
return err
}
const sumRequestMetricSince = `-- name: SumRequestMetricSince :one
SELECT COALESCE(SUM(value), 0)::int
FROM metrics_timeseries
WHERE metric = 'request_total' AND occurred_at >= DATE_TRUNC($1::text, NOW())
`
func (q *Queries) SumRequestMetricSince(ctx context.Context, dollar_1 string) (int32, error) {
row := q.db.QueryRowContext(ctx, sumRequestMetricSince, dollar_1)
var column_1 int32
err := row.Scan(&column_1)
return column_1, err
}
const sumUsageByKey = `-- name: SumUsageByKey :one
SELECT COALESCE(SUM(request_count), 0)::bigint FROM usage_counters
WHERE api_key_id = $1 AND period_month = $2
`
type SumUsageByKeyParams struct {
ApiKeyID uuid.UUID `json:"api_key_id"`
PeriodMonth string `json:"period_month"`
}
func (q *Queries) SumUsageByKey(ctx context.Context, arg SumUsageByKeyParams) (int64, error) {
row := q.db.QueryRowContext(ctx, sumUsageByKey, arg.ApiKeyID, arg.PeriodMonth)
var column_1 int64
err := row.Scan(&column_1)
return column_1, err
}
const sumUsageByService = `-- name: SumUsageByService :one
SELECT COALESCE(SUM(request_count), 0)::bigint FROM usage_counters
WHERE service_id = $1 AND period_month = $2
`
type SumUsageByServiceParams struct {
ServiceID uuid.UUID `json:"service_id"`
PeriodMonth string `json:"period_month"`
}
func (q *Queries) SumUsageByService(ctx context.Context, arg SumUsageByServiceParams) (int64, error) {
row := q.db.QueryRowContext(ctx, sumUsageByService, arg.ServiceID, arg.PeriodMonth)
var column_1 int64
err := row.Scan(&column_1)
return column_1, err
}
const sumUsageCounters = `-- name: SumUsageCounters :one
SELECT COALESCE(SUM(request_count), 0) FROM usage_counters
`
func (q *Queries) SumUsageCounters(ctx context.Context) (interface{}, error) {
row := q.db.QueryRowContext(ctx, sumUsageCounters)
var coalesce interface{}
err := row.Scan(&coalesce)
return coalesce, err
}
const touchAPKeyLastUsed = `-- name: TouchAPKeyLastUsed :exec
UPDATE api_keys SET last_used_at = NOW() WHERE id = $1
`
func (q *Queries) TouchAPKeyLastUsed(ctx context.Context, id uuid.UUID) error {
_, err := q.db.ExecContext(ctx, touchAPKeyLastUsed, id)
return err
}
const upsertUsageCounter = `-- name: UpsertUsageCounter :exec
INSERT INTO usage_counters (api_key_id, service_id, period_month, request_count, updated_at)
VALUES ($1, $2, $3, 1, NOW())
ON CONFLICT (api_key_id, service_id, period_month)
DO UPDATE SET request_count = usage_counters.request_count + 1, updated_at = NOW()
`
type UpsertUsageCounterParams struct {
ApiKeyID uuid.UUID `json:"api_key_id"`
ServiceID uuid.UUID `json:"service_id"`
PeriodMonth string `json:"period_month"`
}
func (q *Queries) UpsertUsageCounter(ctx context.Context, arg UpsertUsageCounterParams) error {
_, err := q.db.ExecContext(ctx, upsertUsageCounter, arg.ApiKeyID, arg.ServiceID, arg.PeriodMonth)
return err
}
@@ -0,0 +1,218 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: audit.sql
package sqlcdb
import (
"context"
"database/sql"
"encoding/json"
"time"
"github.com/google/uuid"
"github.com/sqlc-dev/pqtype"
)
const insertAuditLog = `-- name: InsertAuditLog :exec
INSERT INTO audit_logs (id, user_id, resource, resource_id, action, details, severity, category, label, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
`
type InsertAuditLogParams struct {
ID uuid.UUID `json:"id"`
UserID uuid.NullUUID `json:"user_id"`
Resource string `json:"resource"`
ResourceID uuid.NullUUID `json:"resource_id"`
Action string `json:"action"`
Details pqtype.NullRawMessage `json:"details"`
Severity string `json:"severity"`
Category string `json:"category"`
Label string `json:"label"`
CreatedAt time.Time `json:"created_at"`
}
func (q *Queries) InsertAuditLog(ctx context.Context, arg InsertAuditLogParams) error {
_, err := q.db.ExecContext(ctx, insertAuditLog,
arg.ID,
arg.UserID,
arg.Resource,
arg.ResourceID,
arg.Action,
arg.Details,
arg.Severity,
arg.Category,
arg.Label,
arg.CreatedAt,
)
return err
}
const insertAuditLogWithRequest = `-- name: InsertAuditLogWithRequest :exec
INSERT INTO audit_logs (id, user_id, resource, resource_id, action, details, ip_address, user_agent, severity, category, label, created_at)
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7::inet, $8, $9, $10, $11, $12)
`
type InsertAuditLogWithRequestParams struct {
ID uuid.UUID `json:"id"`
UserID uuid.NullUUID `json:"user_id"`
Resource string `json:"resource"`
ResourceID uuid.NullUUID `json:"resource_id"`
Action string `json:"action"`
Column6 json.RawMessage `json:"column_6"`
Column7 pqtype.Inet `json:"column_7"`
UserAgent sql.NullString `json:"user_agent"`
Severity string `json:"severity"`
Category string `json:"category"`
Label string `json:"label"`
CreatedAt time.Time `json:"created_at"`
}
func (q *Queries) InsertAuditLogWithRequest(ctx context.Context, arg InsertAuditLogWithRequestParams) error {
_, err := q.db.ExecContext(ctx, insertAuditLogWithRequest,
arg.ID,
arg.UserID,
arg.Resource,
arg.ResourceID,
arg.Action,
arg.Column6,
arg.Column7,
arg.UserAgent,
arg.Severity,
arg.Category,
arg.Label,
arg.CreatedAt,
)
return err
}
const listAuditBackfillBatch = `-- name: ListAuditBackfillBatch :many
SELECT id::text, resource, action FROM audit_logs
WHERE label = '' AND id > $1::uuid ORDER BY id LIMIT 500
`
type ListAuditBackfillBatchRow struct {
ID string `json:"id"`
Resource string `json:"resource"`
Action string `json:"action"`
}
func (q *Queries) ListAuditBackfillBatch(ctx context.Context, dollar_1 uuid.UUID) ([]ListAuditBackfillBatchRow, error) {
rows, err := q.db.QueryContext(ctx, listAuditBackfillBatch, dollar_1)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListAuditBackfillBatchRow{}
for rows.Next() {
var i ListAuditBackfillBatchRow
if err := rows.Scan(&i.ID, &i.Resource, &i.Action); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listResourceAuditLogs = `-- name: ListResourceAuditLogs :many
SELECT id, COALESCE(user_id::text, '')::text AS user_id, resource,
COALESCE(resource_id::text, '')::text AS resource_id, action,
COALESCE(details::text, '{}')::text AS details,
COALESCE(ip_address::text, '')::text AS ip_address,
COALESCE(user_agent, '') AS user_agent, created_at
FROM audit_logs
WHERE user_id::text = $1 AND resource = $2 AND resource_id::text = $3
ORDER BY created_at DESC
LIMIT 100
`
type ListResourceAuditLogsParams struct {
UserID uuid.NullUUID `json:"user_id"`
Resource string `json:"resource"`
ResourceID uuid.NullUUID `json:"resource_id"`
}
type ListResourceAuditLogsRow struct {
ID uuid.UUID `json:"id"`
UserID string `json:"user_id"`
Resource string `json:"resource"`
ResourceID string `json:"resource_id"`
Action string `json:"action"`
Details string `json:"details"`
IpAddress string `json:"ip_address"`
UserAgent string `json:"user_agent"`
CreatedAt time.Time `json:"created_at"`
}
func (q *Queries) ListResourceAuditLogs(ctx context.Context, arg ListResourceAuditLogsParams) ([]ListResourceAuditLogsRow, error) {
rows, err := q.db.QueryContext(ctx, listResourceAuditLogs, arg.UserID, arg.Resource, arg.ResourceID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListResourceAuditLogsRow{}
for rows.Next() {
var i ListResourceAuditLogsRow
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.Resource,
&i.ResourceID,
&i.Action,
&i.Details,
&i.IpAddress,
&i.UserAgent,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const pruneAuditLogs = `-- name: PruneAuditLogs :execrows
DELETE FROM audit_logs WHERE created_at < NOW() - ($1::int * INTERVAL '1 day')
`
func (q *Queries) PruneAuditLogs(ctx context.Context, dollar_1 int32) (int64, error) {
result, err := q.db.ExecContext(ctx, pruneAuditLogs, dollar_1)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const setAuditLogMetadata = `-- name: SetAuditLogMetadata :exec
UPDATE audit_logs SET severity = $1, category = $2, label = $3 WHERE id = $4::uuid
`
type SetAuditLogMetadataParams struct {
Severity string `json:"severity"`
Category string `json:"category"`
Label string `json:"label"`
Column4 uuid.UUID `json:"column_4"`
}
func (q *Queries) SetAuditLogMetadata(ctx context.Context, arg SetAuditLogMetadataParams) error {
_, err := q.db.ExecContext(ctx, setAuditLogMetadata,
arg.Severity,
arg.Category,
arg.Label,
arg.Column4,
)
return err
}
@@ -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
}
@@ -157,6 +157,28 @@ func (q *Queries) GetGitProviderTimestamps(ctx context.Context, id uuid.UUID) (G
return i, err
}
const getGitRepoCloneByFullName = `-- name: GetGitRepoCloneByFullName :one
SELECT clone_url, provider_id FROM git_repositories
WHERE full_name = $1 AND user_id = $2 LIMIT 1
`
type GetGitRepoCloneByFullNameParams struct {
FullName string `json:"full_name"`
UserID uuid.UUID `json:"user_id"`
}
type GetGitRepoCloneByFullNameRow struct {
CloneUrl string `json:"clone_url"`
ProviderID uuid.UUID `json:"provider_id"`
}
func (q *Queries) GetGitRepoCloneByFullName(ctx context.Context, arg GetGitRepoCloneByFullNameParams) (GetGitRepoCloneByFullNameRow, error) {
row := q.db.QueryRowContext(ctx, getGitRepoCloneByFullName, arg.FullName, arg.UserID)
var i GetGitRepoCloneByFullNameRow
err := row.Scan(&i.CloneUrl, &i.ProviderID)
return i, err
}
const getGitRepoForPush = `-- name: GetGitRepoForPush :one
SELECT clone_url, full_name, user_id FROM git_repositories WHERE id = $1
`
@@ -174,6 +196,23 @@ func (q *Queries) GetGitRepoForPush(ctx context.Context, id uuid.UUID) (GetGitRe
return i, err
}
const getGitRepoProviderByCloneURL = `-- name: GetGitRepoProviderByCloneURL :one
SELECT provider_id FROM git_repositories
WHERE clone_url = $1 AND user_id = $2 LIMIT 1
`
type GetGitRepoProviderByCloneURLParams struct {
CloneUrl string `json:"clone_url"`
UserID uuid.UUID `json:"user_id"`
}
func (q *Queries) GetGitRepoProviderByCloneURL(ctx context.Context, arg GetGitRepoProviderByCloneURLParams) (uuid.UUID, error) {
row := q.db.QueryRowContext(ctx, getGitRepoProviderByCloneURL, arg.CloneUrl, arg.UserID)
var provider_id uuid.UUID
err := row.Scan(&provider_id)
return provider_id, err
}
const getGitRepositoryByFullName = `-- name: GetGitRepositoryByFullName :one
SELECT id, provider_id, name, full_name, COALESCE(description, '') AS description,
clone_url, COALESCE(webhook_url, '') AS webhook_url,
@@ -0,0 +1,174 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: misc.sql
package sqlcdb
import (
"context"
"database/sql"
"encoding/json"
"time"
"github.com/google/uuid"
)
const countProjects = `-- name: CountProjects :one
SELECT COUNT(*) FROM projects
`
func (q *Queries) CountProjects(ctx context.Context) (int64, error) {
row := q.db.QueryRowContext(ctx, countProjects)
var count int64
err := row.Scan(&count)
return count, err
}
const deleteProjectVariables = `-- name: DeleteProjectVariables :exec
DELETE FROM project_variables WHERE project_id = $1
`
func (q *Queries) DeleteProjectVariables(ctx context.Context, projectID uuid.UUID) error {
_, err := q.db.ExecContext(ctx, deleteProjectVariables, projectID)
return err
}
const getProjectVariableValue = `-- name: GetProjectVariableValue :one
SELECT value FROM project_variables WHERE project_id = $1 AND key = $2
`
type GetProjectVariableValueParams struct {
ProjectID uuid.UUID `json:"project_id"`
Key string `json:"key"`
}
func (q *Queries) GetProjectVariableValue(ctx context.Context, arg GetProjectVariableValueParams) (string, error) {
row := q.db.QueryRowContext(ctx, getProjectVariableValue, arg.ProjectID, arg.Key)
var value string
err := row.Scan(&value)
return value, err
}
const getRegistryAuth = `-- name: GetRegistryAuth :one
SELECT username, password FROM registries
WHERE owner_id::text = $1 AND host = $2
`
type GetRegistryAuthParams struct {
OwnerID uuid.UUID `json:"owner_id"`
Host string `json:"host"`
}
type GetRegistryAuthRow struct {
Username string `json:"username"`
Password string `json:"password"`
}
func (q *Queries) GetRegistryAuth(ctx context.Context, arg GetRegistryAuthParams) (GetRegistryAuthRow, error) {
row := q.db.QueryRowContext(ctx, getRegistryAuth, arg.OwnerID, arg.Host)
var i GetRegistryAuthRow
err := row.Scan(&i.Username, &i.Password)
return i, err
}
const insertEnvVar = `-- name: InsertEnvVar :exec
INSERT INTO environment_variables (id, service_id, key, value, is_secret, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`
type InsertEnvVarParams struct {
ID uuid.UUID `json:"id"`
ServiceID uuid.UUID `json:"service_id"`
Key string `json:"key"`
Value string `json:"value"`
IsSecret sql.NullBool `json:"is_secret"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
func (q *Queries) InsertEnvVar(ctx context.Context, arg InsertEnvVarParams) error {
_, err := q.db.ExecContext(ctx, insertEnvVar,
arg.ID,
arg.ServiceID,
arg.Key,
arg.Value,
arg.IsSecret,
arg.CreatedAt,
arg.UpdatedAt,
)
return err
}
const insertProjectVariable = `-- name: InsertProjectVariable :exec
INSERT INTO project_variables (id, project_id, key, value, is_secret, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`
type InsertProjectVariableParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Key string `json:"key"`
Value string `json:"value"`
IsSecret bool `json:"is_secret"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (q *Queries) InsertProjectVariable(ctx context.Context, arg InsertProjectVariableParams) error {
_, err := q.db.ExecContext(ctx, insertProjectVariable,
arg.ID,
arg.ProjectID,
arg.Key,
arg.Value,
arg.IsSecret,
arg.CreatedAt,
arg.UpdatedAt,
)
return err
}
const updateServiceDeployMeta = `-- name: UpdateServiceDeployMeta :exec
UPDATE services SET port = $1, healthcheck_path = $2,
volumes = COALESCE($3::jsonb, '[]'::jsonb),
git_repo = NULLIF($4::text, ''), git_branch = NULLIF($5::text, ''), builder = $6
WHERE id = $7
`
type UpdateServiceDeployMetaParams struct {
Port int32 `json:"port"`
HealthcheckPath string `json:"healthcheck_path"`
Column3 json.RawMessage `json:"column_3"`
Column4 string `json:"column_4"`
Column5 string `json:"column_5"`
Builder string `json:"builder"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) UpdateServiceDeployMeta(ctx context.Context, arg UpdateServiceDeployMetaParams) error {
_, err := q.db.ExecContext(ctx, updateServiceDeployMeta,
arg.Port,
arg.HealthcheckPath,
arg.Column3,
arg.Column4,
arg.Column5,
arg.Builder,
arg.ID,
)
return err
}
const updateServiceRuntimeMeta = `-- name: UpdateServiceRuntimeMeta :exec
UPDATE services SET port = $1, healthcheck_path = $2 WHERE id = $3
`
type UpdateServiceRuntimeMetaParams struct {
Port int32 `json:"port"`
HealthcheckPath string `json:"healthcheck_path"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) UpdateServiceRuntimeMeta(ctx context.Context, arg UpdateServiceRuntimeMetaParams) error {
_, err := q.db.ExecContext(ctx, updateServiceRuntimeMeta, arg.Port, arg.HealthcheckPath, arg.ID)
return err
}
@@ -49,6 +49,66 @@ type AgentHeartbeat struct {
CreatedAt time.Time `json:"created_at"`
}
type ApiKey struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
KeyHash string `json:"key_hash"`
KeyPrefix string `json:"key_prefix"`
Plan string `json:"plan"`
AllowedServiceIds string `json:"allowed_service_ids"`
Enabled int32 `json:"enabled"`
RpmLimit sql.NullInt32 `json:"rpm_limit"`
MonthlyQuota sql.NullInt32 `json:"monthly_quota"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
LastUsedAt sql.NullTime `json:"last_used_at"`
}
type ApiService struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
UpstreamUrl string `json:"upstream_url"`
RoutePrefix string `json:"route_prefix"`
HealthPath string `json:"health_path"`
UpstreamAuthHeader sql.NullString `json:"upstream_auth_header"`
UpstreamAuthValue sql.NullString `json:"upstream_auth_value"`
InternalToken sql.NullString `json:"internal_token"`
Enabled int32 `json:"enabled"`
RpmLimit sql.NullInt32 `json:"rpm_limit"`
MonthlyQuota sql.NullInt32 `json:"monthly_quota"`
RequestTimeoutMs sql.NullInt32 `json:"request_timeout_ms"`
LastValidationAt sql.NullTime `json:"last_validation_at"`
LastValidationStatus sql.NullString `json:"last_validation_status"`
LastValidationMessage sql.NullString `json:"last_validation_message"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type AppSetting struct {
Key string `json:"key"`
Value string `json:"value"`
IsSecret bool `json:"is_secret"`
UpdatedAt time.Time `json:"updated_at"`
}
type AuditLog struct {
ID uuid.UUID `json:"id"`
Timestamp time.Time `json:"timestamp"`
UserID uuid.NullUUID `json:"user_id"`
Action string `json:"action"`
Resource string `json:"resource"`
ResourceID uuid.NullUUID `json:"resource_id"`
Details pqtype.NullRawMessage `json:"details"`
IpAddress pqtype.Inet `json:"ip_address"`
UserAgent sql.NullString `json:"user_agent"`
Success bool `json:"success"`
CreatedAt time.Time `json:"created_at"`
Severity string `json:"severity"`
Category string `json:"category"`
Label string `json:"label"`
}
type BackupTarget struct {
ID string `json:"id"`
UserID string `json:"user_id"`
@@ -78,6 +138,46 @@ type Banner struct {
UpdatedAt sql.NullTime `json:"updated_at"`
}
type Build struct {
ID string `json:"id"`
ProjectID sql.NullString `json:"project_id"`
ServiceID sql.NullString `json:"service_id"`
Status string `json:"status"`
Progress int32 `json:"progress"`
StartedAt time.Time `json:"started_at"`
CompletedAt sql.NullTime `json:"completed_at"`
ImageName string `json:"image_name"`
ImageTag string `json:"image_tag"`
Size int64 `json:"size"`
Error sql.NullString `json:"error"`
Log string `json:"log"`
Metadata json.RawMessage `json:"metadata"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ComplianceFramework struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
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 {
ID string `json:"id"`
Name string `json:"name"`
@@ -240,6 +340,18 @@ type GitWebhook struct {
UpdatedAt sql.NullTime `json:"updated_at"`
}
type IncidentEvent struct {
ID int32 `json:"id"`
ServiceID uuid.NullUUID `json:"service_id"`
ApiKeyID uuid.NullUUID `json:"api_key_id"`
Code string `json:"code"`
Message string `json:"message"`
Severity string `json:"severity"`
HttpStatus sql.NullInt32 `json:"http_status"`
Count int32 `json:"count"`
OccurredAt time.Time `json:"occurred_at"`
}
type InstanceMetric struct {
ServiceID string `json:"service_id"`
Timestamp time.Time `json:"timestamp"`
@@ -265,6 +377,14 @@ type InstanceMetric struct {
CreatedAt sql.NullTime `json:"created_at"`
}
type MetricsTimeseries struct {
ID int32 `json:"id"`
Metric string `json:"metric"`
Value float32 `json:"value"`
LabelsJson sql.NullString `json:"labels_json"`
OccurredAt time.Time `json:"occurred_at"`
}
type NodeAgent struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -386,6 +506,16 @@ type ProjectMember struct {
CreatedAt sql.NullTime `json:"created_at"`
}
type ProjectVariable struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Key string `json:"key"`
Value string `json:"value"`
IsSecret bool `json:"is_secret"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Registry struct {
ID uuid.UUID `json:"id"`
OwnerID uuid.UUID `json:"owner_id"`
@@ -397,6 +527,19 @@ type Registry struct {
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 {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
@@ -500,6 +643,26 @@ type ServiceTemplate struct {
UpdatedAt sql.NullTime `json:"updated_at"`
}
type UsageCounter struct {
ID int32 `json:"id"`
ApiKeyID uuid.UUID `json:"api_key_id"`
ServiceID uuid.UUID `json:"service_id"`
PeriodMonth string `json:"period_month"`
RequestCount int32 `json:"request_count"`
UpdatedAt time.Time `json:"updated_at"`
}
type User struct {
ID uuid.UUID `json:"id"`
Email string `json:"email"`
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 {
ID uuid.UUID `json:"id"`
TokenHash string `json:"token_hash"`
@@ -524,6 +687,21 @@ type UserToken struct {
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 {
ID uuid.UUID `json:"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
// bucket-sized buckets, then back to timestamptz.
AggregateNodeMetrics(ctx context.Context, arg AggregateNodeMetricsParams) ([]AggregateNodeMetricsRow, error)
CancelBuild(ctx context.Context, arg CancelBuildParams) (int64, error)
CheckSecurityProjectAccess(ctx context.Context, arg CheckSecurityProjectAccessParams) (bool, error)
ClearServiceDomainDefault(ctx context.Context, serviceID uuid.UUID) error
ClearServiceNodePins(ctx context.Context, nodeID sql.NullString) (int64, error)
CloneServiceDomains(ctx context.Context, arg CloneServiceDomainsParams) error
@@ -23,6 +25,9 @@ type Querier interface {
CloneServiceVariables(ctx context.Context, arg CloneServiceVariablesParams) error
CompleteCommand(ctx context.Context, arg CompleteCommandParams) (AgentCommand, error)
CompleteDeployment(ctx context.Context, arg CompleteDeploymentParams) error
ComplianceFrameworkExists(ctx context.Context, id uuid.UUID) (bool, error)
CountAPKeys(ctx context.Context) (int64, error)
CountAPServices(ctx context.Context) (int64, error)
CountAccessibleDeployments(ctx context.Context, arg CountAccessibleDeploymentsParams) (int64, error)
CountActivePreviewsForBranch(ctx context.Context, arg CountActivePreviewsForBranchParams) (int64, error)
CountDatabaseServicesByUserAndName(ctx context.Context, arg CountDatabaseServicesByUserAndNameParams) (int64, error)
@@ -30,12 +35,15 @@ type Querier interface {
CountDeploymentsForService(ctx context.Context, serviceID uuid.UUID) (int64, error)
CountEnvironmentServices(ctx context.Context, environmentID uuid.UUID) (int64, error)
CountGitRepositories(ctx context.Context, arg CountGitRepositoriesParams) (int64, error)
CountProjects(ctx context.Context) (int64, error)
CountProjectsByUser(ctx context.Context, arg CountProjectsByUserParams) (int64, error)
CountServiceDomains(ctx context.Context, serviceID uuid.UUID) (int64, error)
CountServicesByName(ctx context.Context, arg CountServicesByNameParams) (int64, error)
CountServicesByProjectAndName(ctx context.Context, arg CountServicesByProjectAndNameParams) (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)
CreateAgentAuthToken(ctx context.Context, arg CreateAgentAuthTokenParams) (AgentAuthToken, error)
CreateBackupTarget(ctx context.Context, arg CreateBackupTargetParams) error
@@ -59,31 +67,41 @@ type Querier interface {
DatabaseServiceExistsByIDAndUser(ctx context.Context, arg DatabaseServiceExistsByIDAndUserParams) (bool, error)
DeleteAgent(ctx context.Context, id string) error
DeleteAllServiceDomains(ctx context.Context, serviceID uuid.UUID) error
DeleteAppSetting(ctx context.Context, key string) error
DeleteBackupTargetByIDAndUser(ctx context.Context, arg DeleteBackupTargetByIDAndUserParams) error
DeleteBanner(ctx context.Context, id uuid.UUID) error
DeleteCronJob(ctx context.Context, id uuid.UUID) error
DeleteDatabaseServiceByID(ctx context.Context, id string) error
DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) error
DeleteEnvVarsForService(ctx context.Context, serviceID uuid.UUID) error
DeleteGitProvider(ctx context.Context, arg DeleteGitProviderParams) (int64, error)
DeleteNotificationChannel(ctx context.Context, arg DeleteNotificationChannelParams) (int64, error)
DeleteOutboundWebhookByIDAndUser(ctx context.Context, arg DeleteOutboundWebhookByIDAndUserParams) error
DeletePreviewEnvironment(ctx context.Context, id uuid.UUID) error
DeleteProjectByID(ctx context.Context, projectID uuid.UUID) (int64, error)
DeleteProjectEnvironment(ctx context.Context, arg DeleteProjectEnvironmentParams) error
DeleteProjectVariables(ctx context.Context, projectID uuid.UUID) error
DeleteServiceByID(ctx context.Context, id uuid.UUID) error
DeleteServiceContainersOnAgent(ctx context.Context, arg DeleteServiceContainersOnAgentParams) error
DeleteServiceDomain(ctx context.Context, arg DeleteServiceDomainParams) (int64, error)
DeleteUserInvite(ctx context.Context, id uuid.UUID) error
DeleteUserTemplate(ctx context.Context, arg DeleteUserTemplateParams) (int64, error)
FailDeployment(ctx context.Context, arg FailDeploymentParams) error
FailInterruptedDeployments(ctx context.Context) error
FinishCronExecution(ctx context.Context, arg FinishCronExecutionParams) error
GetAPServiceBySlug(ctx context.Context, slug string) (GetAPServiceBySlugRow, error)
GetAPServiceForValidate(ctx context.Context, id uuid.UUID) (GetAPServiceForValidateRow, error)
GetActiveAgentAuthTokenByHash(ctx context.Context, tokenHash string) (AgentAuthToken, error)
GetAdminUserInfo(ctx context.Context, id uuid.UUID) (GetAdminUserInfoRow, error)
GetAgent(ctx context.Context, id string) (NodeAgent, error)
GetAgentByHostAndIP(ctx context.Context, arg GetAgentByHostAndIPParams) (NodeAgent, error)
GetAppSetting(ctx context.Context, key string) (string, error)
GetBackupTargetByID(ctx context.Context, id string) (BackupTarget, error)
GetBackupTargetByIDAndUser(ctx context.Context, arg GetBackupTargetByIDAndUserParams) (BackupTarget, error)
GetBannerByID(ctx context.Context, id uuid.UUID) (Banner, error)
GetBuild(ctx context.Context, id string) (GetBuildRow, error)
GetCommandForAgent(ctx context.Context, arg GetCommandForAgentParams) (AgentCommand, error)
GetComplianceReportProjectID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
GetContainer(ctx context.Context, id string) (ContainerInstance, error)
GetContainerForAgent(ctx context.Context, arg GetContainerForAgentParams) (ContainerInstance, error)
GetCronJob(ctx context.Context, id uuid.UUID) (CronJob, error)
@@ -98,17 +116,23 @@ type Querier interface {
GetDeploymentAccess(ctx context.Context, id uuid.UUID) (GetDeploymentAccessRow, error)
GetDeploymentForRollback(ctx context.Context, id uuid.UUID) (GetDeploymentForRollbackRow, error)
GetDeploymentWithProject(ctx context.Context, id uuid.UUID) (GetDeploymentWithProjectRow, error)
GetEnvironmentProjectID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
GetGitProviderForFetch(ctx context.Context, arg GetGitProviderForFetchParams) (GetGitProviderForFetchRow, error)
GetGitProviderForRepos(ctx context.Context, arg GetGitProviderForReposParams) (GetGitProviderForReposRow, error)
GetGitProviderName(ctx context.Context, arg GetGitProviderNameParams) (string, error)
GetGitProviderNameByID(ctx context.Context, id uuid.UUID) (string, error)
GetGitProviderTimestamps(ctx context.Context, id uuid.UUID) (GetGitProviderTimestampsRow, error)
GetGitRepoCloneByFullName(ctx context.Context, arg GetGitRepoCloneByFullNameParams) (GetGitRepoCloneByFullNameRow, error)
GetGitRepoForPush(ctx context.Context, id uuid.UUID) (GetGitRepoForPushRow, error)
GetGitRepoProviderByCloneURL(ctx context.Context, arg GetGitRepoProviderByCloneURLParams) (uuid.UUID, error)
GetGitRepositoryByFullName(ctx context.Context, arg GetGitRepositoryByFullNameParams) (GetGitRepositoryByFullNameRow, error)
GetGitRepositoryProviderID(ctx context.Context, arg GetGitRepositoryProviderIDParams) (uuid.UUID, error)
GetGitWebhookForPush(ctx context.Context, id uuid.UUID) (GetGitWebhookForPushRow, error)
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)
GetOutboundWebhookByIDAndUser(ctx context.Context, arg GetOutboundWebhookByIDAndUserParams) (OutboundWebhook, error)
GetPreviewEnvironment(ctx context.Context, arg GetPreviewEnvironmentParams) (GetPreviewEnvironmentRow, error)
@@ -122,7 +146,12 @@ type Querier interface {
GetProjectOwner(ctx context.Context, id 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)
GetProjectOwnerText(ctx context.Context, id uuid.UUID) (string, error)
GetProjectReadAccess(ctx context.Context, id uuid.UUID) (GetProjectReadAccessRow, error)
GetProjectRoleForUser(ctx context.Context, arg GetProjectRoleForUserParams) (string, error)
GetProjectVariableValue(ctx context.Context, arg GetProjectVariableValueParams) (string, error)
GetRegistryAuth(ctx context.Context, arg GetRegistryAuthParams) (GetRegistryAuthRow, error)
GetSecurityScanProjectID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
GetServiceAccess(ctx context.Context, id uuid.UUID) (GetServiceAccessRow, error)
GetServiceDomainDefault(ctx context.Context, serviceID uuid.UUID) (string, error)
GetServiceDomainFirst(ctx context.Context, serviceID uuid.UUID) (string, error)
@@ -130,34 +159,69 @@ type Querier interface {
GetServiceEnvironment(ctx context.Context, id uuid.UUID) (string, error)
GetServiceForDeploy(ctx context.Context, id uuid.UUID) (GetServiceForDeployRow, error)
GetServiceForDeployWithOwner(ctx context.Context, id uuid.UUID) (GetServiceForDeployWithOwnerRow, error)
GetServiceNameProjectPort(ctx context.Context, id uuid.UUID) (GetServiceNameProjectPortRow, error)
GetServiceNodeID(ctx context.Context, id uuid.UUID) (sql.NullString, error)
GetServicePlacementBrief(ctx context.Context, id uuid.UUID) (GetServicePlacementBriefRow, error)
GetServicePlacementTags(ctx context.Context, id uuid.UUID) (json.RawMessage, error)
GetServicePort(ctx context.Context, id uuid.UUID) (int32, error)
GetServiceProjectAndStatus(ctx context.Context, id uuid.UUID) (GetServiceProjectAndStatusRow, error)
GetServiceProjectID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
GetServicePublishedPort(ctx context.Context, id uuid.UUID) (int32, error)
GetServiceRuntimeWithOwner(ctx context.Context, id uuid.UUID) (GetServiceRuntimeWithOwnerRow, error)
GetServiceSleep(ctx context.Context, id uuid.UUID) (GetServiceSleepRow, error)
GetServiceSpread(ctx context.Context, id uuid.UUID) (bool, error)
GetServiceStatusText(ctx context.Context, id uuid.UUID) (string, error)
GetServiceTemplateByID(ctx context.Context, id string) (ServiceTemplate, error)
GetServiceTraefikLabels(ctx context.Context, id uuid.UUID) (json.RawMessage, error)
GetServiceTypeBrief(ctx context.Context, arg GetServiceTypeBriefParams) (GetServiceTypeBriefRow, error)
GetServiceVariableValue(ctx context.Context, arg GetServiceVariableValueParams) (string, error)
GetServiceVolumes(ctx context.Context, id uuid.UUID) (json.RawMessage, error)
GetUserByEmailForAuth(ctx context.Context, email string) (GetUserByEmailForAuthRow, error)
GetUserByID(ctx context.Context, id uuid.UUID) (GetUserByIDRow, error)
GetUserInviteByTokenHash(ctx context.Context, tokenHash string) (UserInvite, error)
GetUserIsAdmin(ctx context.Context, id uuid.UUID) (bool, error)
GetUserTokenByHash(ctx context.Context, tokenHash string) (UserToken, error)
GetVulnerabilityMetrics(ctx context.Context, projectID uuid.UUID) (GetVulnerabilityMetricsRow, error)
GetVulnerabilityProjectID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
InsertAPKey(ctx context.Context, arg InsertAPKeyParams) (uuid.UUID, error)
InsertAPService(ctx context.Context, arg InsertAPServiceParams) (uuid.UUID, error)
InsertAgentHeartbeat(ctx context.Context, arg InsertAgentHeartbeatParams) error
InsertAuditLog(ctx context.Context, arg InsertAuditLogParams) error
InsertAuditLogWithRequest(ctx context.Context, arg InsertAuditLogWithRequestParams) error
InsertCronExecution(ctx context.Context, arg InsertCronExecutionParams) error
InsertCronJob(ctx context.Context, arg InsertCronJobParams) error
InsertDeployment(ctx context.Context, arg InsertDeploymentParams) error
InsertEnvVar(ctx context.Context, arg InsertEnvVarParams) error
InsertGatewayIncident(ctx context.Context, arg InsertGatewayIncidentParams) error
InsertGitProvider(ctx context.Context, arg InsertGitProviderParams) error
InsertGitRepository(ctx context.Context, arg InsertGitRepositoryParams) (InsertGitRepositoryRow, error)
InsertMetricPoint(ctx context.Context, labelsJson sql.NullString) error
InsertNotificationChannel(ctx context.Context, arg InsertNotificationChannelParams) (NotificationChannel, error)
InsertPendingDeployment(ctx context.Context, arg InsertPendingDeploymentParams) error
InsertProjectEnvironment(ctx context.Context, arg InsertProjectEnvironmentParams) error
InsertProjectVariable(ctx context.Context, arg InsertProjectVariableParams) error
InsertServiceIncident(ctx context.Context, arg InsertServiceIncidentParams) error
InsertUser(ctx context.Context, arg InsertUserParams) (InsertUserRow, error)
InsertUserAdmin(ctx context.Context, arg InsertUserAdminParams) (InsertUserAdminRow, error)
ListAPKeys(ctx context.Context) ([]ListAPKeysRow, error)
ListAPKeysByPrefix(ctx context.Context, keyPrefix string) ([]ListAPKeysByPrefixRow, error)
ListAPServices(ctx context.Context) ([]ListAPServicesRow, error)
ListActiveBanners(ctx context.Context) ([]Banner, error)
ListActiveOperationDeployments(ctx context.Context, arg ListActiveOperationDeploymentsParams) ([]ListActiveOperationDeploymentsRow, error)
ListAdminUserIDs(ctx context.Context) ([]uuid.UUID, error)
ListAdminUsers(ctx context.Context) ([]ListAdminUsersRow, error)
ListAgentAuthTokens(ctx context.Context) ([]AgentAuthToken, error)
ListAgentHealthRows(ctx context.Context, lastHeartbeat sql.NullTime) ([]ListAgentHealthRowsRow, error)
ListAgentHeartbeatsSince(ctx context.Context, arg ListAgentHeartbeatsSinceParams) ([]AgentHeartbeat, error)
ListAgents(ctx context.Context) ([]NodeAgent, error)
ListAllDatabaseServices(ctx context.Context) ([]DatabaseService, error)
ListAllNotificationChannelsByUser(ctx context.Context, userID uuid.UUID) ([]NotificationChannel, error)
ListAuditBackfillBatch(ctx context.Context, dollar_1 uuid.UUID) ([]ListAuditBackfillBatchRow, error)
ListBackupTargetsByUser(ctx context.Context, userID string) ([]BackupTarget, error)
ListBanners(ctx context.Context) ([]Banner, error)
ListClientEventPaths(ctx context.Context) ([]ListClientEventPathsRow, error)
ListCommandsForAgent(ctx context.Context, nodeAgentID string) ([]AgentCommand, error)
ListComplianceFrameworks(ctx context.Context) ([]ListComplianceFrameworksRow, error)
ListConnectedRepositories(ctx context.Context, arg ListConnectedRepositoriesParams) ([]ListConnectedRepositoriesRow, error)
ListContainersForAgent(ctx context.Context, nodeAgentID string) ([]ContainerInstance, error)
ListCronExecutions(ctx context.Context, cronJobID uuid.UUID) ([]CronExecution, error)
@@ -171,6 +235,7 @@ type Querier interface {
ListExpiredPreviewsForUser(ctx context.Context, arg ListExpiredPreviewsForUserParams) ([]ListExpiredPreviewsForUserRow, error)
ListGitProviders(ctx context.Context, userID uuid.UUID) ([]ListGitProvidersRow, error)
ListGitRepositories(ctx context.Context, arg ListGitRepositoriesParams) ([]ListGitRepositoriesRow, error)
ListIncidentStatusCodes(ctx context.Context) ([]ListIncidentStatusCodesRow, error)
ListInstanceMetrics(ctx context.Context, arg ListInstanceMetricsParams) ([]ListInstanceMetricsRow, error)
ListNodeAgentsForService(ctx context.Context, serviceID string) ([]string, error)
ListNodeMetrics(ctx context.Context, arg ListNodeMetricsParams) ([]ListNodeMetricsRow, error)
@@ -187,12 +252,18 @@ type Querier interface {
// platform admin. Anonymous callers pass user_id = uuid.Nil, is_admin = false.
ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error)
ListRecentAccessibleDeployments(ctx context.Context, arg ListRecentAccessibleDeploymentsParams) ([]ListRecentAccessibleDeploymentsRow, error)
ListRecentCronRuns(ctx context.Context, arg ListRecentCronRunsParams) ([]ListRecentCronRunsRow, error)
ListRecentFailedDeployments(ctx context.Context, arg ListRecentFailedDeploymentsParams) ([]ListRecentFailedDeploymentsRow, error)
ListRecentOperationBackups(ctx context.Context, arg ListRecentOperationBackupsParams) ([]ListRecentOperationBackupsRow, error)
ListRequestsByDay(ctx context.Context) ([]ListRequestsByDayRow, error)
ListResourceAuditLogs(ctx context.Context, arg ListResourceAuditLogsParams) ([]ListResourceAuditLogsRow, error)
// Base domains of every online, schedulable agent carrying the required
// placement tags — auto-domain candidates for spread services.
ListSchedulableAgentDomains(ctx context.Context, dollar_1 json.RawMessage) ([]string, error)
ListSchedulableAgents(ctx context.Context) ([]ListSchedulableAgentsRow, error)
// Only agents carrying every required placement tag (jsonb array containment).
ListSchedulableAgentsMatching(ctx context.Context, dollar_1 json.RawMessage) ([]ListSchedulableAgentsMatchingRow, error)
ListSecretEnvVars(ctx context.Context) ([]ListSecretEnvVarsRow, error)
// Distinct agents holding inventory rows for a service.
ListServiceAgents(ctx context.Context, serviceID string) ([]string, error)
ListServiceContainers(ctx context.Context, serviceID string) ([]ListServiceContainersRow, error)
@@ -205,8 +276,10 @@ type Querier interface {
ListServicesForPush(ctx context.Context, arg ListServicesForPushParams) ([]ListServicesForPushRow, error)
ListSleepCandidates(ctx context.Context) ([]ListSleepCandidatesRow, error)
ListSweepablePreviews(ctx context.Context) ([]ListSweepablePreviewsRow, error)
ListTopAPServices(ctx context.Context) ([]ListTopAPServicesRow, error)
ListUserInvites(ctx context.Context) ([]UserInvite, error)
ListUserTokens(ctx context.Context, userID uuid.UUID) ([]UserToken, error)
ListVulnerabilities(ctx context.Context, projectID uuid.UUID) ([]ListVulnerabilitiesRow, error)
ListWebhookDeliveriesByWebhook(ctx context.Context, webhookID uuid.UUID) ([]WebhookDelivery, error)
MarkAllNotificationsReadByUser(ctx context.Context, arg MarkAllNotificationsReadByUserParams) error
MarkNotificationReadByIDAndUser(ctx context.Context, arg MarkNotificationReadByIDAndUserParams) error
@@ -219,13 +292,21 @@ type Querier interface {
// Same resource-aware ordering as PickLeastLoadedAgent, restricted to agents
// carrying every required placement tag.
PickLeastLoadedAgentMatching(ctx context.Context, dollar_1 json.RawMessage) (string, error)
ProjectMemberExists(ctx context.Context, arg ProjectMemberExistsParams) (bool, error)
PruneAuditLogs(ctx context.Context, dollar_1 int32) (int64, error)
RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error)
RevokeUserToken(ctx context.Context, arg RevokeUserTokenParams) (int64, error)
ScrubCommandPayload(ctx context.Context, arg ScrubCommandPayloadParams) error
ServiceExistsInProject(ctx context.Context, arg ServiceExistsInProjectParams) (bool, error)
ServiceWriteAccess(ctx context.Context, arg ServiceWriteAccessParams) (bool, error)
SetAPKeyEnabled(ctx context.Context, arg SetAPKeyEnabledParams) error
SetAPServiceEnabled(ctx context.Context, arg SetAPServiceEnabledParams) error
SetAPServiceValidation(ctx context.Context, arg SetAPServiceValidationParams) error
SetAgentDefaultDomain(ctx context.Context, arg SetAgentDefaultDomainParams) error
SetAgentSchedulable(ctx context.Context, arg SetAgentSchedulableParams) error
SetAgentStatus(ctx context.Context, arg SetAgentStatusParams) error
SetAgentTags(ctx context.Context, arg SetAgentTagsParams) error
SetAuditLogMetadata(ctx context.Context, arg SetAuditLogMetadataParams) error
SetDatabaseBackupRemoteKeyByID(ctx context.Context, arg SetDatabaseBackupRemoteKeyByIDParams) error
SetDatabaseBackupScheduleByIDAndUser(ctx context.Context, arg SetDatabaseBackupScheduleByIDAndUserParams) error
SetDatabaseBackupStatusByID(ctx context.Context, arg SetDatabaseBackupStatusByIDParams) error
@@ -242,8 +323,14 @@ type Querier interface {
SetServicePublishedPort(ctx context.Context, arg SetServicePublishedPortParams) error
SetServiceReplicas(ctx context.Context, arg SetServiceReplicasParams) error
SetServiceStatus(ctx context.Context, arg SetServiceStatusParams) error
SetUserAdmin(ctx context.Context, arg SetUserAdminParams) (int64, error)
SumRequestMetricSince(ctx context.Context, dollar_1 string) (int32, error)
SumUsageByKey(ctx context.Context, arg SumUsageByKeyParams) (int64, error)
SumUsageByService(ctx context.Context, arg SumUsageByServiceParams) (int64, error)
SumUsageCounters(ctx context.Context) (interface{}, error)
SyncDeploymentProgress(ctx context.Context, arg SyncDeploymentProgressParams) error
SyncPreviewStatuses(ctx context.Context) error
TouchAPKeyLastUsed(ctx context.Context, id uuid.UUID) error
TouchAgentAuthToken(ctx context.Context, tokenHash string) error
TouchServiceDomainChecked(ctx context.Context, id uuid.UUID) error
TouchUserToken(ctx context.Context, id uuid.UUID) error
@@ -264,21 +351,32 @@ type Querier interface {
UpdateDatabaseServiceNameAndPlanByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameAndPlanByIDAndUserParams) error
UpdateDatabaseServiceNameByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameByIDAndUserParams) error
UpdateDatabaseServicePlanByIDAndUser(ctx context.Context, arg UpdateDatabaseServicePlanByIDAndUserParams) error
UpdateEnvVarValue(ctx context.Context, arg UpdateEnvVarValueParams) error
UpdateOutboundWebhook(ctx context.Context, arg UpdateOutboundWebhookParams) error
UpdatePreviewEnvironment(ctx context.Context, arg UpdatePreviewEnvironmentParams) error
UpdateProjectByID(ctx context.Context, arg UpdateProjectByIDParams) (int64, error)
UpdateServiceDeployMeta(ctx context.Context, arg UpdateServiceDeployMetaParams) error
UpdateServicePreviewBranch(ctx context.Context, arg UpdateServicePreviewBranchParams) error
UpdateServiceRuntimeMeta(ctx context.Context, arg UpdateServiceRuntimeMetaParams) error
UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) error
UpdateUserTemplate(ctx context.Context, arg UpdateUserTemplateParams) (int64, error)
UpdateVulnerabilityStatus(ctx context.Context, arg UpdateVulnerabilityStatusParams) error
UpdateWebhookDeliveryResult(ctx context.Context, arg UpdateWebhookDeliveryResultParams) error
UpsertAdminUser(ctx context.Context, arg UpsertAdminUserParams) (uuid.UUID, error)
UpsertAppSetting(ctx context.Context, arg UpsertAppSettingParams) error
UpsertAppSettingValue(ctx context.Context, arg UpsertAppSettingValueParams) error
UpsertBuild(ctx context.Context, arg UpsertBuildParams) error
UpsertEnvironmentVariable(ctx context.Context, arg UpsertEnvironmentVariableParams) error
UpsertGitHubAppProvider(ctx context.Context, arg UpsertGitHubAppProviderParams) (UpsertGitHubAppProviderRow, error)
UpsertGitRepository(ctx context.Context, arg UpsertGitRepositoryParams) error
UpsertGitWebhook(ctx context.Context, arg UpsertGitWebhookParams) (UpsertGitWebhookRow, error)
UpsertInstanceMetrics(ctx context.Context, arg UpsertInstanceMetricsParams) error
UpsertLocalUser(ctx context.Context, arg UpsertLocalUserParams) (UpsertLocalUserRow, error)
UpsertNodeMetrics(ctx context.Context, arg UpsertNodeMetricsParams) error
UpsertServiceContainer(ctx context.Context, arg UpsertServiceContainerParams) error
UpsertServiceMetrics(ctx context.Context, arg UpsertServiceMetricsParams) error
UpsertServiceTemplate(ctx context.Context, arg UpsertServiceTemplateParams) error
UpsertUsageCounter(ctx context.Context, arg UpsertUsageCounterParams) error
}
var _ Querier = (*Queries)(nil)
@@ -0,0 +1,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');
+8
View File
@@ -127,3 +127,11 @@ SELECT s.id, s.project_id, s.name,
s.created_at, s.updated_at
FROM services s
WHERE (s.git_branch = $1 OR $1 = '') AND (s.git_repo = $2 OR s.git_repo = $3);
-- name: GetGitRepoCloneByFullName :one
SELECT clone_url, provider_id FROM git_repositories
WHERE full_name = $1 AND user_id = $2 LIMIT 1;
-- name: GetGitRepoProviderByCloneURL :one
SELECT provider_id FROM git_repositories
WHERE clone_url = $1 AND user_id = $2 LIMIT 1;
+29
View File
@@ -0,0 +1,29 @@
-- name: CountProjects :one
SELECT COUNT(*) FROM projects;
-- name: GetRegistryAuth :one
SELECT username, password FROM registries
WHERE owner_id::text = $1 AND host = $2;
-- name: UpdateServiceRuntimeMeta :exec
UPDATE services SET port = $1, healthcheck_path = $2 WHERE id = $3;
-- name: UpdateServiceDeployMeta :exec
UPDATE services SET port = $1, healthcheck_path = $2,
volumes = COALESCE($3::jsonb, '[]'::jsonb),
git_repo = NULLIF($4::text, ''), git_branch = NULLIF($5::text, ''), builder = $6
WHERE id = $7;
-- name: InsertEnvVar :exec
INSERT INTO environment_variables (id, service_id, key, value, is_secret, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7);
-- name: DeleteProjectVariables :exec
DELETE FROM project_variables WHERE project_id = $1;
-- name: InsertProjectVariable :exec
INSERT INTO project_variables (id, project_id, key, value, is_secret, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7);
-- name: GetProjectVariableValue :one
SELECT value FROM project_variables WHERE project_id = $1 AND key = $2;
+43
View File
@@ -0,0 +1,43 @@
-- name: ListActiveOperationDeployments :many
SELECT d.id, d.service_id, s.name AS service_name, p.name AS project_name, d.status,
COALESCE(d.image_name, '') AS image, d.error, d.started_at, d.completed_at, d.created_at
FROM deployments d
JOIN services s ON s.id = d.service_id
JOIN projects p ON p.id = s.project_id
WHERE d.status IN ('queued','pending','building','deploying','rolling_back')
AND (p.is_approved OR p.owner_id = $1 OR $2::bool
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1))
ORDER BY d.created_at ASC;
-- name: ListRecentFailedDeployments :many
SELECT d.id, d.service_id, s.name AS service_name, p.name AS project_name, d.status,
COALESCE(d.image_name, '') AS image, d.error, d.started_at, d.completed_at, d.created_at
FROM deployments d
JOIN services s ON s.id = d.service_id
JOIN projects p ON p.id = s.project_id
WHERE d.status IN ('failed','cancelled','rolled_back')
AND d.updated_at > NOW() - INTERVAL '24 hours'
AND (p.is_approved OR p.owner_id = $1 OR $2::bool
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1))
ORDER BY d.updated_at DESC
LIMIT 20;
-- name: ListRecentCronRuns :many
SELECT e.id, j.name AS job_name, j.schedule, e.status, e.started_at, e.finished_at, e.error
FROM cron_executions e
JOIN cron_jobs j ON j.id = e.cron_job_id
JOIN projects p ON p.id = j.project_id
WHERE e.started_at > NOW() - INTERVAL '24 hours'
AND (p.is_approved OR p.owner_id = $1 OR $2::bool
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1))
ORDER BY e.started_at DESC
LIMIT 30;
-- name: ListRecentOperationBackups :many
SELECT b.id, b.database_id, ds.name AS db_name, b.status, b.size, b.created_at, b.completed_at
FROM database_backups b
JOIN database_services ds ON ds.id = b.database_id
WHERE b.created_at > NOW() - INTERVAL '24 hours'
AND (ds.user_id = $1 OR $2::bool)
ORDER BY b.created_at DESC
LIMIT 20;
+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;
+181
View File
@@ -144,6 +144,17 @@ CREATE TABLE environment_variables (
UNIQUE(service_id, key)
);
CREATE TABLE project_variables (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
key VARCHAR(255) NOT NULL,
value TEXT NOT NULL DEFAULT '',
is_secret BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
UNIQUE(project_id, key)
);
CREATE TABLE service_templates (
id VARCHAR(50) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
@@ -529,3 +540,173 @@ CREATE TABLE git_webhooks (
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()
);
@@ -25,7 +25,7 @@ const SEVERITY_STYLE: Record<string, { icon: typeof Info; cls: string; dot: stri
};
const SCHEMA: FilterConfig<ActivityEntry>[] = [
{ key: 'q', label: 'Search', type: 'search', searchFields: ['label', 'action', 'user_email'] },
{ key: 'q', label: 'events', type: 'search', searchFields: ['label', 'action', 'user_email'] },
{
key: 'severity', label: 'Severity', type: 'select',
options: [
@@ -67,7 +67,7 @@ export function ActivityPage() {
</div>
</div>
<div className="px-8 py-6 space-y-4">
<div className="px-4 py-6 sm:px-8 space-y-4">
<div className="flex items-start gap-3">
<div className="flex-1">
<FilterBar {...filter} />
@@ -85,7 +85,7 @@ export function AuditLogsPage() {
</div>
</div>
<div className="w-full px-8 py-6">
<div className="w-full px-4 py-6 sm:px-8">
<section className="panel p-6">
<FilterBar {...filter} />
@@ -140,7 +140,7 @@ export function AuditLogsPage() {
<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)]">{log.ipAddress?.replace(/\/\d+$/, '') || '—'}</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>
@@ -192,7 +192,7 @@ export function BuildsPage() {
)}
{/* Stats Overview */}
<div className="w-full px-8 py-6">
<div className="w-full px-4 py-6 sm:px-8">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="panel p-4">
<div className="flex items-center justify-between mb-3">
@@ -263,7 +263,7 @@ export function BuildsPage() {
</div>
{/* Build Table */}
<div className="w-full px-8 py-6">
<div className="w-full px-4 py-6 sm:px-8">
<div className="panel overflow-hidden">
{!isDemoMode && buildsQuery.isLoading ? (
<div className="p-12 text-center">
@@ -15,6 +15,7 @@ function renderMarkdown(markdown: string): string {
function excerpt(markdown: string, length = 140): string {
const text = markdown
.replace(/^#[^\n]*\n/, ' ')
.replace(/```[\s\S]*?```/g, ' ')
.replace(/[#>*`\-[\]()]/g, ' ')
.replace(/\s+/g, ' ')
@@ -119,7 +120,7 @@ export function DocsBrowser() {
<div className="min-w-0 flex-1 overflow-y-auto max-md:hidden">
{selected ? (
<article className="docs-article px-8 py-6">
<article className="docs-article px-4 py-6 sm:px-8">
<p className="mb-1 text-[11px] font-medium uppercase tracking-wider text-[var(--text-tertiary)]">
{selected.section} / {selected.path}
</p>
@@ -330,7 +330,7 @@ export function NodeDetailPage() {
</div>
</div>
<div className="px-8 py-6 space-y-6">
<div className="px-4 py-6 sm:px-8 space-y-6">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Stat label="cpu" value={latest ? `${latest.cpu.usage_percent.toFixed(0)}%` : '—'} sub={latest ? `${latest.cpu.cores} cores` : ''} />
<Stat label="memory" value={latest ? `${latest.memory.usage_percent.toFixed(0)}%` : '—'} sub={latest ? `${formatBytes(latest.memory.available)} free of ${formatBytes(latest.memory.limit)}` : ''} />
@@ -88,7 +88,7 @@ export function OperationsPage() {
<Loader2 size={20} className="animate-spin text-[var(--text-muted)]" />
</div>
) : (
<div className="w-full px-8 py-6 space-y-6">
<div className="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="panel-soft p-4">
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">In flight</p>
@@ -211,9 +211,9 @@ export function ProjectsPage() {
return (
<div className="min-h-screen">
<div className="w-full px-8 py-6">
<div className="w-full px-4 py-6 sm:px-8">
{/* Page head */}
<div className="mb-6 flex items-center justify-between gap-5">
<div className="mb-6 flex flex-wrap items-center justify-between gap-x-5 gap-y-3">
<div>
<h1 className="v-title">Projects<span className="v-cursor">_</span></h1>
<p className="v-mono mt-1.5 text-[11px] text-[var(--text-tertiary)]">
+36 -32
View File
@@ -436,7 +436,7 @@ export function UsagePage() {
title="Usage"
description="Platform usage metrics and operational summaries"
/>
<div className="w-full px-8 py-6">
<div className="w-full px-4 py-6 sm:px-8">
<div className="flex items-center gap-2 mb-6">
<div className={`w-2 h-2 rounded-full ${liveStatus === 'live' ? 'bg-[var(--success)] animate-pulse' : 'bg-[var(--text-muted)]'}`} />
<span className={`text-sm ${liveStatus === 'live' ? 'text-[var(--success)]' : 'text-[var(--text-muted)]'}`}>
@@ -844,7 +844,7 @@ export function PeoplePage() {
title="People"
description="Team management and access control"
/>
<div className="w-full px-8 py-6">
<div className="w-full px-4 py-6 sm:px-8">
{profileQuery.isLoading ? (
<div className="py-16 text-center">
<Loader2 size={24} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
@@ -888,7 +888,7 @@ export function PeoplePage() {
</div>
<form
className="grid gap-3 lg:grid-cols-[1fr_1fr_1fr_auto]"
className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_auto]"
onSubmit={(event) => {
event.preventDefault();
createUserMutation.mutate();
@@ -2145,7 +2145,7 @@ export function SettingsPage() {
title="Settings"
description="Manage account profile and local configuration"
/>
<div className="w-full px-8 py-6">
<div className="w-full px-4 py-6 sm:px-8">
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
{/* Profile Section */}
<section className="panel p-6">
@@ -2376,7 +2376,7 @@ export function DocsPage() {
title="Docs"
description="Guides and references — synced from GitHub, cached offline"
/>
<div className="w-full px-8 py-6">
<div className="w-full px-4 py-6 sm:px-8">
<DocsBrowser />
</div>
</div>
@@ -2513,7 +2513,7 @@ export function DatabasesPage() {
title="Databases"
description="Managed database services — connection info, runtime actions and backups"
/>
<div className="w-full px-8 py-6">
<div className="w-full px-4 py-6 sm:px-8">
<div className="mb-5 flex items-center justify-between">
<p className="text-xs text-[var(--text-tertiary)]">
{databases.length} {databases.length === 1 ? 'database' : 'databases'}
@@ -3251,7 +3251,7 @@ export function HighAvailabilityPage() {
title="High availability"
description="Failover manager, policies, and active alerts"
/>
<div className="p-8 space-y-8">
<div className="p-4 space-y-8 sm:p-8">
<div className="flex items-center gap-2 text-xs text-[var(--text-tertiary)]">
<ShieldCheck size={13} className="text-[var(--accent-primary)]" />
<span>HA state feeds the platform security posture.</span>
@@ -3615,39 +3615,43 @@ export function SecurityPage() {
const projectsQuery = useQuery({ queryKey: ['projects'], queryFn: () => listProjects(), enabled: !isDemoMode });
const projects = isDemoMode ? demoProjects : projectsQuery.data ?? [];
// Auto-select the first project so the page never renders empty — derived, not
// stored, so clearing the select still shows the empty state explicitly.
const effectiveProjectId = projectId || projects[0]?.id || '';
const servicesQuery = useQuery({
queryKey: ['services', projectId],
queryFn: () => listServicesByProject(projectId),
enabled: !isDemoMode && Boolean(projectId),
queryKey: ['services', effectiveProjectId],
queryFn: () => listServicesByProject(effectiveProjectId),
enabled: !isDemoMode && Boolean(effectiveProjectId),
});
const services = isDemoMode ? getDemoServicesByProject(projectId) : servicesQuery.data ?? [];
const services = isDemoMode ? getDemoServicesByProject(effectiveProjectId) : servicesQuery.data ?? [];
const metricsQuery = useQuery({
queryKey: ['security-metrics', projectId],
queryFn: () => getSecurityMetrics(projectId),
enabled: !isDemoMode && Boolean(projectId),
queryKey: ['security-metrics', effectiveProjectId],
queryFn: () => getSecurityMetrics(effectiveProjectId),
enabled: !isDemoMode && Boolean(effectiveProjectId),
refetchInterval: 15_000,
});
const historyQuery = useQuery({
queryKey: ['security-history', projectId],
queryFn: () => getSecurityHistory(projectId),
enabled: !isDemoMode && Boolean(projectId),
queryKey: ['security-history', effectiveProjectId],
queryFn: () => getSecurityHistory(effectiveProjectId),
enabled: !isDemoMode && Boolean(effectiveProjectId),
refetchInterval: 15_000,
});
const vulnsQuery = useQuery({
queryKey: ['security-vulns', projectId],
queryFn: () => listVulnerabilities(projectId),
enabled: !isDemoMode && Boolean(projectId),
queryKey: ['security-vulns', effectiveProjectId],
queryFn: () => listVulnerabilities(effectiveProjectId),
enabled: !isDemoMode && Boolean(effectiveProjectId),
});
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ['security-metrics', projectId] });
queryClient.invalidateQueries({ queryKey: ['security-history', projectId] });
queryClient.invalidateQueries({ queryKey: ['security-vulns', projectId] });
queryClient.invalidateQueries({ queryKey: ['security-metrics', effectiveProjectId] });
queryClient.invalidateQueries({ queryKey: ['security-history', effectiveProjectId] });
queryClient.invalidateQueries({ queryKey: ['security-vulns', effectiveProjectId] });
};
const scanMutation = useMutation({
mutationFn: () => startSecurityScan({
project_id: projectId,
project_id: effectiveProjectId,
service_id: scanServiceId || undefined,
scan_type: scanType as 'dependency' | 'configuration' | 'comprehensive',
}),
@@ -3661,10 +3665,10 @@ export function SecurityPage() {
const metrics = isDemoMode ? demoSecurityMetrics : metricsQuery.data;
const scanHistory = isDemoMode
? demoSecurityScans.filter((s) => s.project_id === projectId)
? demoSecurityScans.filter((s) => s.project_id === effectiveProjectId)
: historyQuery.data ?? [];
const vulns = (isDemoMode
? demoVulnerabilities.filter((v) => v.project_id === projectId)
? demoVulnerabilities.filter((v) => v.project_id === effectiveProjectId)
: vulnsQuery.data ?? []
).filter((v) => v.status !== 'resolved');
const pageError =
@@ -3677,7 +3681,7 @@ export function SecurityPage() {
title="Security"
description="Scan findings, security posture, and compliance status"
/>
<div className="p-8 space-y-8">
<div className="p-4 space-y-8 sm:p-8">
<div className="flex items-center gap-2 text-xs text-[var(--text-tertiary)]">
<HeartPulse size={13} className="text-[var(--accent-primary)]" />
<span>Platform availability and failover live under High availability.</span>
@@ -3695,7 +3699,7 @@ export function SecurityPage() {
<div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Project</label>
<select
value={projectId}
value={effectiveProjectId}
onChange={(e) => { setProjectId(e.target.value); setScanServiceId(''); }}
className="px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)] w-56"
>
@@ -3705,7 +3709,7 @@ export function SecurityPage() {
))}
</select>
</div>
{projectId && (
{effectiveProjectId && (
<>
<div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Scan type</label>
@@ -3748,7 +3752,7 @@ export function SecurityPage() {
)}
</div>
{projectId && metrics && (
{effectiveProjectId && metrics && (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
title="Security score"
@@ -3779,7 +3783,7 @@ export function SecurityPage() {
</div>
)}
{projectId && (
{effectiveProjectId && (
<div className="panel p-6">
<div className="flex items-center gap-3 mb-4">
<Shield size={18} className="text-[var(--accent-primary)]" />
@@ -3835,7 +3839,7 @@ export function SecurityPage() {
</div>
)}
{projectId && (
{effectiveProjectId && (
<div className="panel p-6">
<div className="flex items-center gap-3 mb-4">
<Clock size={18} className="text-[var(--accent-primary)]" />
@@ -143,7 +143,7 @@ function StatusBadge({ status }: { status: string }) {
export function ServiceDetailPage() {
const { projectId = '', serviceId = '' } = useParams<{ projectId: string; serviceId: string }>();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [searchParams, setSearchParams] = useSearchParams();
const queryClient = useQueryClient();
const isDemoMode = useDemoMode();
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
@@ -791,7 +791,7 @@ export function ServiceDetailPage() {
)}
{/* Metrics Overview */}
<div className="w-full px-8 py-6">
<div className="w-full px-4 py-6 sm:px-8">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<EnhancedMetricCard
title="CPU Usage"
@@ -904,7 +904,18 @@ export function ServiceDetailPage() {
return (
<button
key={item.key}
onClick={() => setActiveSection(item.key)}
onClick={() => {
setActiveSection(item.key);
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
if (item.key === 'metrics') next.delete('section');
else next.set('section', item.key);
return next;
},
{ replace: true },
);
}}
className={`tab ${active ? 'active' : ''}`}
>
<Icon size={14} />
@@ -1189,7 +1200,7 @@ export function ServiceDetailPage() {
{' '}
<span className="text-[var(--accent-primary)]">{entry.stream}</span>
{' '}
{entry.message}
{entry.message.replace(/^\d{4}-\d{2}-\d{2}T[\d:.]+Z?\s+/, '')}
</p>
))}
</div>
@@ -1362,7 +1373,7 @@ export function ServiceDetailPage() {
</div>
) : (
<div className="space-y-2">
<div className="grid grid-cols-[1fr_1fr_auto_auto] gap-2 px-1">
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto_auto] gap-2 px-1">
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Key</span>
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Value</span>
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Secret</span>
@@ -1370,7 +1381,7 @@ export function ServiceDetailPage() {
</div>
{varRows.map((row, i) => (
<div key={i}>
<div className="grid grid-cols-[1fr_1fr_auto_auto] gap-2 items-center">
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto_auto] gap-2 items-center">
<input
value={row.key}
onChange={(e) => updateVarRow(i, { key: e.target.value })}
@@ -541,7 +541,7 @@ export function TemplatesPage() {
)}
{/* Main Content */}
<div className="w-full px-8 py-6">
<div className="w-full px-4 py-6 sm:px-8">
<div className="grid grid-cols-1 xl:grid-cols-[380px_1fr] gap-6">
{/* Template List */}
<section className="panel overflow-hidden">
@@ -857,7 +857,11 @@ export function TemplatesPage() {
</div>
</div>
{!signedIn ? (
{sessionQuery.isPending && !isDemoMode ? (
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-4 py-5 text-center">
<Loader2 size={16} className="mx-auto animate-spin text-[var(--text-muted)]" />
</div>
) : !signedIn ? (
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-4 py-5 text-center">
<p className="text-sm text-[var(--text-secondary)]">
Sign in to deploy this template into one of your projects.
@@ -226,7 +226,7 @@ export function WebhooksPage() {
</div>
</div>
<div className="w-full px-8 py-6 space-y-4 max-w-4xl">
<div className="w-full px-4 py-6 sm:px-8 space-y-4 max-w-4xl">
{secret && (
<div className="panel-soft p-4 flex items-center gap-3">
<div className="flex-1 min-w-0">
@@ -1,306 +1,205 @@
import { useEffect, useState } from 'react';
import {
LineMetricChart,
DualLineChart,
DonutChart,
} from '@/shared/components';
interface MetricData {
cpu: number[];
ram: number;
ramUsed: string;
cache: number;
cacheBreakdown: { cache: number; nonCache: number; total: number };
users: number[];
performance: number[];
performanceAlt: number[];
upSpeed: number;
downSpeed: number;
}
import { useEffect, useRef, useState } from 'react';
import { useQueries } from '@tanstack/react-query';
import { Cpu, HardDrive, ArrowDownUp, Boxes } from 'lucide-react';
import { LineMetricChart, DonutChart } from '@/shared/components';
import { getServiceMetrics, type ServiceMetrics } from '@/lib/api-client';
import { formatBytes } from '@/lib/time';
interface MetricsDashboardProps {
projectId?: string;
isRunning?: boolean;
onStop?: () => void;
onRestart?: () => void;
services: { id: string; name: string; status: string }[];
isDemoMode?: boolean;
}
function getRandom(min: number, max: number) {
return Math.floor(Math.random() * (max - min + 1)) + min;
const cardStyle: React.CSSProperties = {
background: 'var(--surface-card, rgba(255,255,255,0.03))',
border: '1px solid var(--border-subtle, rgba(255,255,255,0.09))',
borderRadius: '14px',
padding: '18px 20px',
};
function healthLabel(pct: number): { text: string; color: string } {
if (pct < 50) return { text: 'Good', color: 'var(--success, #5ee6a0)' };
if (pct < 80) return { text: 'Average', color: 'var(--warning, #f2c94c)' };
return { text: 'High', color: 'var(--error, #ff6b6b)' };
}
function generateInitialData(): MetricData {
return {
cpu: Array.from({ length: 24 }, () => getRandom(10, 45)),
ram: getRandom(50, 85),
ramUsed: '5.4 GB',
cache: 352,
cacheBreakdown: { cache: 212, nonCache: 85.5, total: 1750 },
users: Array.from({ length: 15 }, () => getRandom(40, 110)),
performance: Array.from({ length: 12 }, () => getRandom(70, 95)),
performanceAlt: Array.from({ length: 12 }, () => getRandom(60, 85)),
upSpeed: 10.4,
downSpeed: 5.2,
};
}
export function MetricsDashboard({ services, isDemoMode = false }: MetricsDashboardProps) {
const liveServices = services.filter((s) => s.status === 'running');
export function MetricsDashboard({
isRunning = true,
}: MetricsDashboardProps) {
const [data, setData] = useState<MetricData>(generateInitialData);
const [timeRange, setTimeRange] = useState<'day' | 'month' | 'year'>('day');
const metricsQueries = useQueries({
queries: liveServices.map((service) => ({
queryKey: ['service-metrics', service.id],
queryFn: () => getServiceMetrics(service.id),
enabled: !isDemoMode,
refetchInterval: 5000,
retry: false,
})),
});
const samples = metricsQueries
.map((q) => q.data)
.filter((m): m is ServiceMetrics => Boolean(m) && m!.status === 'ok');
const totalCpu = samples.reduce((sum, m) => sum + m.cpu_percent, 0);
const avgCpu = samples.length > 0 ? totalCpu / samples.length : 0;
const memUsed = samples.reduce((sum, m) => sum + m.memory_usage_bytes, 0);
const memLimit = samples.reduce((sum, m) => sum + m.memory_limit_bytes, 0);
const memPct = memLimit > 0 ? (memUsed / memLimit) * 100 : 0;
const netRx = samples.reduce((sum, m) => sum + m.network_rx_bytes, 0);
const netTx = samples.reduce((sum, m) => sum + m.network_tx_bytes, 0);
const instanceCount = samples.reduce((sum, m) => sum + m.instances.length, 0);
// Rolling history for the sparklines — accumulated from real samples.
const [cpuHistory, setCpuHistory] = useState<number[]>([]);
const [netHistory, setNetHistory] = useState<{ rx: number[]; tx: number[] }>({ rx: [], tx: [] });
const lastNetRef = useRef<{ rx: number; tx: number; at: number } | null>(null);
const samplesKey = samples.map((s) => `${s.service_id}:${s.collected_at}`).join('|');
const avgCpuRef = avgCpu;
const netRxRef = netRx;
const netTxRef = netTx;
useEffect(() => {
if (!isRunning) return;
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 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];
const cpuHealth = healthLabel(avgCpu);
const memHealth = healthLabel(memPct);
const reporting = samples.length;
const noData = !isDemoMode && liveServices.length > 0 && reporting === 0;
return (
<div>
{/* Metrics Header - self.html exact match */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '16px' }}>
<span style={{ fontSize: '15px', fontWeight: 700, color: '#e8e9f0' }}>Metrics</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<button style={{
height: '32px',
padding: '0 12px',
borderRadius: '9px',
border: '1px solid rgba(255,255,255,0.09)',
background: 'rgba(255,255,255,0.04)',
color: '#9295a4',
fontSize: '12.5px',
fontWeight: 500,
fontFamily: 'inherit',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '6px',
}}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" />
</svg>
Filter
</button>
<div className="pill-group">
{(['day', 'month', 'year'] as const).map((range) => (
<div
key={range}
className={`pill ${timeRange === range ? 'active' : ''}`}
onClick={() => setTimeRange(range)}
>
{range.charAt(0).toUpperCase() + range.slice(1)}
</div>
))}
<div className="flex items-center justify-between mb-4">
<span className="text-[15px] font-bold text-[var(--text-primary)]">Metrics</span>
<span className="text-xs 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 */}
<div style={cardStyle}>
<div className="flex items-center gap-2.5 mb-3">
<span className="card-icon"><Cpu size={16} /></span>
<span className="text-sm font-semibold text-[var(--text-primary)]">CPU Usage</span>
</div>
<div className="text-4xl font-black tracking-tight text-[var(--text-primary)]">
{avgCpu.toFixed(1)}%
</div>
<div className="mt-1 text-xs text-[var(--text-tertiary)]">
<span style={{ color: cpuHealth.color, fontWeight: 700 }}>{cpuHealth.text}</span>
{' '}avg across running services
</div>
<div style={{ height: 64, marginTop: 12 }}>
{cpuHistory.length > 1 ? (
<LineMetricChart data={cpuHistory} color="var(--accent-primary)" fillOpacity={0.15} showArea height={64} />
) : (
<div className="h-full rounded bg-[var(--surface-muted)] opacity-40" />
)}
</div>
</div>
{/* Memory */}
<div style={cardStyle}>
<div className="flex items-center gap-2.5 mb-3">
<span className="card-icon"><HardDrive size={16} /></span>
<span className="text-sm font-semibold text-[var(--text-primary)]">Memory</span>
</div>
<div className="text-4xl font-black tracking-tight text-[var(--text-primary)]">
{memPct.toFixed(0)}%
</div>
<div className="mt-1 text-xs text-[var(--text-tertiary)]">
<span style={{ color: memHealth.color, fontWeight: 700 }}>{memHealth.text}</span>
{' '}{formatBytes(memUsed)} used
</div>
<div className="mt-2 flex justify-center">
<DonutChart percentage={memPct} size={110} thickness={12} />
</div>
<div className="mt-1 text-center text-xs text-[var(--text-tertiary)] mono">
{formatBytes(memUsed)} / {memLimit > 0 ? formatBytes(memLimit) : '∞'}
</div>
</div>
{/* Network */}
<div style={cardStyle}>
<div className="flex items-center gap-2.5 mb-3">
<span className="card-icon"><ArrowDownUp size={16} /></span>
<span className="text-sm font-semibold text-[var(--text-primary)]">Network</span>
</div>
<div className="text-4xl font-black tracking-tight text-[var(--text-primary)]">
{formatBytes(netRx)}
</div>
<div className="mt-1 text-xs text-[var(--text-tertiary)]">
<span style={{ color: 'var(--accent-primary)', fontWeight: 700 }}>RX</span>
{' '}total · TX {formatBytes(netTx)}
</div>
<div style={{ height: 64, marginTop: 12 }}>
{netHistory.rx.length > 1 ? (
<LineMetricChart data={netHistory.rx} color="#7ab8ff" fillOpacity={0.12} showArea height={64} />
) : (
<div className="h-full rounded bg-[var(--surface-muted)] opacity-40" />
)}
</div>
<div className="mt-2 text-[11px] text-[var(--text-muted)] mono">
{netHistory.rx.length > 0
? `↓ ${formatBytes(netHistory.rx[netHistory.rx.length - 1])}/s · ↑ ${formatBytes(netHistory.tx[netHistory.tx.length - 1])}/s`
: 'rate accumulating…'}
</div>
</div>
{/* Instances */}
<div style={cardStyle}>
<div className="flex items-center gap-2.5 mb-3">
<span className="card-icon"><Boxes size={16} /></span>
<span className="text-sm font-semibold text-[var(--text-primary)]">Instances</span>
</div>
<div className="text-4xl font-black tracking-tight text-[var(--text-primary)]">
{isDemoMode ? liveServices.length : instanceCount}
</div>
<div className="mt-1 text-xs text-[var(--text-tertiary)]">
<span style={{ color: 'var(--success, #5ee6a0)', fontWeight: 700 }}>Running</span>
{' '}containers 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-xs">
<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,#5ee6a0)] align-middle" />
{service.name}
</span>
<span className="mono text-[var(--text-muted)]">
{m ? `${m.cpu_percent.toFixed(1)}%` : i < 3 ? '—' : ''}
</span>
</li>
);
})}
{liveServices.length > 4 && (
<li className="text-[11px] text-[var(--text-muted)]">+{liveServices.length - 4} more</li>
)}
</ul>
</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>
{noData && (
<p className="mt-3 text-xs text-[var(--text-muted)]">
Metrics collector is not reporting yet — check that the node agent is online.
</p>
)}
</div>
);
}
@@ -78,7 +78,7 @@ export function SharedVariablesSection({ projectId }: { projectId: string }) {
) : (
<div className="space-y-2">
{rows.map((row, i) => (
<div key={i} className="grid grid-cols-[1fr_1fr_auto_auto] gap-2 items-center">
<div key={i} className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto_auto] gap-2 items-center">
<input
value={row.key}
onChange={(e) => updateRow(i, { key: e.target.value })}
@@ -765,7 +765,7 @@ export function ProjectWorkspacePage() {
</span>
<button
onClick={() => setCommandPaletteOpen(true)}
className="hidden md:flex items-center gap-2 h-8 px-2.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-muted)] text-xs hover:border-[var(--border-default)] hover:text-[var(--text-secondary)] transition-colors"
className="hidden lg:flex items-center gap-2 h-8 px-2.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-muted)] text-xs hover:border-[var(--border-default)] hover:text-[var(--text-secondary)] transition-colors"
>
<Search size={12} />
<kbd className="px-1 py-0.5 rounded bg-[var(--surface-card)] text-[10px] font-mono">⌘K</kbd>
@@ -826,7 +826,7 @@ export function ProjectWorkspacePage() {
</div>
</div>
)}
<div className="w-full px-8 py-6">
<div className="w-full px-4 py-6 sm:px-8">
<section className="min-w-0">
{activeView === 'observability' && (
<div className="space-y-6">
@@ -860,11 +860,7 @@ export function ProjectWorkspacePage() {
</div>
{/* Metrics Dashboard */}
<MetricsDashboard
isRunning={runningServices > 0}
onStop={() => toast.showToast('Services stopped', 'warning')}
onRestart={() => toast.showToast('Services restarted', 'success')}
/>
<MetricsDashboard services={visibleServices} isDemoMode={isDemoMode} />
{/* Service List */}
<div className="panel p-6">
+2 -1
View File
@@ -492,11 +492,12 @@ export function getApiBaseUrl(): string {
// pingServer hits the public /health route — any HTTP response proves
// reachability, and no auth means no 401 noise in the connectivity probe.
export async function pingServer(): Promise<void> {
export async function pingServer(): Promise<boolean> {
const res = await fetch(`${API_ROOT}/health`);
if (!res.ok) {
throw new Error(`Server returned ${res.status}`);
}
return true;
}
export type SetupStatus = {
+1
View File
@@ -23,6 +23,7 @@ const queryClient = new QueryClient({
// High-churn or bulky keys never hit localStorage — a stale log tail is
// worse than none.
const NEVER_PERSIST = new Set([
'auth-session',
'shell-notifications',
'notifications',
'service-logs',
+5 -1
View File
@@ -59,8 +59,12 @@ export default defineConfig({
},
proxy: {
// Same-origin API path for dev - matches the nginx proxy in the shipped image
'/health': {
target: process.env.VITE_API_PROXY ?? 'http://localhost:8082',
changeOrigin: true,
},
'/api': {
target: 'http://localhost:8082',
target: process.env.VITE_API_PROXY ?? 'http://localhost:8082',
changeOrigin: true,
ws: true,
},