mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e128b7460d | ||
|
|
5b1cf0402c | ||
|
|
9271d0e3ac | ||
|
|
a05d225016 | ||
|
|
61a33a066f | ||
|
|
f88b4f1bf3 | ||
|
|
0dc5857b5f | ||
|
|
66301bb68a | ||
|
|
6bcbc1369e | ||
|
|
81a4ca28d8 | ||
|
|
269e031761 | ||
|
|
ce71b5af24 | ||
|
|
cca20f7eae |
No files matched your search
@@ -124,3 +124,6 @@ ref/
|
||||
.playwright-mcp/
|
||||
canvas-*.png
|
||||
.vercel
|
||||
|
||||
# Local design previews (not shipped)
|
||||
design/
|
||||
@@ -1,3 +1,11 @@
|
||||
> [!IMPORTANT]
|
||||
> **This project is archived.** Containr's goal — Railway's workflow on servers
|
||||
> you own — is being done better by [Ployz](https://github.com/getployz/ployz)
|
||||
> (Apache-2.0 engine + AGPL dashboard, self-hostable). Use that. This repo stays
|
||||
> up as a reference; the canvas implementation in
|
||||
> `app/frontend/src/features/workspace/canvas/` (group frames, context menus,
|
||||
> inferred edges with reasons) may still be worth a look.
|
||||
|
||||
<p align="center">
|
||||
<img src="./containr.svg" alt="Containr" width="120">
|
||||
</p>
|
||||
|
||||
+14
-4
@@ -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** — ~300 raw `database/sql` sites → `sqlc/queries/`. Done in tranches (dynamic queries stay raw): metrics storage + service domains ported.
|
||||
- [x] **sqlc port** — all static `database/sql` call sites → `sqlc/queries/` + generated `sqlcdb`. Merged: metrics + domains (#75), service clone/move (#76), preview environments (#77), deployments + rollback (#78), runtime ops (#79), cron (#80), sleep/wake sweeper (#81), git providers/repos/webhooks (#82), auth + security (#83), operations/settings/audit/activity/builds/misc + apwhy gateway (#84). Raw SQL remains only for runtime-assembled statements (filtered lists, sparse updates) and Better Auth `auth_users` probes.
|
||||
- [ ] **Tests** — canvas/wizard frontend coverage, deployment/template/
|
||||
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) →
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/google/uuid"
|
||||
"github.com/lib/pq"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
@@ -160,21 +162,22 @@ func handleAPwhyServiceValidate(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
ctx := c.Request.Context()
|
||||
|
||||
var upstreamURL, healthPath string
|
||||
var authHeader, authValue sql.NullString
|
||||
var timeoutMs sql.NullInt64
|
||||
err := db.QueryRowContext(ctx, `
|
||||
SELECT upstream_url, health_path, upstream_auth_header, upstream_auth_value, request_timeout_ms
|
||||
FROM api_services WHERE id = $1
|
||||
`, serviceID).Scan(&upstreamURL, &healthPath, &authHeader, &authValue, &timeoutMs)
|
||||
if err != nil {
|
||||
svcUUID, parseErr := uuid.Parse(serviceID)
|
||||
var svc sqlcdb.GetAPServiceForValidateRow
|
||||
var err error
|
||||
if parseErr == nil {
|
||||
svc, err = sqlcdb.New(db.DB).GetAPServiceForValidate(ctx, svcUUID)
|
||||
}
|
||||
if parseErr != nil || err != nil {
|
||||
sendErrorResponse(c, http.StatusNotFound, "SERVICE_NOT_FOUND", "Service not found", nil)
|
||||
return
|
||||
}
|
||||
upstreamURL, healthPath := svc.UpstreamUrl, svc.HealthPath
|
||||
authHeader, authValue := svc.UpstreamAuthHeader, svc.UpstreamAuthValue
|
||||
|
||||
timeout := 8000
|
||||
if timeoutMs.Valid && timeoutMs.Int64 > 0 {
|
||||
timeout = int(timeoutMs.Int64)
|
||||
if svc.RequestTimeoutMs.Valid && svc.RequestTimeoutMs.Int32 > 0 {
|
||||
timeout = int(svc.RequestTimeoutMs.Int32)
|
||||
}
|
||||
target := strings.TrimRight(upstreamURL, "/") + "/" + strings.TrimLeft(healthPath, "/")
|
||||
|
||||
@@ -203,17 +206,19 @@ func handleAPwhyServiceValidate(c *gin.Context) {
|
||||
validationStatus = "healthy"
|
||||
}
|
||||
|
||||
_, _ = db.ExecContext(ctx, `
|
||||
UPDATE api_services
|
||||
SET last_validation_at = NOW(), last_validation_status = $1, last_validation_message = $2, updated_at = NOW()
|
||||
WHERE id = $3
|
||||
`, validationStatus, message, serviceID)
|
||||
q := sqlcdb.New(db.DB)
|
||||
_ = q.SetAPServiceValidation(ctx, sqlcdb.SetAPServiceValidationParams{
|
||||
LastValidationStatus: sql.NullString{String: validationStatus, Valid: true},
|
||||
LastValidationMessage: sql.NullString{String: message, Valid: true},
|
||||
ID: svcUUID,
|
||||
})
|
||||
|
||||
if !ok {
|
||||
_, _ = db.ExecContext(ctx, `
|
||||
INSERT INTO incident_events (service_id, code, message, severity, http_status, occurred_at)
|
||||
VALUES ($1, 'SERVICE_VALIDATION_FAILED', $2, 'medium', $3, NOW())
|
||||
`, serviceID, message, status)
|
||||
_ = q.InsertServiceIncident(ctx, sqlcdb.InsertServiceIncidentParams{
|
||||
ServiceID: uuid.NullUUID{UUID: svcUUID, Valid: true},
|
||||
Message: message,
|
||||
HttpStatus: sql.NullInt32{Int32: int32(status), Valid: true},
|
||||
})
|
||||
}
|
||||
|
||||
sendSuccessResponse(c, http.StatusOK, map[string]interface{}{
|
||||
@@ -231,36 +236,24 @@ func handleAPwhyServicesList(c *gin.Context) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Query services from database
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT id, name, slug, upstream_url, route_prefix, enabled, created_at, updated_at
|
||||
FROM api_services
|
||||
ORDER BY created_at DESC
|
||||
`)
|
||||
rows, err := sqlcdb.New(db.DB).ListAPServices(ctx)
|
||||
if err != nil {
|
||||
sendErrorResponse(c, http.StatusInternalServerError, "DATABASE_ERROR",
|
||||
"Failed to query services", err.Error())
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
services := make([]map[string]interface{}, 0)
|
||||
for rows.Next() {
|
||||
var id, name, slug, upstreamURL, routePrefix, createdAt, updatedAt string
|
||||
var enabled int
|
||||
err := rows.Scan(&id, &name, &slug, &upstreamURL, &routePrefix, &enabled, &createdAt, &updatedAt)
|
||||
if err != nil {
|
||||
continue // Skip malformed rows
|
||||
}
|
||||
|
||||
services := make([]map[string]interface{}, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
services = append(services, map[string]interface{}{
|
||||
"id": id,
|
||||
"name": name,
|
||||
"slug": slug,
|
||||
"upstreamUrl": upstreamURL,
|
||||
"routePrefix": routePrefix,
|
||||
"enabled": enabled != 0,
|
||||
"createdAt": createdAt,
|
||||
"updatedAt": updatedAt,
|
||||
"id": r.ID.String(),
|
||||
"name": r.Name,
|
||||
"slug": r.Slug,
|
||||
"upstreamUrl": r.UpstreamUrl,
|
||||
"routePrefix": r.RoutePrefix,
|
||||
"enabled": r.Enabled != 0,
|
||||
"createdAt": r.CreatedAt,
|
||||
"updatedAt": r.UpdatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -299,14 +292,14 @@ func handleAPwhyServicesCreate(c *gin.Context) {
|
||||
}
|
||||
|
||||
// id defaults to gen_random_uuid(); enabled defaults to 1.
|
||||
var id string
|
||||
err := db.QueryRowContext(ctx, `
|
||||
INSERT INTO api_services (
|
||||
name, slug, upstream_url, route_prefix, health_path,
|
||||
rpm_limit, monthly_quota, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, '/health', $5, $6, NOW(), NOW())
|
||||
RETURNING id
|
||||
`, req.Name, slug, req.UpstreamURL, req.RoutePrefix, rpmLimit, monthlyQuota).Scan(&id)
|
||||
svcID, err := sqlcdb.New(db.DB).InsertAPService(ctx, sqlcdb.InsertAPServiceParams{
|
||||
Name: req.Name,
|
||||
Slug: slug,
|
||||
UpstreamUrl: req.UpstreamURL,
|
||||
RoutePrefix: req.RoutePrefix,
|
||||
RpmLimit: sql.NullInt32{Int32: int32(rpmLimit), Valid: true},
|
||||
MonthlyQuota: sql.NullInt32{Int32: int32(monthlyQuota), Valid: true},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
@@ -320,7 +313,7 @@ func handleAPwhyServicesCreate(c *gin.Context) {
|
||||
}
|
||||
|
||||
serviceData := map[string]interface{}{
|
||||
"id": id,
|
||||
"id": svcID.String(),
|
||||
"name": req.Name,
|
||||
"slug": slug,
|
||||
"upstreamUrl": req.UpstreamURL,
|
||||
@@ -356,13 +349,15 @@ func handleAPwhyServicesPatch(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
|
||||
if input.Enabled != nil {
|
||||
enabled := 0
|
||||
enabled := int32(0)
|
||||
if *input.Enabled {
|
||||
enabled = 1
|
||||
}
|
||||
_, err := db.ExecContext(context.Background(),
|
||||
"UPDATE api_services SET enabled = $1, updated_at = NOW() WHERE id = $2",
|
||||
enabled, serviceID)
|
||||
svcUUID, _ := uuid.Parse(serviceID)
|
||||
err := sqlcdb.New(db.DB).SetAPServiceEnabled(context.Background(), sqlcdb.SetAPServiceEnabledParams{
|
||||
Enabled: enabled,
|
||||
ID: svcUUID,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
@@ -383,11 +378,7 @@ func handleAPwhyServicesPatch(c *gin.Context) {
|
||||
func handleAPwhyKeysList(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
|
||||
rows, err := db.QueryContext(context.Background(), `
|
||||
SELECT id, name, key_prefix, plan, enabled, rpm_limit, monthly_quota, created_at, updated_at
|
||||
FROM api_keys
|
||||
ORDER BY created_at DESC
|
||||
`)
|
||||
rows, err := sqlcdb.New(db.DB).ListAPKeys(context.Background())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"ok": true,
|
||||
@@ -395,34 +386,24 @@ func handleAPwhyKeysList(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
keys := make([]map[string]interface{}, 0)
|
||||
for rows.Next() {
|
||||
var id, name, keyPrefix, plan, createdAt, updatedAt string
|
||||
var enabled int
|
||||
var rpmLimit, monthlyQuota sql.NullInt64
|
||||
|
||||
err := rows.Scan(&id, &name, &keyPrefix, &plan, &enabled, &rpmLimit, &monthlyQuota, &createdAt, &updatedAt)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
keys := make([]map[string]interface{}, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
key := map[string]interface{}{
|
||||
"id": id,
|
||||
"name": name,
|
||||
"keyPrefix": keyPrefix,
|
||||
"plan": plan,
|
||||
"enabled": enabled != 0,
|
||||
"createdAt": createdAt,
|
||||
"updatedAt": updatedAt,
|
||||
"id": r.ID.String(),
|
||||
"name": r.Name,
|
||||
"keyPrefix": r.KeyPrefix,
|
||||
"plan": r.Plan,
|
||||
"enabled": r.Enabled != 0,
|
||||
"createdAt": r.CreatedAt,
|
||||
"updatedAt": r.UpdatedAt,
|
||||
}
|
||||
|
||||
if rpmLimit.Valid {
|
||||
key["rpmLimit"] = rpmLimit.Int64
|
||||
if r.RpmLimit.Valid {
|
||||
key["rpmLimit"] = int64(r.RpmLimit.Int32)
|
||||
}
|
||||
if monthlyQuota.Valid {
|
||||
key["monthlyQuota"] = monthlyQuota.Int64
|
||||
if r.MonthlyQuota.Valid {
|
||||
key["monthlyQuota"] = int64(r.MonthlyQuota.Int32)
|
||||
}
|
||||
|
||||
keys = append(keys, key)
|
||||
@@ -481,14 +462,14 @@ func handleAPwhyKeysCreate(c *gin.Context) {
|
||||
}
|
||||
|
||||
// id defaults to gen_random_uuid(); enabled defaults to 1.
|
||||
var id string
|
||||
err = db.QueryRowContext(context.Background(), `
|
||||
INSERT INTO api_keys (
|
||||
name, key_hash, key_prefix, plan, allowed_service_ids,
|
||||
rpm_limit, monthly_quota, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, '[]', $5, $6, NOW(), NOW())
|
||||
RETURNING id
|
||||
`, input.Name, keyHash, keyPrefix, plan, rpmLimit, monthlyQuota).Scan(&id)
|
||||
keyID, err := sqlcdb.New(db.DB).InsertAPKey(context.Background(), sqlcdb.InsertAPKeyParams{
|
||||
Name: input.Name,
|
||||
KeyHash: keyHash,
|
||||
KeyPrefix: keyPrefix,
|
||||
Plan: plan,
|
||||
RpmLimit: sql.NullInt32{Int32: int32(rpmLimit), Valid: true},
|
||||
MonthlyQuota: sql.NullInt32{Int32: int32(monthlyQuota), Valid: true},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
@@ -501,7 +482,7 @@ func handleAPwhyKeysCreate(c *gin.Context) {
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"ok": true,
|
||||
"data": gin.H{
|
||||
"id": id,
|
||||
"id": keyID.String(),
|
||||
"name": input.Name,
|
||||
"plan": plan,
|
||||
"key": apiKey, // Only return the actual key once
|
||||
@@ -532,13 +513,15 @@ func handleAPwhyKeysPatch(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
|
||||
if input.Enabled != nil {
|
||||
enabled := 0
|
||||
enabled := int32(0)
|
||||
if *input.Enabled {
|
||||
enabled = 1
|
||||
}
|
||||
_, err := db.ExecContext(context.Background(),
|
||||
"UPDATE api_keys SET enabled = $1, updated_at = NOW() WHERE id = $2",
|
||||
enabled, keyID)
|
||||
keyUUID, _ := uuid.Parse(keyID)
|
||||
err := sqlcdb.New(db.DB).SetAPKeyEnabled(context.Background(), sqlcdb.SetAPKeyEnabledParams{
|
||||
Enabled: enabled,
|
||||
ID: keyUUID,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
@@ -560,23 +543,27 @@ func handleAPwhyAnalyticsOps(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
|
||||
// Get counts from database
|
||||
var totalServices, totalKeys, totalUsers int
|
||||
q := sqlcdb.New(db.DB)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := db.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM api_services").Scan(&totalServices); err != nil {
|
||||
totalServices, err := q.CountAPServices(ctx)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"ok": false,
|
||||
"error": "Failed to count services: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
if err := db.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM api_keys").Scan(&totalKeys); err != nil {
|
||||
totalKeys, err := q.CountAPKeys(ctx)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"ok": false,
|
||||
"error": "Failed to count api keys: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
if err := db.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM users").Scan(&totalUsers); err != nil {
|
||||
totalUsers, err := q.CountUsers(ctx)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"ok": false,
|
||||
"error": "Failed to count users: " + err.Error(),
|
||||
@@ -584,8 +571,8 @@ func handleAPwhyAnalyticsOps(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var totalRequests int
|
||||
if err := db.QueryRowContext(context.Background(), "SELECT COALESCE(SUM(request_count), 0) FROM usage_counters").Scan(&totalRequests); err != nil {
|
||||
totalRequests, err := q.SumUsageCounters(ctx)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"ok": false,
|
||||
"error": "Failed to aggregate request counters: " + err.Error(),
|
||||
@@ -593,12 +580,8 @@ func handleAPwhyAnalyticsOps(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var requestsToday int
|
||||
if err := db.QueryRowContext(context.Background(), `
|
||||
SELECT COALESCE(SUM(value), 0)::int
|
||||
FROM metrics_timeseries
|
||||
WHERE metric = 'request_total' AND occurred_at >= DATE_TRUNC('day', NOW())
|
||||
`).Scan(&requestsToday); err != nil {
|
||||
requestsToday, err := q.SumRequestMetricSince(ctx, "day")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"ok": false,
|
||||
"error": "Failed to aggregate today's requests: " + err.Error(),
|
||||
@@ -606,12 +589,8 @@ func handleAPwhyAnalyticsOps(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var requestsThisMonth int
|
||||
if err := db.QueryRowContext(context.Background(), `
|
||||
SELECT COALESCE(SUM(value), 0)::int
|
||||
FROM metrics_timeseries
|
||||
WHERE metric = 'request_total' AND occurred_at >= DATE_TRUNC('month', NOW())
|
||||
`).Scan(&requestsThisMonth); err != nil {
|
||||
requestsThisMonth, err := q.SumRequestMetricSince(ctx, "month")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"ok": false,
|
||||
"error": "Failed to aggregate monthly requests: " + err.Error(),
|
||||
@@ -636,96 +615,49 @@ func handleAPwhyAnalyticsOps(c *gin.Context) {
|
||||
func handleAPwhyAnalyticsTraffic(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
|
||||
q := sqlcdb.New(db.DB)
|
||||
ctx := context.Background()
|
||||
|
||||
topServices := make([]map[string]interface{}, 0)
|
||||
serviceRows, err := db.QueryContext(context.Background(), `
|
||||
SELECT s.id, s.name, COALESCE(SUM(u.request_count), 0) AS total_requests
|
||||
FROM api_services s
|
||||
LEFT JOIN usage_counters u ON u.service_id = s.id
|
||||
GROUP BY s.id, s.name
|
||||
ORDER BY total_requests DESC, s.name ASC
|
||||
LIMIT 10
|
||||
`)
|
||||
if err == nil {
|
||||
defer serviceRows.Close()
|
||||
for serviceRows.Next() {
|
||||
var serviceID, serviceName string
|
||||
var totalRequests int
|
||||
if scanErr := serviceRows.Scan(&serviceID, &serviceName, &totalRequests); scanErr == nil {
|
||||
if serviceRows, err := q.ListTopAPServices(ctx); err == nil {
|
||||
for _, r := range serviceRows {
|
||||
topServices = append(topServices, map[string]interface{}{
|
||||
"service_id": serviceID,
|
||||
"name": serviceName,
|
||||
"requests": totalRequests,
|
||||
"service_id": r.ID.String(),
|
||||
"name": r.Name,
|
||||
"requests": r.TotalRequests,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
requestsByDay := make([]map[string]interface{}, 0)
|
||||
trafficRows, err := db.QueryContext(context.Background(), `
|
||||
SELECT TO_CHAR(DATE_TRUNC('day', occurred_at), 'YYYY-MM-DD') AS day_bucket, COUNT(*) AS total
|
||||
FROM metrics_timeseries
|
||||
WHERE metric = 'request_total' AND occurred_at >= NOW() - INTERVAL '7 days'
|
||||
GROUP BY DATE_TRUNC('day', occurred_at)
|
||||
ORDER BY DATE_TRUNC('day', occurred_at) ASC
|
||||
`)
|
||||
if err == nil {
|
||||
defer trafficRows.Close()
|
||||
for trafficRows.Next() {
|
||||
var day string
|
||||
var count int
|
||||
if scanErr := trafficRows.Scan(&day, &count); scanErr == nil {
|
||||
if trafficRows, err := q.ListRequestsByDay(ctx); err == nil {
|
||||
for _, r := range trafficRows {
|
||||
requestsByDay = append(requestsByDay, map[string]interface{}{
|
||||
"day": day,
|
||||
"requests": count,
|
||||
"day": r.DayBucket,
|
||||
"requests": r.Total,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
statusCodes := make([]map[string]interface{}, 0)
|
||||
statusRows, err := db.QueryContext(context.Background(), `
|
||||
SELECT COALESCE(http_status, 0) AS status_code, COALESCE(SUM(count), 0) AS total
|
||||
FROM incident_events
|
||||
WHERE occurred_at >= NOW() - INTERVAL '7 days'
|
||||
GROUP BY COALESCE(http_status, 0)
|
||||
ORDER BY total DESC, status_code ASC
|
||||
`)
|
||||
if err == nil {
|
||||
defer statusRows.Close()
|
||||
for statusRows.Next() {
|
||||
var code int
|
||||
var total int
|
||||
if scanErr := statusRows.Scan(&code, &total); scanErr == nil {
|
||||
if statusRows, err := q.ListIncidentStatusCodes(ctx); err == nil {
|
||||
for _, r := range statusRows {
|
||||
statusCodes = append(statusCodes, map[string]interface{}{
|
||||
"status_code": code,
|
||||
"count": total,
|
||||
"status_code": r.StatusCode,
|
||||
"count": r.Total,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clientEvents := make([]map[string]interface{}, 0)
|
||||
eventRows, err := db.QueryContext(context.Background(), `
|
||||
SELECT COALESCE((labels_json::jsonb ->> 'path'), 'unknown') AS path, COUNT(*) AS total
|
||||
FROM metrics_timeseries
|
||||
WHERE metric = 'client_event' AND occurred_at >= NOW() - INTERVAL '7 days'
|
||||
GROUP BY COALESCE((labels_json::jsonb ->> 'path'), 'unknown')
|
||||
ORDER BY total DESC, path ASC
|
||||
LIMIT 20
|
||||
`)
|
||||
if err == nil {
|
||||
defer eventRows.Close()
|
||||
for eventRows.Next() {
|
||||
var path string
|
||||
var total int
|
||||
if scanErr := eventRows.Scan(&path, &total); scanErr == nil {
|
||||
if eventRows, err := q.ListClientEventPaths(ctx); err == nil {
|
||||
for _, r := range eventRows {
|
||||
clientEvents = append(clientEvents, map[string]interface{}{
|
||||
"path": path,
|
||||
"count": total,
|
||||
"path": r.Path,
|
||||
"count": r.Total,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"ok": true,
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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"})
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
"containr/internal/build"
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
"containr/internal/docker"
|
||||
"containr/internal/types"
|
||||
|
||||
@@ -669,39 +670,22 @@ func (h *BuildHandler) upsertBuild(status *BuildStatusResponse) error {
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
_, err := h.db.Exec(
|
||||
`INSERT INTO builds
|
||||
(id, project_id, service_id, status, progress, started_at, completed_at, image_name, image_tag, size, error, log, metadata, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb, $14, $14)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
project_id = EXCLUDED.project_id,
|
||||
service_id = EXCLUDED.service_id,
|
||||
status = EXCLUDED.status,
|
||||
progress = EXCLUDED.progress,
|
||||
started_at = EXCLUDED.started_at,
|
||||
completed_at = EXCLUDED.completed_at,
|
||||
image_name = EXCLUDED.image_name,
|
||||
image_tag = EXCLUDED.image_tag,
|
||||
size = EXCLUDED.size,
|
||||
error = EXCLUDED.error,
|
||||
log = EXCLUDED.log,
|
||||
metadata = EXCLUDED.metadata,
|
||||
updated_at = EXCLUDED.updated_at`,
|
||||
status.ID,
|
||||
nullIfEmptyString(status.ProjectID),
|
||||
nullIfEmptyString(status.ServiceID),
|
||||
status.Status,
|
||||
status.Progress,
|
||||
status.StartedAt,
|
||||
status.CompletedAt,
|
||||
status.ImageName,
|
||||
status.ImageTag,
|
||||
status.Size,
|
||||
nullIfEmptyString(status.Error),
|
||||
status.Log,
|
||||
jsonOrEmptyObject(metadataRaw),
|
||||
now,
|
||||
)
|
||||
err := sqlcdb.New(h.db.DB).UpsertBuild(context.Background(), sqlcdb.UpsertBuildParams{
|
||||
ID: status.ID,
|
||||
ProjectID: nsNullIfEmpty(status.ProjectID),
|
||||
ServiceID: nsNullIfEmpty(status.ServiceID),
|
||||
Status: status.Status,
|
||||
Progress: int32(status.Progress),
|
||||
StartedAt: status.StartedAt,
|
||||
CompletedAt: ntPtr(status.CompletedAt),
|
||||
ImageName: status.ImageName,
|
||||
ImageTag: status.ImageTag,
|
||||
Size: status.Size,
|
||||
Error: nsNullIfEmpty(status.Error),
|
||||
Log: status.Log,
|
||||
Column13: json.RawMessage(jsonOrEmptyObject(metadataRaw)),
|
||||
CreatedAt: now,
|
||||
})
|
||||
if err != nil && h.isMissingBuildsTable(err) {
|
||||
return nil
|
||||
}
|
||||
@@ -713,18 +697,37 @@ func (h *BuildHandler) getBuildFromDB(buildID string) (BuildStatusResponse, bool
|
||||
return BuildStatusResponse{}, false, nil
|
||||
}
|
||||
|
||||
row := h.db.QueryRow(
|
||||
`SELECT id, project_id, service_id, status, progress, started_at, completed_at, image_name, image_tag, size, error, log, metadata
|
||||
FROM builds
|
||||
WHERE id = $1`,
|
||||
buildID,
|
||||
)
|
||||
|
||||
build, found, err := scanBuildRow(row.Scan)
|
||||
if err != nil && h.isMissingBuildsTable(err) {
|
||||
row, err := sqlcdb.New(h.db.DB).GetBuild(context.Background(), buildID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) || h.isMissingBuildsTable(err) {
|
||||
return BuildStatusResponse{}, false, nil
|
||||
}
|
||||
return build, found, err
|
||||
return BuildStatusResponse{}, false, err
|
||||
}
|
||||
build := BuildStatusResponse{
|
||||
ID: row.ID,
|
||||
ProjectID: row.ProjectID.String,
|
||||
ServiceID: row.ServiceID.String,
|
||||
Status: row.Status,
|
||||
Progress: int(row.Progress),
|
||||
StartedAt: row.StartedAt.UTC(),
|
||||
ImageName: row.ImageName,
|
||||
ImageTag: row.ImageTag,
|
||||
Size: row.Size,
|
||||
Error: row.Error.String,
|
||||
Log: row.Log,
|
||||
}
|
||||
if t := timePtrNT(row.CompletedAt); t != nil {
|
||||
u := t.UTC()
|
||||
build.CompletedAt = &u
|
||||
}
|
||||
if len(row.Metadata) > 0 {
|
||||
metadata := map[string]string{}
|
||||
if err := json.Unmarshal(row.Metadata, &metadata); err == nil {
|
||||
build.Metadata = metadata
|
||||
}
|
||||
}
|
||||
return build, true, nil
|
||||
}
|
||||
|
||||
func (h *BuildHandler) listBuildsFromDB(projectID, serviceID, status string, page, limit int) ([]BuildStatusResponse, int, error) {
|
||||
@@ -808,19 +811,11 @@ func (h *BuildHandler) cancelBuildInDB(buildID string) bool {
|
||||
return false
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
result, err := h.db.Exec(
|
||||
`UPDATE builds
|
||||
SET status = 'cancelled',
|
||||
progress = 100,
|
||||
completed_at = $1,
|
||||
log = COALESCE(log, '') || $2,
|
||||
updated_at = $1
|
||||
WHERE id = $3
|
||||
AND status NOT IN ('success', 'failed', 'cancelled')`,
|
||||
now,
|
||||
h.formatLogLine("Cancellation requested")+"\n",
|
||||
buildID,
|
||||
)
|
||||
affected, err := sqlcdb.New(h.db.DB).CancelBuild(context.Background(), sqlcdb.CancelBuildParams{
|
||||
CompletedAt: sql.NullTime{Time: now, Valid: true},
|
||||
Log: h.formatLogLine("Cancellation requested") + "\n",
|
||||
ID: buildID,
|
||||
})
|
||||
if err != nil {
|
||||
if h.isMissingBuildsTable(err) {
|
||||
return false
|
||||
@@ -828,9 +823,7 @@ func (h *BuildHandler) cancelBuildInDB(buildID string) bool {
|
||||
log.Printf("failed to cancel build %s in database: %v", buildID, err)
|
||||
return false
|
||||
}
|
||||
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil || affected == 0 {
|
||||
if affected == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -909,12 +902,9 @@ func scanBuildRow(scan func(dest ...interface{}) error) (BuildStatusResponse, bo
|
||||
return parsed, true, nil
|
||||
}
|
||||
|
||||
func nullIfEmptyString(value string) interface{} {
|
||||
func nsNullIfEmpty(value string) sql.NullString {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
return trimmed
|
||||
return sql.NullString{String: trimmed, Valid: trimmed != ""}
|
||||
}
|
||||
|
||||
func jsonOrEmptyObject(raw []byte) string {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+142
-118
@@ -65,13 +65,47 @@ type UpdateCronJobRequest struct {
|
||||
Retention int `json:"retention"`
|
||||
}
|
||||
|
||||
// cronJobFrom maps the sqlc row onto the string-ID API model.
|
||||
func cronJobFrom(j sqlcdb.CronJob) CronJob {
|
||||
return CronJob{
|
||||
ID: j.ID.String(),
|
||||
ProjectID: j.ProjectID.String(),
|
||||
ServiceID: j.ServiceID.String(),
|
||||
Name: j.Name,
|
||||
Schedule: j.Schedule,
|
||||
Command: j.Command,
|
||||
Timezone: j.Timezone.String,
|
||||
Enabled: j.Enabled.Bool,
|
||||
LastRunAt: timePtrNT(j.LastRunAt),
|
||||
NextRunAt: timePtrNT(j.NextRunAt),
|
||||
LastStatus: j.LastStatus.String,
|
||||
LastOutput: j.LastOutput.String,
|
||||
Retention: int(j.Retention.Int32),
|
||||
CreatedAt: j.CreatedAt.Time,
|
||||
UpdatedAt: j.UpdatedAt.Time,
|
||||
}
|
||||
}
|
||||
|
||||
// cronExecutionFrom maps the sqlc row onto the string-ID API model.
|
||||
func cronExecutionFrom(e sqlcdb.CronExecution) CronExecution {
|
||||
return CronExecution{
|
||||
ID: e.ID.String(),
|
||||
CronJobID: e.CronJobID.String(),
|
||||
StartedAt: e.StartedAt,
|
||||
FinishedAt: timePtrNT(e.FinishedAt),
|
||||
Status: e.Status.String,
|
||||
Output: e.Output.String,
|
||||
Error: e.Error.String,
|
||||
}
|
||||
}
|
||||
|
||||
// cronJobProjectID resolves the project a cron job belongs to.
|
||||
func cronJobProjectID(db *database.DB, jobID string) (uuid.UUID, bool) {
|
||||
var projectID uuid.UUID
|
||||
err := db.QueryRow(
|
||||
`SELECT cj.project_id FROM cron_jobs cj WHERE cj.id = $1`,
|
||||
jobID,
|
||||
).Scan(&projectID)
|
||||
id, err := uuid.Parse(jobID)
|
||||
if err != nil {
|
||||
return uuid.Nil, false
|
||||
}
|
||||
projectID, err := sqlcdb.New(db.DB).GetCronJobProjectID(context.Background(), id)
|
||||
if err != nil {
|
||||
return uuid.Nil, false
|
||||
}
|
||||
@@ -137,15 +171,14 @@ func handleCreateCronJob(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var ownerCheck string
|
||||
err := db.QueryRow(
|
||||
`SELECT p.owner_id FROM projects p
|
||||
JOIN services s ON s.project_id = p.id
|
||||
WHERE s.id = $1`,
|
||||
req.ServiceID,
|
||||
).Scan(&ownerCheck)
|
||||
svcID, err := uuid.Parse(req.ServiceID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
ownerCheck, err := sqlcdb.New(db.DB).GetCronOwnerViaService(context.Background(), svcID)
|
||||
|
||||
if err != nil || (ownerCheck != userID && !contextIsAdmin(c)) {
|
||||
if err != nil || (ownerCheck.String() != userID && !contextIsAdmin(c)) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
@@ -178,11 +211,20 @@ func handleCreateCronJob(c *gin.Context) {
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
_, err = db.Exec(
|
||||
`INSERT INTO cron_jobs (id, project_id, service_id, name, schedule, command, timezone, enabled, next_run_at, retention, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
|
||||
job.ID, job.ProjectID, job.ServiceID, job.Name, job.Schedule, job.Command, job.Timezone, job.Enabled, job.NextRunAt, job.Retention, job.CreatedAt, job.UpdatedAt,
|
||||
)
|
||||
err = sqlcdb.New(db.DB).InsertCronJob(context.Background(), sqlcdb.InsertCronJobParams{
|
||||
ID: uuid.MustParse(job.ID),
|
||||
ProjectID: uuid.MustParse(job.ProjectID),
|
||||
ServiceID: uuid.MustParse(job.ServiceID),
|
||||
Name: job.Name,
|
||||
Schedule: job.Schedule,
|
||||
Command: job.Command,
|
||||
Timezone: sql.NullString{String: job.Timezone, Valid: true},
|
||||
Enabled: sql.NullBool{Bool: job.Enabled, Valid: true},
|
||||
NextRunAt: ntPtr(job.NextRunAt),
|
||||
Retention: sql.NullInt32{Int32: int32(job.Retention), Valid: true},
|
||||
CreatedAt: sql.NullTime{Time: job.CreatedAt, Valid: true},
|
||||
UpdatedAt: sql.NullTime{Time: job.UpdatedAt, Valid: true},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create cron job"})
|
||||
@@ -201,22 +243,17 @@ func handleGetCronJob(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
jobID := c.Param("id")
|
||||
|
||||
var job CronJob
|
||||
err := db.QueryRow(
|
||||
`SELECT cj.id, cj.project_id, cj.service_id, cj.name, cj.schedule, cj.timezone,
|
||||
cj.enabled, cj.last_run_at, cj.next_run_at, cj.last_status, cj.last_output,
|
||||
cj.retention, cj.created_at, cj.updated_at
|
||||
FROM cron_jobs cj
|
||||
WHERE cj.id = $1`,
|
||||
jobID,
|
||||
).Scan(&job.ID, &job.ProjectID, &job.ServiceID, &job.Name, &job.Schedule, &job.Timezone,
|
||||
&job.Enabled, &job.LastRunAt, &job.NextRunAt, &job.LastStatus, &job.LastOutput,
|
||||
&job.Retention, &job.CreatedAt, &job.UpdatedAt)
|
||||
|
||||
jid, err := uuid.Parse(jobID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Cron job not found"})
|
||||
return
|
||||
}
|
||||
row, err := sqlcdb.New(db.DB).GetCronJob(context.Background(), jid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Cron job not found"})
|
||||
return
|
||||
}
|
||||
job := cronJobFrom(row)
|
||||
|
||||
projectID, found := cronJobProjectID(db, jobID)
|
||||
if !found {
|
||||
@@ -242,15 +279,14 @@ func handleUpdateCronJob(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var ownerCheck string
|
||||
err := db.QueryRow(
|
||||
`SELECT p.owner_id FROM cron_jobs cj
|
||||
JOIN projects p ON cj.project_id = p.id
|
||||
WHERE cj.id = $1`,
|
||||
jobID,
|
||||
).Scan(&ownerCheck)
|
||||
jid, err := uuid.Parse(jobID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
ownerCheck, err := sqlcdb.New(db.DB).GetCronJobOwner(context.Background(), jid)
|
||||
|
||||
if err != nil || (ownerCheck != userID && !contextIsAdmin(c)) {
|
||||
if err != nil || (ownerCheck.String() != userID && !contextIsAdmin(c)) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
@@ -312,21 +348,19 @@ func handleDeleteCronJob(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
jobID := c.Param("id")
|
||||
|
||||
var ownerCheck string
|
||||
err := db.QueryRow(
|
||||
`SELECT p.owner_id FROM cron_jobs cj
|
||||
JOIN projects p ON cj.project_id = p.id
|
||||
WHERE cj.id = $1`,
|
||||
jobID,
|
||||
).Scan(&ownerCheck)
|
||||
jid, err := uuid.Parse(jobID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
ownerCheck, err := sqlcdb.New(db.DB).GetCronJobOwner(context.Background(), jid)
|
||||
|
||||
if err != nil || (ownerCheck != userID && !contextIsAdmin(c)) {
|
||||
if err != nil || (ownerCheck.String() != userID && !contextIsAdmin(c)) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
_, err = db.Exec("DELETE FROM cron_jobs WHERE id = $1", jobID)
|
||||
if err != nil {
|
||||
if err := sqlcdb.New(db.DB).DeleteCronJob(context.Background(), jid); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete cron job"})
|
||||
return
|
||||
}
|
||||
@@ -350,29 +384,20 @@ func handleGetCronExecutions(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := db.Query(
|
||||
`SELECT id, cron_job_id, started_at, finished_at, status, output, error
|
||||
FROM cron_executions
|
||||
WHERE cron_job_id = $1
|
||||
ORDER BY started_at DESC
|
||||
LIMIT 100`,
|
||||
jobID,
|
||||
)
|
||||
|
||||
jid, err := uuid.Parse(jobID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch executions"})
|
||||
return
|
||||
}
|
||||
rows, err := sqlcdb.New(db.DB).ListCronExecutions(context.Background(), jid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch executions"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var executions []CronExecution
|
||||
for rows.Next() {
|
||||
var exec CronExecution
|
||||
err := rows.Scan(&exec.ID, &exec.CronJobID, &exec.StartedAt, &exec.FinishedAt, &exec.Status, &exec.Output, &exec.Error)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
executions = append(executions, exec)
|
||||
for _, e := range rows {
|
||||
executions = append(executions, cronExecutionFrom(e))
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"executions": executions})
|
||||
@@ -383,29 +408,33 @@ func handleTriggerCronJob(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
jobID := c.Param("id")
|
||||
|
||||
var job CronJob
|
||||
var ownerCheck string
|
||||
err := db.QueryRow(
|
||||
`SELECT cj.service_id, cj.command, cj.schedule, cj.timezone, cj.retention, p.owner_id
|
||||
FROM cron_jobs cj
|
||||
JOIN projects p ON cj.project_id = p.id
|
||||
WHERE cj.id = $1`,
|
||||
jobID,
|
||||
).Scan(&job.ServiceID, &job.Command, &job.Schedule, &job.Timezone, &job.Retention, &ownerCheck)
|
||||
|
||||
if err != nil || (ownerCheck != userID && !contextIsAdmin(c)) {
|
||||
jid, err := uuid.Parse(jobID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
trig, err := sqlcdb.New(db.DB).GetCronJobForTrigger(context.Background(), jid)
|
||||
if err != nil || (trig.OwnerID.String() != userID && !contextIsAdmin(c)) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
job := CronJob{
|
||||
ServiceID: trig.ServiceID.String(),
|
||||
Command: trig.Command,
|
||||
Schedule: trig.Schedule,
|
||||
Timezone: trig.Timezone.String,
|
||||
Retention: int(trig.Retention.Int32),
|
||||
}
|
||||
|
||||
execID := uuid.New().String()
|
||||
now := time.Now()
|
||||
|
||||
_, err = db.Exec(
|
||||
`INSERT INTO cron_executions (id, cron_job_id, started_at, status)
|
||||
VALUES ($1, $2, $3, $4)`,
|
||||
execID, jobID, now, "running",
|
||||
)
|
||||
_ = sqlcdb.New(db.DB).InsertCronExecution(context.Background(), sqlcdb.InsertCronExecutionParams{
|
||||
ID: uuid.MustParse(execID),
|
||||
CronJobID: jid,
|
||||
StartedAt: now,
|
||||
Status: sql.NullString{String: "running", Valid: true},
|
||||
})
|
||||
|
||||
dockerClient, _ := c.Get("docker_client")
|
||||
dockerCli, _ := dockerClient.(*docker.Client)
|
||||
@@ -458,39 +487,25 @@ func StartCronScheduler(ctx context.Context, db *database.DB, dockerClient *dock
|
||||
}
|
||||
|
||||
func runDueCronJobs(db *database.DB, dockerClient *docker.Client) {
|
||||
rows, err := db.Query(
|
||||
`SELECT id, project_id, service_id, name, schedule, command, timezone, enabled, retention
|
||||
FROM cron_jobs
|
||||
WHERE enabled = TRUE AND next_run_at IS NOT NULL AND next_run_at <= NOW()`)
|
||||
q := sqlcdb.New(db.DB)
|
||||
jobs, err := q.ListDueCronJobs(context.Background())
|
||||
if err != nil {
|
||||
log.Printf("cron scheduler: failed to list due jobs: %v", err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type dueJob struct {
|
||||
CronJob
|
||||
Command string
|
||||
}
|
||||
var jobs []dueJob
|
||||
for rows.Next() {
|
||||
var j dueJob
|
||||
if err := rows.Scan(&j.ID, &j.ProjectID, &j.ServiceID, &j.Name, &j.Schedule,
|
||||
&j.Command, &j.Timezone, &j.Enabled, &j.Retention); err != nil {
|
||||
continue
|
||||
}
|
||||
jobs = append(jobs, j)
|
||||
}
|
||||
|
||||
for _, j := range jobs {
|
||||
execID := uuid.New().String()
|
||||
if _, err := db.Exec(
|
||||
`INSERT INTO cron_executions (id, cron_job_id, started_at, status) VALUES ($1, $2, $3, $4)`,
|
||||
execID, j.ID, time.Now(), "running"); err != nil {
|
||||
if err := q.InsertCronExecution(context.Background(), sqlcdb.InsertCronExecutionParams{
|
||||
ID: uuid.MustParse(execID),
|
||||
CronJobID: j.ID,
|
||||
StartedAt: time.Now(),
|
||||
Status: sql.NullString{String: "running", Valid: true},
|
||||
}); err != nil {
|
||||
log.Printf("cron scheduler: failed to record execution for %s: %v", j.ID, err)
|
||||
continue
|
||||
}
|
||||
go executeCronJob(db, dockerClient, j.ID, j.ServiceID, execID, j.Command, j.Schedule, j.Timezone, j.Retention)
|
||||
go executeCronJob(db, dockerClient, j.ID.String(), j.ServiceID.String(), execID, j.Command, j.Schedule, j.Timezone.String, int(j.Retention.Int32))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -584,23 +599,32 @@ func executeCronJob(db *database.DB, dockerClient *docker.Client, jobID, service
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
db.Exec(
|
||||
`UPDATE cron_executions SET finished_at = $1, status = $2, output = $3, error = $4 WHERE id = $5`,
|
||||
now, status, output, errText, execID,
|
||||
)
|
||||
q := sqlcdb.New(db.DB)
|
||||
if eid, err := uuid.Parse(execID); err == nil {
|
||||
_ = q.FinishCronExecution(context.Background(), sqlcdb.FinishCronExecutionParams{
|
||||
FinishedAt: sql.NullTime{Time: now, Valid: true},
|
||||
Status: sql.NullString{String: status, Valid: true},
|
||||
Output: sql.NullString{String: output, Valid: true},
|
||||
Error: sql.NullString{String: errText, Valid: true},
|
||||
ID: eid,
|
||||
})
|
||||
}
|
||||
|
||||
next, _ := calculateNextRun(schedule, timezone)
|
||||
db.Exec(
|
||||
`UPDATE cron_jobs SET last_run_at = $1, last_status = $2, last_output = $3, next_run_at = $4 WHERE id = $5`,
|
||||
now, status, output, next, jobID,
|
||||
)
|
||||
if jid, err := uuid.Parse(jobID); err == nil {
|
||||
_ = q.UpdateCronJobRun(context.Background(), sqlcdb.UpdateCronJobRunParams{
|
||||
LastRunAt: sql.NullTime{Time: now, Valid: true},
|
||||
LastStatus: sql.NullString{String: status, Valid: true},
|
||||
LastOutput: sql.NullString{String: output, Valid: true},
|
||||
NextRunAt: ntPtr(next),
|
||||
ID: jid,
|
||||
})
|
||||
|
||||
if retention > 0 {
|
||||
db.Exec(
|
||||
`DELETE FROM cron_executions WHERE cron_job_id = $1 AND id NOT IN (
|
||||
SELECT id FROM cron_executions WHERE cron_job_id = $1
|
||||
ORDER BY started_at DESC LIMIT $2)`,
|
||||
jobID, retention,
|
||||
)
|
||||
_ = q.TrimCronExecutions(context.Background(), sqlcdb.TrimCronExecutionsParams{
|
||||
CronJobID: jid,
|
||||
Limit: int32(retention),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,16 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestCalculateNextRun(t *testing.T) {
|
||||
@@ -64,3 +71,123 @@ func TestCalculateNextRunTimezone(t *testing.T) {
|
||||
t.Logf("utc next: %v / tokyo next: %v", utcNext, tokyoNext)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCronLiveInvariants exercises the cron.sql queries on live Postgres:
|
||||
// insert/read/owner join, execution lifecycle, run bookkeeping, retention trim.
|
||||
func TestCronLiveInvariants(t *testing.T) {
|
||||
dsn := testDSNString(t)
|
||||
db, err := database.NewConnection(dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
ctx := context.Background()
|
||||
q := sqlcdb.New(db.DB)
|
||||
|
||||
userID := uuid.New()
|
||||
projectID := uuid.New()
|
||||
envID := uuid.New()
|
||||
serviceID := uuid.New()
|
||||
jobID := uuid.New()
|
||||
suffix := strings.ReplaceAll(jobID.String()[:8], "-", "")
|
||||
must := func(err error) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Fatalf("setup: %v", err)
|
||||
}
|
||||
}
|
||||
_, err = db.Exec(`INSERT INTO users (id, email, password_hash, name) VALUES ($1, $2, 'x', 'test')`,
|
||||
userID, "cron-"+suffix+"@test.local")
|
||||
must(err)
|
||||
_, err = db.Exec(`INSERT INTO projects (id, name, owner_id, is_approved) VALUES ($1, $2, $3, true)`,
|
||||
projectID, "cron-"+suffix, userID)
|
||||
must(err)
|
||||
_, err = db.Exec(`INSERT INTO environments (id, name, project_id) VALUES ($1, 'production', $2)`,
|
||||
envID, projectID)
|
||||
must(err)
|
||||
_, err = db.Exec(`INSERT INTO services (id, name, project_id, environment_id, service_type, source_type)
|
||||
VALUES ($1, $2, $3, $4, 'web', 'image')`,
|
||||
serviceID, "cron-"+suffix, projectID, envID)
|
||||
must(err)
|
||||
defer func() { _, _ = db.Exec(`DELETE FROM users WHERE id = $1`, userID) }()
|
||||
|
||||
next, err := calculateNextRun("0 4 * * *", "UTC")
|
||||
if err != nil {
|
||||
t.Fatalf("schedule: %v", err)
|
||||
}
|
||||
must(q.InsertCronJob(ctx, sqlcdb.InsertCronJobParams{
|
||||
ID: jobID, ProjectID: projectID, ServiceID: serviceID,
|
||||
Name: "job-" + suffix, Schedule: "0 4 * * *", Command: "echo hi",
|
||||
Timezone: sql.NullString{String: "UTC", Valid: true},
|
||||
Enabled: sql.NullBool{Bool: true, Valid: true},
|
||||
NextRunAt: ntPtr(next),
|
||||
}))
|
||||
job, err := q.GetCronJob(ctx, jobID)
|
||||
if err != nil || job.Name != "job-"+suffix || job.Command != "echo hi" {
|
||||
t.Fatalf("job: %v %+v", err, job)
|
||||
}
|
||||
owner, err := q.GetCronJobOwner(ctx, jobID)
|
||||
if err != nil || owner != userID {
|
||||
t.Fatalf("owner: %v %v", err, owner)
|
||||
}
|
||||
owner2, err := q.GetCronOwnerViaService(ctx, serviceID)
|
||||
if err != nil || owner2 != userID {
|
||||
t.Fatalf("owner via service: %v %v", err, owner2)
|
||||
}
|
||||
pid, err := q.GetCronJobProjectID(ctx, jobID)
|
||||
if err != nil || pid != projectID {
|
||||
t.Fatalf("project: %v %v", err, pid)
|
||||
}
|
||||
|
||||
// Trigger-shaped read.
|
||||
trig, err := q.GetCronJobForTrigger(ctx, jobID)
|
||||
if err != nil || trig.ServiceID != serviceID || trig.OwnerID != userID {
|
||||
t.Fatalf("trigger: %v %+v", err, trig)
|
||||
}
|
||||
|
||||
// Execution lifecycle + retention trim.
|
||||
for i := 0; i < 3; i++ {
|
||||
execID := uuid.New()
|
||||
must(q.InsertCronExecution(ctx, sqlcdb.InsertCronExecutionParams{
|
||||
ID: execID, CronJobID: jobID,
|
||||
StartedAt: time.Now().Add(time.Duration(i) * time.Second),
|
||||
Status: sql.NullString{String: "running", Valid: true},
|
||||
}))
|
||||
must(q.FinishCronExecution(ctx, sqlcdb.FinishCronExecutionParams{
|
||||
FinishedAt: sql.NullTime{Time: time.Now(), Valid: true},
|
||||
Status: sql.NullString{String: "success", Valid: true},
|
||||
Output: sql.NullString{String: "ok", Valid: true},
|
||||
Error: sql.NullString{String: "", Valid: true},
|
||||
ID: execID,
|
||||
}))
|
||||
}
|
||||
execs, err := q.ListCronExecutions(ctx, jobID)
|
||||
if err != nil || len(execs) != 3 {
|
||||
t.Fatalf("execs: %v %d", err, len(execs))
|
||||
}
|
||||
if execs[0].Status.String != "success" || !execs[0].FinishedAt.Valid {
|
||||
t.Fatalf("exec row: %+v", execs[0])
|
||||
}
|
||||
must(q.TrimCronExecutions(ctx, sqlcdb.TrimCronExecutionsParams{CronJobID: jobID, Limit: 1}))
|
||||
execs, _ = q.ListCronExecutions(ctx, jobID)
|
||||
if len(execs) != 1 {
|
||||
t.Fatalf("trim kept %d, want 1", len(execs))
|
||||
}
|
||||
|
||||
must(q.UpdateCronJobRun(ctx, sqlcdb.UpdateCronJobRunParams{
|
||||
LastRunAt: sql.NullTime{Time: time.Now(), Valid: true},
|
||||
LastStatus: sql.NullString{String: "success", Valid: true},
|
||||
LastOutput: sql.NullString{String: "ok", Valid: true},
|
||||
NextRunAt: ntPtr(next),
|
||||
ID: jobID,
|
||||
}))
|
||||
job, _ = q.GetCronJob(ctx, jobID)
|
||||
if job.LastStatus.String != "success" || !job.LastRunAt.Valid {
|
||||
t.Fatalf("post-run job: %+v", job)
|
||||
}
|
||||
|
||||
must(q.DeleteCronJob(ctx, jobID))
|
||||
if _, err := q.GetCronJob(ctx, jobID); err == nil {
|
||||
t.Fatal("deleted job still readable")
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,14 @@ package api
|
||||
|
||||
import (
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
"containr/internal/deployment"
|
||||
"containr/internal/deployqueue"
|
||||
"containr/internal/docker"
|
||||
"containr/internal/source"
|
||||
"containr/internal/types"
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
@@ -37,6 +39,72 @@ type DeploymentModel struct {
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
}
|
||||
|
||||
// deploymentRowShape carries the deployment columns shared by the
|
||||
// generated list/detail/rollback row types.
|
||||
type deploymentRowShape struct {
|
||||
ID uuid.UUID
|
||||
ServiceID uuid.UUID
|
||||
CommitHash sql.NullString
|
||||
Status sql.NullString
|
||||
ImageName sql.NullString
|
||||
ImageTag sql.NullString
|
||||
BuildLog sql.NullString
|
||||
RuntimeLog sql.NullString
|
||||
Error sql.NullString
|
||||
StartedAt sql.NullTime
|
||||
CompletedAt sql.NullTime
|
||||
CreatedAt sql.NullTime
|
||||
UpdatedAt sql.NullTime
|
||||
}
|
||||
|
||||
func strPtrNS(v sql.NullString) *string {
|
||||
if !v.Valid {
|
||||
return nil
|
||||
}
|
||||
s := v.String
|
||||
return &s
|
||||
}
|
||||
|
||||
func ptrStr(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
}
|
||||
|
||||
func timePtrNT(v sql.NullTime) *time.Time {
|
||||
if !v.Valid {
|
||||
return nil
|
||||
}
|
||||
t := v.Time
|
||||
return &t
|
||||
}
|
||||
|
||||
func ntPtr(t *time.Time) sql.NullTime {
|
||||
if t == nil {
|
||||
return sql.NullTime{}
|
||||
}
|
||||
return sql.NullTime{Time: *t, Valid: true}
|
||||
}
|
||||
|
||||
func deploymentModelFrom(r deploymentRowShape) DeploymentModel {
|
||||
return DeploymentModel{
|
||||
ID: r.ID,
|
||||
ServiceID: r.ServiceID,
|
||||
CommitHash: strPtrNS(r.CommitHash),
|
||||
Status: r.Status.String,
|
||||
ImageName: r.ImageName.String,
|
||||
ImageTag: r.ImageTag.String,
|
||||
BuildLog: r.BuildLog.String,
|
||||
RuntimeLog: r.RuntimeLog.String,
|
||||
Error: strPtrNS(r.Error),
|
||||
StartedAt: timePtrNT(r.StartedAt),
|
||||
CompletedAt: timePtrNT(r.CompletedAt),
|
||||
CreatedAt: r.CreatedAt.Time,
|
||||
UpdatedAt: r.UpdatedAt.Time,
|
||||
}
|
||||
}
|
||||
|
||||
type CreateDeploymentRequest struct {
|
||||
CommitHash string `json:"commit_hash"`
|
||||
Branch string `json:"branch"`
|
||||
@@ -84,44 +152,29 @@ func handleGetDeployments(c *gin.Context) {
|
||||
|
||||
offset, limit := pageWindow(c, 50, 100)
|
||||
|
||||
var total int
|
||||
_ = db.(*database.DB).QueryRow(
|
||||
`SELECT COUNT(*) FROM deployments WHERE service_id = $1`, serviceID,
|
||||
).Scan(&total)
|
||||
q := sqlcdb.New(db.(*database.DB).DB)
|
||||
total, _ := q.CountDeploymentsForService(c.Request.Context(), serviceID)
|
||||
|
||||
rows, err := db.(*database.DB).Query(
|
||||
`SELECT id, service_id, commit_hash, status, image_name, image_tag,
|
||||
build_log, runtime_log, error, started_at, completed_at, created_at, updated_at
|
||||
FROM deployments
|
||||
WHERE service_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2 OFFSET $3`,
|
||||
serviceID, limit, offset,
|
||||
)
|
||||
rows, err := q.ListDeploymentsForService(c.Request.Context(), sqlcdb.ListDeploymentsForServiceParams{
|
||||
ServiceID: serviceID, Limit: int32(limit), Offset: int32(offset)})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve deployments"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var deployments []DeploymentModel
|
||||
for rows.Next() {
|
||||
var d DeploymentModel
|
||||
err := rows.Scan(
|
||||
&d.ID, &d.ServiceID, &d.CommitHash, &d.Status, &d.ImageName, &d.ImageTag,
|
||||
&d.BuildLog, &d.RuntimeLog, &d.Error, &d.StartedAt, &d.CompletedAt,
|
||||
&d.CreatedAt, &d.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan deployment"})
|
||||
return
|
||||
}
|
||||
deployments = append(deployments, d)
|
||||
deployments := make([]DeploymentModel, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
deployments = append(deployments, deploymentModelFrom(deploymentRowShape{
|
||||
ID: r.ID, ServiceID: r.ServiceID, CommitHash: r.CommitHash, Status: r.Status,
|
||||
ImageName: r.ImageName, ImageTag: r.ImageTag, BuildLog: r.BuildLog,
|
||||
RuntimeLog: r.RuntimeLog, Error: r.Error, StartedAt: r.StartedAt,
|
||||
CompletedAt: r.CompletedAt, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt,
|
||||
}))
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"deployments": deployments,
|
||||
"pagination": paginationMeta(offset, limit, total),
|
||||
"pagination": paginationMeta(offset, limit, int(total)),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -152,51 +205,33 @@ func handleGetRecentDeployments(c *gin.Context) {
|
||||
|
||||
offset, limit := pageWindow(c, 10, 50)
|
||||
|
||||
var total int
|
||||
if err := db.(*database.DB).QueryRow(
|
||||
`SELECT COUNT(*)
|
||||
FROM deployments d
|
||||
JOIN services s ON s.id = d.service_id
|
||||
JOIN projects p ON p.id = s.project_id
|
||||
WHERE 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)`,
|
||||
userID, isAdmin,
|
||||
).Scan(&total); err != nil {
|
||||
q := sqlcdb.New(db.(*database.DB).DB)
|
||||
total, err := q.CountAccessibleDeployments(c.Request.Context(), sqlcdb.CountAccessibleDeploymentsParams{
|
||||
OwnerID: userID, Column2: isAdmin})
|
||||
if err != nil {
|
||||
total = 0
|
||||
}
|
||||
|
||||
rows, err := db.(*database.DB).Query(
|
||||
`SELECT d.id, d.service_id, s.name, p.name, d.status,
|
||||
COALESCE(d.image_name, ''), 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 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 DESC
|
||||
LIMIT $3 OFFSET $4`,
|
||||
userID, isAdmin, limit, offset,
|
||||
)
|
||||
rows, err := q.ListRecentAccessibleDeployments(c.Request.Context(), sqlcdb.ListRecentAccessibleDeploymentsParams{
|
||||
OwnerID: userID, Column2: isAdmin, Limit: int32(limit), Offset: int32(offset)})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve deployments"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
deployments := []RecentDeployment{}
|
||||
for rows.Next() {
|
||||
var d RecentDeployment
|
||||
if err := rows.Scan(&d.ID, &d.ServiceID, &d.ServiceName, &d.ProjectName,
|
||||
&d.Status, &d.ImageName, &d.StartedAt, &d.CompletedAt, &d.CreatedAt); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan deployment"})
|
||||
return
|
||||
}
|
||||
deployments = append(deployments, d)
|
||||
deployments := make([]RecentDeployment, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
deployments = append(deployments, RecentDeployment{
|
||||
ID: r.ID, ServiceID: r.ServiceID, ServiceName: r.ServiceName,
|
||||
ProjectName: r.ProjectName, Status: r.Status.String, ImageName: r.ImageName,
|
||||
StartedAt: timePtrNT(r.StartedAt), CompletedAt: timePtrNT(r.CompletedAt),
|
||||
CreatedAt: r.CreatedAt.Time,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"deployments": deployments,
|
||||
"pagination": paginationMeta(offset, limit, total),
|
||||
"pagination": paginationMeta(offset, limit, int(total)),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -230,38 +265,27 @@ func handleCreateDeployment(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var service Service
|
||||
var projectOwner string
|
||||
err = db.(*database.DB).QueryRow(
|
||||
`SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command,
|
||||
s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory,
|
||||
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
||||
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
||||
COALESCE(s.restart_policy, 'unless-stopped'),
|
||||
COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''),
|
||||
COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''),
|
||||
COALESCE(s.static_dir, ''),
|
||||
s.created_at, s.updated_at, p.owner_id
|
||||
FROM services s
|
||||
JOIN projects p ON s.project_id = p.id
|
||||
WHERE s.id = $1`,
|
||||
serviceID,
|
||||
).Scan(
|
||||
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
|
||||
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
||||
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
||||
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
||||
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
|
||||
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
|
||||
&service.CreatedAt, &service.UpdatedAt, &projectOwner,
|
||||
)
|
||||
|
||||
ctx := c.Request.Context()
|
||||
q := sqlcdb.New(db.(*database.DB).DB)
|
||||
sr, err := q.GetServiceForDeployWithOwner(ctx, serviceID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
||||
return
|
||||
}
|
||||
service := serviceForDeployFromRow(sqlcdb.GetServiceForDeployRow{
|
||||
ID: sr.ID, ProjectID: sr.ProjectID, Name: sr.Name, Type: sr.Type,
|
||||
Status: sr.Status, Image: sr.Image, Command: sr.Command,
|
||||
Environment: sr.Environment, GitRepo: sr.GitRepo, GitBranch: sr.GitBranch,
|
||||
BuildPath: sr.BuildPath, Cpu: sr.Cpu, Memory: sr.Memory,
|
||||
Replicas: sr.Replicas, Port: sr.Port, Domain: sr.Domain,
|
||||
HealthcheckPath: sr.HealthcheckPath, RestartPolicy: sr.RestartPolicy,
|
||||
Builder: sr.Builder, CpuReserve: sr.CpuReserve, MemoryReserve: sr.MemoryReserve,
|
||||
StaticBuildCmd: sr.StaticBuildCmd, StaticDir: sr.StaticDir,
|
||||
CreatedAt: sr.CreatedAt, UpdatedAt: sr.UpdatedAt,
|
||||
})
|
||||
projectOwner := sr.OwnerID
|
||||
|
||||
if projectOwner != userID.(string) {
|
||||
if projectOwner.String() != userID.(string) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
@@ -287,12 +311,17 @@ func handleCreateDeployment(c *gin.Context) {
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
_, err = db.(*database.DB).Exec(
|
||||
`INSERT INTO deployments
|
||||
(id, service_id, version, commit_hash, status, image_name, image_tag, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
d.ID, d.ServiceID, fmt.Sprintf("v%d", now.Unix()), d.CommitHash, d.Status, d.ImageName, d.ImageTag, d.CreatedAt, d.UpdatedAt,
|
||||
)
|
||||
err = q.InsertDeployment(ctx, sqlcdb.InsertDeploymentParams{
|
||||
ID: d.ID,
|
||||
ServiceID: d.ServiceID,
|
||||
Version: fmt.Sprintf("v%d", now.Unix()),
|
||||
CommitHash: sql.NullString{String: ptrStr(d.CommitHash), Valid: d.CommitHash != nil},
|
||||
Status: sql.NullString{String: d.Status, Valid: true},
|
||||
ImageName: sql.NullString{String: d.ImageName, Valid: true},
|
||||
ImageTag: sql.NullString{String: d.ImageTag, Valid: true},
|
||||
CreatedAt: sql.NullTime{Time: d.CreatedAt, Valid: true},
|
||||
UpdatedAt: sql.NullTime{Time: d.UpdatedAt, Valid: true},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create deployment"})
|
||||
@@ -303,20 +332,20 @@ func handleCreateDeployment(c *gin.Context) {
|
||||
if !exists || engine == nil {
|
||||
unavailableErr := "Deployment engine unavailable. Docker may not be configured on this server."
|
||||
completedAt := time.Now()
|
||||
_, _ = db.(*database.DB).Exec(
|
||||
`UPDATE deployments
|
||||
SET status = 'failed', error = $1, completed_at = $2, updated_at = $2
|
||||
WHERE id = $3`,
|
||||
unavailableErr, completedAt, d.ID,
|
||||
)
|
||||
_ = q.FailDeployment(ctx, sqlcdb.FailDeploymentParams{
|
||||
Error: sql.NullString{String: unavailableErr, Valid: true},
|
||||
CompletedAt: sql.NullTime{Time: completedAt, Valid: true},
|
||||
ID: d.ID,
|
||||
})
|
||||
d.Status = "failed"
|
||||
d.Error = &unavailableErr
|
||||
d.CompletedAt = &completedAt
|
||||
} else {
|
||||
_, err = db.(*database.DB).Exec(
|
||||
`UPDATE services SET status = 'building', updated_at = $1 WHERE id = $2`,
|
||||
time.Now(), serviceID,
|
||||
)
|
||||
err = q.SetServiceStatus(ctx, sqlcdb.SetServiceStatusParams{
|
||||
Status: sql.NullString{String: "building", Valid: true},
|
||||
UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true},
|
||||
ID: serviceID,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update service status"})
|
||||
return
|
||||
@@ -331,14 +360,17 @@ func handleCreateDeployment(c *gin.Context) {
|
||||
})
|
||||
if pos > 0 {
|
||||
d.Status = "queued"
|
||||
_, _ = db.(*database.DB).Exec(
|
||||
`UPDATE deployments SET status = 'queued', updated_at = $1 WHERE id = $2`,
|
||||
time.Now(), d.ID,
|
||||
)
|
||||
_, _ = db.(*database.DB).Exec(
|
||||
`UPDATE services SET status = 'queued', updated_at = $1 WHERE id = $2`,
|
||||
time.Now(), serviceID,
|
||||
)
|
||||
now := time.Now()
|
||||
_ = q.SetDeploymentStatus(ctx, sqlcdb.SetDeploymentStatusParams{
|
||||
Status: sql.NullString{String: "queued", Valid: true},
|
||||
UpdatedAt: sql.NullTime{Time: now, Valid: true},
|
||||
ID: d.ID,
|
||||
})
|
||||
_ = q.SetServiceStatus(ctx, sqlcdb.SetServiceStatusParams{
|
||||
Status: sql.NullString{String: "queued", Valid: true},
|
||||
UpdatedAt: sql.NullTime{Time: now, Valid: true},
|
||||
ID: serviceID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -380,6 +412,7 @@ func runDeploymentAndSyncWithImage(
|
||||
) {
|
||||
ctx, cancel := context.WithTimeout(parentCtx, 30*time.Minute)
|
||||
defer cancel()
|
||||
q := sqlcdb.New(db.DB)
|
||||
|
||||
// The checkout and any shipped artifacts die with this deployment.
|
||||
var artifactIDs []string
|
||||
@@ -392,10 +425,11 @@ func runDeploymentAndSyncWithImage(
|
||||
}()
|
||||
failDeploy := func(msg string) {
|
||||
failedAt := time.Now()
|
||||
_, _ = db.Exec(
|
||||
`UPDATE deployments SET status = 'failed', error = $1, completed_at = $2, updated_at = $2 WHERE id = $3`,
|
||||
msg, failedAt, dbDeployment.ID,
|
||||
)
|
||||
_ = q.FailDeployment(ctx, sqlcdb.FailDeploymentParams{
|
||||
Error: sql.NullString{String: msg, Valid: true},
|
||||
CompletedAt: sql.NullTime{Time: failedAt, Valid: true},
|
||||
ID: dbDeployment.ID,
|
||||
})
|
||||
}
|
||||
|
||||
sourcePath := strings.TrimSpace(service.BuildPath)
|
||||
@@ -423,11 +457,10 @@ func runDeploymentAndSyncWithImage(
|
||||
replicas = 1
|
||||
}
|
||||
|
||||
var publishedPort int32
|
||||
// Best effort: reuse the host port from the last live deployment so public
|
||||
// URLs survive redeploys. Column exists post-migration; older DBs get
|
||||
// ephemeral ports.
|
||||
_ = db.QueryRow(`SELECT published_port FROM services WHERE id = $1`, service.ID).Scan(&publishedPort)
|
||||
publishedPort, _ := q.GetServicePublishedPort(ctx, service.ID)
|
||||
|
||||
publicPort := int32(service.Port)
|
||||
if service.Builder == "static" && publicPort == 0 {
|
||||
@@ -451,7 +484,9 @@ func runDeploymentAndSyncWithImage(
|
||||
// remote builds package it into a context artifact the node builds.
|
||||
if service.GitRepo != "" && imageOverride == "" {
|
||||
var ownerID string
|
||||
_ = db.QueryRow(`SELECT owner_id FROM projects WHERE id = $1`, service.ProjectID).Scan(&ownerID)
|
||||
if o, err := q.GetProjectOwner(ctx, service.ProjectID); err == nil {
|
||||
ownerID = o.String()
|
||||
}
|
||||
branch := req.Branch
|
||||
if branch == "" {
|
||||
branch = service.GitBranch
|
||||
@@ -535,7 +570,9 @@ func runDeploymentAndSyncWithImage(
|
||||
}
|
||||
} else {
|
||||
var ownerID string
|
||||
_ = db.QueryRow(`SELECT owner_id FROM projects WHERE id = $1`, service.ProjectID).Scan(&ownerID)
|
||||
if o, err := q.GetProjectOwner(ctx, service.ProjectID); err == nil {
|
||||
ownerID = o.String()
|
||||
}
|
||||
auth := registryAuthFor(db, ownerID, image)
|
||||
deployReq.BuildConfig = &deployment.BuildConfig{
|
||||
BuildType: "prebuilt",
|
||||
@@ -605,10 +642,11 @@ func runDeploymentAndSyncWithImage(
|
||||
|
||||
if msg := capacityCheck(parentCtx, db, engine.DockerClient(), service, replicas); msg != "" {
|
||||
failedAt := time.Now()
|
||||
_, _ = db.Exec(
|
||||
`UPDATE deployments SET status = 'failed', error = $1, completed_at = $2, updated_at = $2 WHERE id = $3`,
|
||||
msg, failedAt, dbDeployment.ID,
|
||||
)
|
||||
_ = q.FailDeployment(ctx, sqlcdb.FailDeploymentParams{
|
||||
Error: sql.NullString{String: msg, Valid: true},
|
||||
CompletedAt: sql.NullTime{Time: failedAt, Valid: true},
|
||||
ID: dbDeployment.ID,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -616,16 +654,16 @@ func runDeploymentAndSyncWithImage(
|
||||
if err != nil {
|
||||
failedAt := time.Now()
|
||||
failure := "Failed to start deployment engine: " + err.Error()
|
||||
_, _ = db.Exec(
|
||||
`UPDATE deployments
|
||||
SET status = 'failed', error = $1, completed_at = $2, updated_at = $2
|
||||
WHERE id = $3`,
|
||||
failure, failedAt, dbDeployment.ID,
|
||||
)
|
||||
_, _ = db.Exec(
|
||||
`UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`,
|
||||
failedAt, service.ID,
|
||||
)
|
||||
_ = q.FailDeployment(ctx, sqlcdb.FailDeploymentParams{
|
||||
Error: sql.NullString{String: failure, Valid: true},
|
||||
CompletedAt: sql.NullTime{Time: failedAt, Valid: true},
|
||||
ID: dbDeployment.ID,
|
||||
})
|
||||
_ = q.SetServiceStatus(ctx, sqlcdb.SetServiceStatusParams{
|
||||
Status: sql.NullString{String: "failed", Valid: true},
|
||||
UpdatedAt: sql.NullTime{Time: failedAt, Valid: true},
|
||||
ID: service.ID,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -640,16 +678,17 @@ func runDeploymentAndSyncWithImage(
|
||||
if errors.Is(ctx.Err(), context.Canceled) {
|
||||
finalStatus, finalErr = "cancelled", "Deployment cancelled"
|
||||
}
|
||||
_, _ = db.Exec(
|
||||
`UPDATE deployments
|
||||
SET status = $1, error = $2, completed_at = $3, updated_at = $3
|
||||
WHERE id = $4`,
|
||||
finalStatus, finalErr, failedAt, dbDeployment.ID,
|
||||
)
|
||||
_, _ = db.Exec(
|
||||
`UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`,
|
||||
finalStatus, failedAt, service.ID,
|
||||
)
|
||||
_ = q.CompleteDeployment(ctx, sqlcdb.CompleteDeploymentParams{
|
||||
Status: sql.NullString{String: finalStatus, Valid: true},
|
||||
Error: sql.NullString{String: finalErr, Valid: true},
|
||||
CompletedAt: sql.NullTime{Time: failedAt, Valid: true},
|
||||
ID: dbDeployment.ID,
|
||||
})
|
||||
_ = q.SetServiceStatus(ctx, sqlcdb.SetServiceStatusParams{
|
||||
Status: sql.NullString{String: finalStatus, Valid: true},
|
||||
UpdatedAt: sql.NullTime{Time: failedAt, Valid: true},
|
||||
ID: service.ID,
|
||||
})
|
||||
return
|
||||
case <-syncTicker.C:
|
||||
current, getErr := engine.GetDeployment(engineDeployment.ID)
|
||||
@@ -660,49 +699,36 @@ func runDeploymentAndSyncWithImage(
|
||||
dbStatus := mapEngineStatusToDBStatus(current.Status)
|
||||
imageName, imageTag := splitImageReference(current.ImageName, dbDeployment.ImageTag)
|
||||
|
||||
var dbError interface{}
|
||||
if current.Error != "" {
|
||||
dbError = current.Error
|
||||
}
|
||||
|
||||
_, _ = db.Exec(
|
||||
`UPDATE deployments
|
||||
SET status = $1,
|
||||
image_name = $2,
|
||||
image_tag = $3,
|
||||
build_log = $4,
|
||||
runtime_log = $5,
|
||||
error = $6,
|
||||
started_at = $7,
|
||||
completed_at = $8,
|
||||
updated_at = $9
|
||||
WHERE id = $10`,
|
||||
dbStatus,
|
||||
imageName,
|
||||
imageTag,
|
||||
current.BuildLog,
|
||||
current.DeployLog,
|
||||
dbError,
|
||||
current.StartedAt,
|
||||
current.CompletedAt,
|
||||
time.Now(),
|
||||
dbDeployment.ID,
|
||||
)
|
||||
now := time.Now()
|
||||
_ = q.SyncDeploymentProgress(ctx, sqlcdb.SyncDeploymentProgressParams{
|
||||
Status: sql.NullString{String: dbStatus, Valid: true},
|
||||
ImageName: sql.NullString{String: imageName, Valid: true},
|
||||
ImageTag: sql.NullString{String: imageTag, Valid: true},
|
||||
BuildLog: sql.NullString{String: current.BuildLog, Valid: true},
|
||||
RuntimeLog: sql.NullString{String: current.DeployLog, Valid: true},
|
||||
Error: sql.NullString{String: current.Error, Valid: current.Error != ""},
|
||||
StartedAt: ntPtr(current.StartedAt),
|
||||
CompletedAt: ntPtr(current.CompletedAt),
|
||||
UpdatedAt: sql.NullTime{Time: now, Valid: true},
|
||||
ID: dbDeployment.ID,
|
||||
})
|
||||
|
||||
switch dbStatus {
|
||||
case "deployed":
|
||||
_, _ = db.Exec(
|
||||
`UPDATE services SET status = 'running', updated_at = $1 WHERE id = $2`,
|
||||
time.Now(), service.ID,
|
||||
)
|
||||
_ = q.SetServiceStatus(ctx, sqlcdb.SetServiceStatusParams{
|
||||
Status: sql.NullString{String: "running", Valid: true},
|
||||
UpdatedAt: sql.NullTime{Time: now, Valid: true},
|
||||
ID: service.ID,
|
||||
})
|
||||
insertUserNotification(db, userID, "deployment", "Deployment succeeded",
|
||||
fmt.Sprintf("Service %s is now running.", service.Name), "service", service.ID.String())
|
||||
return
|
||||
case "failed":
|
||||
_, _ = db.Exec(
|
||||
`UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`,
|
||||
time.Now(), service.ID,
|
||||
)
|
||||
_ = q.SetServiceStatus(ctx, sqlcdb.SetServiceStatusParams{
|
||||
Status: sql.NullString{String: "failed", Valid: true},
|
||||
UpdatedAt: sql.NullTime{Time: now, Valid: true},
|
||||
ID: service.ID,
|
||||
})
|
||||
body := fmt.Sprintf("Service %s failed to deploy.", service.Name)
|
||||
if current.Error != "" {
|
||||
body = fmt.Sprintf("Service %s failed to deploy: %s", service.Name, current.Error)
|
||||
@@ -754,21 +780,16 @@ func handleCancelDeployment(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var status, owner string
|
||||
var serviceID uuid.UUID
|
||||
err = db.(*database.DB).QueryRow(
|
||||
`SELECT d.status, d.service_id, p.owner_id
|
||||
FROM deployments d
|
||||
JOIN services s ON d.service_id = s.id
|
||||
JOIN projects p ON s.project_id = p.id
|
||||
WHERE d.id = $1`,
|
||||
deploymentID,
|
||||
).Scan(&status, &serviceID, &owner)
|
||||
ctx := c.Request.Context()
|
||||
q := sqlcdb.New(db.(*database.DB).DB)
|
||||
access, err := q.GetDeploymentAccess(ctx, deploymentID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found", "code": "NOT_FOUND"})
|
||||
return
|
||||
}
|
||||
if owner != userID.(string) && !contextIsAdmin(c) {
|
||||
serviceID := access.ServiceID
|
||||
status := access.Status.String
|
||||
if access.OwnerID.String() != userID.(string) && !contextIsAdmin(c) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied", "code": "FORBIDDEN"})
|
||||
return
|
||||
}
|
||||
@@ -782,14 +803,17 @@ func handleCancelDeployment(c *gin.Context) {
|
||||
switch getDeployQueue(c).Cancel(serviceID, deploymentID) {
|
||||
case deployqueue.WasQueued:
|
||||
now := time.Now()
|
||||
_, _ = db.(*database.DB).Exec(
|
||||
`UPDATE deployments SET status = 'cancelled', error = 'Deployment cancelled', completed_at = $1, updated_at = $1 WHERE id = $2`,
|
||||
now, deploymentID,
|
||||
)
|
||||
_, _ = db.(*database.DB).Exec(
|
||||
`UPDATE services SET status = 'cancelled', updated_at = $1 WHERE id = $2`,
|
||||
now, serviceID,
|
||||
)
|
||||
_ = q.CompleteDeployment(ctx, sqlcdb.CompleteDeploymentParams{
|
||||
Status: sql.NullString{String: "cancelled", Valid: true},
|
||||
Error: sql.NullString{String: "Deployment cancelled", Valid: true},
|
||||
CompletedAt: sql.NullTime{Time: now, Valid: true},
|
||||
ID: deploymentID,
|
||||
})
|
||||
_ = q.SetServiceStatus(ctx, sqlcdb.SetServiceStatusParams{
|
||||
Status: sql.NullString{String: "cancelled", Valid: true},
|
||||
UpdatedAt: sql.NullTime{Time: now, Valid: true},
|
||||
ID: serviceID,
|
||||
})
|
||||
c.JSON(http.StatusOK, gin.H{"status": "cancelled"})
|
||||
case deployqueue.WasActive:
|
||||
c.JSON(http.StatusAccepted, gin.H{"status": "cancelling"})
|
||||
@@ -832,27 +856,18 @@ func handleGetDeployment(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var d DeploymentModel
|
||||
var projectID uuid.UUID
|
||||
err = db.(*database.DB).QueryRow(
|
||||
`SELECT d.id, d.service_id, d.commit_hash, d.status, d.image_name, d.image_tag,
|
||||
d.build_log, d.runtime_log, d.error, d.started_at, d.completed_at,
|
||||
d.created_at, d.updated_at, p.id
|
||||
FROM deployments d
|
||||
JOIN services s ON d.service_id = s.id
|
||||
JOIN projects p ON s.project_id = p.id
|
||||
WHERE d.id = $1`,
|
||||
deploymentID,
|
||||
).Scan(
|
||||
&d.ID, &d.ServiceID, &d.CommitHash, &d.Status, &d.ImageName, &d.ImageTag,
|
||||
&d.BuildLog, &d.RuntimeLog, &d.Error, &d.StartedAt, &d.CompletedAt,
|
||||
&d.CreatedAt, &d.UpdatedAt, &projectID,
|
||||
)
|
||||
|
||||
r, err := sqlcdb.New(db.(*database.DB).DB).GetDeploymentWithProject(c.Request.Context(), deploymentID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"})
|
||||
return
|
||||
}
|
||||
projectID := r.ProjectID
|
||||
d := deploymentModelFrom(deploymentRowShape{
|
||||
ID: r.ID, ServiceID: r.ServiceID, CommitHash: r.CommitHash, Status: r.Status,
|
||||
ImageName: r.ImageName, ImageTag: r.ImageTag, BuildLog: r.BuildLog,
|
||||
RuntimeLog: r.RuntimeLog, Error: r.Error, StartedAt: r.StartedAt,
|
||||
CompletedAt: r.CompletedAt, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt,
|
||||
})
|
||||
|
||||
if _, allowed := projectReadAccess(c, db.(*database.DB), projectID); !allowed {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"})
|
||||
@@ -882,33 +897,23 @@ func handleRollbackDeployment(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var targetDeployment DeploymentModel
|
||||
var serviceID uuid.UUID
|
||||
var ownerCheck string
|
||||
|
||||
err = db.(*database.DB).QueryRow(
|
||||
`SELECT d.id, d.service_id, d.commit_hash, d.status, d.image_name, d.image_tag,
|
||||
d.build_log, d.runtime_log, d.error, d.started_at, d.completed_at,
|
||||
d.created_at, d.updated_at, p.owner_id
|
||||
FROM deployments d
|
||||
JOIN services s ON d.service_id = s.id
|
||||
JOIN projects p ON s.project_id = p.id
|
||||
WHERE d.id = $1`,
|
||||
deploymentID,
|
||||
).Scan(
|
||||
&targetDeployment.ID, &serviceID, &targetDeployment.CommitHash, &targetDeployment.Status,
|
||||
&targetDeployment.ImageName, &targetDeployment.ImageTag, &targetDeployment.BuildLog,
|
||||
&targetDeployment.RuntimeLog, &targetDeployment.Error, &targetDeployment.StartedAt,
|
||||
&targetDeployment.CompletedAt, &targetDeployment.CreatedAt, &targetDeployment.UpdatedAt,
|
||||
&ownerCheck,
|
||||
)
|
||||
|
||||
ctx := c.Request.Context()
|
||||
q := sqlcdb.New(db.(*database.DB).DB)
|
||||
r, err := q.GetDeploymentForRollback(ctx, deploymentID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"})
|
||||
return
|
||||
}
|
||||
serviceID := r.ServiceID
|
||||
targetDeployment := deploymentModelFrom(deploymentRowShape{
|
||||
ID: r.ID, ServiceID: r.ServiceID, CommitHash: r.CommitHash, Status: r.Status,
|
||||
ImageName: r.ImageName, ImageTag: r.ImageTag, BuildLog: r.BuildLog,
|
||||
RuntimeLog: r.RuntimeLog, Error: r.Error, StartedAt: r.StartedAt,
|
||||
CompletedAt: r.CompletedAt, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt,
|
||||
})
|
||||
ownerCheck := r.OwnerID
|
||||
|
||||
if ownerCheck != userID.(string) && !contextIsAdmin(c) {
|
||||
if ownerCheck.String() != userID.(string) && !contextIsAdmin(c) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
@@ -931,23 +936,28 @@ func handleRollbackDeployment(c *gin.Context) {
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
_, err = db.(*database.DB).Exec(
|
||||
`INSERT INTO deployments
|
||||
(id, service_id, version, commit_hash, status, image_name, image_tag, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
rollback.ID, rollback.ServiceID, fmt.Sprintf("rollback-%d", now.Unix()), rollback.CommitHash, rollback.Status,
|
||||
rollback.ImageName, rollback.ImageTag, rollback.CreatedAt, rollback.UpdatedAt,
|
||||
)
|
||||
err = q.InsertDeployment(ctx, sqlcdb.InsertDeploymentParams{
|
||||
ID: rollback.ID,
|
||||
ServiceID: rollback.ServiceID,
|
||||
Version: fmt.Sprintf("rollback-%d", now.Unix()),
|
||||
CommitHash: sql.NullString{String: ptrStr(rollback.CommitHash), Valid: rollback.CommitHash != nil},
|
||||
Status: sql.NullString{String: rollback.Status, Valid: true},
|
||||
ImageName: sql.NullString{String: rollback.ImageName, Valid: true},
|
||||
ImageTag: sql.NullString{String: rollback.ImageTag, Valid: true},
|
||||
CreatedAt: sql.NullTime{Time: rollback.CreatedAt, Valid: true},
|
||||
UpdatedAt: sql.NullTime{Time: rollback.UpdatedAt, Valid: true},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create rollback deployment"})
|
||||
return
|
||||
}
|
||||
|
||||
_, err = db.(*database.DB).Exec(
|
||||
`UPDATE services SET status = 'building', updated_at = $1 WHERE id = $2`,
|
||||
time.Now(), serviceID,
|
||||
)
|
||||
err = q.SetServiceStatus(ctx, sqlcdb.SetServiceStatusParams{
|
||||
Status: sql.NullString{String: "building", Valid: true},
|
||||
UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true},
|
||||
ID: serviceID,
|
||||
})
|
||||
|
||||
// Real rollback: redeploy the target deployment's image.
|
||||
engineValue, _ := c.Get("deployment_engine")
|
||||
@@ -963,36 +973,18 @@ func handleRollbackDeployment(c *gin.Context) {
|
||||
if engine == nil {
|
||||
reason = "Deployment engine unavailable. Docker may not be configured on this server."
|
||||
}
|
||||
_, _ = db.(*database.DB).Exec(
|
||||
`UPDATE deployments SET status = 'failed', error = $1, completed_at = $2, updated_at = $2 WHERE id = $3`,
|
||||
reason, completedAt, rollbackID,
|
||||
)
|
||||
_, _ = db.(*database.DB).Exec(
|
||||
`UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`,
|
||||
completedAt, serviceID,
|
||||
)
|
||||
_ = q.FailDeployment(ctx, sqlcdb.FailDeploymentParams{
|
||||
Error: sql.NullString{String: reason, Valid: true},
|
||||
CompletedAt: sql.NullTime{Time: completedAt, Valid: true},
|
||||
ID: rollbackID,
|
||||
})
|
||||
_ = q.SetServiceStatus(ctx, sqlcdb.SetServiceStatusParams{
|
||||
Status: sql.NullString{String: "failed", Valid: true},
|
||||
UpdatedAt: sql.NullTime{Time: completedAt, Valid: true},
|
||||
ID: serviceID,
|
||||
})
|
||||
} else {
|
||||
var service Service
|
||||
_ = db.(*database.DB).QueryRow(
|
||||
`SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command,
|
||||
s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory,
|
||||
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
||||
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
||||
COALESCE(s.restart_policy, 'unless-stopped'),
|
||||
COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''),
|
||||
COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''),
|
||||
COALESCE(s.static_dir, ''),
|
||||
s.created_at, s.updated_at
|
||||
FROM services s WHERE s.id = $1`, serviceID,
|
||||
).Scan(
|
||||
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
|
||||
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
||||
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
||||
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
||||
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
|
||||
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
|
||||
&service.CreatedAt, &service.UpdatedAt,
|
||||
)
|
||||
service, _ := loadServiceForDeploy(db.(*database.DB), serviceID)
|
||||
|
||||
rollbackReq := CreateDeploymentRequest{
|
||||
CommitHash: func() string {
|
||||
|
||||
@@ -2,10 +2,11 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
@@ -338,3 +339,263 @@ func TestPreviewEnvironmentsLiveInvariants(t *testing.T) {
|
||||
}
|
||||
must(q.DeletePreviewEnvironment(ctx, previewID))
|
||||
}
|
||||
|
||||
// Live deployment lifecycle: generated queries preserve list ordering,
|
||||
// the access join, and the status-transition writes.
|
||||
func TestDeploymentsLiveInvariants(t *testing.T) {
|
||||
dsn := testDSNString(t)
|
||||
db, err := database.NewConnection(dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
ctx := context.Background()
|
||||
q := sqlcdb.New(db.DB)
|
||||
|
||||
userID := uuid.New()
|
||||
projectID := uuid.New()
|
||||
envID := uuid.New()
|
||||
serviceID := uuid.New()
|
||||
deployID := uuid.New()
|
||||
suffix := strings.ReplaceAll(deployID.String()[:8], "-", "")
|
||||
must := func(err error) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Fatalf("setup: %v", err)
|
||||
}
|
||||
}
|
||||
_, err = db.Exec(`INSERT INTO users (id, email, password_hash, name) VALUES ($1, $2, 'x', 'test')`,
|
||||
userID, "dep-"+suffix+"@test.local")
|
||||
must(err)
|
||||
_, err = db.Exec(`INSERT INTO projects (id, name, owner_id, is_approved) VALUES ($1, $2, $3, true)`,
|
||||
projectID, "dep-"+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, status)
|
||||
VALUES ($1, $2, $3, $4, 'web', 'image', 'running')`,
|
||||
serviceID, "dep-"+suffix, projectID, envID)
|
||||
must(err)
|
||||
defer func() { _, _ = db.Exec(`DELETE FROM users WHERE id = $1`, userID) }()
|
||||
|
||||
must(q.InsertDeployment(ctx, sqlcdb.InsertDeploymentParams{
|
||||
ID: deployID,
|
||||
ServiceID: serviceID,
|
||||
Version: "v1-" + suffix,
|
||||
Status: sql.NullString{String: "pending", Valid: true},
|
||||
}))
|
||||
total, err := q.CountDeploymentsForService(ctx, serviceID)
|
||||
if err != nil || total != 1 {
|
||||
t.Fatalf("count: %v %d", err, total)
|
||||
}
|
||||
rows, err := q.ListDeploymentsForService(ctx, sqlcdb.ListDeploymentsForServiceParams{
|
||||
ServiceID: serviceID, Limit: 10, Offset: 0})
|
||||
if err != nil || len(rows) != 1 || rows[0].ID != deployID {
|
||||
t.Fatalf("list: %v %+v", err, rows)
|
||||
}
|
||||
|
||||
// Access join returns owner.
|
||||
access, err := q.GetDeploymentAccess(ctx, deployID)
|
||||
if err != nil || access.OwnerID != userID || access.ServiceID != serviceID {
|
||||
t.Fatalf("access: %v %+v", err, access)
|
||||
}
|
||||
|
||||
// Recent feed honors the access predicate.
|
||||
feed, err := q.ListRecentAccessibleDeployments(ctx, sqlcdb.ListRecentAccessibleDeploymentsParams{
|
||||
OwnerID: userID, Limit: 10, Offset: 0})
|
||||
if err != nil {
|
||||
t.Fatalf("feed: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, d := range feed {
|
||||
if d.ID == deployID && d.ServiceName == "dep-"+suffix {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("feed missing deployment: %+v", feed)
|
||||
}
|
||||
|
||||
// Status transitions.
|
||||
must(q.SetDeploymentStatus(ctx, sqlcdb.SetDeploymentStatusParams{
|
||||
Status: sql.NullString{String: "queued", Valid: true}, UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true}, ID: deployID}))
|
||||
must(q.SyncDeploymentProgress(ctx, sqlcdb.SyncDeploymentProgressParams{
|
||||
Status: sql.NullString{String: "deployed", Valid: true},
|
||||
ImageName: sql.NullString{String: "nginx", Valid: true},
|
||||
ImageTag: sql.NullString{String: "alpine", Valid: true},
|
||||
UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true},
|
||||
ID: deployID,
|
||||
}))
|
||||
r, err := q.GetDeploymentWithProject(ctx, deployID)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if r.Status.String != "deployed" || r.ImageName.String != "nginx" || r.ProjectID != projectID {
|
||||
t.Fatalf("post-sync row: %+v", r)
|
||||
}
|
||||
must(q.FailDeployment(ctx, sqlcdb.FailDeploymentParams{
|
||||
Error: sql.NullString{String: "boom", Valid: true},
|
||||
CompletedAt: sql.NullTime{Time: time.Now(), Valid: true},
|
||||
ID: deployID,
|
||||
}))
|
||||
r, _ = q.GetDeploymentWithProject(ctx, deployID)
|
||||
if r.Status.String != "failed" || r.Error.String != "boom" {
|
||||
t.Fatalf("post-fail row: %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRuntimeLiveInvariants exercises the runtime.sql queries: the
|
||||
// legacy-column COALESCE chain in GetServiceRuntimeWithOwner, the
|
||||
// deployed-image concat, and the conditional published-port update.
|
||||
func TestRuntimeLiveInvariants(t *testing.T) {
|
||||
dsn := testDSNString(t)
|
||||
db, err := database.NewConnection(dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
ctx := context.Background()
|
||||
q := sqlcdb.New(db.DB)
|
||||
|
||||
userID := uuid.New()
|
||||
projectID := uuid.New()
|
||||
envID := uuid.New()
|
||||
serviceID := uuid.New()
|
||||
suffix := strings.ReplaceAll(serviceID.String()[:8], "-", "")
|
||||
must := func(err error) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Fatalf("setup: %v", err)
|
||||
}
|
||||
}
|
||||
_, err = db.Exec(`INSERT INTO users (id, email, password_hash, name) VALUES ($1, $2, 'x', 'test')`,
|
||||
userID, "rt-"+suffix+"@test.local")
|
||||
must(err)
|
||||
_, err = db.Exec(`INSERT INTO projects (id, name, owner_id, is_approved) VALUES ($1, $2, $3, true)`,
|
||||
projectID, "rt-"+suffix, userID)
|
||||
must(err)
|
||||
_, err = db.Exec(`INSERT INTO environments (id, name, project_id) VALUES ($1, 'production', $2)`,
|
||||
envID, projectID)
|
||||
must(err)
|
||||
// Only legacy columns populated — the COALESCE chain must surface them.
|
||||
_, err = db.Exec(`INSERT INTO services (id, name, project_id, environment_id, service_type, source_type,
|
||||
source_url, image_name, start_command, port) VALUES ($1, $2, $3, $4, 'worker', 'git', $5, $6, 'run.sh', 8080)`,
|
||||
serviceID, "rt-"+suffix, projectID, envID, "https://git.example/x.git", "repo/img:latest")
|
||||
must(err)
|
||||
defer func() { _, _ = db.Exec(`DELETE FROM users WHERE id = $1`, userID) }()
|
||||
|
||||
row, err := q.GetServiceRuntimeWithOwner(ctx, serviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("runtime row: %v", err)
|
||||
}
|
||||
if row.Type != "worker" || row.Image != "repo/img:latest" || row.Command != "run.sh" ||
|
||||
row.GitRepo != "https://git.example/x.git" || row.OwnerID != userID {
|
||||
t.Fatalf("COALESCE chain lost legacy columns: %+v", row)
|
||||
}
|
||||
svc := serviceFromRuntimeRow(row)
|
||||
if svc.Type != "worker" || svc.Image != "repo/img:latest" || svc.Port != 8080 {
|
||||
t.Fatalf("mapped service wrong: %+v", svc)
|
||||
}
|
||||
|
||||
// Sibling refs + port lookup.
|
||||
refs, err := q.ListProjectServiceRefs(ctx, projectID)
|
||||
if err != nil || len(refs) != 1 || refs[0].Name != "rt-"+suffix {
|
||||
t.Fatalf("refs: %v %+v", err, refs)
|
||||
}
|
||||
port, err := q.GetServicePort(ctx, serviceID)
|
||||
if err != nil || port != 8080 {
|
||||
t.Fatalf("port: %v %d", err, port)
|
||||
}
|
||||
|
||||
// Variables round-trip.
|
||||
_, err = db.Exec(`INSERT INTO environment_variables (id, service_id, key, value, is_secret)
|
||||
VALUES (gen_random_uuid(), $1, 'API_KEY', 'shh', true), (gen_random_uuid(), $1, 'PLAIN', 'p', false)`,
|
||||
serviceID)
|
||||
must(err)
|
||||
vars, err := q.ListServiceVariablesWithSecret(ctx, serviceID)
|
||||
if err != nil || len(vars) != 2 {
|
||||
t.Fatalf("vars: %v %+v", err, vars)
|
||||
}
|
||||
vals, err := q.ListServiceVariableValues(ctx, serviceID)
|
||||
if err != nil || len(vals) != 2 {
|
||||
t.Fatalf("vals: %v %+v", err, vals)
|
||||
}
|
||||
got, err := q.GetServiceVariableValue(ctx, sqlcdb.GetServiceVariableValueParams{ServiceID: serviceID, Key: "API_KEY"})
|
||||
if err != nil || got != "shh" {
|
||||
t.Fatalf("var value: %v %q", err, got)
|
||||
}
|
||||
|
||||
// Node agent listing through container_instances.
|
||||
_, err = db.Exec(`INSERT INTO node_agents (id, name, hostname, ip_address, port) VALUES ($1, $2, 'h', '127.0.0.1', 9999)`,
|
||||
"agent-"+suffix, "agent-"+suffix)
|
||||
must(err)
|
||||
_, err = db.Exec(`INSERT INTO container_instances (id, name, image, project_id, service_id, node_agent_id)
|
||||
VALUES ($1, 'c', 'img', $2, $3, $4)`,
|
||||
"ci-"+suffix, projectID.String(), serviceID.String(), "agent-"+suffix)
|
||||
must(err)
|
||||
agents, err := q.ListNodeAgentsForService(ctx, serviceID.String())
|
||||
if err != nil || len(agents) != 1 || agents[0] != "agent-"+suffix {
|
||||
t.Fatalf("agents: %v %+v", err, agents)
|
||||
}
|
||||
|
||||
// Last deployed image: only 'deployed' rows, latest first.
|
||||
d1, d2 := uuid.New(), uuid.New()
|
||||
must(q.InsertDeployment(ctx, sqlcdb.InsertDeploymentParams{
|
||||
ID: d1, ServiceID: serviceID, Version: "v1",
|
||||
Status: sql.NullString{String: "failed", Valid: true},
|
||||
}))
|
||||
must(q.InsertDeployment(ctx, sqlcdb.InsertDeploymentParams{
|
||||
ID: d2, ServiceID: serviceID, Version: "v2",
|
||||
Status: sql.NullString{String: "deployed", Valid: true},
|
||||
}))
|
||||
must(q.SyncDeploymentProgress(ctx, sqlcdb.SyncDeploymentProgressParams{
|
||||
Status: sql.NullString{String: "deployed", Valid: true},
|
||||
ImageName: sql.NullString{String: "app", Valid: true},
|
||||
ImageTag: sql.NullString{String: "v2", Valid: true},
|
||||
UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true},
|
||||
ID: d2,
|
||||
}))
|
||||
img, err := q.GetLastDeployedImage(ctx, serviceID)
|
||||
if err != nil || img != "app:v2" {
|
||||
t.Fatalf("last image: %v %q", err, img)
|
||||
}
|
||||
|
||||
// Conditional published-port update: writes once, no-ops on repeat.
|
||||
must(q.SetServicePublishedPort(ctx, sqlcdb.SetServicePublishedPortParams{PublishedPort: 30001, ID: serviceID}))
|
||||
p, err := q.GetServicePublishedPort(ctx, serviceID)
|
||||
if err != nil || p != 30001 {
|
||||
t.Fatalf("published port: %v %d", err, p)
|
||||
}
|
||||
must(q.SetServiceReplicas(ctx, sqlcdb.SetServiceReplicasParams{
|
||||
Replicas: 3, UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true}, ID: serviceID}))
|
||||
row, _ = q.GetServiceRuntimeWithOwner(ctx, serviceID)
|
||||
if row.Replicas != 3 {
|
||||
t.Fatalf("replicas: %+v", row)
|
||||
}
|
||||
|
||||
// Sleeper reads: candidate sweep + domain/status text lookups.
|
||||
_, err = db.Exec(`UPDATE services SET sleep_enabled = true, domain = 'rt.example.com', status = 'running' WHERE id = $1`, serviceID)
|
||||
must(err)
|
||||
cands, err := q.ListSleepCandidates(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("sleep candidates: %v", err)
|
||||
}
|
||||
var mine *sqlcdb.ListSleepCandidatesRow
|
||||
for i := range cands {
|
||||
if cands[i].ID == serviceID {
|
||||
mine = &cands[i]
|
||||
}
|
||||
}
|
||||
if mine == nil || mine.SleepIdleMinutes != 15 {
|
||||
t.Fatalf("candidate missing/idle wrong: %+v", mine)
|
||||
}
|
||||
dom, err := q.GetServiceDomainText(ctx, serviceID)
|
||||
if err != nil || dom != "rt.example.com" {
|
||||
t.Fatalf("domain text: %v %q", err, dom)
|
||||
}
|
||||
st, err := q.GetServiceStatusText(ctx, serviceID)
|
||||
if err != nil || st != "running" {
|
||||
t.Fatalf("status text: %v %q", err, st)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+195
-219
@@ -2,6 +2,8 @@ package api
|
||||
|
||||
import (
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
@@ -92,41 +94,73 @@ type CreateWebhookRequest struct {
|
||||
Branch string `json:"branch"`
|
||||
}
|
||||
|
||||
func gitRepoFrom(r sqlcdb.ListGitRepositoriesRow) GitRepository {
|
||||
return GitRepository{
|
||||
ID: r.ID.String(),
|
||||
ProviderID: r.ProviderID.String(),
|
||||
Name: r.Name,
|
||||
FullName: r.FullName,
|
||||
Description: r.Description,
|
||||
CloneURL: r.CloneUrl,
|
||||
WebhookURL: r.WebhookUrl,
|
||||
DefaultBranch: r.DefaultBranch.String,
|
||||
IsPrivate: r.IsPrivate.Bool,
|
||||
UserID: r.UserID.String(),
|
||||
CreatedAt: r.CreatedAt.Time,
|
||||
UpdatedAt: r.UpdatedAt.Time,
|
||||
}
|
||||
}
|
||||
|
||||
func gitRepoFromFullNameRow(r sqlcdb.GetGitRepositoryByFullNameRow) GitRepository {
|
||||
return GitRepository{
|
||||
ID: r.ID.String(),
|
||||
ProviderID: r.ProviderID.String(),
|
||||
Name: r.Name,
|
||||
FullName: r.FullName,
|
||||
Description: r.Description,
|
||||
CloneURL: r.CloneUrl,
|
||||
WebhookURL: r.WebhookUrl,
|
||||
DefaultBranch: r.DefaultBranch.String,
|
||||
IsPrivate: r.IsPrivate.Bool,
|
||||
UserID: r.UserID.String(),
|
||||
CreatedAt: r.CreatedAt.Time,
|
||||
UpdatedAt: r.UpdatedAt.Time,
|
||||
}
|
||||
}
|
||||
|
||||
func gitProviderFrom(p sqlcdb.ListGitProvidersRow) GitProvider {
|
||||
return GitProvider{
|
||||
ID: p.ID.String(),
|
||||
Name: p.Name,
|
||||
DisplayName: p.DisplayName,
|
||||
APIUrl: p.ApiUrl,
|
||||
WebhookUrl: p.WebhookUrl,
|
||||
UserID: p.UserID.String(),
|
||||
CreatedAt: p.CreatedAt.Time,
|
||||
UpdatedAt: p.UpdatedAt.Time,
|
||||
}
|
||||
}
|
||||
|
||||
func handleGetGitProviders(c *gin.Context) {
|
||||
ctx, ok := requireGitRequestContext(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := ctx.db.Query(`
|
||||
SELECT id, name, display_name, api_url, webhook_url, user_id, created_at, updated_at
|
||||
FROM git_providers
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
`, ctx.userID)
|
||||
userID, err := uuid.Parse(ctx.userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
providers := make([]GitProvider, 0)
|
||||
for rows.Next() {
|
||||
var provider GitProvider
|
||||
if err := rows.Scan(
|
||||
&provider.ID,
|
||||
&provider.Name,
|
||||
&provider.DisplayName,
|
||||
&provider.APIUrl,
|
||||
&provider.WebhookUrl,
|
||||
&provider.UserID,
|
||||
&provider.CreatedAt,
|
||||
&provider.UpdatedAt,
|
||||
); err != nil {
|
||||
rows, err := sqlcdb.New(ctx.db.DB).ListGitProviders(context.Background(), userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
||||
return
|
||||
}
|
||||
providers = append(providers, provider)
|
||||
|
||||
providers := make([]GitProvider, 0, len(rows))
|
||||
for _, p := range rows {
|
||||
providers = append(providers, gitProviderFrom(p))
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"providers": providers})
|
||||
@@ -173,21 +207,22 @@ func handleConnectGitHubApp(c *gin.Context) {
|
||||
UserID: ctx.userID,
|
||||
}
|
||||
|
||||
if err := ctx.db.QueryRow(`
|
||||
INSERT INTO git_providers (id, name, display_name, api_url, webhook_url, access_token, user_id, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
|
||||
ON CONFLICT (name, user_id)
|
||||
DO UPDATE SET
|
||||
display_name = EXCLUDED.display_name,
|
||||
api_url = EXCLUDED.api_url,
|
||||
webhook_url = EXCLUDED.webhook_url,
|
||||
access_token = EXCLUDED.access_token,
|
||||
updated_at = NOW()
|
||||
RETURNING id, created_at, updated_at
|
||||
`, provider.ID, provider.Name, provider.DisplayName, provider.APIUrl, provider.WebhookUrl, provider.AccessToken, provider.UserID).Scan(&provider.ID, &provider.CreatedAt, &provider.UpdatedAt); err != nil {
|
||||
prow, err := sqlcdb.New(ctx.db.DB).UpsertGitHubAppProvider(context.Background(), sqlcdb.UpsertGitHubAppProviderParams{
|
||||
ID: uuid.MustParse(provider.ID),
|
||||
Name: provider.Name,
|
||||
DisplayName: provider.DisplayName,
|
||||
ApiUrl: provider.APIUrl,
|
||||
WebhookUrl: provider.WebhookUrl,
|
||||
AccessToken: provider.AccessToken,
|
||||
UserID: uuid.MustParse(provider.UserID),
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to connect GitHub App installation"})
|
||||
return
|
||||
}
|
||||
provider.ID = prow.ID.String()
|
||||
provider.CreatedAt = prow.CreatedAt.Time
|
||||
provider.UpdatedAt = prow.UpdatedAt.Time
|
||||
|
||||
provider.AccessToken = ""
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
@@ -259,10 +294,16 @@ func handleCreateGitProvider(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := ctx.db.Exec(`
|
||||
INSERT INTO git_providers (id, name, display_name, api_url, webhook_url, access_token, user_id, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
|
||||
`, provider.ID, provider.Name, provider.DisplayName, provider.APIUrl, provider.WebhookUrl, provider.AccessToken, provider.UserID); err != nil {
|
||||
q := sqlcdb.New(ctx.db.DB)
|
||||
if err := q.InsertGitProvider(context.Background(), sqlcdb.InsertGitProviderParams{
|
||||
ID: uuid.MustParse(provider.ID),
|
||||
Name: provider.Name,
|
||||
DisplayName: provider.DisplayName,
|
||||
ApiUrl: provider.APIUrl,
|
||||
WebhookUrl: provider.WebhookUrl,
|
||||
AccessToken: provider.AccessToken,
|
||||
UserID: uuid.MustParse(provider.UserID),
|
||||
}); err != nil {
|
||||
if isUniqueConstraintError(err) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Provider already connected for this account"})
|
||||
return
|
||||
@@ -271,13 +312,12 @@ func handleCreateGitProvider(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := ctx.db.QueryRow(`
|
||||
SELECT created_at, updated_at
|
||||
FROM git_providers
|
||||
WHERE id = $1
|
||||
`, provider.ID).Scan(&provider.CreatedAt, &provider.UpdatedAt); err != nil {
|
||||
if ts, err := q.GetGitProviderTimestamps(context.Background(), uuid.MustParse(provider.ID)); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read created provider"})
|
||||
return
|
||||
} else {
|
||||
provider.CreatedAt = ts.CreatedAt.Time
|
||||
provider.UpdatedAt = ts.UpdatedAt.Time
|
||||
}
|
||||
|
||||
provider.AccessToken = ""
|
||||
@@ -296,12 +336,11 @@ func handleGetGitRepositories(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var providerName, providerDisplayName, providerAccessToken, providerAPIURL string
|
||||
err := ctx.db.QueryRow(`
|
||||
SELECT name, display_name, access_token, api_url
|
||||
FROM git_providers
|
||||
WHERE id = $1 AND user_id = $2
|
||||
`, providerID, ctx.userID).Scan(&providerName, &providerDisplayName, &providerAccessToken, &providerAPIURL)
|
||||
q := sqlcdb.New(ctx.db.DB)
|
||||
provRow, err := q.GetGitProviderForRepos(context.Background(), sqlcdb.GetGitProviderForReposParams{
|
||||
ID: uuid.MustParse(providerID),
|
||||
UserID: uuid.MustParse(ctx.userID),
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Provider not found"})
|
||||
@@ -310,6 +349,10 @@ func handleGetGitRepositories(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
||||
return
|
||||
}
|
||||
providerName := provRow.Name
|
||||
providerDisplayName := provRow.DisplayName
|
||||
providerAccessToken := provRow.AccessToken
|
||||
providerAPIURL := provRow.ApiUrl
|
||||
|
||||
if c.DefaultQuery("sync", "1") != "0" {
|
||||
if syncErr := syncProviderRepositories(ctx.db, providerID, ctx.userID, providerName, providerAPIURL, providerAccessToken); syncErr != nil {
|
||||
@@ -324,53 +367,30 @@ func handleGetGitRepositories(c *gin.Context) {
|
||||
}
|
||||
offset := (page - 1) * limit
|
||||
search := strings.TrimSpace(c.Query("search"))
|
||||
searchNullable := sql.NullString{String: search, Valid: search != ""}
|
||||
|
||||
rows, err := ctx.db.Query(`
|
||||
SELECT id, provider_id, name, full_name, COALESCE(description, ''), clone_url, COALESCE(webhook_url, ''),
|
||||
default_branch, is_private, user_id, created_at, updated_at
|
||||
FROM git_repositories
|
||||
WHERE provider_id = $1 AND user_id = $2
|
||||
AND ($3::text IS NULL OR full_name ILIKE ('%' || $3::text || '%') OR name ILIKE ('%' || $3::text || '%'))
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT $4 OFFSET $5
|
||||
`, providerID, ctx.userID, searchNullable, limit, offset)
|
||||
repoRows, err := q.ListGitRepositories(context.Background(), sqlcdb.ListGitRepositoriesParams{
|
||||
ProviderID: uuid.MustParse(providerID),
|
||||
UserID: uuid.MustParse(ctx.userID),
|
||||
Column3: search,
|
||||
Limit: int32(limit),
|
||||
Offset: int32(offset),
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
repositories := make([]GitRepository, 0)
|
||||
for rows.Next() {
|
||||
var repo GitRepository
|
||||
if err := rows.Scan(
|
||||
&repo.ID,
|
||||
&repo.ProviderID,
|
||||
&repo.Name,
|
||||
&repo.FullName,
|
||||
&repo.Description,
|
||||
&repo.CloneURL,
|
||||
&repo.WebhookURL,
|
||||
&repo.DefaultBranch,
|
||||
&repo.IsPrivate,
|
||||
&repo.UserID,
|
||||
&repo.CreatedAt,
|
||||
&repo.UpdatedAt,
|
||||
); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
||||
return
|
||||
}
|
||||
repositories = append(repositories, repo)
|
||||
repositories := make([]GitRepository, 0, len(repoRows))
|
||||
for _, r := range repoRows {
|
||||
repositories = append(repositories, gitRepoFrom(r))
|
||||
}
|
||||
|
||||
var total int
|
||||
if err := ctx.db.QueryRow(`
|
||||
SELECT COUNT(*)
|
||||
FROM git_repositories
|
||||
WHERE provider_id = $1 AND user_id = $2
|
||||
AND ($3::text IS NULL OR full_name ILIKE ('%' || $3::text || '%') OR name ILIKE ('%' || $3::text || '%'))
|
||||
`, providerID, ctx.userID, searchNullable).Scan(&total); err != nil {
|
||||
total, err := q.CountGitRepositories(context.Background(), sqlcdb.CountGitRepositoriesParams{
|
||||
ProviderID: uuid.MustParse(providerID),
|
||||
UserID: uuid.MustParse(ctx.userID),
|
||||
Column3: search,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
||||
return
|
||||
}
|
||||
@@ -408,12 +428,11 @@ func handleConnectGitRepository(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var providerName string
|
||||
err := ctx.db.QueryRow(`
|
||||
SELECT name
|
||||
FROM git_providers
|
||||
WHERE id = $1 AND user_id = $2
|
||||
`, providerID, ctx.userID).Scan(&providerName)
|
||||
q := sqlcdb.New(ctx.db.DB)
|
||||
providerName, err := q.GetGitProviderName(context.Background(), sqlcdb.GetGitProviderNameParams{
|
||||
ID: uuid.MustParse(providerID),
|
||||
UserID: uuid.MustParse(ctx.userID),
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Provider not found"})
|
||||
@@ -429,31 +448,16 @@ func handleConnectGitRepository(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var existing GitRepository
|
||||
err = ctx.db.QueryRow(`
|
||||
SELECT id, provider_id, name, full_name, COALESCE(description, ''), clone_url, COALESCE(webhook_url, ''),
|
||||
default_branch, is_private, user_id, created_at, updated_at
|
||||
FROM git_repositories
|
||||
WHERE provider_id = $1 AND full_name = $2 AND user_id = $3
|
||||
`, providerID, fullName, ctx.userID).Scan(
|
||||
&existing.ID,
|
||||
&existing.ProviderID,
|
||||
&existing.Name,
|
||||
&existing.FullName,
|
||||
&existing.Description,
|
||||
&existing.CloneURL,
|
||||
&existing.WebhookURL,
|
||||
&existing.DefaultBranch,
|
||||
&existing.IsPrivate,
|
||||
&existing.UserID,
|
||||
&existing.CreatedAt,
|
||||
&existing.UpdatedAt,
|
||||
)
|
||||
existingRow, err := q.GetGitRepositoryByFullName(context.Background(), sqlcdb.GetGitRepositoryByFullNameParams{
|
||||
ProviderID: uuid.MustParse(providerID),
|
||||
FullName: fullName,
|
||||
UserID: uuid.MustParse(ctx.userID),
|
||||
})
|
||||
switch {
|
||||
case err == nil:
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Repository already connected",
|
||||
"repository": existing,
|
||||
"repository": gitRepoFromFullNameRow(existingRow),
|
||||
})
|
||||
return
|
||||
case !errors.Is(err, sql.ErrNoRows):
|
||||
@@ -473,13 +477,18 @@ func handleConnectGitRepository(c *gin.Context) {
|
||||
UserID: ctx.userID,
|
||||
}
|
||||
|
||||
if err := ctx.db.QueryRow(`
|
||||
INSERT INTO git_repositories (
|
||||
id, provider_id, name, full_name, description, clone_url, default_branch, is_private, user_id, created_at, updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW())
|
||||
RETURNING created_at, updated_at
|
||||
`, repo.ID, repo.ProviderID, repo.Name, repo.FullName, repo.Description, repo.CloneURL, repo.DefaultBranch, repo.IsPrivate, repo.UserID).Scan(&repo.CreatedAt, &repo.UpdatedAt); err != nil {
|
||||
ins, err := q.InsertGitRepository(context.Background(), sqlcdb.InsertGitRepositoryParams{
|
||||
ID: uuid.MustParse(repo.ID),
|
||||
ProviderID: uuid.MustParse(repo.ProviderID),
|
||||
Name: repo.Name,
|
||||
FullName: repo.FullName,
|
||||
Description: sql.NullString{String: repo.Description, Valid: true},
|
||||
CloneUrl: repo.CloneURL,
|
||||
DefaultBranch: sql.NullString{String: repo.DefaultBranch, Valid: true},
|
||||
IsPrivate: sql.NullBool{Bool: repo.IsPrivate, Valid: true},
|
||||
UserID: uuid.MustParse(repo.UserID),
|
||||
})
|
||||
if err != nil {
|
||||
if isUniqueConstraintError(err) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Repository is already connected"})
|
||||
return
|
||||
@@ -487,6 +496,8 @@ func handleConnectGitRepository(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to connect repository"})
|
||||
return
|
||||
}
|
||||
repo.CreatedAt = ins.CreatedAt.Time
|
||||
repo.UpdatedAt = ins.UpdatedAt.Time
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"message": "Repository connected successfully",
|
||||
@@ -516,12 +527,10 @@ func handleCreateWebhook(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var providerID string
|
||||
err := ctx.db.QueryRow(`
|
||||
SELECT provider_id
|
||||
FROM git_repositories
|
||||
WHERE id = $1 AND user_id = $2
|
||||
`, repoID, ctx.userID).Scan(&providerID)
|
||||
providerUUID, err := sqlcdb.New(ctx.db.DB).GetGitRepositoryProviderID(context.Background(), sqlcdb.GetGitRepositoryProviderIDParams{
|
||||
ID: uuid.MustParse(repoID),
|
||||
UserID: uuid.MustParse(ctx.userID),
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Repository not found"})
|
||||
@@ -530,6 +539,7 @@ func handleCreateWebhook(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
||||
return
|
||||
}
|
||||
providerID := providerUUID.String()
|
||||
|
||||
eventsJSON, err := json.Marshal(req.Events)
|
||||
if err != nil {
|
||||
@@ -552,23 +562,22 @@ func handleCreateWebhook(c *gin.Context) {
|
||||
Active: true,
|
||||
}
|
||||
|
||||
if err := ctx.db.QueryRow(`
|
||||
INSERT INTO git_webhooks (
|
||||
id, repo_id, provider_id, events, webhook_secret, active, branch_filter, created_at, updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, TRUE, $6, NOW(), NOW())
|
||||
ON CONFLICT (repo_id, provider_id)
|
||||
DO UPDATE SET
|
||||
events = EXCLUDED.events,
|
||||
webhook_secret = EXCLUDED.webhook_secret,
|
||||
active = TRUE,
|
||||
branch_filter = EXCLUDED.branch_filter,
|
||||
updated_at = NOW()
|
||||
RETURNING id, active, created_at, updated_at
|
||||
`, webhook.ID, webhook.RepoID, webhook.ProviderID, webhook.Events, webhook.Secret, strings.TrimSpace(req.Branch)).Scan(&webhook.ID, &webhook.Active, &webhook.CreatedAt, &webhook.UpdatedAt); err != nil {
|
||||
wrow, err := sqlcdb.New(ctx.db.DB).UpsertGitWebhook(context.Background(), sqlcdb.UpsertGitWebhookParams{
|
||||
ID: uuid.MustParse(webhook.ID),
|
||||
RepoID: uuid.MustParse(webhook.RepoID),
|
||||
ProviderID: uuid.MustParse(webhook.ProviderID),
|
||||
Events: webhook.Events,
|
||||
WebhookSecret: webhook.Secret,
|
||||
BranchFilter: sql.NullString{String: strings.TrimSpace(req.Branch), Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to configure webhook"})
|
||||
return
|
||||
}
|
||||
webhook.ID = wrow.ID.String()
|
||||
webhook.Active = wrow.Active.Bool
|
||||
webhook.CreatedAt = wrow.CreatedAt.Time
|
||||
webhook.UpdatedAt = wrow.UpdatedAt.Time
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"message": "Webhook configured successfully",
|
||||
@@ -598,69 +607,39 @@ func handleGetConnectedRepositories(c *gin.Context) {
|
||||
}
|
||||
offset := (page - 1) * limit
|
||||
|
||||
rows, err := ctx.db.Query(`
|
||||
SELECT r.id, r.provider_id, r.name, r.full_name, COALESCE(r.description, ''), r.clone_url,
|
||||
r.default_branch, r.is_private, r.user_id, r.created_at, r.updated_at,
|
||||
p.name as provider_name, p.display_name
|
||||
FROM git_repositories r
|
||||
JOIN git_providers p ON r.provider_id = p.id
|
||||
WHERE r.user_id = $1
|
||||
ORDER BY r.updated_at DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
`, ctx.userID, limit, offset)
|
||||
q := sqlcdb.New(ctx.db.DB)
|
||||
repoRows, err := q.ListConnectedRepositories(context.Background(), sqlcdb.ListConnectedRepositoriesParams{
|
||||
UserID: uuid.MustParse(ctx.userID),
|
||||
Limit: int32(limit),
|
||||
Offset: int32(offset),
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
repositories := make([]map[string]interface{}, 0)
|
||||
for rows.Next() {
|
||||
var repo GitRepository
|
||||
var providerName, providerDisplayName string
|
||||
if err := rows.Scan(
|
||||
&repo.ID,
|
||||
&repo.ProviderID,
|
||||
&repo.Name,
|
||||
&repo.FullName,
|
||||
&repo.Description,
|
||||
&repo.CloneURL,
|
||||
&repo.DefaultBranch,
|
||||
&repo.IsPrivate,
|
||||
&repo.UserID,
|
||||
&repo.CreatedAt,
|
||||
&repo.UpdatedAt,
|
||||
&providerName,
|
||||
&providerDisplayName,
|
||||
); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
||||
return
|
||||
}
|
||||
|
||||
repositories := make([]map[string]interface{}, 0, len(repoRows))
|
||||
for _, r := range repoRows {
|
||||
repositories = append(repositories, map[string]interface{}{
|
||||
"id": repo.ID,
|
||||
"provider_id": repo.ProviderID,
|
||||
"name": repo.Name,
|
||||
"full_name": repo.FullName,
|
||||
"description": repo.Description,
|
||||
"clone_url": repo.CloneURL,
|
||||
"default_branch": repo.DefaultBranch,
|
||||
"is_private": repo.IsPrivate,
|
||||
"created_at": repo.CreatedAt,
|
||||
"updated_at": repo.UpdatedAt,
|
||||
"id": r.ID.String(),
|
||||
"provider_id": r.ProviderID.String(),
|
||||
"name": r.Name,
|
||||
"full_name": r.FullName,
|
||||
"description": r.Description,
|
||||
"clone_url": r.CloneUrl,
|
||||
"default_branch": r.DefaultBranch.String,
|
||||
"is_private": r.IsPrivate.Bool,
|
||||
"created_at": r.CreatedAt.Time,
|
||||
"updated_at": r.UpdatedAt.Time,
|
||||
"provider": map[string]string{
|
||||
"name": providerName,
|
||||
"display_name": providerDisplayName,
|
||||
"name": r.ProviderName,
|
||||
"display_name": r.DisplayName,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
var total int
|
||||
if err := ctx.db.QueryRow(`
|
||||
SELECT COUNT(*)
|
||||
FROM git_repositories
|
||||
WHERE user_id = $1
|
||||
`, ctx.userID).Scan(&total); err != nil {
|
||||
total, err := q.CountUserRepositories(context.Background(), uuid.MustParse(ctx.userID))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
||||
return
|
||||
}
|
||||
@@ -705,20 +684,17 @@ func syncProviderRepositories(db *database.DB, providerID, userID, providerName,
|
||||
defaultBranch = "main"
|
||||
}
|
||||
|
||||
_, err := db.Exec(`
|
||||
INSERT INTO git_repositories (
|
||||
id, provider_id, name, full_name, description, clone_url, default_branch, is_private, user_id, created_at, updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW())
|
||||
ON CONFLICT (provider_id, full_name)
|
||||
DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
description = EXCLUDED.description,
|
||||
clone_url = EXCLUDED.clone_url,
|
||||
default_branch = EXCLUDED.default_branch,
|
||||
is_private = EXCLUDED.is_private,
|
||||
updated_at = NOW()
|
||||
`, uuid.NewString(), providerID, repository.Name, repository.FullName, repository.Description, cloneURL, defaultBranch, repository.IsPrivate, userID)
|
||||
err := sqlcdb.New(db.DB).UpsertGitRepository(context.Background(), sqlcdb.UpsertGitRepositoryParams{
|
||||
ID: uuid.New(),
|
||||
ProviderID: uuid.MustParse(providerID),
|
||||
Name: repository.Name,
|
||||
FullName: repository.FullName,
|
||||
Description: sql.NullString{String: repository.Description, Valid: true},
|
||||
CloneUrl: cloneURL,
|
||||
DefaultBranch: sql.NullString{String: defaultBranch, Valid: true},
|
||||
IsPrivate: sql.NullBool{Bool: repository.IsPrivate, Valid: true},
|
||||
UserID: uuid.MustParse(userID),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1323,16 +1299,15 @@ func handleDeleteGitProvider(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
result, err := ctx.db.Exec(
|
||||
"DELETE FROM git_providers WHERE id = $1 AND user_id = $2",
|
||||
providerID, ctx.userID,
|
||||
)
|
||||
affected, err := sqlcdb.New(ctx.db.DB).DeleteGitProvider(context.Background(), sqlcdb.DeleteGitProviderParams{
|
||||
ID: uuid.MustParse(providerID),
|
||||
UserID: uuid.MustParse(ctx.userID),
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete provider"})
|
||||
return
|
||||
}
|
||||
|
||||
affected, _ := result.RowsAffected()
|
||||
if affected == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Provider not found"})
|
||||
return
|
||||
@@ -1362,12 +1337,10 @@ func handleGetGitRepositoryBranches(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var providerName, providerAPIURL, providerAccessToken string
|
||||
err := ctx.db.QueryRow(`
|
||||
SELECT name, api_url, access_token
|
||||
FROM git_providers
|
||||
WHERE id = $1 AND user_id = $2
|
||||
`, providerID, ctx.userID).Scan(&providerName, &providerAPIURL, &providerAccessToken)
|
||||
pf, err := sqlcdb.New(ctx.db.DB).GetGitProviderForFetch(context.Background(), sqlcdb.GetGitProviderForFetchParams{
|
||||
ID: uuid.MustParse(providerID),
|
||||
UserID: uuid.MustParse(ctx.userID),
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Provider not found"})
|
||||
@@ -1376,6 +1349,9 @@ func handleGetGitRepositoryBranches(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
||||
return
|
||||
}
|
||||
providerName := pf.Name
|
||||
providerAPIURL := pf.ApiUrl
|
||||
providerAccessToken := pf.AccessToken
|
||||
|
||||
branches, err := fetchRepositoryBranches(providerName, providerAPIURL, providerAccessToken, owner, repo)
|
||||
if err != nil {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"time"
|
||||
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
"containr/internal/deployment"
|
||||
"containr/internal/deployqueue"
|
||||
|
||||
@@ -38,22 +40,15 @@ func handleGitWebhookPush(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
secret string
|
||||
repoID string
|
||||
providerID string
|
||||
branchFilter string
|
||||
active bool
|
||||
)
|
||||
err := db.QueryRow(`
|
||||
SELECT webhook_secret, repo_id, provider_id, COALESCE(branch_filter, ''), active
|
||||
FROM git_webhooks WHERE id = $1`, webhookID).
|
||||
Scan(&secret, &repoID, &providerID, &branchFilter, &active)
|
||||
q := sqlcdb.New(db.DB)
|
||||
hook, err := q.GetGitWebhookForPush(context.Background(), uuid.MustParse(webhookID))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Webhook not found"})
|
||||
return
|
||||
}
|
||||
if !active {
|
||||
secret := hook.WebhookSecret
|
||||
branchFilter := hook.BranchFilter
|
||||
if !hook.Active.Bool {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Webhook is disabled"})
|
||||
return
|
||||
}
|
||||
@@ -64,8 +59,7 @@ func handleGitWebhookPush(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var providerName string
|
||||
_ = db.QueryRow(`SELECT name FROM git_providers WHERE id = $1`, providerID).Scan(&providerName)
|
||||
providerName, _ := q.GetGitProviderNameByID(context.Background(), hook.ProviderID)
|
||||
|
||||
if !verifyGitWebhookSignature(providerName, secret, c.Request, body) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid signature"})
|
||||
@@ -118,14 +112,12 @@ func handleGitWebhookPush(c *gin.Context) {
|
||||
commit = payload.After
|
||||
}
|
||||
|
||||
var cloneURL, fullName, repoUserID string
|
||||
err = db.QueryRow(
|
||||
`SELECT clone_url, full_name, user_id FROM git_repositories WHERE id = $1`, repoID,
|
||||
).Scan(&cloneURL, &fullName, &repoUserID)
|
||||
repo, err := q.GetGitRepoForPush(context.Background(), hook.RepoID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Repository not found"})
|
||||
return
|
||||
}
|
||||
cloneURL, fullName, repoUserID := repo.CloneUrl, repo.FullName, repo.UserID.String()
|
||||
|
||||
engineValue, _ := c.Get("deployment_engine")
|
||||
engine, _ := engineValue.(*deployment.DeploymentEngine)
|
||||
@@ -147,34 +139,37 @@ func handleGitWebhookPush(c *gin.Context) {
|
||||
// the pushed repo+branch. Shared by the per-repo webhook receiver and the
|
||||
// GitHub App webhook endpoint.
|
||||
func dispatchPushToServices(c *gin.Context, db *database.DB, engine *deployment.DeploymentEngine, cloneURL, fullName, branch, commit, repoUserID string) (int, error) {
|
||||
// Sync-style events may carry no branch — match services on repo only.
|
||||
branchClause := "s.git_branch = $1"
|
||||
if branch == "" {
|
||||
branchClause = "TRUE"
|
||||
}
|
||||
rows, err := db.Query(fmt.Sprintf(`
|
||||
SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command,
|
||||
s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory,
|
||||
s.created_at, s.updated_at
|
||||
FROM services s
|
||||
WHERE %s AND (s.git_repo = $2 OR s.git_repo = $3)`, branchClause),
|
||||
branch, cloneURL, fullName)
|
||||
q := sqlcdb.New(db.DB)
|
||||
ctx := context.Background()
|
||||
// Sync-style events may carry no branch — empty branch matches all.
|
||||
rows, err := q.ListServicesForPush(ctx, sqlcdb.ListServicesForPushParams{
|
||||
GitBranch: sql.NullString{String: branch, Valid: true},
|
||||
GitRepo: sql.NullString{String: cloneURL, Valid: true},
|
||||
GitRepo_2: sql.NullString{String: fullName, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var services []Service
|
||||
for rows.Next() {
|
||||
var s Service
|
||||
if err := rows.Scan(
|
||||
&s.ID, &s.ProjectID, &s.Name, &s.Type, &s.Status, &s.Image, &s.Command,
|
||||
&s.Environment, &s.GitRepo, &s.GitBranch, &s.BuildPath, &s.CPU, &s.Memory,
|
||||
&s.CreatedAt, &s.UpdatedAt,
|
||||
); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
services = append(services, s)
|
||||
for _, row := range rows {
|
||||
services = append(services, Service{
|
||||
ID: row.ID,
|
||||
ProjectID: row.ProjectID,
|
||||
Name: row.Name,
|
||||
Type: row.Type,
|
||||
Status: row.Status,
|
||||
Image: row.Image,
|
||||
Command: row.Command,
|
||||
Environment: row.Environment,
|
||||
GitRepo: row.GitRepo,
|
||||
GitBranch: row.GitBranch,
|
||||
BuildPath: row.BuildPath,
|
||||
CPU: row.Cpu,
|
||||
Memory: row.Memory,
|
||||
CreatedAt: row.CreatedAt.Time,
|
||||
UpdatedAt: row.UpdatedAt.Time,
|
||||
})
|
||||
}
|
||||
|
||||
enqueued := 0
|
||||
@@ -192,27 +187,34 @@ func dispatchPushToServices(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, $4, $5, $6, $7, $8, $9)`,
|
||||
d.ID, d.ServiceID, fmt.Sprintf("v%d", now.Unix()), d.CommitHash, d.Status, d.ImageName, d.ImageTag, d.CreatedAt, d.UpdatedAt,
|
||||
); err != nil {
|
||||
if err := q.InsertDeployment(ctx, sqlcdb.InsertDeploymentParams{
|
||||
ID: d.ID,
|
||||
ServiceID: d.ServiceID,
|
||||
Version: fmt.Sprintf("v%d", now.Unix()),
|
||||
CommitHash: sql.NullString{String: ptrStr(d.CommitHash), Valid: d.CommitHash != nil},
|
||||
Status: sql.NullString{String: d.Status, Valid: true},
|
||||
ImageName: sql.NullString{String: d.ImageName, Valid: true},
|
||||
ImageTag: sql.NullString{String: d.ImageTag, Valid: true},
|
||||
CreatedAt: sql.NullTime{Time: d.CreatedAt, Valid: true},
|
||||
UpdatedAt: sql.NullTime{Time: d.UpdatedAt, Valid: true},
|
||||
}); err != nil {
|
||||
continue
|
||||
}
|
||||
if engine == nil {
|
||||
failedAt := time.Now()
|
||||
failure := "Deployment engine unavailable. Docker may not be configured on this server."
|
||||
_, _ = db.Exec(
|
||||
`UPDATE deployments SET status = 'failed', error = $1, completed_at = $2, updated_at = $2 WHERE id = $3`,
|
||||
failure, failedAt, d.ID,
|
||||
)
|
||||
failedAt := time.Now()
|
||||
_ = q.FailDeployment(ctx, sqlcdb.FailDeploymentParams{
|
||||
Error: sql.NullString{String: failure, Valid: true},
|
||||
CompletedAt: sql.NullTime{Time: failedAt, Valid: true},
|
||||
ID: d.ID,
|
||||
})
|
||||
continue
|
||||
}
|
||||
_, _ = db.Exec(
|
||||
`UPDATE services SET status = 'building', updated_at = $1 WHERE id = $2`,
|
||||
time.Now(), service.ID,
|
||||
)
|
||||
_ = q.SetServiceStatus(ctx, sqlcdb.SetServiceStatusParams{
|
||||
Status: sql.NullString{String: "building", Valid: true},
|
||||
UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true},
|
||||
ID: service.ID,
|
||||
})
|
||||
// Webhook pushes default to a clean build — stale layers are the
|
||||
// classic silent-rollback failure (dflow convention).
|
||||
branchForBuild := branch
|
||||
@@ -226,14 +228,17 @@ func dispatchPushToServices(c *gin.Context, db *database.DB, engine *deployment.
|
||||
runDeploymentAndSync(jctx, db, engine, &d, service, webhookReq, repoUserID)
|
||||
},
|
||||
}); pos > 0 {
|
||||
_, _ = db.Exec(
|
||||
`UPDATE deployments SET status = 'queued', updated_at = $1 WHERE id = $2`,
|
||||
time.Now(), d.ID,
|
||||
)
|
||||
_, _ = db.Exec(
|
||||
`UPDATE services SET status = 'queued', updated_at = $1 WHERE id = $2`,
|
||||
time.Now(), service.ID,
|
||||
)
|
||||
now := sql.NullTime{Time: time.Now(), Valid: true}
|
||||
_ = q.SetDeploymentStatus(ctx, sqlcdb.SetDeploymentStatusParams{
|
||||
Status: sql.NullString{String: "queued", Valid: true},
|
||||
UpdatedAt: now,
|
||||
ID: d.ID,
|
||||
})
|
||||
_ = q.SetServiceStatus(ctx, sqlcdb.SetServiceStatusParams{
|
||||
Status: sql.NullString{String: "queued", Valid: true},
|
||||
UpdatedAt: now,
|
||||
ID: service.ID,
|
||||
})
|
||||
}
|
||||
enqueued++
|
||||
}
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
package api
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestNormalizeRepositoryFullName(t *testing.T) {
|
||||
cases := []struct {
|
||||
@@ -83,3 +93,195 @@ func TestDeriveCloneURL(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Live invariant test against real Postgres — set METRICS_TEST_DSN (or
|
||||
// DATABASE_URL). Covers the generated git provider/repo/webhook queries plus
|
||||
// the push-dispatch service matcher (empty branch must match all branches).
|
||||
func TestGitLiveInvariants(t *testing.T) {
|
||||
dsn := testDSNString(t)
|
||||
db, err := database.NewConnection(dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
ctx := context.Background()
|
||||
q := sqlcdb.New(db.DB)
|
||||
|
||||
userID := uuid.New()
|
||||
projectID := uuid.New()
|
||||
envID := uuid.New()
|
||||
providerID := uuid.New()
|
||||
repoID := uuid.New()
|
||||
webhookID := uuid.New()
|
||||
svcMain := uuid.New()
|
||||
svcDev := uuid.New()
|
||||
suffix := strings.ReplaceAll(userID.String()[:8], "-", "")
|
||||
must := func(err error) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Fatalf("setup: %v", err)
|
||||
}
|
||||
}
|
||||
_, err = db.Exec(`INSERT INTO users (id, email, password_hash, name) VALUES ($1, $2, 'x', 'test')`,
|
||||
userID, "git-"+suffix+"@test.local")
|
||||
must(err)
|
||||
_, err = db.Exec(`INSERT INTO projects (id, name, owner_id, is_approved) VALUES ($1, $2, $3, true)`,
|
||||
projectID, "git-"+suffix, userID)
|
||||
must(err)
|
||||
_, err = db.Exec(`INSERT INTO environments (id, name, project_id) VALUES ($1, 'production', $2)`,
|
||||
envID, projectID)
|
||||
must(err)
|
||||
defer func() { _, _ = db.Exec(`DELETE FROM users WHERE id = $1`, userID) }()
|
||||
|
||||
// Provider lifecycle.
|
||||
must(q.InsertGitProvider(ctx, sqlcdb.InsertGitProviderParams{
|
||||
ID: providerID, Name: "github", DisplayName: "GitHub",
|
||||
ApiUrl: "https://api.github.com", WebhookUrl: "https://hooks.test",
|
||||
AccessToken: "tok", UserID: userID,
|
||||
}))
|
||||
providers, err := q.ListGitProviders(ctx, userID)
|
||||
if err != nil || len(providers) == 0 {
|
||||
t.Fatalf("providers: %v %v", err, providers)
|
||||
}
|
||||
name, err := q.GetGitProviderNameByID(ctx, providerID)
|
||||
if err != nil || name != "github" {
|
||||
t.Fatalf("provider name: %v %q", err, name)
|
||||
}
|
||||
fetch, err := q.GetGitProviderForFetch(ctx, sqlcdb.GetGitProviderForFetchParams{ID: providerID, UserID: userID})
|
||||
if err != nil || fetch.AccessToken != "tok" || fetch.ApiUrl != "https://api.github.com" {
|
||||
t.Fatalf("fetch: %v %+v", err, fetch)
|
||||
}
|
||||
|
||||
// Repository insert + search.
|
||||
cloneURL := "https://github.com/acme/svc-" + suffix + ".git"
|
||||
fullName := "acme/svc-" + suffix
|
||||
_, err = q.InsertGitRepository(ctx, sqlcdb.InsertGitRepositoryParams{
|
||||
ID: repoID, ProviderID: providerID, Name: "svc-" + suffix, FullName: fullName,
|
||||
Description: sql.NullString{String: "", Valid: true},
|
||||
CloneUrl: cloneURL,
|
||||
DefaultBranch: sql.NullString{String: "main", Valid: true},
|
||||
IsPrivate: sql.NullBool{Bool: false, Valid: true},
|
||||
UserID: userID,
|
||||
})
|
||||
must(err)
|
||||
byName, err := q.GetGitRepositoryByFullName(ctx, sqlcdb.GetGitRepositoryByFullNameParams{
|
||||
ProviderID: providerID, FullName: fullName, UserID: userID,
|
||||
})
|
||||
if err != nil || byName.ID != repoID || byName.CloneUrl != cloneURL {
|
||||
t.Fatalf("repo by full name: %v %+v", err, byName)
|
||||
}
|
||||
all, err := q.ListGitRepositories(ctx, sqlcdb.ListGitRepositoriesParams{
|
||||
ProviderID: providerID, UserID: userID, Column3: "", Limit: 50, Offset: 0,
|
||||
})
|
||||
if err != nil || len(all) == 0 {
|
||||
t.Fatalf("repo list (empty search): %v %v", err, all)
|
||||
}
|
||||
hit, err := q.ListGitRepositories(ctx, sqlcdb.ListGitRepositoriesParams{
|
||||
ProviderID: providerID, UserID: userID, Column3: "svc-" + suffix, Limit: 50, Offset: 0,
|
||||
})
|
||||
if err != nil || len(hit) != 1 || hit[0].ID != repoID {
|
||||
t.Fatalf("repo list (matching search): %v %v", err, hit)
|
||||
}
|
||||
miss, err := q.ListGitRepositories(ctx, sqlcdb.ListGitRepositoriesParams{
|
||||
ProviderID: providerID, UserID: userID, Column3: "zzz-no-match", Limit: 50, Offset: 0,
|
||||
})
|
||||
if err != nil || len(miss) != 0 {
|
||||
t.Fatalf("repo list (non-matching search): %v %v", err, miss)
|
||||
}
|
||||
count, err := q.CountGitRepositories(ctx, sqlcdb.CountGitRepositoriesParams{
|
||||
ProviderID: providerID, UserID: userID, Column3: "",
|
||||
})
|
||||
if err != nil || count < 1 {
|
||||
t.Fatalf("repo count: %v %d", err, count)
|
||||
}
|
||||
// Upsert on (provider_id, full_name) conflict — updates rather than errors.
|
||||
must(q.UpsertGitRepository(ctx, sqlcdb.UpsertGitRepositoryParams{
|
||||
ID: uuid.New(), ProviderID: providerID, Name: "renamed-" + suffix, FullName: fullName,
|
||||
Description: sql.NullString{String: "d", Valid: true},
|
||||
CloneUrl: cloneURL,
|
||||
DefaultBranch: sql.NullString{String: "main", Valid: true},
|
||||
IsPrivate: sql.NullBool{Bool: true, Valid: true},
|
||||
UserID: userID,
|
||||
}))
|
||||
byName, err = q.GetGitRepositoryByFullName(ctx, sqlcdb.GetGitRepositoryByFullNameParams{
|
||||
ProviderID: providerID, FullName: fullName, UserID: userID,
|
||||
})
|
||||
if err != nil || byName.Name != "renamed-"+suffix {
|
||||
t.Fatalf("repo after upsert: %v %+v", err, byName)
|
||||
}
|
||||
|
||||
// Connected-repos join + webhook lifecycle.
|
||||
connected, err := q.ListConnectedRepositories(ctx, sqlcdb.ListConnectedRepositoriesParams{
|
||||
UserID: userID, Limit: 50, Offset: 0,
|
||||
})
|
||||
if err != nil || len(connected) == 0 {
|
||||
t.Fatalf("connected repos: %v %v", err, connected)
|
||||
}
|
||||
wh, err := q.UpsertGitWebhook(ctx, sqlcdb.UpsertGitWebhookParams{
|
||||
ID: webhookID, RepoID: repoID, ProviderID: providerID,
|
||||
Events: "push", WebhookSecret: "sec-" + suffix,
|
||||
BranchFilter: sql.NullString{String: "", Valid: true},
|
||||
})
|
||||
must(err)
|
||||
if !wh.Active.Bool {
|
||||
t.Fatalf("webhook inactive after upsert: %+v", wh)
|
||||
}
|
||||
push, err := q.GetGitWebhookForPush(ctx, webhookID)
|
||||
if err != nil || push.WebhookSecret != "sec-"+suffix || push.ProviderID != providerID {
|
||||
t.Fatalf("webhook for push: %v %+v", err, push)
|
||||
}
|
||||
pushRepo, err := q.GetGitRepoForPush(ctx, repoID)
|
||||
if err != nil || pushRepo.CloneUrl != cloneURL || pushRepo.FullName != fullName || pushRepo.UserID != userID {
|
||||
t.Fatalf("repo for push: %v %+v", err, pushRepo)
|
||||
}
|
||||
|
||||
// Push-dispatch service matcher: branch filter and empty-branch match-all.
|
||||
for id, branch := range map[uuid.UUID]string{svcMain: "main", svcDev: "dev"} {
|
||||
_, err = db.Exec(`INSERT INTO services (id, name, project_id, environment_id, service_type, source_type, git_repo, git_branch)
|
||||
VALUES ($1, $2, $3, $4, 'web', 'git', $5, $6)`,
|
||||
id, "svc-"+suffix+"-"+branch, projectID, envID, cloneURL, branch)
|
||||
must(err)
|
||||
}
|
||||
servicesFor := func(branch string) []sqlcdb.ListServicesForPushRow {
|
||||
rows, err := q.ListServicesForPush(ctx, sqlcdb.ListServicesForPushParams{
|
||||
GitBranch: sql.NullString{String: branch, Valid: true},
|
||||
GitRepo: sql.NullString{String: cloneURL, Valid: true},
|
||||
GitRepo_2: sql.NullString{String: fullName, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("services for push %q: %v", branch, err)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
mainRows := servicesFor("main")
|
||||
if len(mainRows) != 1 || mainRows[0].ID != svcMain || mainRows[0].GitBranch != "main" {
|
||||
t.Fatalf("main branch dispatch: %+v", mainRows)
|
||||
}
|
||||
devRows := servicesFor("dev")
|
||||
if len(devRows) != 1 || devRows[0].ID != svcDev {
|
||||
t.Fatalf("dev branch dispatch: %+v", devRows)
|
||||
}
|
||||
allRows := servicesFor("")
|
||||
if len(allRows) != 2 {
|
||||
t.Fatalf("empty-branch dispatch should match all: %+v", allRows)
|
||||
}
|
||||
// Repo may also be referenced by its full_name.
|
||||
fullRows, err := q.ListServicesForPush(ctx, sqlcdb.ListServicesForPushParams{
|
||||
GitBranch: sql.NullString{String: "main", Valid: true},
|
||||
GitRepo: sql.NullString{String: "[email protected]:other/none.git", Valid: true},
|
||||
GitRepo_2: sql.NullString{String: cloneURL, Valid: true},
|
||||
})
|
||||
if err != nil || len(fullRows) != 1 {
|
||||
t.Fatalf("full-name repo match: %v %+v", err, fullRows)
|
||||
}
|
||||
|
||||
// Provider delete is owner-scoped.
|
||||
other, err := q.DeleteGitProvider(ctx, sqlcdb.DeleteGitProviderParams{ID: providerID, UserID: uuid.New()})
|
||||
if err != nil || other != 0 {
|
||||
t.Fatalf("foreign delete affected %d rows: %v", other, err)
|
||||
}
|
||||
deleted, err := q.DeleteGitProvider(ctx, sqlcdb.DeleteGitProviderParams{ID: providerID, UserID: userID})
|
||||
if err != nil || deleted != 1 {
|
||||
t.Fatalf("owner delete affected %d rows: %v", deleted, err)
|
||||
}
|
||||
}
|
||||
@@ -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":
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"time"
|
||||
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
"containr/internal/deployment"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -206,9 +207,8 @@ func handleAdminGitHubAppConvert(c *gin.Context) {
|
||||
// GET /admin/git/github-app — provisioning status for the settings UI.
|
||||
func handleAdminGetGitHubApp(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
var stored string
|
||||
source := "none"
|
||||
if err := db.QueryRow(`SELECT value FROM app_settings WHERE key = $1`, settingGitHubAppID).Scan(&stored); err == nil && strings.TrimSpace(stored) != "" {
|
||||
if v, err := sqlcdb.New(db.DB).GetAppSetting(c.Request.Context(), settingGitHubAppID); err == nil && strings.TrimSpace(v) != "" {
|
||||
source = "settings"
|
||||
} else if strings.TrimSpace(os.Getenv("GITHUB_APP_ID")) != "" {
|
||||
source = "env"
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -775,11 +775,7 @@ func resolvePreviewHost(db *database.DB, cloneID uuid.UUID, envName string) stri
|
||||
|
||||
// loadServiceForDeploy hydrates the Service fields runDeploymentAndSync
|
||||
// needs — mirrors the loader in handleDeployService.
|
||||
func loadServiceForDeploy(db *database.DB, serviceID uuid.UUID) (Service, error) {
|
||||
r, err := sqlcdb.New(db.DB).GetServiceForDeploy(context.Background(), serviceID)
|
||||
if err != nil {
|
||||
return Service{}, err
|
||||
}
|
||||
func serviceForDeployFromRow(r sqlcdb.GetServiceForDeployRow) Service {
|
||||
return Service{
|
||||
ID: r.ID, ProjectID: r.ProjectID, Name: r.Name, Type: r.Type.String,
|
||||
Status: r.Status.String, Image: r.Image.String, Command: r.Command.String,
|
||||
@@ -791,7 +787,15 @@ func loadServiceForDeploy(db *database.DB, serviceID uuid.UUID) (Service, error)
|
||||
Builder: r.Builder, CPUReserve: r.CpuReserve, MemoryReserve: r.MemoryReserve,
|
||||
StaticBuildCmd: r.StaticBuildCmd, StaticDir: r.StaticDir,
|
||||
CreatedAt: r.CreatedAt.Time, UpdatedAt: r.UpdatedAt.Time,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func loadServiceForDeploy(db *database.DB, serviceID uuid.UUID) (Service, error) {
|
||||
r, err := sqlcdb.New(db.DB).GetServiceForDeploy(context.Background(), serviceID)
|
||||
if err != nil {
|
||||
return Service{}, err
|
||||
}
|
||||
return serviceForDeployFromRow(r), nil
|
||||
}
|
||||
|
||||
// StartPreviewSweeper expires preview environments past their TTL — tears
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"containr/internal/build"
|
||||
"containr/internal/config"
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
"containr/internal/deployment"
|
||||
"containr/internal/deployqueue"
|
||||
"containr/internal/docker"
|
||||
@@ -21,6 +22,7 @@ import (
|
||||
"containr/internal/secrets"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg *config.Config) {
|
||||
@@ -83,19 +85,18 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
// 127.0.0.1 fallback stays for node-bound checks.
|
||||
docker := dockerClient
|
||||
haManager.WithServiceResolver(func(ctx context.Context, serviceID string) (string, int, error) {
|
||||
var name, projectID string
|
||||
var port int
|
||||
err := db.QueryRowContext(ctx,
|
||||
`SELECT name, project_id::text, COALESCE(port, 0) FROM services WHERE id = $1`,
|
||||
serviceID,
|
||||
).Scan(&name, &projectID, &port)
|
||||
sid, err := uuid.Parse(serviceID)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
if err := docker.ConnectSelfToNetwork(ctx, deployment.ProjectNetworkName(projectID)); err != nil {
|
||||
svc, err := sqlcdb.New(db.DB).GetServiceNameProjectPort(ctx, sid)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return name, port, nil
|
||||
if err := docker.ConnectSelfToNetwork(ctx, deployment.ProjectNetworkName(svc.ProjectID)); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return svc.Name, int(svc.Port), nil
|
||||
})
|
||||
}
|
||||
haAPIManager := NewHAManager(haManager)
|
||||
@@ -137,31 +138,25 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
// Boot sweep: in-memory engine state dies on restart, so anything that
|
||||
// looked in-flight is actually orphaned — mark it failed rather than
|
||||
// leaving phantom 'building'/'queued' rows.
|
||||
_, _ = db.Exec(
|
||||
`UPDATE deployments SET status = 'failed', error = 'Interrupted by server restart', completed_at = NOW(), updated_at = NOW()
|
||||
WHERE status IN ('queued', 'pending', 'building', 'deploying', 'rolling_back')`,
|
||||
)
|
||||
bootQ := sqlcdb.New(db.DB)
|
||||
_ = bootQ.FailInterruptedDeployments(context.Background())
|
||||
|
||||
// Boot sweep: encrypt any plaintext secret values left over from before
|
||||
// encryption-at-rest landed. Transparent and idempotent.
|
||||
if encRows, err := db.Query(`SELECT id, value FROM environment_variables WHERE is_secret = TRUE`); err == nil {
|
||||
type encRow struct {
|
||||
id string
|
||||
value string
|
||||
if encRows, err := bootQ.ListSecretEnvVars(context.Background()); err == nil {
|
||||
toEncrypt := 0
|
||||
for _, r := range encRows {
|
||||
if secrets.IsEncrypted(r.Value) {
|
||||
continue
|
||||
}
|
||||
var toEncrypt []encRow
|
||||
for encRows.Next() {
|
||||
var r encRow
|
||||
if err := encRows.Scan(&r.id, &r.value); err == nil && !secrets.IsEncrypted(r.value) {
|
||||
toEncrypt = append(toEncrypt, r)
|
||||
if err := bootQ.UpdateEnvVarValue(context.Background(), sqlcdb.UpdateEnvVarValueParams{
|
||||
Value: secrets.Encrypt(r.Value), ID: r.ID,
|
||||
}); err == nil {
|
||||
toEncrypt++
|
||||
}
|
||||
}
|
||||
encRows.Close()
|
||||
for _, r := range toEncrypt {
|
||||
_, _ = db.Exec(`UPDATE environment_variables SET value = $1, updated_at = NOW() WHERE id = $2`, secrets.Encrypt(r.value), r.id)
|
||||
}
|
||||
if len(toEncrypt) > 0 {
|
||||
log.Printf("secrets: encrypted %d plaintext secret variable(s)", len(toEncrypt))
|
||||
if toEncrypt > 0 {
|
||||
log.Printf("secrets: encrypted %d plaintext secret variable(s)", toEncrypt)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+103
-197
@@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
"containr/internal/deployment"
|
||||
"containr/internal/deployqueue"
|
||||
"containr/internal/secrets"
|
||||
@@ -22,22 +24,17 @@ import (
|
||||
// service — covers pinned placements, spread replicas, and leftovers from a
|
||||
// since-cleared pin.
|
||||
func remoteAgentsForService(ctx context.Context, db *database.DB, serviceID uuid.UUID) []string {
|
||||
rows, err := db.Query(
|
||||
`SELECT DISTINCT node_agent_id FROM container_instances WHERE service_id = $1`,
|
||||
serviceID,
|
||||
)
|
||||
ids, err := sqlcdb.New(db.DB).ListNodeAgentsForService(ctx, serviceID.String())
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if rows.Scan(&id) == nil && id != "" {
|
||||
ids = append(ids, id)
|
||||
out := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if id != "" {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
return out
|
||||
}
|
||||
|
||||
// serviceLifecycle routes a start/stop/restart action to every agent holding
|
||||
@@ -99,7 +96,9 @@ func serviceRuntimeSpec(db *database.DB, service Service) (deployment.RuntimeSpe
|
||||
// Best effort: reuse the host port from the last live deployment so the
|
||||
// public URL survives restarts. Column exists post-migration; older DBs
|
||||
// silently get ephemeral ports.
|
||||
_ = db.QueryRow(`SELECT published_port FROM services WHERE id = $1`, service.ID).Scan(&spec.PublishedPort)
|
||||
if p, err := sqlcdb.New(db.DB).GetServicePublishedPort(context.Background(), service.ID); err == nil {
|
||||
spec.PublishedPort = p
|
||||
}
|
||||
if cmd := strings.TrimSpace(service.Command); cmd != "" {
|
||||
spec.Command = strings.Fields(cmd)
|
||||
}
|
||||
@@ -137,42 +136,23 @@ var refVarPattern = regexp.MustCompile(`\$\{\{\s*([A-Za-z0-9_-]+)\.([A-Za-z_][A-
|
||||
// secrets) and expands ${{service.KEY}} references. KEY may be a variable on
|
||||
// the referenced service, or the builtins HOST (service name alias) and PORT.
|
||||
func resolveServiceEnv(db *database.DB, service Service) (map[string]string, error) {
|
||||
rows, err := db.Query(
|
||||
`SELECT key, value FROM environment_variables WHERE service_id = $1`,
|
||||
service.ID,
|
||||
)
|
||||
q := sqlcdb.New(db.DB)
|
||||
vars, err := q.ListServiceVariableValues(context.Background(), service.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
env := map[string]string{}
|
||||
for rows.Next() {
|
||||
var k, v string
|
||||
if err := rows.Scan(&k, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
env[k] = secrets.Decrypt(v)
|
||||
for _, v := range vars {
|
||||
env[v.Key] = secrets.Decrypt(v.Value)
|
||||
}
|
||||
|
||||
var refServices []struct{ ID, Name string }
|
||||
svcRows, err := db.Query(
|
||||
`SELECT id, name FROM services WHERE project_id = $1`,
|
||||
service.ProjectID,
|
||||
)
|
||||
refServices, err := q.ListProjectServiceRefs(context.Background(), service.ProjectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer svcRows.Close()
|
||||
for svcRows.Next() {
|
||||
var s struct{ ID, Name string }
|
||||
if err := svcRows.Scan(&s.ID, &s.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
refServices = append(refServices, s)
|
||||
}
|
||||
|
||||
byName := map[string]string{}
|
||||
byName := map[string]uuid.UUID{}
|
||||
for _, s := range refServices {
|
||||
byName[strings.ToLower(s.Name)] = s.ID
|
||||
}
|
||||
@@ -189,17 +169,15 @@ func resolveServiceEnv(db *database.DB, service Service) (map[string]string, err
|
||||
case "HOST":
|
||||
return serviceName, true
|
||||
case "PORT":
|
||||
var port int
|
||||
if err := db.QueryRow(`SELECT COALESCE(port, 0) FROM services WHERE id = $1`, id).Scan(&port); err == nil && port > 0 {
|
||||
return strconv.Itoa(port), true
|
||||
if port, err := q.GetServicePort(context.Background(), id); err == nil && port > 0 {
|
||||
return strconv.Itoa(int(port)), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
var value string
|
||||
err := db.QueryRow(
|
||||
`SELECT value FROM environment_variables WHERE service_id = $1 AND key = $2`,
|
||||
id, key,
|
||||
).Scan(&value)
|
||||
value, err := q.GetServiceVariableValue(context.Background(), sqlcdb.GetServiceVariableValueParams{
|
||||
ServiceID: id,
|
||||
Key: key,
|
||||
})
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
@@ -272,6 +250,15 @@ func parseMemoryLimit(mem string) int64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// setServiceStatusNow writes the stored status with a fresh updated_at.
|
||||
func setServiceStatusNow(db *database.DB, id uuid.UUID, status string) {
|
||||
_ = sqlcdb.New(db.DB).SetServiceStatus(context.Background(), sqlcdb.SetServiceStatusParams{
|
||||
Status: sql.NullString{String: status, Valid: true},
|
||||
UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true},
|
||||
ID: id,
|
||||
})
|
||||
}
|
||||
|
||||
func getRuntimeEngine(c *gin.Context) (*deployment.DeploymentEngine, *database.DB, bool) {
|
||||
dbValue, exists := c.Get("db")
|
||||
if !exists {
|
||||
@@ -286,6 +273,22 @@ func getRuntimeEngine(c *gin.Context) (*deployment.DeploymentEngine, *database.D
|
||||
return engineValue.(*deployment.DeploymentEngine), dbValue.(*database.DB), true
|
||||
}
|
||||
|
||||
// serviceFromRuntimeRow maps the sqlc runtime row (with legacy-column
|
||||
// fallbacks applied in SQL) onto the API Service model.
|
||||
func serviceFromRuntimeRow(r sqlcdb.GetServiceRuntimeWithOwnerRow) Service {
|
||||
return Service{
|
||||
ID: r.ID, ProjectID: r.ProjectID, Name: r.Name, Type: r.Type,
|
||||
Status: r.Status, Image: r.Image, Command: r.Command,
|
||||
Environment: r.Environment, GitRepo: r.GitRepo, GitBranch: r.GitBranch,
|
||||
BuildPath: r.BuildPath, CPU: r.Cpu, Memory: r.Memory,
|
||||
Replicas: int(r.Replicas), Port: int(r.Port), Domain: r.Domain,
|
||||
HealthCheckPath: r.HealthcheckPath, RestartPolicy: r.RestartPolicy,
|
||||
Builder: r.Builder, CPUReserve: r.CpuReserve,
|
||||
MemoryReserve: r.MemoryReserve, StaticBuildCmd: r.StaticBuildCmd,
|
||||
StaticDir: r.StaticDir, CreatedAt: r.CreatedAt.Time, UpdatedAt: r.UpdatedAt.Time,
|
||||
}
|
||||
}
|
||||
|
||||
func loadOwnedService(c *gin.Context, db *database.DB) (Service, bool) {
|
||||
serviceID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
@@ -298,43 +301,16 @@ func loadOwnedService(c *gin.Context, db *database.DB) (Service, bool) {
|
||||
return Service{}, false
|
||||
}
|
||||
|
||||
var service Service
|
||||
var owner string
|
||||
err = db.QueryRow(
|
||||
`SELECT s.id, s.project_id, s.name,
|
||||
COALESCE(s.type, s.service_type, ''), COALESCE(s.status, ''),
|
||||
COALESCE(s.image, s.image_name, ''), COALESCE(s.command, s.start_command, ''),
|
||||
COALESCE(s.environment, ''), COALESCE(s.git_repo, s.source_url, ''),
|
||||
COALESCE(s.git_branch, ''), COALESCE(s.build_path, ''),
|
||||
COALESCE(s.cpu, ''), COALESCE(s.memory, ''),
|
||||
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
||||
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
||||
COALESCE(s.restart_policy, 'unless-stopped'),
|
||||
COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''),
|
||||
COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''),
|
||||
COALESCE(s.static_dir, ''),
|
||||
s.created_at, s.updated_at, p.owner_id
|
||||
FROM services s JOIN projects p ON s.project_id = p.id
|
||||
WHERE s.id = $1`,
|
||||
serviceID,
|
||||
).Scan(
|
||||
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
|
||||
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
||||
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
||||
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
||||
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
|
||||
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
|
||||
&service.CreatedAt, &service.UpdatedAt, &owner,
|
||||
)
|
||||
row, err := sqlcdb.New(db.DB).GetServiceRuntimeWithOwner(context.Background(), serviceID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
||||
return Service{}, false
|
||||
}
|
||||
if owner != userID.(string) && !isAdminUser(db, userID.(string)) {
|
||||
if row.OwnerID.String() != userID.(string) && !isAdminUser(db, userID.(string)) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return Service{}, false
|
||||
}
|
||||
return service, true
|
||||
return serviceFromRuntimeRow(row), true
|
||||
}
|
||||
|
||||
// loadReadableService loads a service when the caller may read its project:
|
||||
@@ -346,39 +322,13 @@ func loadReadableService(c *gin.Context, db *database.DB) (Service, bool) {
|
||||
return Service{}, false
|
||||
}
|
||||
|
||||
var service Service
|
||||
var projectID uuid.UUID
|
||||
err = db.QueryRow(
|
||||
`SELECT s.id, s.project_id, s.name,
|
||||
COALESCE(s.type, s.service_type, ''), COALESCE(s.status, ''),
|
||||
COALESCE(s.image, s.image_name, ''), COALESCE(s.command, s.start_command, ''),
|
||||
COALESCE(s.environment, ''), COALESCE(s.git_repo, s.source_url, ''),
|
||||
COALESCE(s.git_branch, ''), COALESCE(s.build_path, ''),
|
||||
COALESCE(s.cpu, ''), COALESCE(s.memory, ''),
|
||||
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
||||
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
||||
COALESCE(s.restart_policy, 'unless-stopped'),
|
||||
COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''),
|
||||
COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''),
|
||||
COALESCE(s.static_dir, ''),
|
||||
s.created_at, s.updated_at, s.project_id
|
||||
FROM services s
|
||||
WHERE s.id = $1`,
|
||||
serviceID,
|
||||
).Scan(
|
||||
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
|
||||
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
||||
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
||||
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
||||
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
|
||||
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
|
||||
&service.CreatedAt, &service.UpdatedAt, &projectID,
|
||||
)
|
||||
row, err := sqlcdb.New(db.DB).GetServiceRuntimeWithOwner(context.Background(), serviceID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
||||
return Service{}, false
|
||||
}
|
||||
if _, allowed := projectReadAccess(c, db, projectID); !allowed {
|
||||
service := serviceFromRuntimeRow(row)
|
||||
if _, allowed := projectReadAccess(c, db, service.ProjectID); !allowed {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
||||
return Service{}, false
|
||||
}
|
||||
@@ -429,7 +379,7 @@ func reconcileNow(c *gin.Context, engine *deployment.DeploymentEngine, db *datab
|
||||
status = state.Status
|
||||
persistPublishedPort(db, service.ID, state.Ports)
|
||||
}
|
||||
_, _ = db.Exec(`UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`, status, time.Now(), service.ID)
|
||||
setServiceStatusNow(db, service.ID, status)
|
||||
c.JSON(http.StatusOK, gin.H{"runtime": state})
|
||||
return true
|
||||
}
|
||||
@@ -448,7 +398,7 @@ func handleServiceStart(c *gin.Context) {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
_, _ = db.Exec(`UPDATE services SET status = 'running', updated_at = $1 WHERE id = $2`, time.Now(), service.ID)
|
||||
setServiceStatusNow(db, service.ID, "running")
|
||||
c.JSON(http.StatusOK, gin.H{"status": "running"})
|
||||
}
|
||||
|
||||
@@ -466,7 +416,7 @@ func handleServiceStop(c *gin.Context) {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
_, _ = db.Exec(`UPDATE services SET status = 'stopped', updated_at = $1 WHERE id = $2`, time.Now(), service.ID)
|
||||
setServiceStatusNow(db, service.ID, "stopped")
|
||||
c.JSON(http.StatusOK, gin.H{"status": "stopped"})
|
||||
}
|
||||
|
||||
@@ -485,7 +435,7 @@ func handleServiceRestart(c *gin.Context) {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
_, _ = db.Exec(`UPDATE services SET status = 'running', updated_at = $1 WHERE id = $2`, time.Now(), service.ID)
|
||||
setServiceStatusNow(db, service.ID, "running")
|
||||
c.JSON(http.StatusOK, gin.H{"status": "running"})
|
||||
}
|
||||
|
||||
@@ -505,12 +455,8 @@ func handleServiceRedeploy(c *gin.Context) {
|
||||
image := service.Image
|
||||
if image == "" {
|
||||
// Git-sourced service: reuse the last successful deployment image.
|
||||
err := db.QueryRow(
|
||||
`SELECT image_name || ':' || image_tag FROM deployments
|
||||
WHERE service_id = $1 AND status = 'deployed' AND image_name <> ''
|
||||
ORDER BY created_at DESC LIMIT 1`,
|
||||
service.ID,
|
||||
).Scan(&image)
|
||||
var err error
|
||||
image, err = sqlcdb.New(db.DB).GetLastDeployedImage(context.Background(), service.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "No deployable image yet — run a deployment first"})
|
||||
return
|
||||
@@ -556,7 +502,7 @@ func reconcileServiceJob(ctx context.Context, db *database.DB, engine *deploymen
|
||||
if state.Status != "" {
|
||||
status = state.Status
|
||||
}
|
||||
_, _ = db.Exec(`UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`, status, time.Now(), service.ID)
|
||||
setServiceStatusNow(db, service.ID, status)
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
@@ -575,12 +521,9 @@ func enqueueServiceRedeploy(c *gin.Context, db *database.DB, service Service) bo
|
||||
}
|
||||
image := service.Image
|
||||
if image == "" {
|
||||
if err := db.QueryRow(
|
||||
`SELECT image_name || ':' || image_tag FROM deployments
|
||||
WHERE service_id = $1 AND status = 'deployed' AND image_name <> ''
|
||||
ORDER BY created_at DESC LIMIT 1`,
|
||||
service.ID,
|
||||
).Scan(&image); err != nil {
|
||||
var err error
|
||||
image, err = sqlcdb.New(db.DB).GetLastDeployedImage(context.Background(), service.ID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -628,10 +571,10 @@ func persistPublishedPort(db *database.DB, serviceID uuid.UUID, ports []uint16)
|
||||
if len(ports) == 0 {
|
||||
return
|
||||
}
|
||||
_, _ = db.Exec(
|
||||
`UPDATE services SET published_port = $1 WHERE id = $2 AND published_port <> $1`,
|
||||
int(ports[0]), serviceID,
|
||||
)
|
||||
_ = sqlcdb.New(db.DB).SetServicePublishedPort(context.Background(), sqlcdb.SetServicePublishedPortParams{
|
||||
PublishedPort: int32(ports[0]),
|
||||
ID: serviceID,
|
||||
})
|
||||
}
|
||||
|
||||
// liveServiceStatus reconciles the stored status with real container state.
|
||||
@@ -654,7 +597,7 @@ func liveServiceStatus(c *gin.Context, db *database.DB, service *Service) {
|
||||
}
|
||||
if state.Status != service.Status {
|
||||
service.Status = state.Status
|
||||
_, _ = db.Exec(`UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`, state.Status, time.Now(), service.ID)
|
||||
setServiceStatusNow(db, service.ID, state.Status)
|
||||
}
|
||||
persistPublishedPort(db, service.ID, state.Ports)
|
||||
if service.Domain != "" {
|
||||
@@ -676,44 +619,22 @@ func runtimeScaleService(c *gin.Context, serviceID string, replicas int) error {
|
||||
return fmt.Errorf("runtime unavailable")
|
||||
}
|
||||
|
||||
var service Service
|
||||
var owner string
|
||||
err := db.QueryRow(
|
||||
`SELECT s.id, s.project_id, s.name,
|
||||
COALESCE(s.type, s.service_type, ''), COALESCE(s.status, ''),
|
||||
COALESCE(s.image, s.image_name, ''), COALESCE(s.command, s.start_command, ''),
|
||||
COALESCE(s.environment, ''), COALESCE(s.git_repo, s.source_url, ''),
|
||||
COALESCE(s.git_branch, ''), COALESCE(s.build_path, ''),
|
||||
COALESCE(s.cpu, ''), COALESCE(s.memory, ''),
|
||||
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
||||
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
||||
COALESCE(s.restart_policy, 'unless-stopped'),
|
||||
COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''),
|
||||
COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''),
|
||||
COALESCE(s.static_dir, ''),
|
||||
s.created_at, s.updated_at, p.owner_id
|
||||
FROM services s JOIN projects p ON s.project_id = p.id
|
||||
WHERE s.id = $1`, serviceID,
|
||||
).Scan(
|
||||
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
|
||||
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
||||
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
||||
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
||||
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
|
||||
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
|
||||
&service.CreatedAt, &service.UpdatedAt, &owner,
|
||||
)
|
||||
q := sqlcdb.New(db.DB)
|
||||
sid, err := uuid.Parse(serviceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
row, err := q.GetServiceRuntimeWithOwner(context.Background(), sid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
service := serviceFromRuntimeRow(row)
|
||||
if service.Image == "" {
|
||||
if err := db.QueryRow(
|
||||
`SELECT image_name || ':' || image_tag FROM deployments
|
||||
WHERE service_id = $1 AND status = 'deployed' AND image_name <> ''
|
||||
ORDER BY created_at DESC LIMIT 1`, service.ID,
|
||||
).Scan(&service.Image); err != nil {
|
||||
image, err := q.GetLastDeployedImage(context.Background(), service.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("no deployed image to scale")
|
||||
}
|
||||
service.Image = image
|
||||
}
|
||||
|
||||
spec, err := serviceRuntimeSpec(db, service)
|
||||
@@ -731,7 +652,11 @@ func runtimeScaleService(c *gin.Context, serviceID string, replicas int) error {
|
||||
if state != nil {
|
||||
persistPublishedPort(db, service.ID, state.Ports)
|
||||
}
|
||||
_, _ = db.Exec(`UPDATE services SET replicas = $1, updated_at = $2 WHERE id = $3`, replicas, time.Now(), service.ID)
|
||||
_ = q.SetServiceReplicas(context.Background(), sqlcdb.SetServiceReplicasParams{
|
||||
Replicas: int32(replicas),
|
||||
UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true},
|
||||
ID: service.ID,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -750,48 +675,35 @@ func handleServiceEnvCheck(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := db.Query(
|
||||
`SELECT key, value, COALESCE(is_secret, false) FROM environment_variables WHERE service_id = $1`,
|
||||
service.ID,
|
||||
)
|
||||
q := sqlcdb.New(db.DB)
|
||||
vars, err := q.ListServiceVariablesWithSecret(context.Background(), service.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var unresolved, empty, unreadable []string
|
||||
var raw []struct{ key, val string }
|
||||
for rows.Next() {
|
||||
var k, v string
|
||||
var secret bool
|
||||
if err := rows.Scan(&k, &v, &secret); err != nil {
|
||||
continue
|
||||
}
|
||||
decrypted := secrets.Decrypt(v)
|
||||
if secret && secrets.IsEncrypted(v) && decrypted == v {
|
||||
for _, v := range vars {
|
||||
decrypted := secrets.Decrypt(v.Value)
|
||||
if v.IsSecret && secrets.IsEncrypted(v.Value) && decrypted == v.Value {
|
||||
// Ciphertext failed to decrypt — Decrypt returns the input.
|
||||
unreadable = append(unreadable, k)
|
||||
unreadable = append(unreadable, v.Key)
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(decrypted) == "" {
|
||||
empty = append(empty, k)
|
||||
empty = append(empty, v.Key)
|
||||
}
|
||||
raw = append(raw, struct{ key, val string }{k, decrypted})
|
||||
raw = append(raw, struct{ key, val string }{v.Key, decrypted})
|
||||
}
|
||||
|
||||
// Project siblings for ${{name.KEY}} resolution.
|
||||
siblingIDs := map[string]string{}
|
||||
svcRows, err := db.Query(`SELECT id, name FROM services WHERE project_id = $1`, service.ProjectID)
|
||||
if err == nil {
|
||||
for svcRows.Next() {
|
||||
var id, name string
|
||||
if err := svcRows.Scan(&id, &name); err == nil {
|
||||
siblingIDs[strings.ToLower(name)] = id
|
||||
siblingIDs := map[string]uuid.UUID{}
|
||||
if refs, err := q.ListProjectServiceRefs(context.Background(), service.ProjectID); err == nil {
|
||||
for _, s := range refs {
|
||||
siblingIDs[strings.ToLower(s.Name)] = s.ID
|
||||
}
|
||||
}
|
||||
svcRows.Close()
|
||||
}
|
||||
|
||||
resolvable := func(serviceName, key string) bool {
|
||||
id, ok := siblingIDs[strings.ToLower(serviceName)]
|
||||
@@ -802,20 +714,14 @@ func handleServiceEnvCheck(c *gin.Context) {
|
||||
return true
|
||||
}
|
||||
if strings.EqualFold(key, "PORT") {
|
||||
var port int
|
||||
if err := db.QueryRow(`SELECT COALESCE(port, 0) FROM services WHERE id = $1`, id).Scan(&port); err == nil {
|
||||
return port > 0
|
||||
port, err := q.GetServicePort(context.Background(), id)
|
||||
return err == nil && port > 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
var n int
|
||||
if err := db.QueryRow(
|
||||
`SELECT COUNT(*) FROM environment_variables WHERE service_id = $1 AND key = $2`,
|
||||
id, key,
|
||||
).Scan(&n); err == nil {
|
||||
return n > 0
|
||||
}
|
||||
return false
|
||||
_, err := q.GetServiceVariableValue(context.Background(), sqlcdb.GetServiceVariableValueParams{
|
||||
ServiceID: id,
|
||||
Key: key,
|
||||
})
|
||||
return err == nil
|
||||
}
|
||||
|
||||
for _, kv := range raw {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -86,20 +86,17 @@ type sleepCandidate struct {
|
||||
}
|
||||
|
||||
func sweepSleeping(ctx context.Context, db *database.DB, dc *docker.Client, engine *deployment.DeploymentEngine, apiPort int) {
|
||||
rows, err := db.QueryContext(ctx,
|
||||
`SELECT id, project_id, name, COALESCE(status,''), COALESCE(sleep_idle_minutes,15)
|
||||
FROM services WHERE sleep_enabled AND COALESCE(status,'') IN ('running','deployed','degraded','starting')`)
|
||||
sweepRows, err := sqlcdb.New(db.DB).ListSleepCandidates(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var candidates []sleepCandidate
|
||||
for rows.Next() {
|
||||
var s sleepCandidate
|
||||
if err := rows.Scan(&s.ID, &s.ProjectID, &s.Name, &s.Status, &s.IdleMinutes); err == nil {
|
||||
candidates = append(candidates, s)
|
||||
for _, r := range sweepRows {
|
||||
candidates = append(candidates, sleepCandidate{
|
||||
ID: r.ID, ProjectID: r.ProjectID, Name: r.Name,
|
||||
Status: r.Status, IdleMinutes: int(r.SleepIdleMinutes),
|
||||
})
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
q := sqlcdb.New(db.DB)
|
||||
for _, svc := range candidates {
|
||||
@@ -187,11 +184,10 @@ func putToSleepRemote(ctx context.Context, db *database.DB, q *sqlcdb.Queries, s
|
||||
tracker.mu.Lock()
|
||||
delete(tracker.activity, svc.ID)
|
||||
tracker.mu.Unlock()
|
||||
_, _ = db.Exec(`UPDATE services SET status = 'sleeping', updated_at = $1 WHERE id = $2`, time.Now(), svc.ID)
|
||||
setServiceStatusNow(db, svc.ID, "sleeping")
|
||||
log.Printf("sleep: %s (%s) remote replicas removed after idle timeout", svc.Name, svc.ID)
|
||||
|
||||
var domain string
|
||||
_ = db.QueryRow(`SELECT COALESCE(domain,'') FROM services WHERE id = $1`, svc.ID).Scan(&domain)
|
||||
domain, _ := q.GetServiceDomainText(ctx, svc.ID)
|
||||
domains := serviceDomainNames(db, svc.ID, domain)
|
||||
if len(domains) > 0 {
|
||||
writeWakeIngress(svc.ID.String(), domains, apiPort)
|
||||
@@ -250,11 +246,10 @@ func putToSleep(ctx context.Context, db *database.DB, dc *docker.Client, engine
|
||||
tracker.mu.Lock()
|
||||
delete(tracker.activity, svc.ID)
|
||||
tracker.mu.Unlock()
|
||||
_, _ = db.Exec(`UPDATE services SET status = 'sleeping', updated_at = $1 WHERE id = $2`, time.Now(), svc.ID)
|
||||
setServiceStatusNow(db, svc.ID, "sleeping")
|
||||
log.Printf("sleep: %s (%s) scaled to zero after idle timeout", svc.Name, svc.ID)
|
||||
|
||||
var domain string
|
||||
_ = db.QueryRow(`SELECT COALESCE(domain,'') FROM services WHERE id = $1`, svc.ID).Scan(&domain)
|
||||
domain, _ := sqlcdb.New(db.DB).GetServiceDomainText(ctx, svc.ID)
|
||||
domains := serviceDomainNames(db, svc.ID, domain)
|
||||
if len(domains) == 0 {
|
||||
return
|
||||
@@ -444,7 +439,9 @@ func pruneWakeContainers(ctx context.Context, db *database.DB, dc *docker.Client
|
||||
}
|
||||
}
|
||||
var status string
|
||||
_ = db.QueryRow(`SELECT COALESCE(status,'') FROM services WHERE id = $1`, svcID).Scan(&status)
|
||||
if sid, err := uuid.Parse(svcID); err == nil {
|
||||
status, _ = sqlcdb.New(db.DB).GetServiceStatusText(ctx, sid)
|
||||
}
|
||||
if live > 0 || status == "stopped" || status == "" {
|
||||
_ = dc.RemoveContainer(ctx, c.ID, true)
|
||||
}
|
||||
@@ -491,34 +488,11 @@ func triggerServiceWake(c *gin.Context) (int, gin.H) {
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, gin.H{"error": "invalid service id"}
|
||||
}
|
||||
var service Service
|
||||
err = db.QueryRow(
|
||||
`SELECT s.id, s.project_id, s.name,
|
||||
COALESCE(s.type, s.service_type, ''), COALESCE(s.status, ''),
|
||||
COALESCE(s.image, s.image_name, ''), COALESCE(s.command, s.start_command, ''),
|
||||
COALESCE(s.environment, ''), COALESCE(s.git_repo, s.source_url, ''),
|
||||
COALESCE(s.git_branch, ''), COALESCE(s.build_path, ''),
|
||||
COALESCE(s.cpu, ''), COALESCE(s.memory, ''),
|
||||
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
||||
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
||||
COALESCE(s.restart_policy, 'unless-stopped'),
|
||||
COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''),
|
||||
COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''),
|
||||
COALESCE(s.static_dir, ''),
|
||||
s.created_at, s.updated_at
|
||||
FROM services s WHERE s.id = $1`, id,
|
||||
).Scan(
|
||||
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
|
||||
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
||||
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
||||
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
||||
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
|
||||
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
|
||||
&service.CreatedAt, &service.UpdatedAt,
|
||||
)
|
||||
row, err := sqlcdb.New(db.DB).GetServiceRuntimeWithOwner(context.Background(), id)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, gin.H{"error": "service not found"}
|
||||
}
|
||||
service := serviceFromRuntimeRow(row)
|
||||
if service.Status == "running" || service.Status == "deployed" {
|
||||
return http.StatusOK, gin.H{"status": service.Status}
|
||||
}
|
||||
@@ -528,13 +502,13 @@ func triggerServiceWake(c *gin.Context) (int, gin.H) {
|
||||
spec, err := serviceRuntimeSpec(db, service)
|
||||
if err != nil {
|
||||
log.Printf("wake: runtime spec for %s failed: %v", service.ID, err)
|
||||
_, _ = db.Exec(`UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`, time.Now(), service.ID)
|
||||
setServiceStatusNow(db, service.ID, "failed")
|
||||
return
|
||||
}
|
||||
state, err := engine.ReconcileService(ctx, spec)
|
||||
if err != nil {
|
||||
log.Printf("wake: reconcile for %s failed: %v", service.ID, err)
|
||||
_, _ = db.Exec(`UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`, time.Now(), service.ID)
|
||||
setServiceStatusNow(db, service.ID, "failed")
|
||||
return
|
||||
}
|
||||
status := "running"
|
||||
@@ -542,7 +516,7 @@ func triggerServiceWake(c *gin.Context) (int, gin.H) {
|
||||
status = state.Status
|
||||
persistPublishedPort(db, service.ID, state.Ports)
|
||||
}
|
||||
_, _ = db.Exec(`UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`, status, time.Now(), service.ID)
|
||||
setServiceStatusNow(db, service.ID, status)
|
||||
}()
|
||||
return http.StatusAccepted, gin.H{"status": "waking"}
|
||||
}
|
||||
@@ -563,8 +537,6 @@ func handleServiceSleep(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var port int
|
||||
_ = db.QueryRow(`SELECT COALESCE(port,0) FROM services WHERE id = $1`, service.ID).Scan(&port)
|
||||
svc := sleepCandidate{
|
||||
ID: service.ID,
|
||||
ProjectID: service.ProjectID,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: cron.sql
|
||||
|
||||
package sqlcdb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const deleteCronJob = `-- name: DeleteCronJob :exec
|
||||
DELETE FROM cron_jobs WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteCronJob(ctx context.Context, id uuid.UUID) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteCronJob, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const finishCronExecution = `-- name: FinishCronExecution :exec
|
||||
UPDATE cron_executions SET finished_at = $1, status = $2, output = $3, error = $4 WHERE id = $5
|
||||
`
|
||||
|
||||
type FinishCronExecutionParams struct {
|
||||
FinishedAt sql.NullTime `json:"finished_at"`
|
||||
Status sql.NullString `json:"status"`
|
||||
Output sql.NullString `json:"output"`
|
||||
Error sql.NullString `json:"error"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
}
|
||||
|
||||
func (q *Queries) FinishCronExecution(ctx context.Context, arg FinishCronExecutionParams) error {
|
||||
_, err := q.db.ExecContext(ctx, finishCronExecution,
|
||||
arg.FinishedAt,
|
||||
arg.Status,
|
||||
arg.Output,
|
||||
arg.Error,
|
||||
arg.ID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const getCronJob = `-- name: GetCronJob :one
|
||||
SELECT id, project_id, service_id, name, schedule, command, timezone,
|
||||
enabled, last_run_at, next_run_at, last_status, last_output,
|
||||
retention, created_at, updated_at
|
||||
FROM cron_jobs WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetCronJob(ctx context.Context, id uuid.UUID) (CronJob, error) {
|
||||
row := q.db.QueryRowContext(ctx, getCronJob, id)
|
||||
var i CronJob
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.ServiceID,
|
||||
&i.Name,
|
||||
&i.Schedule,
|
||||
&i.Command,
|
||||
&i.Timezone,
|
||||
&i.Enabled,
|
||||
&i.LastRunAt,
|
||||
&i.NextRunAt,
|
||||
&i.LastStatus,
|
||||
&i.LastOutput,
|
||||
&i.Retention,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getCronJobForTrigger = `-- name: GetCronJobForTrigger :one
|
||||
SELECT cj.service_id, cj.command, cj.schedule, cj.timezone, cj.retention, p.owner_id
|
||||
FROM cron_jobs cj
|
||||
JOIN projects p ON cj.project_id = p.id
|
||||
WHERE cj.id = $1
|
||||
`
|
||||
|
||||
type GetCronJobForTriggerRow struct {
|
||||
ServiceID uuid.UUID `json:"service_id"`
|
||||
Command string `json:"command"`
|
||||
Schedule string `json:"schedule"`
|
||||
Timezone sql.NullString `json:"timezone"`
|
||||
Retention sql.NullInt32 `json:"retention"`
|
||||
OwnerID uuid.UUID `json:"owner_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetCronJobForTrigger(ctx context.Context, id uuid.UUID) (GetCronJobForTriggerRow, error) {
|
||||
row := q.db.QueryRowContext(ctx, getCronJobForTrigger, id)
|
||||
var i GetCronJobForTriggerRow
|
||||
err := row.Scan(
|
||||
&i.ServiceID,
|
||||
&i.Command,
|
||||
&i.Schedule,
|
||||
&i.Timezone,
|
||||
&i.Retention,
|
||||
&i.OwnerID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getCronJobOwner = `-- name: GetCronJobOwner :one
|
||||
SELECT p.owner_id FROM cron_jobs cj
|
||||
JOIN projects p ON cj.project_id = p.id
|
||||
WHERE cj.id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetCronJobOwner(ctx context.Context, id uuid.UUID) (uuid.UUID, error) {
|
||||
row := q.db.QueryRowContext(ctx, getCronJobOwner, id)
|
||||
var owner_id uuid.UUID
|
||||
err := row.Scan(&owner_id)
|
||||
return owner_id, err
|
||||
}
|
||||
|
||||
const getCronJobProjectID = `-- name: GetCronJobProjectID :one
|
||||
SELECT project_id FROM cron_jobs WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetCronJobProjectID(ctx context.Context, id uuid.UUID) (uuid.UUID, error) {
|
||||
row := q.db.QueryRowContext(ctx, getCronJobProjectID, id)
|
||||
var project_id uuid.UUID
|
||||
err := row.Scan(&project_id)
|
||||
return project_id, err
|
||||
}
|
||||
|
||||
const getCronOwnerViaService = `-- name: GetCronOwnerViaService :one
|
||||
SELECT p.owner_id FROM projects p
|
||||
JOIN services s ON s.project_id = p.id
|
||||
WHERE s.id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetCronOwnerViaService(ctx context.Context, id uuid.UUID) (uuid.UUID, error) {
|
||||
row := q.db.QueryRowContext(ctx, getCronOwnerViaService, id)
|
||||
var owner_id uuid.UUID
|
||||
err := row.Scan(&owner_id)
|
||||
return owner_id, err
|
||||
}
|
||||
|
||||
const insertCronExecution = `-- name: InsertCronExecution :exec
|
||||
INSERT INTO cron_executions (id, cron_job_id, started_at, status)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
`
|
||||
|
||||
type InsertCronExecutionParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
CronJobID uuid.UUID `json:"cron_job_id"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
Status sql.NullString `json:"status"`
|
||||
}
|
||||
|
||||
func (q *Queries) InsertCronExecution(ctx context.Context, arg InsertCronExecutionParams) error {
|
||||
_, err := q.db.ExecContext(ctx, insertCronExecution,
|
||||
arg.ID,
|
||||
arg.CronJobID,
|
||||
arg.StartedAt,
|
||||
arg.Status,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const insertCronJob = `-- name: InsertCronJob :exec
|
||||
INSERT INTO cron_jobs (id, project_id, service_id, name, schedule, command, timezone, enabled, next_run_at, retention, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
`
|
||||
|
||||
type InsertCronJobParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
ServiceID uuid.UUID `json:"service_id"`
|
||||
Name string `json:"name"`
|
||||
Schedule string `json:"schedule"`
|
||||
Command string `json:"command"`
|
||||
Timezone sql.NullString `json:"timezone"`
|
||||
Enabled sql.NullBool `json:"enabled"`
|
||||
NextRunAt sql.NullTime `json:"next_run_at"`
|
||||
Retention sql.NullInt32 `json:"retention"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) InsertCronJob(ctx context.Context, arg InsertCronJobParams) error {
|
||||
_, err := q.db.ExecContext(ctx, insertCronJob,
|
||||
arg.ID,
|
||||
arg.ProjectID,
|
||||
arg.ServiceID,
|
||||
arg.Name,
|
||||
arg.Schedule,
|
||||
arg.Command,
|
||||
arg.Timezone,
|
||||
arg.Enabled,
|
||||
arg.NextRunAt,
|
||||
arg.Retention,
|
||||
arg.CreatedAt,
|
||||
arg.UpdatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const listCronExecutions = `-- name: ListCronExecutions :many
|
||||
SELECT id, cron_job_id, started_at, finished_at, status, output, error
|
||||
FROM cron_executions
|
||||
WHERE cron_job_id = $1
|
||||
ORDER BY started_at DESC
|
||||
LIMIT 100
|
||||
`
|
||||
|
||||
func (q *Queries) ListCronExecutions(ctx context.Context, cronJobID uuid.UUID) ([]CronExecution, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listCronExecutions, cronJobID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []CronExecution{}
|
||||
for rows.Next() {
|
||||
var i CronExecution
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.CronJobID,
|
||||
&i.StartedAt,
|
||||
&i.FinishedAt,
|
||||
&i.Status,
|
||||
&i.Output,
|
||||
&i.Error,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listDueCronJobs = `-- name: ListDueCronJobs :many
|
||||
SELECT id, project_id, service_id, name, schedule, command, timezone, enabled, retention
|
||||
FROM cron_jobs
|
||||
WHERE enabled = TRUE AND next_run_at IS NOT NULL AND next_run_at <= NOW()
|
||||
`
|
||||
|
||||
type ListDueCronJobsRow struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
ServiceID uuid.UUID `json:"service_id"`
|
||||
Name string `json:"name"`
|
||||
Schedule string `json:"schedule"`
|
||||
Command string `json:"command"`
|
||||
Timezone sql.NullString `json:"timezone"`
|
||||
Enabled sql.NullBool `json:"enabled"`
|
||||
Retention sql.NullInt32 `json:"retention"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListDueCronJobs(ctx context.Context) ([]ListDueCronJobsRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listDueCronJobs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ListDueCronJobsRow{}
|
||||
for rows.Next() {
|
||||
var i ListDueCronJobsRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.ServiceID,
|
||||
&i.Name,
|
||||
&i.Schedule,
|
||||
&i.Command,
|
||||
&i.Timezone,
|
||||
&i.Enabled,
|
||||
&i.Retention,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const trimCronExecutions = `-- name: TrimCronExecutions :exec
|
||||
DELETE FROM cron_executions WHERE cron_executions.cron_job_id = $1 AND cron_executions.id NOT IN (
|
||||
SELECT ce.id FROM cron_executions ce WHERE ce.cron_job_id = $1
|
||||
ORDER BY ce.started_at DESC LIMIT $2)
|
||||
`
|
||||
|
||||
type TrimCronExecutionsParams struct {
|
||||
CronJobID uuid.UUID `json:"cron_job_id"`
|
||||
Limit int32 `json:"limit"`
|
||||
}
|
||||
|
||||
func (q *Queries) TrimCronExecutions(ctx context.Context, arg TrimCronExecutionsParams) error {
|
||||
_, err := q.db.ExecContext(ctx, trimCronExecutions, arg.CronJobID, arg.Limit)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateCronJobRun = `-- name: UpdateCronJobRun :exec
|
||||
UPDATE cron_jobs SET last_run_at = $1, last_status = $2, last_output = $3, next_run_at = $4 WHERE id = $5
|
||||
`
|
||||
|
||||
type UpdateCronJobRunParams struct {
|
||||
LastRunAt sql.NullTime `json:"last_run_at"`
|
||||
LastStatus sql.NullString `json:"last_status"`
|
||||
LastOutput sql.NullString `json:"last_output"`
|
||||
NextRunAt sql.NullTime `json:"next_run_at"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateCronJobRun(ctx context.Context, arg UpdateCronJobRunParams) error {
|
||||
_, err := q.db.ExecContext(ctx, updateCronJobRun,
|
||||
arg.LastRunAt,
|
||||
arg.LastStatus,
|
||||
arg.LastOutput,
|
||||
arg.NextRunAt,
|
||||
arg.ID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: deployments.sql
|
||||
|
||||
package sqlcdb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const completeDeployment = `-- name: CompleteDeployment :exec
|
||||
UPDATE deployments
|
||||
SET status = $1, error = $2, completed_at = $3, updated_at = $3
|
||||
WHERE id = $4
|
||||
`
|
||||
|
||||
type CompleteDeploymentParams struct {
|
||||
Status sql.NullString `json:"status"`
|
||||
Error sql.NullString `json:"error"`
|
||||
CompletedAt sql.NullTime `json:"completed_at"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
}
|
||||
|
||||
func (q *Queries) CompleteDeployment(ctx context.Context, arg CompleteDeploymentParams) error {
|
||||
_, err := q.db.ExecContext(ctx, completeDeployment,
|
||||
arg.Status,
|
||||
arg.Error,
|
||||
arg.CompletedAt,
|
||||
arg.ID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const countAccessibleDeployments = `-- name: CountAccessibleDeployments :one
|
||||
SELECT COUNT(*)
|
||||
FROM deployments d
|
||||
JOIN services s ON s.id = d.service_id
|
||||
JOIN projects p ON p.id = s.project_id
|
||||
WHERE 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)
|
||||
`
|
||||
|
||||
type CountAccessibleDeploymentsParams struct {
|
||||
OwnerID uuid.UUID `json:"owner_id"`
|
||||
Column2 bool `json:"column_2"`
|
||||
}
|
||||
|
||||
func (q *Queries) CountAccessibleDeployments(ctx context.Context, arg CountAccessibleDeploymentsParams) (int64, error) {
|
||||
row := q.db.QueryRowContext(ctx, countAccessibleDeployments, arg.OwnerID, arg.Column2)
|
||||
var count int64
|
||||
err := row.Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
const countDeploymentsForService = `-- name: CountDeploymentsForService :one
|
||||
SELECT COUNT(*) FROM deployments WHERE service_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) CountDeploymentsForService(ctx context.Context, serviceID uuid.UUID) (int64, error) {
|
||||
row := q.db.QueryRowContext(ctx, countDeploymentsForService, serviceID)
|
||||
var count int64
|
||||
err := row.Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
const failDeployment = `-- name: FailDeployment :exec
|
||||
UPDATE deployments
|
||||
SET status = 'failed', error = $1, completed_at = $2, updated_at = $2
|
||||
WHERE id = $3
|
||||
`
|
||||
|
||||
type FailDeploymentParams struct {
|
||||
Error sql.NullString `json:"error"`
|
||||
CompletedAt sql.NullTime `json:"completed_at"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
}
|
||||
|
||||
func (q *Queries) FailDeployment(ctx context.Context, arg FailDeploymentParams) error {
|
||||
_, err := q.db.ExecContext(ctx, failDeployment, arg.Error, arg.CompletedAt, arg.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
const getDeploymentAccess = `-- name: GetDeploymentAccess :one
|
||||
SELECT d.status, d.service_id, p.owner_id
|
||||
FROM deployments d
|
||||
JOIN services s ON d.service_id = s.id
|
||||
JOIN projects p ON s.project_id = p.id
|
||||
WHERE d.id = $1
|
||||
`
|
||||
|
||||
type GetDeploymentAccessRow struct {
|
||||
Status sql.NullString `json:"status"`
|
||||
ServiceID uuid.UUID `json:"service_id"`
|
||||
OwnerID uuid.UUID `json:"owner_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetDeploymentAccess(ctx context.Context, id uuid.UUID) (GetDeploymentAccessRow, error) {
|
||||
row := q.db.QueryRowContext(ctx, getDeploymentAccess, id)
|
||||
var i GetDeploymentAccessRow
|
||||
err := row.Scan(&i.Status, &i.ServiceID, &i.OwnerID)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getDeploymentForRollback = `-- name: GetDeploymentForRollback :one
|
||||
SELECT d.id, d.service_id, d.commit_hash, d.status, d.image_name, d.image_tag,
|
||||
d.build_log, d.runtime_log, d.error, d.started_at, d.completed_at,
|
||||
d.created_at, d.updated_at, p.owner_id
|
||||
FROM deployments d
|
||||
JOIN services s ON d.service_id = s.id
|
||||
JOIN projects p ON s.project_id = p.id
|
||||
WHERE d.id = $1
|
||||
`
|
||||
|
||||
type GetDeploymentForRollbackRow struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ServiceID uuid.UUID `json:"service_id"`
|
||||
CommitHash sql.NullString `json:"commit_hash"`
|
||||
Status sql.NullString `json:"status"`
|
||||
ImageName sql.NullString `json:"image_name"`
|
||||
ImageTag sql.NullString `json:"image_tag"`
|
||||
BuildLog sql.NullString `json:"build_log"`
|
||||
RuntimeLog sql.NullString `json:"runtime_log"`
|
||||
Error sql.NullString `json:"error"`
|
||||
StartedAt sql.NullTime `json:"started_at"`
|
||||
CompletedAt sql.NullTime `json:"completed_at"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
OwnerID uuid.UUID `json:"owner_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetDeploymentForRollback(ctx context.Context, id uuid.UUID) (GetDeploymentForRollbackRow, error) {
|
||||
row := q.db.QueryRowContext(ctx, getDeploymentForRollback, id)
|
||||
var i GetDeploymentForRollbackRow
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ServiceID,
|
||||
&i.CommitHash,
|
||||
&i.Status,
|
||||
&i.ImageName,
|
||||
&i.ImageTag,
|
||||
&i.BuildLog,
|
||||
&i.RuntimeLog,
|
||||
&i.Error,
|
||||
&i.StartedAt,
|
||||
&i.CompletedAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.OwnerID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getDeploymentWithProject = `-- name: GetDeploymentWithProject :one
|
||||
SELECT d.id, d.service_id, d.commit_hash, d.status, d.image_name, d.image_tag,
|
||||
d.build_log, d.runtime_log, d.error, d.started_at, d.completed_at,
|
||||
d.created_at, d.updated_at, p.id AS project_id
|
||||
FROM deployments d
|
||||
JOIN services s ON d.service_id = s.id
|
||||
JOIN projects p ON s.project_id = p.id
|
||||
WHERE d.id = $1
|
||||
`
|
||||
|
||||
type GetDeploymentWithProjectRow struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ServiceID uuid.UUID `json:"service_id"`
|
||||
CommitHash sql.NullString `json:"commit_hash"`
|
||||
Status sql.NullString `json:"status"`
|
||||
ImageName sql.NullString `json:"image_name"`
|
||||
ImageTag sql.NullString `json:"image_tag"`
|
||||
BuildLog sql.NullString `json:"build_log"`
|
||||
RuntimeLog sql.NullString `json:"runtime_log"`
|
||||
Error sql.NullString `json:"error"`
|
||||
StartedAt sql.NullTime `json:"started_at"`
|
||||
CompletedAt sql.NullTime `json:"completed_at"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetDeploymentWithProject(ctx context.Context, id uuid.UUID) (GetDeploymentWithProjectRow, error) {
|
||||
row := q.db.QueryRowContext(ctx, getDeploymentWithProject, id)
|
||||
var i GetDeploymentWithProjectRow
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ServiceID,
|
||||
&i.CommitHash,
|
||||
&i.Status,
|
||||
&i.ImageName,
|
||||
&i.ImageTag,
|
||||
&i.BuildLog,
|
||||
&i.RuntimeLog,
|
||||
&i.Error,
|
||||
&i.StartedAt,
|
||||
&i.CompletedAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.ProjectID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getServiceForDeployWithOwner = `-- name: GetServiceForDeployWithOwner :one
|
||||
SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command,
|
||||
s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory,
|
||||
COALESCE(s.replicas, 1) AS replicas, COALESCE(s.port, 0) AS port,
|
||||
COALESCE(s.domain, '') AS domain, COALESCE(s.healthcheck_path, '') AS healthcheck_path,
|
||||
COALESCE(s.restart_policy, 'unless-stopped') AS restart_policy,
|
||||
COALESCE(s.builder, 'auto') AS builder, COALESCE(s.cpu_reserve, '') AS cpu_reserve,
|
||||
COALESCE(s.memory_reserve, '') AS memory_reserve, COALESCE(s.static_build_cmd, '') AS static_build_cmd,
|
||||
COALESCE(s.static_dir, '') AS static_dir,
|
||||
s.created_at, s.updated_at, p.owner_id
|
||||
FROM services s
|
||||
JOIN projects p ON s.project_id = p.id
|
||||
WHERE s.id = $1
|
||||
`
|
||||
|
||||
type GetServiceForDeployWithOwnerRow struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
Name string `json:"name"`
|
||||
Type sql.NullString `json:"type"`
|
||||
Status sql.NullString `json:"status"`
|
||||
Image sql.NullString `json:"image"`
|
||||
Command sql.NullString `json:"command"`
|
||||
Environment sql.NullString `json:"environment"`
|
||||
GitRepo sql.NullString `json:"git_repo"`
|
||||
GitBranch sql.NullString `json:"git_branch"`
|
||||
BuildPath sql.NullString `json:"build_path"`
|
||||
Cpu sql.NullString `json:"cpu"`
|
||||
Memory sql.NullString `json:"memory"`
|
||||
Replicas int32 `json:"replicas"`
|
||||
Port int32 `json:"port"`
|
||||
Domain string `json:"domain"`
|
||||
HealthcheckPath string `json:"healthcheck_path"`
|
||||
RestartPolicy string `json:"restart_policy"`
|
||||
Builder string `json:"builder"`
|
||||
CpuReserve string `json:"cpu_reserve"`
|
||||
MemoryReserve string `json:"memory_reserve"`
|
||||
StaticBuildCmd string `json:"static_build_cmd"`
|
||||
StaticDir string `json:"static_dir"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
OwnerID uuid.UUID `json:"owner_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetServiceForDeployWithOwner(ctx context.Context, id uuid.UUID) (GetServiceForDeployWithOwnerRow, error) {
|
||||
row := q.db.QueryRowContext(ctx, getServiceForDeployWithOwner, id)
|
||||
var i GetServiceForDeployWithOwnerRow
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.Name,
|
||||
&i.Type,
|
||||
&i.Status,
|
||||
&i.Image,
|
||||
&i.Command,
|
||||
&i.Environment,
|
||||
&i.GitRepo,
|
||||
&i.GitBranch,
|
||||
&i.BuildPath,
|
||||
&i.Cpu,
|
||||
&i.Memory,
|
||||
&i.Replicas,
|
||||
&i.Port,
|
||||
&i.Domain,
|
||||
&i.HealthcheckPath,
|
||||
&i.RestartPolicy,
|
||||
&i.Builder,
|
||||
&i.CpuReserve,
|
||||
&i.MemoryReserve,
|
||||
&i.StaticBuildCmd,
|
||||
&i.StaticDir,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.OwnerID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getServicePublishedPort = `-- name: GetServicePublishedPort :one
|
||||
SELECT published_port FROM services WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetServicePublishedPort(ctx context.Context, id uuid.UUID) (int32, error) {
|
||||
row := q.db.QueryRowContext(ctx, getServicePublishedPort, id)
|
||||
var published_port int32
|
||||
err := row.Scan(&published_port)
|
||||
return published_port, err
|
||||
}
|
||||
|
||||
const insertDeployment = `-- name: InsertDeployment :exec
|
||||
INSERT INTO deployments
|
||||
(id, service_id, version, commit_hash, status, image_name, image_tag, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
`
|
||||
|
||||
type InsertDeploymentParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ServiceID uuid.UUID `json:"service_id"`
|
||||
Version string `json:"version"`
|
||||
CommitHash sql.NullString `json:"commit_hash"`
|
||||
Status sql.NullString `json:"status"`
|
||||
ImageName sql.NullString `json:"image_name"`
|
||||
ImageTag sql.NullString `json:"image_tag"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) InsertDeployment(ctx context.Context, arg InsertDeploymentParams) error {
|
||||
_, err := q.db.ExecContext(ctx, insertDeployment,
|
||||
arg.ID,
|
||||
arg.ServiceID,
|
||||
arg.Version,
|
||||
arg.CommitHash,
|
||||
arg.Status,
|
||||
arg.ImageName,
|
||||
arg.ImageTag,
|
||||
arg.CreatedAt,
|
||||
arg.UpdatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const listDeploymentsForService = `-- name: ListDeploymentsForService :many
|
||||
SELECT id, service_id, commit_hash, status, image_name, image_tag,
|
||||
build_log, runtime_log, error, started_at, completed_at, created_at, updated_at
|
||||
FROM deployments
|
||||
WHERE service_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
`
|
||||
|
||||
type ListDeploymentsForServiceParams struct {
|
||||
ServiceID uuid.UUID `json:"service_id"`
|
||||
Limit int32 `json:"limit"`
|
||||
Offset int32 `json:"offset"`
|
||||
}
|
||||
|
||||
type ListDeploymentsForServiceRow struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ServiceID uuid.UUID `json:"service_id"`
|
||||
CommitHash sql.NullString `json:"commit_hash"`
|
||||
Status sql.NullString `json:"status"`
|
||||
ImageName sql.NullString `json:"image_name"`
|
||||
ImageTag sql.NullString `json:"image_tag"`
|
||||
BuildLog sql.NullString `json:"build_log"`
|
||||
RuntimeLog sql.NullString `json:"runtime_log"`
|
||||
Error sql.NullString `json:"error"`
|
||||
StartedAt sql.NullTime `json:"started_at"`
|
||||
CompletedAt sql.NullTime `json:"completed_at"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListDeploymentsForService(ctx context.Context, arg ListDeploymentsForServiceParams) ([]ListDeploymentsForServiceRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listDeploymentsForService, arg.ServiceID, arg.Limit, arg.Offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ListDeploymentsForServiceRow{}
|
||||
for rows.Next() {
|
||||
var i ListDeploymentsForServiceRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.ServiceID,
|
||||
&i.CommitHash,
|
||||
&i.Status,
|
||||
&i.ImageName,
|
||||
&i.ImageTag,
|
||||
&i.BuildLog,
|
||||
&i.RuntimeLog,
|
||||
&i.Error,
|
||||
&i.StartedAt,
|
||||
&i.CompletedAt,
|
||||
&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 listRecentAccessibleDeployments = `-- name: ListRecentAccessibleDeployments :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_name, 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 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 DESC
|
||||
LIMIT $3 OFFSET $4
|
||||
`
|
||||
|
||||
type ListRecentAccessibleDeploymentsParams struct {
|
||||
OwnerID uuid.UUID `json:"owner_id"`
|
||||
Column2 bool `json:"column_2"`
|
||||
Limit int32 `json:"limit"`
|
||||
Offset int32 `json:"offset"`
|
||||
}
|
||||
|
||||
type ListRecentAccessibleDeploymentsRow 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"`
|
||||
ImageName string `json:"image_name"`
|
||||
StartedAt sql.NullTime `json:"started_at"`
|
||||
CompletedAt sql.NullTime `json:"completed_at"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListRecentAccessibleDeployments(ctx context.Context, arg ListRecentAccessibleDeploymentsParams) ([]ListRecentAccessibleDeploymentsRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listRecentAccessibleDeployments,
|
||||
arg.OwnerID,
|
||||
arg.Column2,
|
||||
arg.Limit,
|
||||
arg.Offset,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ListRecentAccessibleDeploymentsRow{}
|
||||
for rows.Next() {
|
||||
var i ListRecentAccessibleDeploymentsRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.ServiceID,
|
||||
&i.ServiceName,
|
||||
&i.ProjectName,
|
||||
&i.Status,
|
||||
&i.ImageName,
|
||||
&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 setDeploymentStatus = `-- name: SetDeploymentStatus :exec
|
||||
UPDATE deployments SET status = $1, updated_at = $2 WHERE id = $3
|
||||
`
|
||||
|
||||
type SetDeploymentStatusParams struct {
|
||||
Status sql.NullString `json:"status"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
}
|
||||
|
||||
func (q *Queries) SetDeploymentStatus(ctx context.Context, arg SetDeploymentStatusParams) error {
|
||||
_, err := q.db.ExecContext(ctx, setDeploymentStatus, arg.Status, arg.UpdatedAt, arg.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
const setServiceStatus = `-- name: SetServiceStatus :exec
|
||||
UPDATE services SET status = $1, updated_at = $2 WHERE id = $3
|
||||
`
|
||||
|
||||
type SetServiceStatusParams struct {
|
||||
Status sql.NullString `json:"status"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
}
|
||||
|
||||
func (q *Queries) SetServiceStatus(ctx context.Context, arg SetServiceStatusParams) error {
|
||||
_, err := q.db.ExecContext(ctx, setServiceStatus, arg.Status, arg.UpdatedAt, arg.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
const syncDeploymentProgress = `-- name: SyncDeploymentProgress :exec
|
||||
UPDATE deployments
|
||||
SET status = $1,
|
||||
image_name = $2,
|
||||
image_tag = $3,
|
||||
build_log = $4,
|
||||
runtime_log = $5,
|
||||
error = $6,
|
||||
started_at = $7,
|
||||
completed_at = $8,
|
||||
updated_at = $9
|
||||
WHERE id = $10
|
||||
`
|
||||
|
||||
type SyncDeploymentProgressParams struct {
|
||||
Status sql.NullString `json:"status"`
|
||||
ImageName sql.NullString `json:"image_name"`
|
||||
ImageTag sql.NullString `json:"image_tag"`
|
||||
BuildLog sql.NullString `json:"build_log"`
|
||||
RuntimeLog sql.NullString `json:"runtime_log"`
|
||||
Error sql.NullString `json:"error"`
|
||||
StartedAt sql.NullTime `json:"started_at"`
|
||||
CompletedAt sql.NullTime `json:"completed_at"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
}
|
||||
|
||||
func (q *Queries) SyncDeploymentProgress(ctx context.Context, arg SyncDeploymentProgressParams) error {
|
||||
_, err := q.db.ExecContext(ctx, syncDeploymentProgress,
|
||||
arg.Status,
|
||||
arg.ImageName,
|
||||
arg.ImageTag,
|
||||
arg.BuildLog,
|
||||
arg.RuntimeLog,
|
||||
arg.Error,
|
||||
arg.StartedAt,
|
||||
arg.CompletedAt,
|
||||
arg.UpdatedAt,
|
||||
arg.ID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,776 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: git.sql
|
||||
|
||||
package sqlcdb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const countGitRepositories = `-- name: CountGitRepositories :one
|
||||
SELECT COUNT(*)
|
||||
FROM git_repositories
|
||||
WHERE provider_id = $1 AND user_id = $2
|
||||
AND ($3::text IS NULL OR full_name ILIKE ('%' || $3::text || '%') OR name ILIKE ('%' || $3::text || '%'))
|
||||
`
|
||||
|
||||
type CountGitRepositoriesParams struct {
|
||||
ProviderID uuid.UUID `json:"provider_id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Column3 string `json:"column_3"`
|
||||
}
|
||||
|
||||
func (q *Queries) CountGitRepositories(ctx context.Context, arg CountGitRepositoriesParams) (int64, error) {
|
||||
row := q.db.QueryRowContext(ctx, countGitRepositories, arg.ProviderID, arg.UserID, arg.Column3)
|
||||
var count int64
|
||||
err := row.Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
const countUserRepositories = `-- name: CountUserRepositories :one
|
||||
SELECT COUNT(*) FROM git_repositories WHERE user_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) CountUserRepositories(ctx context.Context, userID uuid.UUID) (int64, error) {
|
||||
row := q.db.QueryRowContext(ctx, countUserRepositories, userID)
|
||||
var count int64
|
||||
err := row.Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
const deleteGitProvider = `-- name: DeleteGitProvider :execrows
|
||||
DELETE FROM git_providers WHERE id = $1 AND user_id = $2
|
||||
`
|
||||
|
||||
type DeleteGitProviderParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) DeleteGitProvider(ctx context.Context, arg DeleteGitProviderParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, deleteGitProvider, arg.ID, arg.UserID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const getGitProviderForFetch = `-- name: GetGitProviderForFetch :one
|
||||
SELECT name, api_url, access_token
|
||||
FROM git_providers
|
||||
WHERE id = $1 AND user_id = $2
|
||||
`
|
||||
|
||||
type GetGitProviderForFetchParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
}
|
||||
|
||||
type GetGitProviderForFetchRow struct {
|
||||
Name string `json:"name"`
|
||||
ApiUrl string `json:"api_url"`
|
||||
AccessToken string `json:"access_token"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetGitProviderForFetch(ctx context.Context, arg GetGitProviderForFetchParams) (GetGitProviderForFetchRow, error) {
|
||||
row := q.db.QueryRowContext(ctx, getGitProviderForFetch, arg.ID, arg.UserID)
|
||||
var i GetGitProviderForFetchRow
|
||||
err := row.Scan(&i.Name, &i.ApiUrl, &i.AccessToken)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getGitProviderForRepos = `-- name: GetGitProviderForRepos :one
|
||||
SELECT name, display_name, access_token, api_url
|
||||
FROM git_providers
|
||||
WHERE id = $1 AND user_id = $2
|
||||
`
|
||||
|
||||
type GetGitProviderForReposParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
}
|
||||
|
||||
type GetGitProviderForReposRow struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
AccessToken string `json:"access_token"`
|
||||
ApiUrl string `json:"api_url"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetGitProviderForRepos(ctx context.Context, arg GetGitProviderForReposParams) (GetGitProviderForReposRow, error) {
|
||||
row := q.db.QueryRowContext(ctx, getGitProviderForRepos, arg.ID, arg.UserID)
|
||||
var i GetGitProviderForReposRow
|
||||
err := row.Scan(
|
||||
&i.Name,
|
||||
&i.DisplayName,
|
||||
&i.AccessToken,
|
||||
&i.ApiUrl,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getGitProviderName = `-- name: GetGitProviderName :one
|
||||
SELECT name FROM git_providers WHERE id = $1 AND user_id = $2
|
||||
`
|
||||
|
||||
type GetGitProviderNameParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetGitProviderName(ctx context.Context, arg GetGitProviderNameParams) (string, error) {
|
||||
row := q.db.QueryRowContext(ctx, getGitProviderName, arg.ID, arg.UserID)
|
||||
var name string
|
||||
err := row.Scan(&name)
|
||||
return name, err
|
||||
}
|
||||
|
||||
const getGitProviderNameByID = `-- name: GetGitProviderNameByID :one
|
||||
SELECT name FROM git_providers WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetGitProviderNameByID(ctx context.Context, id uuid.UUID) (string, error) {
|
||||
row := q.db.QueryRowContext(ctx, getGitProviderNameByID, id)
|
||||
var name string
|
||||
err := row.Scan(&name)
|
||||
return name, err
|
||||
}
|
||||
|
||||
const getGitProviderTimestamps = `-- name: GetGitProviderTimestamps :one
|
||||
SELECT created_at, updated_at FROM git_providers WHERE id = $1
|
||||
`
|
||||
|
||||
type GetGitProviderTimestampsRow struct {
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetGitProviderTimestamps(ctx context.Context, id uuid.UUID) (GetGitProviderTimestampsRow, error) {
|
||||
row := q.db.QueryRowContext(ctx, getGitProviderTimestamps, id)
|
||||
var i GetGitProviderTimestampsRow
|
||||
err := row.Scan(&i.CreatedAt, &i.UpdatedAt)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getGitRepoCloneByFullName = `-- name: GetGitRepoCloneByFullName :one
|
||||
SELECT clone_url, provider_id FROM git_repositories
|
||||
WHERE full_name = $1 AND user_id = $2 LIMIT 1
|
||||
`
|
||||
|
||||
type GetGitRepoCloneByFullNameParams struct {
|
||||
FullName string `json:"full_name"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
}
|
||||
|
||||
type GetGitRepoCloneByFullNameRow struct {
|
||||
CloneUrl string `json:"clone_url"`
|
||||
ProviderID uuid.UUID `json:"provider_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetGitRepoCloneByFullName(ctx context.Context, arg GetGitRepoCloneByFullNameParams) (GetGitRepoCloneByFullNameRow, error) {
|
||||
row := q.db.QueryRowContext(ctx, getGitRepoCloneByFullName, arg.FullName, arg.UserID)
|
||||
var i GetGitRepoCloneByFullNameRow
|
||||
err := row.Scan(&i.CloneUrl, &i.ProviderID)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getGitRepoForPush = `-- name: GetGitRepoForPush :one
|
||||
SELECT clone_url, full_name, user_id FROM git_repositories WHERE id = $1
|
||||
`
|
||||
|
||||
type GetGitRepoForPushRow struct {
|
||||
CloneUrl string `json:"clone_url"`
|
||||
FullName string `json:"full_name"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetGitRepoForPush(ctx context.Context, id uuid.UUID) (GetGitRepoForPushRow, error) {
|
||||
row := q.db.QueryRowContext(ctx, getGitRepoForPush, id)
|
||||
var i GetGitRepoForPushRow
|
||||
err := row.Scan(&i.CloneUrl, &i.FullName, &i.UserID)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getGitRepoProviderByCloneURL = `-- name: GetGitRepoProviderByCloneURL :one
|
||||
SELECT provider_id FROM git_repositories
|
||||
WHERE clone_url = $1 AND user_id = $2 LIMIT 1
|
||||
`
|
||||
|
||||
type GetGitRepoProviderByCloneURLParams struct {
|
||||
CloneUrl string `json:"clone_url"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetGitRepoProviderByCloneURL(ctx context.Context, arg GetGitRepoProviderByCloneURLParams) (uuid.UUID, error) {
|
||||
row := q.db.QueryRowContext(ctx, getGitRepoProviderByCloneURL, arg.CloneUrl, arg.UserID)
|
||||
var provider_id uuid.UUID
|
||||
err := row.Scan(&provider_id)
|
||||
return provider_id, err
|
||||
}
|
||||
|
||||
const getGitRepositoryByFullName = `-- name: GetGitRepositoryByFullName :one
|
||||
SELECT id, provider_id, name, full_name, COALESCE(description, '') AS description,
|
||||
clone_url, COALESCE(webhook_url, '') AS webhook_url,
|
||||
default_branch, is_private, user_id, created_at, updated_at
|
||||
FROM git_repositories
|
||||
WHERE provider_id = $1 AND full_name = $2 AND user_id = $3
|
||||
`
|
||||
|
||||
type GetGitRepositoryByFullNameParams struct {
|
||||
ProviderID uuid.UUID `json:"provider_id"`
|
||||
FullName string `json:"full_name"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
}
|
||||
|
||||
type GetGitRepositoryByFullNameRow struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProviderID uuid.UUID `json:"provider_id"`
|
||||
Name string `json:"name"`
|
||||
FullName string `json:"full_name"`
|
||||
Description string `json:"description"`
|
||||
CloneUrl string `json:"clone_url"`
|
||||
WebhookUrl string `json:"webhook_url"`
|
||||
DefaultBranch sql.NullString `json:"default_branch"`
|
||||
IsPrivate sql.NullBool `json:"is_private"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetGitRepositoryByFullName(ctx context.Context, arg GetGitRepositoryByFullNameParams) (GetGitRepositoryByFullNameRow, error) {
|
||||
row := q.db.QueryRowContext(ctx, getGitRepositoryByFullName, arg.ProviderID, arg.FullName, arg.UserID)
|
||||
var i GetGitRepositoryByFullNameRow
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ProviderID,
|
||||
&i.Name,
|
||||
&i.FullName,
|
||||
&i.Description,
|
||||
&i.CloneUrl,
|
||||
&i.WebhookUrl,
|
||||
&i.DefaultBranch,
|
||||
&i.IsPrivate,
|
||||
&i.UserID,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getGitRepositoryProviderID = `-- name: GetGitRepositoryProviderID :one
|
||||
SELECT provider_id FROM git_repositories WHERE id = $1 AND user_id = $2
|
||||
`
|
||||
|
||||
type GetGitRepositoryProviderIDParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetGitRepositoryProviderID(ctx context.Context, arg GetGitRepositoryProviderIDParams) (uuid.UUID, error) {
|
||||
row := q.db.QueryRowContext(ctx, getGitRepositoryProviderID, arg.ID, arg.UserID)
|
||||
var provider_id uuid.UUID
|
||||
err := row.Scan(&provider_id)
|
||||
return provider_id, err
|
||||
}
|
||||
|
||||
const getGitWebhookForPush = `-- name: GetGitWebhookForPush :one
|
||||
SELECT webhook_secret, repo_id, provider_id, COALESCE(branch_filter, '') AS branch_filter, active
|
||||
FROM git_webhooks WHERE id = $1
|
||||
`
|
||||
|
||||
type GetGitWebhookForPushRow struct {
|
||||
WebhookSecret string `json:"webhook_secret"`
|
||||
RepoID uuid.UUID `json:"repo_id"`
|
||||
ProviderID uuid.UUID `json:"provider_id"`
|
||||
BranchFilter string `json:"branch_filter"`
|
||||
Active sql.NullBool `json:"active"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetGitWebhookForPush(ctx context.Context, id uuid.UUID) (GetGitWebhookForPushRow, error) {
|
||||
row := q.db.QueryRowContext(ctx, getGitWebhookForPush, id)
|
||||
var i GetGitWebhookForPushRow
|
||||
err := row.Scan(
|
||||
&i.WebhookSecret,
|
||||
&i.RepoID,
|
||||
&i.ProviderID,
|
||||
&i.BranchFilter,
|
||||
&i.Active,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const insertGitProvider = `-- name: InsertGitProvider :exec
|
||||
INSERT INTO git_providers (id, name, display_name, api_url, webhook_url, access_token, user_id, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
|
||||
`
|
||||
|
||||
type InsertGitProviderParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
ApiUrl string `json:"api_url"`
|
||||
WebhookUrl string `json:"webhook_url"`
|
||||
AccessToken string `json:"access_token"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) InsertGitProvider(ctx context.Context, arg InsertGitProviderParams) error {
|
||||
_, err := q.db.ExecContext(ctx, insertGitProvider,
|
||||
arg.ID,
|
||||
arg.Name,
|
||||
arg.DisplayName,
|
||||
arg.ApiUrl,
|
||||
arg.WebhookUrl,
|
||||
arg.AccessToken,
|
||||
arg.UserID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const insertGitRepository = `-- name: InsertGitRepository :one
|
||||
INSERT INTO git_repositories (id, provider_id, name, full_name, description, clone_url, default_branch, is_private, user_id, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW())
|
||||
RETURNING created_at, updated_at
|
||||
`
|
||||
|
||||
type InsertGitRepositoryParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProviderID uuid.UUID `json:"provider_id"`
|
||||
Name string `json:"name"`
|
||||
FullName string `json:"full_name"`
|
||||
Description sql.NullString `json:"description"`
|
||||
CloneUrl string `json:"clone_url"`
|
||||
DefaultBranch sql.NullString `json:"default_branch"`
|
||||
IsPrivate sql.NullBool `json:"is_private"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
}
|
||||
|
||||
type InsertGitRepositoryRow struct {
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) InsertGitRepository(ctx context.Context, arg InsertGitRepositoryParams) (InsertGitRepositoryRow, error) {
|
||||
row := q.db.QueryRowContext(ctx, insertGitRepository,
|
||||
arg.ID,
|
||||
arg.ProviderID,
|
||||
arg.Name,
|
||||
arg.FullName,
|
||||
arg.Description,
|
||||
arg.CloneUrl,
|
||||
arg.DefaultBranch,
|
||||
arg.IsPrivate,
|
||||
arg.UserID,
|
||||
)
|
||||
var i InsertGitRepositoryRow
|
||||
err := row.Scan(&i.CreatedAt, &i.UpdatedAt)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listConnectedRepositories = `-- name: ListConnectedRepositories :many
|
||||
SELECT r.id, r.provider_id, r.name, r.full_name, COALESCE(r.description, '') AS description, r.clone_url,
|
||||
r.default_branch, r.is_private, r.user_id, r.created_at, r.updated_at,
|
||||
p.name AS provider_name, p.display_name
|
||||
FROM git_repositories r
|
||||
JOIN git_providers p ON r.provider_id = p.id
|
||||
WHERE r.user_id = $1
|
||||
ORDER BY r.updated_at DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
`
|
||||
|
||||
type ListConnectedRepositoriesParams struct {
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Limit int32 `json:"limit"`
|
||||
Offset int32 `json:"offset"`
|
||||
}
|
||||
|
||||
type ListConnectedRepositoriesRow struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProviderID uuid.UUID `json:"provider_id"`
|
||||
Name string `json:"name"`
|
||||
FullName string `json:"full_name"`
|
||||
Description string `json:"description"`
|
||||
CloneUrl string `json:"clone_url"`
|
||||
DefaultBranch sql.NullString `json:"default_branch"`
|
||||
IsPrivate sql.NullBool `json:"is_private"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
ProviderName string `json:"provider_name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListConnectedRepositories(ctx context.Context, arg ListConnectedRepositoriesParams) ([]ListConnectedRepositoriesRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listConnectedRepositories, arg.UserID, arg.Limit, arg.Offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ListConnectedRepositoriesRow{}
|
||||
for rows.Next() {
|
||||
var i ListConnectedRepositoriesRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.ProviderID,
|
||||
&i.Name,
|
||||
&i.FullName,
|
||||
&i.Description,
|
||||
&i.CloneUrl,
|
||||
&i.DefaultBranch,
|
||||
&i.IsPrivate,
|
||||
&i.UserID,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.ProviderName,
|
||||
&i.DisplayName,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listGitProviders = `-- name: ListGitProviders :many
|
||||
SELECT id, name, display_name, api_url, webhook_url, user_id, created_at, updated_at
|
||||
FROM git_providers
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
type ListGitProvidersRow struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
ApiUrl string `json:"api_url"`
|
||||
WebhookUrl string `json:"webhook_url"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListGitProviders(ctx context.Context, userID uuid.UUID) ([]ListGitProvidersRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listGitProviders, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ListGitProvidersRow{}
|
||||
for rows.Next() {
|
||||
var i ListGitProvidersRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.DisplayName,
|
||||
&i.ApiUrl,
|
||||
&i.WebhookUrl,
|
||||
&i.UserID,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listGitRepositories = `-- name: ListGitRepositories :many
|
||||
SELECT id, provider_id, name, full_name, COALESCE(description, '') AS description,
|
||||
clone_url, COALESCE(webhook_url, '') AS webhook_url,
|
||||
default_branch, is_private, user_id, created_at, updated_at
|
||||
FROM git_repositories
|
||||
WHERE provider_id = $1 AND user_id = $2
|
||||
AND ($3::text IS NULL OR full_name ILIKE ('%' || $3::text || '%') OR name ILIKE ('%' || $3::text || '%'))
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT $4 OFFSET $5
|
||||
`
|
||||
|
||||
type ListGitRepositoriesParams struct {
|
||||
ProviderID uuid.UUID `json:"provider_id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Column3 string `json:"column_3"`
|
||||
Limit int32 `json:"limit"`
|
||||
Offset int32 `json:"offset"`
|
||||
}
|
||||
|
||||
type ListGitRepositoriesRow struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProviderID uuid.UUID `json:"provider_id"`
|
||||
Name string `json:"name"`
|
||||
FullName string `json:"full_name"`
|
||||
Description string `json:"description"`
|
||||
CloneUrl string `json:"clone_url"`
|
||||
WebhookUrl string `json:"webhook_url"`
|
||||
DefaultBranch sql.NullString `json:"default_branch"`
|
||||
IsPrivate sql.NullBool `json:"is_private"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListGitRepositories(ctx context.Context, arg ListGitRepositoriesParams) ([]ListGitRepositoriesRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listGitRepositories,
|
||||
arg.ProviderID,
|
||||
arg.UserID,
|
||||
arg.Column3,
|
||||
arg.Limit,
|
||||
arg.Offset,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ListGitRepositoriesRow{}
|
||||
for rows.Next() {
|
||||
var i ListGitRepositoriesRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.ProviderID,
|
||||
&i.Name,
|
||||
&i.FullName,
|
||||
&i.Description,
|
||||
&i.CloneUrl,
|
||||
&i.WebhookUrl,
|
||||
&i.DefaultBranch,
|
||||
&i.IsPrivate,
|
||||
&i.UserID,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listServicesForPush = `-- name: ListServicesForPush :many
|
||||
SELECT s.id, s.project_id, s.name,
|
||||
COALESCE(s.type, s.service_type, '') AS type, COALESCE(s.status, '') AS status,
|
||||
COALESCE(s.image, s.image_name, '') AS image, COALESCE(s.command, s.start_command, '') AS command,
|
||||
COALESCE(s.environment, '') AS environment, COALESCE(s.git_repo, s.source_url, '') AS git_repo,
|
||||
COALESCE(s.git_branch, '') AS git_branch, COALESCE(s.build_path, '') AS build_path,
|
||||
COALESCE(s.cpu, '') AS cpu, COALESCE(s.memory, '') AS memory,
|
||||
s.created_at, s.updated_at
|
||||
FROM services s
|
||||
WHERE (s.git_branch = $1 OR $1 = '') AND (s.git_repo = $2 OR s.git_repo = $3)
|
||||
`
|
||||
|
||||
type ListServicesForPushParams struct {
|
||||
GitBranch sql.NullString `json:"git_branch"`
|
||||
GitRepo sql.NullString `json:"git_repo"`
|
||||
GitRepo_2 sql.NullString `json:"git_repo_2"`
|
||||
}
|
||||
|
||||
type ListServicesForPushRow struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
Image string `json:"image"`
|
||||
Command string `json:"command"`
|
||||
Environment string `json:"environment"`
|
||||
GitRepo string `json:"git_repo"`
|
||||
GitBranch string `json:"git_branch"`
|
||||
BuildPath string `json:"build_path"`
|
||||
Cpu string `json:"cpu"`
|
||||
Memory string `json:"memory"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListServicesForPush(ctx context.Context, arg ListServicesForPushParams) ([]ListServicesForPushRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listServicesForPush, arg.GitBranch, arg.GitRepo, arg.GitRepo_2)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ListServicesForPushRow{}
|
||||
for rows.Next() {
|
||||
var i ListServicesForPushRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.Name,
|
||||
&i.Type,
|
||||
&i.Status,
|
||||
&i.Image,
|
||||
&i.Command,
|
||||
&i.Environment,
|
||||
&i.GitRepo,
|
||||
&i.GitBranch,
|
||||
&i.BuildPath,
|
||||
&i.Cpu,
|
||||
&i.Memory,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const upsertGitHubAppProvider = `-- name: UpsertGitHubAppProvider :one
|
||||
INSERT INTO git_providers (id, name, display_name, api_url, webhook_url, access_token, user_id, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
|
||||
ON CONFLICT (name, user_id)
|
||||
DO UPDATE SET
|
||||
display_name = EXCLUDED.display_name,
|
||||
api_url = EXCLUDED.api_url,
|
||||
webhook_url = EXCLUDED.webhook_url,
|
||||
access_token = EXCLUDED.access_token,
|
||||
updated_at = NOW()
|
||||
RETURNING id, created_at, updated_at
|
||||
`
|
||||
|
||||
type UpsertGitHubAppProviderParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
ApiUrl string `json:"api_url"`
|
||||
WebhookUrl string `json:"webhook_url"`
|
||||
AccessToken string `json:"access_token"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
}
|
||||
|
||||
type UpsertGitHubAppProviderRow struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpsertGitHubAppProvider(ctx context.Context, arg UpsertGitHubAppProviderParams) (UpsertGitHubAppProviderRow, error) {
|
||||
row := q.db.QueryRowContext(ctx, upsertGitHubAppProvider,
|
||||
arg.ID,
|
||||
arg.Name,
|
||||
arg.DisplayName,
|
||||
arg.ApiUrl,
|
||||
arg.WebhookUrl,
|
||||
arg.AccessToken,
|
||||
arg.UserID,
|
||||
)
|
||||
var i UpsertGitHubAppProviderRow
|
||||
err := row.Scan(&i.ID, &i.CreatedAt, &i.UpdatedAt)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const upsertGitRepository = `-- name: UpsertGitRepository :exec
|
||||
INSERT INTO git_repositories (id, provider_id, name, full_name, description, clone_url, default_branch, is_private, user_id, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW())
|
||||
ON CONFLICT (provider_id, full_name)
|
||||
DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
description = EXCLUDED.description,
|
||||
clone_url = EXCLUDED.clone_url,
|
||||
default_branch = EXCLUDED.default_branch,
|
||||
is_private = EXCLUDED.is_private,
|
||||
updated_at = NOW()
|
||||
`
|
||||
|
||||
type UpsertGitRepositoryParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProviderID uuid.UUID `json:"provider_id"`
|
||||
Name string `json:"name"`
|
||||
FullName string `json:"full_name"`
|
||||
Description sql.NullString `json:"description"`
|
||||
CloneUrl string `json:"clone_url"`
|
||||
DefaultBranch sql.NullString `json:"default_branch"`
|
||||
IsPrivate sql.NullBool `json:"is_private"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpsertGitRepository(ctx context.Context, arg UpsertGitRepositoryParams) error {
|
||||
_, err := q.db.ExecContext(ctx, upsertGitRepository,
|
||||
arg.ID,
|
||||
arg.ProviderID,
|
||||
arg.Name,
|
||||
arg.FullName,
|
||||
arg.Description,
|
||||
arg.CloneUrl,
|
||||
arg.DefaultBranch,
|
||||
arg.IsPrivate,
|
||||
arg.UserID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const upsertGitWebhook = `-- name: UpsertGitWebhook :one
|
||||
INSERT INTO git_webhooks (id, repo_id, provider_id, events, webhook_secret, active, branch_filter, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, TRUE, $6, NOW(), NOW())
|
||||
ON CONFLICT (repo_id, provider_id)
|
||||
DO UPDATE SET
|
||||
events = EXCLUDED.events,
|
||||
webhook_secret = EXCLUDED.webhook_secret,
|
||||
active = TRUE,
|
||||
branch_filter = EXCLUDED.branch_filter,
|
||||
updated_at = NOW()
|
||||
RETURNING id, active, created_at, updated_at
|
||||
`
|
||||
|
||||
type UpsertGitWebhookParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
RepoID uuid.UUID `json:"repo_id"`
|
||||
ProviderID uuid.UUID `json:"provider_id"`
|
||||
Events string `json:"events"`
|
||||
WebhookSecret string `json:"webhook_secret"`
|
||||
BranchFilter sql.NullString `json:"branch_filter"`
|
||||
}
|
||||
|
||||
type UpsertGitWebhookRow struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Active sql.NullBool `json:"active"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpsertGitWebhook(ctx context.Context, arg UpsertGitWebhookParams) (UpsertGitWebhookRow, error) {
|
||||
row := q.db.QueryRowContext(ctx, upsertGitWebhook,
|
||||
arg.ID,
|
||||
arg.RepoID,
|
||||
arg.ProviderID,
|
||||
arg.Events,
|
||||
arg.WebhookSecret,
|
||||
arg.BranchFilter,
|
||||
)
|
||||
var i UpsertGitWebhookRow
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Active,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: misc.sql
|
||||
|
||||
package sqlcdb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const countProjects = `-- name: CountProjects :one
|
||||
SELECT COUNT(*) FROM projects
|
||||
`
|
||||
|
||||
func (q *Queries) CountProjects(ctx context.Context) (int64, error) {
|
||||
row := q.db.QueryRowContext(ctx, countProjects)
|
||||
var count int64
|
||||
err := row.Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
const deleteProjectVariables = `-- name: DeleteProjectVariables :exec
|
||||
DELETE FROM project_variables WHERE project_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteProjectVariables(ctx context.Context, projectID uuid.UUID) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteProjectVariables, projectID)
|
||||
return err
|
||||
}
|
||||
|
||||
const getProjectVariableValue = `-- name: GetProjectVariableValue :one
|
||||
SELECT value FROM project_variables WHERE project_id = $1 AND key = $2
|
||||
`
|
||||
|
||||
type GetProjectVariableValueParams struct {
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetProjectVariableValue(ctx context.Context, arg GetProjectVariableValueParams) (string, error) {
|
||||
row := q.db.QueryRowContext(ctx, getProjectVariableValue, arg.ProjectID, arg.Key)
|
||||
var value string
|
||||
err := row.Scan(&value)
|
||||
return value, err
|
||||
}
|
||||
|
||||
const getRegistryAuth = `-- name: GetRegistryAuth :one
|
||||
SELECT username, password FROM registries
|
||||
WHERE owner_id::text = $1 AND host = $2
|
||||
`
|
||||
|
||||
type GetRegistryAuthParams struct {
|
||||
OwnerID uuid.UUID `json:"owner_id"`
|
||||
Host string `json:"host"`
|
||||
}
|
||||
|
||||
type GetRegistryAuthRow struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetRegistryAuth(ctx context.Context, arg GetRegistryAuthParams) (GetRegistryAuthRow, error) {
|
||||
row := q.db.QueryRowContext(ctx, getRegistryAuth, arg.OwnerID, arg.Host)
|
||||
var i GetRegistryAuthRow
|
||||
err := row.Scan(&i.Username, &i.Password)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const insertEnvVar = `-- name: InsertEnvVar :exec
|
||||
INSERT INTO environment_variables (id, service_id, key, value, is_secret, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
`
|
||||
|
||||
type InsertEnvVarParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ServiceID uuid.UUID `json:"service_id"`
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
IsSecret sql.NullBool `json:"is_secret"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) InsertEnvVar(ctx context.Context, arg InsertEnvVarParams) error {
|
||||
_, err := q.db.ExecContext(ctx, insertEnvVar,
|
||||
arg.ID,
|
||||
arg.ServiceID,
|
||||
arg.Key,
|
||||
arg.Value,
|
||||
arg.IsSecret,
|
||||
arg.CreatedAt,
|
||||
arg.UpdatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const insertProjectVariable = `-- name: InsertProjectVariable :exec
|
||||
INSERT INTO project_variables (id, project_id, key, value, is_secret, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
`
|
||||
|
||||
type InsertProjectVariableParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
IsSecret bool `json:"is_secret"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) InsertProjectVariable(ctx context.Context, arg InsertProjectVariableParams) error {
|
||||
_, err := q.db.ExecContext(ctx, insertProjectVariable,
|
||||
arg.ID,
|
||||
arg.ProjectID,
|
||||
arg.Key,
|
||||
arg.Value,
|
||||
arg.IsSecret,
|
||||
arg.CreatedAt,
|
||||
arg.UpdatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateServiceDeployMeta = `-- name: UpdateServiceDeployMeta :exec
|
||||
UPDATE services SET port = $1, healthcheck_path = $2,
|
||||
volumes = COALESCE($3::jsonb, '[]'::jsonb),
|
||||
git_repo = NULLIF($4::text, ''), git_branch = NULLIF($5::text, ''), builder = $6
|
||||
WHERE id = $7
|
||||
`
|
||||
|
||||
type UpdateServiceDeployMetaParams struct {
|
||||
Port int32 `json:"port"`
|
||||
HealthcheckPath string `json:"healthcheck_path"`
|
||||
Column3 json.RawMessage `json:"column_3"`
|
||||
Column4 string `json:"column_4"`
|
||||
Column5 string `json:"column_5"`
|
||||
Builder string `json:"builder"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateServiceDeployMeta(ctx context.Context, arg UpdateServiceDeployMetaParams) error {
|
||||
_, err := q.db.ExecContext(ctx, updateServiceDeployMeta,
|
||||
arg.Port,
|
||||
arg.HealthcheckPath,
|
||||
arg.Column3,
|
||||
arg.Column4,
|
||||
arg.Column5,
|
||||
arg.Builder,
|
||||
arg.ID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateServiceRuntimeMeta = `-- name: UpdateServiceRuntimeMeta :exec
|
||||
UPDATE services SET port = $1, healthcheck_path = $2 WHERE id = $3
|
||||
`
|
||||
|
||||
type UpdateServiceRuntimeMetaParams struct {
|
||||
Port int32 `json:"port"`
|
||||
HealthcheckPath string `json:"healthcheck_path"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateServiceRuntimeMeta(ctx context.Context, arg UpdateServiceRuntimeMetaParams) error {
|
||||
_, err := q.db.ExecContext(ctx, updateServiceRuntimeMeta, arg.Port, arg.HealthcheckPath, arg.ID)
|
||||
return err
|
||||
}
|
||||
@@ -49,6 +49,66 @@ type AgentHeartbeat struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
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"`
|
||||
@@ -98,6 +198,34 @@ type ContainerInstance struct {
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
}
|
||||
|
||||
type CronExecution struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
CronJobID uuid.UUID `json:"cron_job_id"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
FinishedAt sql.NullTime `json:"finished_at"`
|
||||
Status sql.NullString `json:"status"`
|
||||
Output sql.NullString `json:"output"`
|
||||
Error sql.NullString `json:"error"`
|
||||
}
|
||||
|
||||
type CronJob struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
ServiceID uuid.UUID `json:"service_id"`
|
||||
Name string `json:"name"`
|
||||
Schedule string `json:"schedule"`
|
||||
Command string `json:"command"`
|
||||
Timezone sql.NullString `json:"timezone"`
|
||||
Enabled sql.NullBool `json:"enabled"`
|
||||
LastRunAt sql.NullTime `json:"last_run_at"`
|
||||
NextRunAt sql.NullTime `json:"next_run_at"`
|
||||
LastStatus sql.NullString `json:"last_status"`
|
||||
LastOutput sql.NullString `json:"last_output"`
|
||||
Retention sql.NullInt32 `json:"retention"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
}
|
||||
|
||||
type DatabaseBackup struct {
|
||||
ID string `json:"id"`
|
||||
DatabaseID string `json:"database_id"`
|
||||
@@ -172,6 +300,58 @@ type EnvironmentVariable struct {
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
}
|
||||
|
||||
type GitProvider struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
ApiUrl string `json:"api_url"`
|
||||
WebhookUrl string `json:"webhook_url"`
|
||||
AccessToken string `json:"access_token"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
}
|
||||
|
||||
type GitRepository struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProviderID uuid.UUID `json:"provider_id"`
|
||||
Name string `json:"name"`
|
||||
FullName string `json:"full_name"`
|
||||
Description sql.NullString `json:"description"`
|
||||
CloneUrl string `json:"clone_url"`
|
||||
WebhookUrl sql.NullString `json:"webhook_url"`
|
||||
DefaultBranch sql.NullString `json:"default_branch"`
|
||||
IsPrivate sql.NullBool `json:"is_private"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
}
|
||||
|
||||
type GitWebhook struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
RepoID uuid.UUID `json:"repo_id"`
|
||||
ProviderID uuid.UUID `json:"provider_id"`
|
||||
Events string `json:"events"`
|
||||
WebhookSecret string `json:"webhook_secret"`
|
||||
RemoteWebhookID sql.NullString `json:"remote_webhook_id"`
|
||||
Active sql.NullBool `json:"active"`
|
||||
BranchFilter sql.NullString `json:"branch_filter"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
}
|
||||
|
||||
type IncidentEvent struct {
|
||||
ID int32 `json:"id"`
|
||||
ServiceID uuid.NullUUID `json:"service_id"`
|
||||
ApiKeyID uuid.NullUUID `json:"api_key_id"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Severity string `json:"severity"`
|
||||
HttpStatus sql.NullInt32 `json:"http_status"`
|
||||
Count int32 `json:"count"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
}
|
||||
|
||||
type InstanceMetric struct {
|
||||
ServiceID string `json:"service_id"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
@@ -197,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"`
|
||||
@@ -318,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"`
|
||||
@@ -329,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"`
|
||||
@@ -373,6 +584,8 @@ type Service struct {
|
||||
StaticDir string `json:"static_dir"`
|
||||
NodeID sql.NullString `json:"node_id"`
|
||||
Spread bool `json:"spread"`
|
||||
SleepEnabled bool `json:"sleep_enabled"`
|
||||
SleepIdleMinutes int32 `json:"sleep_idle_minutes"`
|
||||
PlacementTags json.RawMessage `json:"placement_tags"`
|
||||
TraefikLabels json.RawMessage `json:"traefik_labels"`
|
||||
}
|
||||
@@ -430,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"`
|
||||
@@ -454,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,21 +16,34 @@ 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
|
||||
CloneServiceRow(ctx context.Context, arg CloneServiceRowParams) error
|
||||
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)
|
||||
CountDatabasesUsingBackupTarget(ctx context.Context, targetID sql.NullString) (int64, error)
|
||||
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
|
||||
@@ -54,34 +67,72 @@ 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)
|
||||
GetCronJobForTrigger(ctx context.Context, id uuid.UUID) (GetCronJobForTriggerRow, error)
|
||||
GetCronJobOwner(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
|
||||
GetCronJobProjectID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
|
||||
GetCronOwnerViaService(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
|
||||
GetDatabaseBackupByIDAndDatabaseAndUser(ctx context.Context, arg GetDatabaseBackupByIDAndDatabaseAndUserParams) (DatabaseBackup, error)
|
||||
GetDatabaseServiceByID(ctx context.Context, id string) (DatabaseService, error)
|
||||
GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetDatabaseServiceByIDAndUserParams) (DatabaseService, error)
|
||||
GetDatabaseServiceOwnerID(ctx context.Context, id string) (string, error)
|
||||
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)
|
||||
@@ -95,38 +146,98 @@ 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)
|
||||
GetServiceDomainText(ctx context.Context, id uuid.UUID) (string, error)
|
||||
GetServiceEnvironment(ctx context.Context, id uuid.UUID) (string, error)
|
||||
GetServiceForDeploy(ctx context.Context, id uuid.UUID) (GetServiceForDeployRow, error)
|
||||
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)
|
||||
ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg ListDatabaseBackupsByDatabaseAndUserParams) ([]DatabaseBackup, error)
|
||||
ListDatabaseServicesByProject(ctx context.Context, projectID uuid.NullUUID) ([]DatabaseService, error)
|
||||
ListDatabaseServicesByUser(ctx context.Context, userID string) ([]DatabaseService, error)
|
||||
ListDeploymentsForService(ctx context.Context, arg ListDeploymentsForServiceParams) ([]ListDeploymentsForServiceRow, error)
|
||||
ListDueCronJobs(ctx context.Context) ([]ListDueCronJobsRow, error)
|
||||
ListDueDatabaseBackups(ctx context.Context) ([]ListDueDatabaseBackupsRow, error)
|
||||
ListEnabledOutboundWebhooks(ctx context.Context) ([]OutboundWebhook, error)
|
||||
ListExpiredPreviewsForUser(ctx context.Context, arg ListExpiredPreviewsForUserParams) ([]ListExpiredPreviewsForUserRow, error)
|
||||
ListGitProviders(ctx context.Context, userID uuid.UUID) ([]ListGitProvidersRow, error)
|
||||
ListGitRepositories(ctx context.Context, arg ListGitRepositoriesParams) ([]ListGitRepositoriesRow, error)
|
||||
ListIncidentStatusCodes(ctx context.Context) ([]ListIncidentStatusCodesRow, error)
|
||||
ListInstanceMetrics(ctx context.Context, arg ListInstanceMetricsParams) ([]ListInstanceMetricsRow, error)
|
||||
ListNodeAgentsForService(ctx context.Context, serviceID string) ([]string, error)
|
||||
ListNodeMetrics(ctx context.Context, arg ListNodeMetricsParams) ([]ListNodeMetricsRow, error)
|
||||
ListNotificationChannelsByUser(ctx context.Context, userID uuid.UUID) ([]NotificationChannel, error)
|
||||
ListNotificationsByUser(ctx context.Context, arg ListNotificationsByUserParams) ([]Notification, error)
|
||||
@@ -135,16 +246,24 @@ type Querier interface {
|
||||
ListPendingProjects(ctx context.Context) ([]Project, error)
|
||||
ListPreviewEnvironmentsForProject(ctx context.Context, projectID uuid.UUID) ([]ListPreviewEnvironmentsForProjectRow, error)
|
||||
ListProjectEnvironments(ctx context.Context, projectID uuid.UUID) ([]ListProjectEnvironmentsRow, error)
|
||||
ListProjectServiceRefs(ctx context.Context, projectID uuid.UUID) ([]ListProjectServiceRefsRow, error)
|
||||
// Visibility model: a project is readable when it is approved for public
|
||||
// display, when the caller owns or is a member of it, or when the caller is a
|
||||
// platform admin. Anonymous callers pass user_id = uuid.Nil, is_admin = false.
|
||||
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)
|
||||
@@ -152,9 +271,15 @@ type Querier interface {
|
||||
ListServiceMetrics(ctx context.Context, arg ListServiceMetricsParams) ([]ListServiceMetricsRow, error)
|
||||
ListServiceTemplatesByCategoryForUser(ctx context.Context, arg ListServiceTemplatesByCategoryForUserParams) ([]ServiceTemplate, error)
|
||||
ListServiceTemplatesForUser(ctx context.Context, ownerID uuid.NullUUID) ([]ServiceTemplate, error)
|
||||
ListServiceVariableValues(ctx context.Context, serviceID uuid.UUID) ([]ListServiceVariableValuesRow, error)
|
||||
ListServiceVariablesWithSecret(ctx context.Context, serviceID uuid.UUID) ([]ListServiceVariablesWithSecretRow, error)
|
||||
ListServicesForPush(ctx context.Context, arg ListServicesForPushParams) ([]ListServicesForPushRow, error)
|
||||
ListSleepCandidates(ctx context.Context) ([]ListSleepCandidatesRow, error)
|
||||
ListSweepablePreviews(ctx context.Context) ([]ListSweepablePreviewsRow, error)
|
||||
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
|
||||
@@ -167,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
|
||||
@@ -183,13 +316,25 @@ type Querier interface {
|
||||
SetDatabaseServiceStatusAndConnectionByID(ctx context.Context, arg SetDatabaseServiceStatusAndConnectionByIDParams) error
|
||||
SetDatabaseServiceStatusByID(ctx context.Context, arg SetDatabaseServiceStatusByIDParams) error
|
||||
SetDatabaseServiceStatusByIDAndUser(ctx context.Context, arg SetDatabaseServiceStatusByIDAndUserParams) error
|
||||
SetDeploymentStatus(ctx context.Context, arg SetDeploymentStatusParams) error
|
||||
SetProjectApproved(ctx context.Context, arg SetProjectApprovedParams) (int64, error)
|
||||
SetServiceDomainDefault(ctx context.Context, arg SetServiceDomainDefaultParams) (int64, error)
|
||||
SetServiceLegacyDomain(ctx context.Context, arg SetServiceLegacyDomainParams) error
|
||||
SetServicePublishedPort(ctx context.Context, arg SetServicePublishedPortParams) error
|
||||
SetServiceReplicas(ctx context.Context, arg SetServiceReplicasParams) error
|
||||
SetServiceStatus(ctx context.Context, arg SetServiceStatusParams) error
|
||||
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
|
||||
TrimCronExecutions(ctx context.Context, arg TrimCronExecutionsParams) error
|
||||
UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeAgent, error)
|
||||
UpdateAgentHeartbeat(ctx context.Context, arg UpdateAgentHeartbeatParams) error
|
||||
UpdateBackupTargetByIDAndUser(ctx context.Context, arg UpdateBackupTargetByIDAndUserParams) error
|
||||
@@ -202,21 +347,36 @@ type Querier interface {
|
||||
// removed replica. $4 is the cumulative rx+tx byte counter.
|
||||
UpdateContainerStateByName(ctx context.Context, arg UpdateContainerStateByNameParams) error
|
||||
UpdateContainerStatus(ctx context.Context, arg UpdateContainerStatusParams) error
|
||||
UpdateCronJobRun(ctx context.Context, arg UpdateCronJobRunParams) error
|
||||
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,355 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: runtime.sql
|
||||
|
||||
package sqlcdb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const getLastDeployedImage = `-- name: GetLastDeployedImage :one
|
||||
SELECT (image_name || ':' || image_tag)::text AS image FROM deployments
|
||||
WHERE service_id = $1 AND status = 'deployed' AND image_name <> ''
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
`
|
||||
|
||||
func (q *Queries) GetLastDeployedImage(ctx context.Context, serviceID uuid.UUID) (string, error) {
|
||||
row := q.db.QueryRowContext(ctx, getLastDeployedImage, serviceID)
|
||||
var image string
|
||||
err := row.Scan(&image)
|
||||
return image, err
|
||||
}
|
||||
|
||||
const getServiceDomainText = `-- name: GetServiceDomainText :one
|
||||
SELECT COALESCE(domain,'') AS domain FROM services WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetServiceDomainText(ctx context.Context, id uuid.UUID) (string, error) {
|
||||
row := q.db.QueryRowContext(ctx, getServiceDomainText, id)
|
||||
var domain string
|
||||
err := row.Scan(&domain)
|
||||
return domain, err
|
||||
}
|
||||
|
||||
const getServicePort = `-- name: GetServicePort :one
|
||||
SELECT COALESCE(port, 0) AS port FROM services WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetServicePort(ctx context.Context, id uuid.UUID) (int32, error) {
|
||||
row := q.db.QueryRowContext(ctx, getServicePort, id)
|
||||
var port int32
|
||||
err := row.Scan(&port)
|
||||
return port, err
|
||||
}
|
||||
|
||||
const getServiceRuntimeWithOwner = `-- name: GetServiceRuntimeWithOwner :one
|
||||
SELECT s.id, s.project_id, s.name,
|
||||
COALESCE(s.type, s.service_type, '') AS type, COALESCE(s.status, '') AS status,
|
||||
COALESCE(s.image, s.image_name, '') AS image, COALESCE(s.command, s.start_command, '') AS command,
|
||||
COALESCE(s.environment, '') AS environment, COALESCE(s.git_repo, s.source_url, '') AS git_repo,
|
||||
COALESCE(s.git_branch, '') AS git_branch, COALESCE(s.build_path, '') AS build_path,
|
||||
COALESCE(s.cpu, '') AS cpu, COALESCE(s.memory, '') AS memory,
|
||||
COALESCE(s.replicas, 1) AS replicas, COALESCE(s.port, 0) AS port,
|
||||
COALESCE(s.domain, '') AS domain, COALESCE(s.healthcheck_path, '') AS healthcheck_path,
|
||||
COALESCE(s.restart_policy, 'unless-stopped') AS restart_policy,
|
||||
COALESCE(s.builder, 'auto') AS builder, COALESCE(s.cpu_reserve, '') AS cpu_reserve,
|
||||
COALESCE(s.memory_reserve, '') AS memory_reserve, COALESCE(s.static_build_cmd, '') AS static_build_cmd,
|
||||
COALESCE(s.static_dir, '') AS static_dir,
|
||||
s.created_at, s.updated_at, p.owner_id
|
||||
FROM services s
|
||||
JOIN projects p ON s.project_id = p.id
|
||||
WHERE s.id = $1
|
||||
`
|
||||
|
||||
type GetServiceRuntimeWithOwnerRow struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
Image string `json:"image"`
|
||||
Command string `json:"command"`
|
||||
Environment string `json:"environment"`
|
||||
GitRepo string `json:"git_repo"`
|
||||
GitBranch string `json:"git_branch"`
|
||||
BuildPath string `json:"build_path"`
|
||||
Cpu string `json:"cpu"`
|
||||
Memory string `json:"memory"`
|
||||
Replicas int32 `json:"replicas"`
|
||||
Port int32 `json:"port"`
|
||||
Domain string `json:"domain"`
|
||||
HealthcheckPath string `json:"healthcheck_path"`
|
||||
RestartPolicy string `json:"restart_policy"`
|
||||
Builder string `json:"builder"`
|
||||
CpuReserve string `json:"cpu_reserve"`
|
||||
MemoryReserve string `json:"memory_reserve"`
|
||||
StaticBuildCmd string `json:"static_build_cmd"`
|
||||
StaticDir string `json:"static_dir"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
OwnerID uuid.UUID `json:"owner_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetServiceRuntimeWithOwner(ctx context.Context, id uuid.UUID) (GetServiceRuntimeWithOwnerRow, error) {
|
||||
row := q.db.QueryRowContext(ctx, getServiceRuntimeWithOwner, id)
|
||||
var i GetServiceRuntimeWithOwnerRow
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.Name,
|
||||
&i.Type,
|
||||
&i.Status,
|
||||
&i.Image,
|
||||
&i.Command,
|
||||
&i.Environment,
|
||||
&i.GitRepo,
|
||||
&i.GitBranch,
|
||||
&i.BuildPath,
|
||||
&i.Cpu,
|
||||
&i.Memory,
|
||||
&i.Replicas,
|
||||
&i.Port,
|
||||
&i.Domain,
|
||||
&i.HealthcheckPath,
|
||||
&i.RestartPolicy,
|
||||
&i.Builder,
|
||||
&i.CpuReserve,
|
||||
&i.MemoryReserve,
|
||||
&i.StaticBuildCmd,
|
||||
&i.StaticDir,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.OwnerID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getServiceStatusText = `-- name: GetServiceStatusText :one
|
||||
SELECT COALESCE(status,'') AS status FROM services WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetServiceStatusText(ctx context.Context, id uuid.UUID) (string, error) {
|
||||
row := q.db.QueryRowContext(ctx, getServiceStatusText, id)
|
||||
var status string
|
||||
err := row.Scan(&status)
|
||||
return status, err
|
||||
}
|
||||
|
||||
const getServiceVariableValue = `-- name: GetServiceVariableValue :one
|
||||
SELECT value FROM environment_variables WHERE service_id = $1 AND key = $2
|
||||
`
|
||||
|
||||
type GetServiceVariableValueParams struct {
|
||||
ServiceID uuid.UUID `json:"service_id"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetServiceVariableValue(ctx context.Context, arg GetServiceVariableValueParams) (string, error) {
|
||||
row := q.db.QueryRowContext(ctx, getServiceVariableValue, arg.ServiceID, arg.Key)
|
||||
var value string
|
||||
err := row.Scan(&value)
|
||||
return value, err
|
||||
}
|
||||
|
||||
const listNodeAgentsForService = `-- name: ListNodeAgentsForService :many
|
||||
SELECT DISTINCT node_agent_id FROM container_instances WHERE service_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) ListNodeAgentsForService(ctx context.Context, serviceID string) ([]string, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listNodeAgentsForService, serviceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []string{}
|
||||
for rows.Next() {
|
||||
var node_agent_id string
|
||||
if err := rows.Scan(&node_agent_id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, node_agent_id)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listProjectServiceRefs = `-- name: ListProjectServiceRefs :many
|
||||
SELECT id, name FROM services WHERE project_id = $1
|
||||
`
|
||||
|
||||
type ListProjectServiceRefsRow struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListProjectServiceRefs(ctx context.Context, projectID uuid.UUID) ([]ListProjectServiceRefsRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listProjectServiceRefs, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ListProjectServiceRefsRow{}
|
||||
for rows.Next() {
|
||||
var i ListProjectServiceRefsRow
|
||||
if err := rows.Scan(&i.ID, &i.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listServiceVariableValues = `-- name: ListServiceVariableValues :many
|
||||
SELECT key, value FROM environment_variables WHERE service_id = $1
|
||||
`
|
||||
|
||||
type ListServiceVariableValuesRow struct {
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListServiceVariableValues(ctx context.Context, serviceID uuid.UUID) ([]ListServiceVariableValuesRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listServiceVariableValues, serviceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ListServiceVariableValuesRow{}
|
||||
for rows.Next() {
|
||||
var i ListServiceVariableValuesRow
|
||||
if err := rows.Scan(&i.Key, &i.Value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listServiceVariablesWithSecret = `-- name: ListServiceVariablesWithSecret :many
|
||||
SELECT key, value, COALESCE(is_secret, false) AS is_secret
|
||||
FROM environment_variables WHERE service_id = $1
|
||||
`
|
||||
|
||||
type ListServiceVariablesWithSecretRow struct {
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
IsSecret bool `json:"is_secret"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListServiceVariablesWithSecret(ctx context.Context, serviceID uuid.UUID) ([]ListServiceVariablesWithSecretRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listServiceVariablesWithSecret, serviceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ListServiceVariablesWithSecretRow{}
|
||||
for rows.Next() {
|
||||
var i ListServiceVariablesWithSecretRow
|
||||
if err := rows.Scan(&i.Key, &i.Value, &i.IsSecret); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listSleepCandidates = `-- name: ListSleepCandidates :many
|
||||
SELECT id, project_id, name, COALESCE(status,'') AS status,
|
||||
COALESCE(sleep_idle_minutes, 15) AS sleep_idle_minutes
|
||||
FROM services
|
||||
WHERE sleep_enabled AND COALESCE(status,'') IN ('running','deployed','degraded','starting')
|
||||
`
|
||||
|
||||
type ListSleepCandidatesRow struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
SleepIdleMinutes int32 `json:"sleep_idle_minutes"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListSleepCandidates(ctx context.Context) ([]ListSleepCandidatesRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listSleepCandidates)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ListSleepCandidatesRow{}
|
||||
for rows.Next() {
|
||||
var i ListSleepCandidatesRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.Name,
|
||||
&i.Status,
|
||||
&i.SleepIdleMinutes,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const setServicePublishedPort = `-- name: SetServicePublishedPort :exec
|
||||
UPDATE services SET published_port = $1 WHERE id = $2 AND published_port <> $1
|
||||
`
|
||||
|
||||
type SetServicePublishedPortParams struct {
|
||||
PublishedPort int32 `json:"published_port"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
}
|
||||
|
||||
func (q *Queries) SetServicePublishedPort(ctx context.Context, arg SetServicePublishedPortParams) error {
|
||||
_, err := q.db.ExecContext(ctx, setServicePublishedPort, arg.PublishedPort, arg.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
const setServiceReplicas = `-- name: SetServiceReplicas :exec
|
||||
UPDATE services SET replicas = $1, updated_at = $2 WHERE id = $3
|
||||
`
|
||||
|
||||
type SetServiceReplicasParams struct {
|
||||
Replicas int32 `json:"replicas"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
}
|
||||
|
||||
func (q *Queries) SetServiceReplicas(ctx context.Context, arg SetServiceReplicasParams) error {
|
||||
_, err := q.db.ExecContext(ctx, setServiceReplicas, arg.Replicas, arg.UpdatedAt, arg.ID)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: security.sql
|
||||
|
||||
package sqlcdb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const checkSecurityProjectAccess = `-- name: CheckSecurityProjectAccess :one
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM projects p
|
||||
WHERE p.id = $1
|
||||
AND (p.is_approved
|
||||
OR $3::bool
|
||||
OR p.owner_id = $2 OR EXISTS (
|
||||
SELECT 1 FROM project_members pm
|
||||
WHERE pm.project_id = p.id AND pm.user_id = $2
|
||||
))
|
||||
)
|
||||
`
|
||||
|
||||
type CheckSecurityProjectAccessParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
OwnerID uuid.UUID `json:"owner_id"`
|
||||
Column3 bool `json:"column_3"`
|
||||
}
|
||||
|
||||
func (q *Queries) CheckSecurityProjectAccess(ctx context.Context, arg CheckSecurityProjectAccessParams) (bool, error) {
|
||||
row := q.db.QueryRowContext(ctx, checkSecurityProjectAccess, arg.ID, arg.OwnerID, arg.Column3)
|
||||
var exists bool
|
||||
err := row.Scan(&exists)
|
||||
return exists, err
|
||||
}
|
||||
|
||||
const complianceFrameworkExists = `-- name: ComplianceFrameworkExists :one
|
||||
SELECT EXISTS(SELECT 1 FROM compliance_frameworks WHERE id = $1)
|
||||
`
|
||||
|
||||
func (q *Queries) ComplianceFrameworkExists(ctx context.Context, id uuid.UUID) (bool, error) {
|
||||
row := q.db.QueryRowContext(ctx, complianceFrameworkExists, id)
|
||||
var exists bool
|
||||
err := row.Scan(&exists)
|
||||
return exists, err
|
||||
}
|
||||
|
||||
const getComplianceReportProjectID = `-- name: GetComplianceReportProjectID :one
|
||||
SELECT project_id FROM compliance_reports WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetComplianceReportProjectID(ctx context.Context, id uuid.UUID) (uuid.UUID, error) {
|
||||
row := q.db.QueryRowContext(ctx, getComplianceReportProjectID, id)
|
||||
var project_id uuid.UUID
|
||||
err := row.Scan(&project_id)
|
||||
return project_id, err
|
||||
}
|
||||
|
||||
const getLatestComplianceReport = `-- name: GetLatestComplianceReport :one
|
||||
SELECT overall_status, score, assessment_date
|
||||
FROM compliance_reports
|
||||
WHERE project_id = $1
|
||||
ORDER BY assessment_date DESC
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
type GetLatestComplianceReportRow struct {
|
||||
OverallStatus string `json:"overall_status"`
|
||||
Score int32 `json:"score"`
|
||||
AssessmentDate time.Time `json:"assessment_date"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetLatestComplianceReport(ctx context.Context, projectID uuid.UUID) (GetLatestComplianceReportRow, error) {
|
||||
row := q.db.QueryRowContext(ctx, getLatestComplianceReport, projectID)
|
||||
var i GetLatestComplianceReportRow
|
||||
err := row.Scan(&i.OverallStatus, &i.Score, &i.AssessmentDate)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getLatestSecurityScan = `-- name: GetLatestSecurityScan :one
|
||||
SELECT id, COALESCE((summary->>'score')::int, 0)::int AS score, started_at AS scanned_at, status
|
||||
FROM security_scans
|
||||
WHERE project_id = $1
|
||||
ORDER BY started_at DESC
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
type GetLatestSecurityScanRow struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Score int32 `json:"score"`
|
||||
ScannedAt time.Time `json:"scanned_at"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetLatestSecurityScan(ctx context.Context, projectID uuid.UUID) (GetLatestSecurityScanRow, error) {
|
||||
row := q.db.QueryRowContext(ctx, getLatestSecurityScan, projectID)
|
||||
var i GetLatestSecurityScanRow
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Score,
|
||||
&i.ScannedAt,
|
||||
&i.Status,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getProjectOwnerText = `-- name: GetProjectOwnerText :one
|
||||
SELECT owner_id::text FROM projects WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetProjectOwnerText(ctx context.Context, id uuid.UUID) (string, error) {
|
||||
row := q.db.QueryRowContext(ctx, getProjectOwnerText, id)
|
||||
var owner_id string
|
||||
err := row.Scan(&owner_id)
|
||||
return owner_id, err
|
||||
}
|
||||
|
||||
const getSecurityScanProjectID = `-- name: GetSecurityScanProjectID :one
|
||||
SELECT project_id FROM security_scans WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetSecurityScanProjectID(ctx context.Context, id uuid.UUID) (uuid.UUID, error) {
|
||||
row := q.db.QueryRowContext(ctx, getSecurityScanProjectID, id)
|
||||
var project_id uuid.UUID
|
||||
err := row.Scan(&project_id)
|
||||
return project_id, err
|
||||
}
|
||||
|
||||
const getVulnerabilityMetrics = `-- name: GetVulnerabilityMetrics :one
|
||||
SELECT
|
||||
COUNT(*) AS total,
|
||||
COUNT(*) FILTER (WHERE severity = 'critical') AS critical,
|
||||
COUNT(*) FILTER (WHERE severity = 'high') AS high,
|
||||
COUNT(*) FILTER (WHERE severity = 'medium') AS medium,
|
||||
COUNT(*) FILTER (WHERE severity = 'low') AS low,
|
||||
COUNT(*) FILTER (WHERE status = 'open') AS open,
|
||||
COUNT(*) FILTER (WHERE status = 'resolved') AS resolved
|
||||
FROM vulnerabilities
|
||||
WHERE project_id = $1
|
||||
`
|
||||
|
||||
type GetVulnerabilityMetricsRow struct {
|
||||
Total int64 `json:"total"`
|
||||
Critical int64 `json:"critical"`
|
||||
High int64 `json:"high"`
|
||||
Medium int64 `json:"medium"`
|
||||
Low int64 `json:"low"`
|
||||
Open int64 `json:"open"`
|
||||
Resolved int64 `json:"resolved"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetVulnerabilityMetrics(ctx context.Context, projectID uuid.UUID) (GetVulnerabilityMetricsRow, error) {
|
||||
row := q.db.QueryRowContext(ctx, getVulnerabilityMetrics, projectID)
|
||||
var i GetVulnerabilityMetricsRow
|
||||
err := row.Scan(
|
||||
&i.Total,
|
||||
&i.Critical,
|
||||
&i.High,
|
||||
&i.Medium,
|
||||
&i.Low,
|
||||
&i.Open,
|
||||
&i.Resolved,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getVulnerabilityProjectID = `-- name: GetVulnerabilityProjectID :one
|
||||
SELECT project_id FROM vulnerabilities WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetVulnerabilityProjectID(ctx context.Context, id uuid.UUID) (uuid.UUID, error) {
|
||||
row := q.db.QueryRowContext(ctx, getVulnerabilityProjectID, id)
|
||||
var project_id uuid.UUID
|
||||
err := row.Scan(&project_id)
|
||||
return project_id, err
|
||||
}
|
||||
|
||||
const listComplianceFrameworks = `-- name: ListComplianceFrameworks :many
|
||||
SELECT id, name, description, version, enabled, created_at
|
||||
FROM compliance_frameworks
|
||||
WHERE enabled = true
|
||||
ORDER BY name
|
||||
`
|
||||
|
||||
type ListComplianceFrameworksRow struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description sql.NullString `json:"description"`
|
||||
Version string `json:"version"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListComplianceFrameworks(ctx context.Context) ([]ListComplianceFrameworksRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listComplianceFrameworks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ListComplianceFrameworksRow{}
|
||||
for rows.Next() {
|
||||
var i ListComplianceFrameworksRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.Description,
|
||||
&i.Version,
|
||||
&i.Enabled,
|
||||
&i.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listVulnerabilities = `-- name: ListVulnerabilities :many
|
||||
SELECT id, type, severity, title, description, service_id, status, found_at, resolved_at
|
||||
FROM vulnerabilities
|
||||
WHERE project_id = $1
|
||||
ORDER BY
|
||||
CASE severity
|
||||
WHEN 'critical' THEN 1
|
||||
WHEN 'high' THEN 2
|
||||
WHEN 'medium' THEN 3
|
||||
WHEN 'low' THEN 4
|
||||
END,
|
||||
found_at DESC
|
||||
`
|
||||
|
||||
type ListVulnerabilitiesRow struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Severity string `json:"severity"`
|
||||
Title string `json:"title"`
|
||||
Description sql.NullString `json:"description"`
|
||||
ServiceID uuid.NullUUID `json:"service_id"`
|
||||
Status string `json:"status"`
|
||||
FoundAt time.Time `json:"found_at"`
|
||||
ResolvedAt sql.NullTime `json:"resolved_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListVulnerabilities(ctx context.Context, projectID uuid.UUID) ([]ListVulnerabilitiesRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listVulnerabilities, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ListVulnerabilitiesRow{}
|
||||
for rows.Next() {
|
||||
var i ListVulnerabilitiesRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Type,
|
||||
&i.Severity,
|
||||
&i.Title,
|
||||
&i.Description,
|
||||
&i.ServiceID,
|
||||
&i.Status,
|
||||
&i.FoundAt,
|
||||
&i.ResolvedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const serviceExistsInProject = `-- name: ServiceExistsInProject :one
|
||||
SELECT EXISTS(SELECT 1 FROM services WHERE id = $1 AND project_id = $2)
|
||||
`
|
||||
|
||||
type ServiceExistsInProjectParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) ServiceExistsInProject(ctx context.Context, arg ServiceExistsInProjectParams) (bool, error) {
|
||||
row := q.db.QueryRowContext(ctx, serviceExistsInProject, arg.ID, arg.ProjectID)
|
||||
var exists bool
|
||||
err := row.Scan(&exists)
|
||||
return exists, err
|
||||
}
|
||||
|
||||
const updateVulnerabilityStatus = `-- name: UpdateVulnerabilityStatus :exec
|
||||
UPDATE vulnerabilities SET status = $1, resolved_at = $2 WHERE id = $3
|
||||
`
|
||||
|
||||
type UpdateVulnerabilityStatusParams struct {
|
||||
Status string `json:"status"`
|
||||
ResolvedAt sql.NullTime `json:"resolved_at"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateVulnerabilityStatus(ctx context.Context, arg UpdateVulnerabilityStatusParams) error {
|
||||
_, err := q.db.ExecContext(ctx, updateVulnerabilityStatus, arg.Status, arg.ResolvedAt, arg.ID)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: services_meta.sql
|
||||
|
||||
package sqlcdb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const deleteEnvVarsForService = `-- name: DeleteEnvVarsForService :exec
|
||||
DELETE FROM environment_variables WHERE service_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteEnvVarsForService(ctx context.Context, serviceID uuid.UUID) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteEnvVarsForService, serviceID)
|
||||
return err
|
||||
}
|
||||
|
||||
const failInterruptedDeployments = `-- name: FailInterruptedDeployments :exec
|
||||
UPDATE deployments SET status = 'failed', error = 'Interrupted by server restart', completed_at = NOW(), updated_at = NOW()
|
||||
WHERE status IN ('queued', 'pending', 'building', 'deploying', 'rolling_back')
|
||||
`
|
||||
|
||||
func (q *Queries) FailInterruptedDeployments(ctx context.Context) error {
|
||||
_, err := q.db.ExecContext(ctx, failInterruptedDeployments)
|
||||
return err
|
||||
}
|
||||
|
||||
const getEnvironmentProjectID = `-- name: GetEnvironmentProjectID :one
|
||||
SELECT project_id FROM environments WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetEnvironmentProjectID(ctx context.Context, id uuid.UUID) (uuid.UUID, error) {
|
||||
row := q.db.QueryRowContext(ctx, getEnvironmentProjectID, id)
|
||||
var project_id uuid.UUID
|
||||
err := row.Scan(&project_id)
|
||||
return project_id, err
|
||||
}
|
||||
|
||||
const getNodeAgentName = `-- name: GetNodeAgentName :one
|
||||
SELECT name FROM node_agents WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetNodeAgentName(ctx context.Context, id string) (string, error) {
|
||||
row := q.db.QueryRowContext(ctx, getNodeAgentName, id)
|
||||
var name string
|
||||
err := row.Scan(&name)
|
||||
return name, err
|
||||
}
|
||||
|
||||
const getProjectReadAccess = `-- name: GetProjectReadAccess :one
|
||||
SELECT is_approved, owner_id::text FROM projects WHERE id = $1
|
||||
`
|
||||
|
||||
type GetProjectReadAccessRow struct {
|
||||
IsApproved bool `json:"is_approved"`
|
||||
OwnerID string `json:"owner_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetProjectReadAccess(ctx context.Context, id uuid.UUID) (GetProjectReadAccessRow, error) {
|
||||
row := q.db.QueryRowContext(ctx, getProjectReadAccess, id)
|
||||
var i GetProjectReadAccessRow
|
||||
err := row.Scan(&i.IsApproved, &i.OwnerID)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getServiceNameProjectPort = `-- name: GetServiceNameProjectPort :one
|
||||
SELECT name, project_id::text, COALESCE(port, 0)::int AS port
|
||||
FROM services WHERE id::text = $1
|
||||
`
|
||||
|
||||
type GetServiceNameProjectPortRow struct {
|
||||
Name string `json:"name"`
|
||||
ProjectID string `json:"project_id"`
|
||||
Port int32 `json:"port"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetServiceNameProjectPort(ctx context.Context, id uuid.UUID) (GetServiceNameProjectPortRow, error) {
|
||||
row := q.db.QueryRowContext(ctx, getServiceNameProjectPort, id)
|
||||
var i GetServiceNameProjectPortRow
|
||||
err := row.Scan(&i.Name, &i.ProjectID, &i.Port)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getServiceNodeID = `-- name: GetServiceNodeID :one
|
||||
SELECT node_id FROM services WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetServiceNodeID(ctx context.Context, id uuid.UUID) (sql.NullString, error) {
|
||||
row := q.db.QueryRowContext(ctx, getServiceNodeID, id)
|
||||
var node_id sql.NullString
|
||||
err := row.Scan(&node_id)
|
||||
return node_id, err
|
||||
}
|
||||
|
||||
const getServicePlacementTags = `-- name: GetServicePlacementTags :one
|
||||
SELECT placement_tags FROM services WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetServicePlacementTags(ctx context.Context, id uuid.UUID) (json.RawMessage, error) {
|
||||
row := q.db.QueryRowContext(ctx, getServicePlacementTags, id)
|
||||
var placement_tags json.RawMessage
|
||||
err := row.Scan(&placement_tags)
|
||||
return placement_tags, err
|
||||
}
|
||||
|
||||
const getServiceProjectAndStatus = `-- name: GetServiceProjectAndStatus :one
|
||||
SELECT s.project_id, COALESCE(s.status, '') AS status
|
||||
FROM services s WHERE s.id = $1
|
||||
`
|
||||
|
||||
type GetServiceProjectAndStatusRow struct {
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetServiceProjectAndStatus(ctx context.Context, id uuid.UUID) (GetServiceProjectAndStatusRow, error) {
|
||||
row := q.db.QueryRowContext(ctx, getServiceProjectAndStatus, id)
|
||||
var i GetServiceProjectAndStatusRow
|
||||
err := row.Scan(&i.ProjectID, &i.Status)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getServiceProjectID = `-- name: GetServiceProjectID :one
|
||||
SELECT project_id FROM services WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetServiceProjectID(ctx context.Context, id uuid.UUID) (uuid.UUID, error) {
|
||||
row := q.db.QueryRowContext(ctx, getServiceProjectID, id)
|
||||
var project_id uuid.UUID
|
||||
err := row.Scan(&project_id)
|
||||
return project_id, err
|
||||
}
|
||||
|
||||
const getServiceSleep = `-- name: GetServiceSleep :one
|
||||
SELECT COALESCE(sleep_enabled, false) AS sleep_enabled, COALESCE(sleep_idle_minutes, 15) AS sleep_idle_minutes
|
||||
FROM services WHERE id = $1
|
||||
`
|
||||
|
||||
type GetServiceSleepRow struct {
|
||||
SleepEnabled bool `json:"sleep_enabled"`
|
||||
SleepIdleMinutes int32 `json:"sleep_idle_minutes"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetServiceSleep(ctx context.Context, id uuid.UUID) (GetServiceSleepRow, error) {
|
||||
row := q.db.QueryRowContext(ctx, getServiceSleep, id)
|
||||
var i GetServiceSleepRow
|
||||
err := row.Scan(&i.SleepEnabled, &i.SleepIdleMinutes)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getServiceSpread = `-- name: GetServiceSpread :one
|
||||
SELECT COALESCE(spread, false) FROM services WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetServiceSpread(ctx context.Context, id uuid.UUID) (bool, error) {
|
||||
row := q.db.QueryRowContext(ctx, getServiceSpread, id)
|
||||
var spread bool
|
||||
err := row.Scan(&spread)
|
||||
return spread, err
|
||||
}
|
||||
|
||||
const getServiceTraefikLabels = `-- name: GetServiceTraefikLabels :one
|
||||
SELECT traefik_labels FROM services WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetServiceTraefikLabels(ctx context.Context, id uuid.UUID) (json.RawMessage, error) {
|
||||
row := q.db.QueryRowContext(ctx, getServiceTraefikLabels, id)
|
||||
var traefik_labels json.RawMessage
|
||||
err := row.Scan(&traefik_labels)
|
||||
return traefik_labels, err
|
||||
}
|
||||
|
||||
const getServiceVolumes = `-- name: GetServiceVolumes :one
|
||||
SELECT volumes FROM services WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetServiceVolumes(ctx context.Context, id uuid.UUID) (json.RawMessage, error) {
|
||||
row := q.db.QueryRowContext(ctx, getServiceVolumes, id)
|
||||
var volumes json.RawMessage
|
||||
err := row.Scan(&volumes)
|
||||
return volumes, err
|
||||
}
|
||||
|
||||
const listAgentHealthRows = `-- name: ListAgentHealthRows :many
|
||||
SELECT id, COALESCE(name, hostname, id::text) AS name, COALESCE(status, '') AS status,
|
||||
COALESCE(last_heartbeat < $1, TRUE)::bool AS stale, auto_prune
|
||||
FROM node_agents
|
||||
`
|
||||
|
||||
type ListAgentHealthRowsRow struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Stale bool `json:"stale"`
|
||||
AutoPrune bool `json:"auto_prune"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListAgentHealthRows(ctx context.Context, lastHeartbeat sql.NullTime) ([]ListAgentHealthRowsRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listAgentHealthRows, lastHeartbeat)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ListAgentHealthRowsRow{}
|
||||
for rows.Next() {
|
||||
var i ListAgentHealthRowsRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.Status,
|
||||
&i.Stale,
|
||||
&i.AutoPrune,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listSecretEnvVars = `-- name: ListSecretEnvVars :many
|
||||
SELECT id, value FROM environment_variables WHERE is_secret = TRUE
|
||||
`
|
||||
|
||||
type ListSecretEnvVarsRow struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListSecretEnvVars(ctx context.Context) ([]ListSecretEnvVarsRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listSecretEnvVars)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ListSecretEnvVarsRow{}
|
||||
for rows.Next() {
|
||||
var i ListSecretEnvVarsRow
|
||||
if err := rows.Scan(&i.ID, &i.Value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const projectMemberExists = `-- name: ProjectMemberExists :one
|
||||
SELECT EXISTS(SELECT 1 FROM project_members WHERE project_id = $1 AND user_id = $2)
|
||||
`
|
||||
|
||||
type ProjectMemberExistsParams struct {
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) ProjectMemberExists(ctx context.Context, arg ProjectMemberExistsParams) (bool, error) {
|
||||
row := q.db.QueryRowContext(ctx, projectMemberExists, arg.ProjectID, arg.UserID)
|
||||
var exists bool
|
||||
err := row.Scan(&exists)
|
||||
return exists, err
|
||||
}
|
||||
|
||||
const setAgentStatus = `-- name: SetAgentStatus :exec
|
||||
UPDATE node_agents SET status = $1, updated_at = NOW() WHERE id = $2
|
||||
`
|
||||
|
||||
type SetAgentStatusParams struct {
|
||||
Status sql.NullString `json:"status"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
func (q *Queries) SetAgentStatus(ctx context.Context, arg SetAgentStatusParams) error {
|
||||
_, err := q.db.ExecContext(ctx, setAgentStatus, arg.Status, arg.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateEnvVarValue = `-- name: UpdateEnvVarValue :exec
|
||||
UPDATE environment_variables SET value = $1, updated_at = NOW() WHERE id = $2
|
||||
`
|
||||
|
||||
type UpdateEnvVarValueParams struct {
|
||||
Value string `json:"value"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateEnvVarValue(ctx context.Context, arg UpdateEnvVarValueParams) error {
|
||||
_, err := q.db.ExecContext(ctx, updateEnvVarValue, arg.Value, arg.ID)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: settings.sql
|
||||
|
||||
package sqlcdb
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const deleteAppSetting = `-- name: DeleteAppSetting :exec
|
||||
DELETE FROM app_settings WHERE key = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteAppSetting(ctx context.Context, key string) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteAppSetting, key)
|
||||
return err
|
||||
}
|
||||
|
||||
const getAppSetting = `-- name: GetAppSetting :one
|
||||
SELECT value FROM app_settings WHERE key = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetAppSetting(ctx context.Context, key string) (string, error) {
|
||||
row := q.db.QueryRowContext(ctx, getAppSetting, key)
|
||||
var value string
|
||||
err := row.Scan(&value)
|
||||
return value, err
|
||||
}
|
||||
|
||||
const getUserIsAdmin = `-- name: GetUserIsAdmin :one
|
||||
SELECT is_admin FROM users WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserIsAdmin(ctx context.Context, id uuid.UUID) (bool, error) {
|
||||
row := q.db.QueryRowContext(ctx, getUserIsAdmin, id)
|
||||
var is_admin bool
|
||||
err := row.Scan(&is_admin)
|
||||
return is_admin, err
|
||||
}
|
||||
|
||||
const listAdminUserIDs = `-- name: ListAdminUserIDs :many
|
||||
SELECT id FROM users WHERE is_admin
|
||||
`
|
||||
|
||||
func (q *Queries) ListAdminUserIDs(ctx context.Context) ([]uuid.UUID, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listAdminUserIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []uuid.UUID{}
|
||||
for rows.Next() {
|
||||
var id uuid.UUID
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, id)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const upsertAppSetting = `-- name: UpsertAppSetting :exec
|
||||
INSERT INTO app_settings (key, value, is_secret, updated_at)
|
||||
VALUES ($1, $2, $3, NOW())
|
||||
ON CONFLICT (key) DO UPDATE
|
||||
SET value = EXCLUDED.value, is_secret = EXCLUDED.is_secret, updated_at = NOW()
|
||||
`
|
||||
|
||||
type UpsertAppSettingParams struct {
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
IsSecret bool `json:"is_secret"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpsertAppSetting(ctx context.Context, arg UpsertAppSettingParams) error {
|
||||
_, err := q.db.ExecContext(ctx, upsertAppSetting, arg.Key, arg.Value, arg.IsSecret)
|
||||
return err
|
||||
}
|
||||
|
||||
const upsertAppSettingValue = `-- name: UpsertAppSettingValue :exec
|
||||
INSERT INTO app_settings (key, value, updated_at) VALUES ($1, $2, NOW())
|
||||
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()
|
||||
`
|
||||
|
||||
type UpsertAppSettingValueParams struct {
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpsertAppSettingValue(ctx context.Context, arg UpsertAppSettingValueParams) error {
|
||||
_, err := q.db.ExecContext(ctx, upsertAppSettingValue, arg.Key, arg.Value)
|
||||
return err
|
||||
}
|
||||
@@ -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());
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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');
|
||||
@@ -0,0 +1,58 @@
|
||||
-- name: GetCronJobProjectID :one
|
||||
SELECT project_id FROM cron_jobs WHERE id = $1;
|
||||
|
||||
-- name: GetCronOwnerViaService :one
|
||||
SELECT p.owner_id FROM projects p
|
||||
JOIN services s ON s.project_id = p.id
|
||||
WHERE s.id = $1;
|
||||
|
||||
-- name: InsertCronJob :exec
|
||||
INSERT INTO cron_jobs (id, project_id, service_id, name, schedule, command, timezone, enabled, next_run_at, retention, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12);
|
||||
|
||||
-- name: GetCronJob :one
|
||||
SELECT id, project_id, service_id, name, schedule, command, timezone,
|
||||
enabled, last_run_at, next_run_at, last_status, last_output,
|
||||
retention, created_at, updated_at
|
||||
FROM cron_jobs WHERE id = $1;
|
||||
|
||||
-- name: GetCronJobOwner :one
|
||||
SELECT p.owner_id FROM cron_jobs cj
|
||||
JOIN projects p ON cj.project_id = p.id
|
||||
WHERE cj.id = $1;
|
||||
|
||||
-- name: DeleteCronJob :exec
|
||||
DELETE FROM cron_jobs WHERE id = $1;
|
||||
|
||||
-- name: ListCronExecutions :many
|
||||
SELECT id, cron_job_id, started_at, finished_at, status, output, error
|
||||
FROM cron_executions
|
||||
WHERE cron_job_id = $1
|
||||
ORDER BY started_at DESC
|
||||
LIMIT 100;
|
||||
|
||||
-- name: GetCronJobForTrigger :one
|
||||
SELECT cj.service_id, cj.command, cj.schedule, cj.timezone, cj.retention, p.owner_id
|
||||
FROM cron_jobs cj
|
||||
JOIN projects p ON cj.project_id = p.id
|
||||
WHERE cj.id = $1;
|
||||
|
||||
-- name: InsertCronExecution :exec
|
||||
INSERT INTO cron_executions (id, cron_job_id, started_at, status)
|
||||
VALUES ($1, $2, $3, $4);
|
||||
|
||||
-- name: ListDueCronJobs :many
|
||||
SELECT id, project_id, service_id, name, schedule, command, timezone, enabled, retention
|
||||
FROM cron_jobs
|
||||
WHERE enabled = TRUE AND next_run_at IS NOT NULL AND next_run_at <= NOW();
|
||||
|
||||
-- name: FinishCronExecution :exec
|
||||
UPDATE cron_executions SET finished_at = $1, status = $2, output = $3, error = $4 WHERE id = $5;
|
||||
|
||||
-- name: UpdateCronJobRun :exec
|
||||
UPDATE cron_jobs SET last_run_at = $1, last_status = $2, last_output = $3, next_run_at = $4 WHERE id = $5;
|
||||
|
||||
-- name: TrimCronExecutions :exec
|
||||
DELETE FROM cron_executions WHERE cron_executions.cron_job_id = $1 AND cron_executions.id NOT IN (
|
||||
SELECT ce.id FROM cron_executions ce WHERE ce.cron_job_id = $1
|
||||
ORDER BY ce.started_at DESC LIMIT $2);
|
||||
@@ -0,0 +1,105 @@
|
||||
-- name: CountDeploymentsForService :one
|
||||
SELECT COUNT(*) FROM deployments WHERE service_id = $1;
|
||||
|
||||
-- name: ListDeploymentsForService :many
|
||||
SELECT id, service_id, commit_hash, status, image_name, image_tag,
|
||||
build_log, runtime_log, error, started_at, completed_at, created_at, updated_at
|
||||
FROM deployments
|
||||
WHERE service_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2 OFFSET $3;
|
||||
|
||||
-- name: CountAccessibleDeployments :one
|
||||
SELECT COUNT(*)
|
||||
FROM deployments d
|
||||
JOIN services s ON s.id = d.service_id
|
||||
JOIN projects p ON p.id = s.project_id
|
||||
WHERE 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);
|
||||
|
||||
-- name: ListRecentAccessibleDeployments :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_name, 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 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 DESC
|
||||
LIMIT $3 OFFSET $4;
|
||||
|
||||
-- name: GetServiceForDeployWithOwner :one
|
||||
SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command,
|
||||
s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory,
|
||||
COALESCE(s.replicas, 1) AS replicas, COALESCE(s.port, 0) AS port,
|
||||
COALESCE(s.domain, '') AS domain, COALESCE(s.healthcheck_path, '') AS healthcheck_path,
|
||||
COALESCE(s.restart_policy, 'unless-stopped') AS restart_policy,
|
||||
COALESCE(s.builder, 'auto') AS builder, COALESCE(s.cpu_reserve, '') AS cpu_reserve,
|
||||
COALESCE(s.memory_reserve, '') AS memory_reserve, COALESCE(s.static_build_cmd, '') AS static_build_cmd,
|
||||
COALESCE(s.static_dir, '') AS static_dir,
|
||||
s.created_at, s.updated_at, p.owner_id
|
||||
FROM services s
|
||||
JOIN projects p ON s.project_id = p.id
|
||||
WHERE s.id = $1;
|
||||
|
||||
-- name: InsertDeployment :exec
|
||||
INSERT INTO deployments
|
||||
(id, service_id, version, commit_hash, status, image_name, image_tag, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9);
|
||||
|
||||
-- name: FailDeployment :exec
|
||||
UPDATE deployments
|
||||
SET status = 'failed', error = $1, completed_at = $2, updated_at = $2
|
||||
WHERE id = $3;
|
||||
|
||||
-- name: CompleteDeployment :exec
|
||||
UPDATE deployments
|
||||
SET status = $1, error = $2, completed_at = $3, updated_at = $3
|
||||
WHERE id = $4;
|
||||
|
||||
-- name: SetDeploymentStatus :exec
|
||||
UPDATE deployments SET status = $1, updated_at = $2 WHERE id = $3;
|
||||
|
||||
-- name: SetServiceStatus :exec
|
||||
UPDATE services SET status = $1, updated_at = $2 WHERE id = $3;
|
||||
|
||||
-- name: SyncDeploymentProgress :exec
|
||||
UPDATE deployments
|
||||
SET status = $1,
|
||||
image_name = $2,
|
||||
image_tag = $3,
|
||||
build_log = $4,
|
||||
runtime_log = $5,
|
||||
error = $6,
|
||||
started_at = $7,
|
||||
completed_at = $8,
|
||||
updated_at = $9
|
||||
WHERE id = $10;
|
||||
|
||||
-- name: GetDeploymentAccess :one
|
||||
SELECT d.status, d.service_id, p.owner_id
|
||||
FROM deployments d
|
||||
JOIN services s ON d.service_id = s.id
|
||||
JOIN projects p ON s.project_id = p.id
|
||||
WHERE d.id = $1;
|
||||
|
||||
-- name: GetDeploymentWithProject :one
|
||||
SELECT d.id, d.service_id, d.commit_hash, d.status, d.image_name, d.image_tag,
|
||||
d.build_log, d.runtime_log, d.error, d.started_at, d.completed_at,
|
||||
d.created_at, d.updated_at, p.id AS project_id
|
||||
FROM deployments d
|
||||
JOIN services s ON d.service_id = s.id
|
||||
JOIN projects p ON s.project_id = p.id
|
||||
WHERE d.id = $1;
|
||||
|
||||
-- name: GetDeploymentForRollback :one
|
||||
SELECT d.id, d.service_id, d.commit_hash, d.status, d.image_name, d.image_tag,
|
||||
d.build_log, d.runtime_log, d.error, d.started_at, d.completed_at,
|
||||
d.created_at, d.updated_at, p.owner_id
|
||||
FROM deployments d
|
||||
JOIN services s ON d.service_id = s.id
|
||||
JOIN projects p ON s.project_id = p.id
|
||||
WHERE d.id = $1;
|
||||
|
||||
-- name: GetServicePublishedPort :one
|
||||
SELECT published_port FROM services WHERE id = $1;
|
||||
@@ -0,0 +1,137 @@
|
||||
-- name: ListGitProviders :many
|
||||
SELECT id, name, display_name, api_url, webhook_url, user_id, created_at, updated_at
|
||||
FROM git_providers
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC;
|
||||
|
||||
-- name: UpsertGitHubAppProvider :one
|
||||
INSERT INTO git_providers (id, name, display_name, api_url, webhook_url, access_token, user_id, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
|
||||
ON CONFLICT (name, user_id)
|
||||
DO UPDATE SET
|
||||
display_name = EXCLUDED.display_name,
|
||||
api_url = EXCLUDED.api_url,
|
||||
webhook_url = EXCLUDED.webhook_url,
|
||||
access_token = EXCLUDED.access_token,
|
||||
updated_at = NOW()
|
||||
RETURNING id, created_at, updated_at;
|
||||
|
||||
-- name: InsertGitProvider :exec
|
||||
INSERT INTO git_providers (id, name, display_name, api_url, webhook_url, access_token, user_id, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW());
|
||||
|
||||
-- name: GetGitProviderTimestamps :one
|
||||
SELECT created_at, updated_at FROM git_providers WHERE id = $1;
|
||||
|
||||
-- name: GetGitProviderForRepos :one
|
||||
SELECT name, display_name, access_token, api_url
|
||||
FROM git_providers
|
||||
WHERE id = $1 AND user_id = $2;
|
||||
|
||||
-- name: GetGitProviderName :one
|
||||
SELECT name FROM git_providers WHERE id = $1 AND user_id = $2;
|
||||
|
||||
-- name: GetGitProviderForFetch :one
|
||||
SELECT name, api_url, access_token
|
||||
FROM git_providers
|
||||
WHERE id = $1 AND user_id = $2;
|
||||
|
||||
-- name: DeleteGitProvider :execrows
|
||||
DELETE FROM git_providers WHERE id = $1 AND user_id = $2;
|
||||
|
||||
-- name: ListGitRepositories :many
|
||||
SELECT id, provider_id, name, full_name, COALESCE(description, '') AS description,
|
||||
clone_url, COALESCE(webhook_url, '') AS webhook_url,
|
||||
default_branch, is_private, user_id, created_at, updated_at
|
||||
FROM git_repositories
|
||||
WHERE provider_id = $1 AND user_id = $2
|
||||
AND ($3::text IS NULL OR full_name ILIKE ('%' || $3::text || '%') OR name ILIKE ('%' || $3::text || '%'))
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT $4 OFFSET $5;
|
||||
|
||||
-- name: CountGitRepositories :one
|
||||
SELECT COUNT(*)
|
||||
FROM git_repositories
|
||||
WHERE provider_id = $1 AND user_id = $2
|
||||
AND ($3::text IS NULL OR full_name ILIKE ('%' || $3::text || '%') OR name ILIKE ('%' || $3::text || '%'));
|
||||
|
||||
-- name: GetGitRepositoryByFullName :one
|
||||
SELECT id, provider_id, name, full_name, COALESCE(description, '') AS description,
|
||||
clone_url, COALESCE(webhook_url, '') AS webhook_url,
|
||||
default_branch, is_private, user_id, created_at, updated_at
|
||||
FROM git_repositories
|
||||
WHERE provider_id = $1 AND full_name = $2 AND user_id = $3;
|
||||
|
||||
-- name: InsertGitRepository :one
|
||||
INSERT INTO git_repositories (id, provider_id, name, full_name, description, clone_url, default_branch, is_private, user_id, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW())
|
||||
RETURNING created_at, updated_at;
|
||||
|
||||
-- name: UpsertGitRepository :exec
|
||||
INSERT INTO git_repositories (id, provider_id, name, full_name, description, clone_url, default_branch, is_private, user_id, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW())
|
||||
ON CONFLICT (provider_id, full_name)
|
||||
DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
description = EXCLUDED.description,
|
||||
clone_url = EXCLUDED.clone_url,
|
||||
default_branch = EXCLUDED.default_branch,
|
||||
is_private = EXCLUDED.is_private,
|
||||
updated_at = NOW();
|
||||
|
||||
-- name: GetGitRepositoryProviderID :one
|
||||
SELECT provider_id FROM git_repositories WHERE id = $1 AND user_id = $2;
|
||||
|
||||
-- name: UpsertGitWebhook :one
|
||||
INSERT INTO git_webhooks (id, repo_id, provider_id, events, webhook_secret, active, branch_filter, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, TRUE, $6, NOW(), NOW())
|
||||
ON CONFLICT (repo_id, provider_id)
|
||||
DO UPDATE SET
|
||||
events = EXCLUDED.events,
|
||||
webhook_secret = EXCLUDED.webhook_secret,
|
||||
active = TRUE,
|
||||
branch_filter = EXCLUDED.branch_filter,
|
||||
updated_at = NOW()
|
||||
RETURNING id, active, created_at, updated_at;
|
||||
|
||||
-- name: ListConnectedRepositories :many
|
||||
SELECT r.id, r.provider_id, r.name, r.full_name, COALESCE(r.description, '') AS description, r.clone_url,
|
||||
r.default_branch, r.is_private, r.user_id, r.created_at, r.updated_at,
|
||||
p.name AS provider_name, p.display_name
|
||||
FROM git_repositories r
|
||||
JOIN git_providers p ON r.provider_id = p.id
|
||||
WHERE r.user_id = $1
|
||||
ORDER BY r.updated_at DESC
|
||||
LIMIT $2 OFFSET $3;
|
||||
|
||||
-- name: CountUserRepositories :one
|
||||
SELECT COUNT(*) FROM git_repositories WHERE user_id = $1;
|
||||
|
||||
-- name: GetGitWebhookForPush :one
|
||||
SELECT webhook_secret, repo_id, provider_id, COALESCE(branch_filter, '') AS branch_filter, active
|
||||
FROM git_webhooks WHERE id = $1;
|
||||
|
||||
-- name: GetGitProviderNameByID :one
|
||||
SELECT name FROM git_providers WHERE id = $1;
|
||||
|
||||
-- name: GetGitRepoForPush :one
|
||||
SELECT clone_url, full_name, user_id FROM git_repositories WHERE id = $1;
|
||||
|
||||
-- name: ListServicesForPush :many
|
||||
SELECT s.id, s.project_id, s.name,
|
||||
COALESCE(s.type, s.service_type, '') AS type, COALESCE(s.status, '') AS status,
|
||||
COALESCE(s.image, s.image_name, '') AS image, COALESCE(s.command, s.start_command, '') AS command,
|
||||
COALESCE(s.environment, '') AS environment, COALESCE(s.git_repo, s.source_url, '') AS git_repo,
|
||||
COALESCE(s.git_branch, '') AS git_branch, COALESCE(s.build_path, '') AS build_path,
|
||||
COALESCE(s.cpu, '') AS cpu, COALESCE(s.memory, '') AS memory,
|
||||
s.created_at, s.updated_at
|
||||
FROM services s
|
||||
WHERE (s.git_branch = $1 OR $1 = '') AND (s.git_repo = $2 OR s.git_repo = $3);
|
||||
|
||||
-- name: GetGitRepoCloneByFullName :one
|
||||
SELECT clone_url, provider_id FROM git_repositories
|
||||
WHERE full_name = $1 AND user_id = $2 LIMIT 1;
|
||||
|
||||
-- name: GetGitRepoProviderByCloneURL :one
|
||||
SELECT provider_id FROM git_repositories
|
||||
WHERE clone_url = $1 AND user_id = $2 LIMIT 1;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,43 @@
|
||||
-- name: ListActiveOperationDeployments :many
|
||||
SELECT d.id, d.service_id, s.name AS service_name, p.name AS project_name, d.status,
|
||||
COALESCE(d.image_name, '') AS image, d.error, d.started_at, d.completed_at, d.created_at
|
||||
FROM deployments d
|
||||
JOIN services s ON s.id = d.service_id
|
||||
JOIN projects p ON p.id = s.project_id
|
||||
WHERE d.status IN ('queued','pending','building','deploying','rolling_back')
|
||||
AND (p.is_approved OR p.owner_id = $1 OR $2::bool
|
||||
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1))
|
||||
ORDER BY d.created_at ASC;
|
||||
|
||||
-- name: ListRecentFailedDeployments :many
|
||||
SELECT d.id, d.service_id, s.name AS service_name, p.name AS project_name, d.status,
|
||||
COALESCE(d.image_name, '') AS image, d.error, d.started_at, d.completed_at, d.created_at
|
||||
FROM deployments d
|
||||
JOIN services s ON s.id = d.service_id
|
||||
JOIN projects p ON p.id = s.project_id
|
||||
WHERE d.status IN ('failed','cancelled','rolled_back')
|
||||
AND d.updated_at > NOW() - INTERVAL '24 hours'
|
||||
AND (p.is_approved OR p.owner_id = $1 OR $2::bool
|
||||
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1))
|
||||
ORDER BY d.updated_at DESC
|
||||
LIMIT 20;
|
||||
|
||||
-- name: ListRecentCronRuns :many
|
||||
SELECT e.id, j.name AS job_name, j.schedule, e.status, e.started_at, e.finished_at, e.error
|
||||
FROM cron_executions e
|
||||
JOIN cron_jobs j ON j.id = e.cron_job_id
|
||||
JOIN projects p ON p.id = j.project_id
|
||||
WHERE e.started_at > NOW() - INTERVAL '24 hours'
|
||||
AND (p.is_approved OR p.owner_id = $1 OR $2::bool
|
||||
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1))
|
||||
ORDER BY e.started_at DESC
|
||||
LIMIT 30;
|
||||
|
||||
-- name: ListRecentOperationBackups :many
|
||||
SELECT b.id, b.database_id, ds.name AS db_name, b.status, b.size, b.created_at, b.completed_at
|
||||
FROM database_backups b
|
||||
JOIN database_services ds ON ds.id = b.database_id
|
||||
WHERE b.created_at > NOW() - INTERVAL '24 hours'
|
||||
AND (ds.user_id = $1 OR $2::bool)
|
||||
ORDER BY b.created_at DESC
|
||||
LIMIT 20;
|
||||
@@ -0,0 +1,59 @@
|
||||
-- name: ListNodeAgentsForService :many
|
||||
SELECT DISTINCT node_agent_id FROM container_instances WHERE service_id = $1;
|
||||
|
||||
-- name: ListServiceVariableValues :many
|
||||
SELECT key, value FROM environment_variables WHERE service_id = $1;
|
||||
|
||||
-- name: ListServiceVariablesWithSecret :many
|
||||
SELECT key, value, COALESCE(is_secret, false) AS is_secret
|
||||
FROM environment_variables WHERE service_id = $1;
|
||||
|
||||
-- name: ListProjectServiceRefs :many
|
||||
SELECT id, name FROM services WHERE project_id = $1;
|
||||
|
||||
-- name: GetServicePort :one
|
||||
SELECT COALESCE(port, 0) AS port FROM services WHERE id = $1;
|
||||
|
||||
-- name: GetServiceVariableValue :one
|
||||
SELECT value FROM environment_variables WHERE service_id = $1 AND key = $2;
|
||||
|
||||
-- name: GetServiceRuntimeWithOwner :one
|
||||
SELECT s.id, s.project_id, s.name,
|
||||
COALESCE(s.type, s.service_type, '') AS type, COALESCE(s.status, '') AS status,
|
||||
COALESCE(s.image, s.image_name, '') AS image, COALESCE(s.command, s.start_command, '') AS command,
|
||||
COALESCE(s.environment, '') AS environment, COALESCE(s.git_repo, s.source_url, '') AS git_repo,
|
||||
COALESCE(s.git_branch, '') AS git_branch, COALESCE(s.build_path, '') AS build_path,
|
||||
COALESCE(s.cpu, '') AS cpu, COALESCE(s.memory, '') AS memory,
|
||||
COALESCE(s.replicas, 1) AS replicas, COALESCE(s.port, 0) AS port,
|
||||
COALESCE(s.domain, '') AS domain, COALESCE(s.healthcheck_path, '') AS healthcheck_path,
|
||||
COALESCE(s.restart_policy, 'unless-stopped') AS restart_policy,
|
||||
COALESCE(s.builder, 'auto') AS builder, COALESCE(s.cpu_reserve, '') AS cpu_reserve,
|
||||
COALESCE(s.memory_reserve, '') AS memory_reserve, COALESCE(s.static_build_cmd, '') AS static_build_cmd,
|
||||
COALESCE(s.static_dir, '') AS static_dir,
|
||||
s.created_at, s.updated_at, p.owner_id
|
||||
FROM services s
|
||||
JOIN projects p ON s.project_id = p.id
|
||||
WHERE s.id = $1;
|
||||
|
||||
-- name: GetLastDeployedImage :one
|
||||
SELECT (image_name || ':' || image_tag)::text AS image FROM deployments
|
||||
WHERE service_id = $1 AND status = 'deployed' AND image_name <> ''
|
||||
ORDER BY created_at DESC LIMIT 1;
|
||||
|
||||
-- name: SetServicePublishedPort :exec
|
||||
UPDATE services SET published_port = $1 WHERE id = $2 AND published_port <> $1;
|
||||
|
||||
-- name: SetServiceReplicas :exec
|
||||
UPDATE services SET replicas = $1, updated_at = $2 WHERE id = $3;
|
||||
|
||||
-- name: ListSleepCandidates :many
|
||||
SELECT id, project_id, name, COALESCE(status,'') AS status,
|
||||
COALESCE(sleep_idle_minutes, 15) AS sleep_idle_minutes
|
||||
FROM services
|
||||
WHERE sleep_enabled AND COALESCE(status,'') IN ('running','deployed','degraded','starting');
|
||||
|
||||
-- name: GetServiceDomainText :one
|
||||
SELECT COALESCE(domain,'') AS domain FROM services WHERE id = $1;
|
||||
|
||||
-- name: GetServiceStatusText :one
|
||||
SELECT COALESCE(status,'') AS status FROM services WHERE id = $1;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -69,6 +69,8 @@ CREATE TABLE services (
|
||||
static_dir VARCHAR(255) NOT NULL DEFAULT '',
|
||||
node_id VARCHAR(255),
|
||||
spread BOOLEAN NOT NULL DEFAULT false,
|
||||
sleep_enabled BOOLEAN NOT NULL DEFAULT false,
|
||||
sleep_idle_minutes INTEGER NOT NULL DEFAULT 15,
|
||||
placement_tags JSONB NOT NULL DEFAULT '[]',
|
||||
traefik_labels JSONB NOT NULL DEFAULT '{}'
|
||||
);
|
||||
@@ -142,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,
|
||||
@@ -459,3 +472,241 @@ CREATE TABLE instance_metrics (
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
PRIMARY KEY (service_id, timestamp, instance_id)
|
||||
);
|
||||
|
||||
CREATE TABLE cron_jobs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
service_id UUID NOT NULL REFERENCES services(id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
schedule VARCHAR(100) NOT NULL,
|
||||
command TEXT NOT NULL,
|
||||
timezone VARCHAR(50) DEFAULT 'UTC',
|
||||
enabled BOOLEAN DEFAULT true,
|
||||
last_run_at TIMESTAMP WITH TIME ZONE,
|
||||
next_run_at TIMESTAMP WITH TIME ZONE,
|
||||
last_status VARCHAR(50),
|
||||
last_output TEXT,
|
||||
retention INTEGER DEFAULT 30,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE cron_executions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
cron_job_id UUID NOT NULL REFERENCES cron_jobs(id) ON DELETE CASCADE,
|
||||
started_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
finished_at TIMESTAMP WITH TIME ZONE,
|
||||
status VARCHAR(50) DEFAULT 'pending',
|
||||
output TEXT,
|
||||
error TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE git_providers (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
display_name VARCHAR(255) NOT NULL,
|
||||
api_url VARCHAR(255) NOT NULL,
|
||||
webhook_url VARCHAR(255) NOT NULL,
|
||||
access_token TEXT NOT NULL,
|
||||
user_id UUID NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE git_repositories (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
provider_id UUID NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
full_name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
clone_url VARCHAR(255) NOT NULL,
|
||||
webhook_url VARCHAR(255),
|
||||
default_branch VARCHAR(255) DEFAULT 'main',
|
||||
is_private BOOLEAN DEFAULT false,
|
||||
user_id UUID NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE git_webhooks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
repo_id UUID NOT NULL,
|
||||
provider_id UUID NOT NULL,
|
||||
events TEXT NOT NULL,
|
||||
webhook_secret TEXT NOT NULL,
|
||||
remote_webhook_id VARCHAR(255),
|
||||
active BOOLEAN DEFAULT true,
|
||||
branch_filter VARCHAR(255),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
avatar_url VARCHAR(500),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
is_admin BOOLEAN NOT NULL DEFAULT false
|
||||
);
|
||||
|
||||
CREATE TABLE compliance_frameworks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(100) NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
version VARCHAR(20) NOT NULL,
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE security_scans (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id UUID NOT NULL,
|
||||
service_id UUID,
|
||||
scan_type VARCHAR(50) NOT NULL,
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'running',
|
||||
started_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
completed_at TIMESTAMP WITH TIME ZONE,
|
||||
summary JSONB DEFAULT '{}',
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE compliance_reports (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id UUID NOT NULL,
|
||||
framework_id UUID NOT NULL,
|
||||
assessment_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
assessor VARCHAR(255),
|
||||
overall_status VARCHAR(50) NOT NULL,
|
||||
score INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE vulnerabilities (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
type VARCHAR(50) NOT NULL,
|
||||
severity VARCHAR(20) NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
service_id UUID,
|
||||
project_id UUID NOT NULL,
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'open',
|
||||
found_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
resolved_at TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE app_settings (
|
||||
key VARCHAR(255) PRIMARY KEY,
|
||||
value TEXT NOT NULL DEFAULT '',
|
||||
is_secret BOOLEAN NOT NULL DEFAULT false,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE audit_logs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
timestamp TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
user_id UUID,
|
||||
action VARCHAR(100) NOT NULL,
|
||||
resource VARCHAR(100) NOT NULL,
|
||||
resource_id UUID,
|
||||
details JSONB DEFAULT '{}',
|
||||
ip_address INET,
|
||||
user_agent TEXT,
|
||||
success BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
severity TEXT NOT NULL DEFAULT 'info',
|
||||
category TEXT NOT NULL DEFAULT '',
|
||||
label TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE builds (
|
||||
id VARCHAR(255) PRIMARY KEY,
|
||||
project_id VARCHAR(255),
|
||||
service_id VARCHAR(255),
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'pending',
|
||||
progress INTEGER NOT NULL DEFAULT 0,
|
||||
started_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
completed_at TIMESTAMP WITH TIME ZONE,
|
||||
image_name VARCHAR(500) NOT NULL DEFAULT '',
|
||||
image_tag VARCHAR(200) NOT NULL DEFAULT '',
|
||||
size BIGINT NOT NULL DEFAULT 0,
|
||||
error TEXT,
|
||||
log TEXT NOT NULL DEFAULT '',
|
||||
metadata JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- APwhy API gateway (api_services/api_keys + usage/metrics/incident planes)
|
||||
CREATE TABLE api_services (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
upstream_url TEXT NOT NULL,
|
||||
route_prefix TEXT NOT NULL UNIQUE,
|
||||
health_path TEXT NOT NULL DEFAULT '/health',
|
||||
upstream_auth_header TEXT,
|
||||
upstream_auth_value TEXT,
|
||||
internal_token TEXT,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
rpm_limit INTEGER,
|
||||
monthly_quota INTEGER,
|
||||
request_timeout_ms INTEGER DEFAULT 8000,
|
||||
last_validation_at TIMESTAMP WITHOUT TIME ZONE,
|
||||
last_validation_status TEXT,
|
||||
last_validation_message TEXT,
|
||||
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE api_keys (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
key_hash TEXT NOT NULL,
|
||||
key_prefix TEXT NOT NULL,
|
||||
plan TEXT NOT NULL DEFAULT 'free',
|
||||
allowed_service_ids TEXT NOT NULL DEFAULT '[]',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
rpm_limit INTEGER DEFAULT 60,
|
||||
monthly_quota INTEGER DEFAULT 1000,
|
||||
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT now(),
|
||||
last_used_at TIMESTAMP WITHOUT TIME ZONE
|
||||
);
|
||||
|
||||
CREATE TABLE usage_counters (
|
||||
id INTEGER PRIMARY KEY,
|
||||
api_key_id UUID NOT NULL REFERENCES api_keys(id) ON DELETE CASCADE,
|
||||
service_id UUID NOT NULL REFERENCES api_services(id) ON DELETE CASCADE,
|
||||
period_month TEXT NOT NULL,
|
||||
request_count INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT now(),
|
||||
UNIQUE (api_key_id, service_id, period_month)
|
||||
);
|
||||
|
||||
CREATE TABLE metrics_timeseries (
|
||||
id INTEGER PRIMARY KEY,
|
||||
metric TEXT NOT NULL,
|
||||
value REAL NOT NULL,
|
||||
labels_json TEXT,
|
||||
occurred_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE incident_events (
|
||||
id INTEGER PRIMARY KEY,
|
||||
service_id UUID REFERENCES api_services(id) ON DELETE SET NULL,
|
||||
api_key_id UUID,
|
||||
code TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
severity TEXT NOT NULL DEFAULT 'medium',
|
||||
http_status INTEGER,
|
||||
count INTEGER NOT NULL DEFAULT 1,
|
||||
occurred_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -5,6 +5,12 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/containr.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>containr</title>
|
||||
<script>
|
||||
try {
|
||||
document.documentElement.dataset.theme =
|
||||
localStorage.getItem('containr.theme') === 'light' ? 'light' : 'dark';
|
||||
} catch { document.documentElement.dataset.theme = 'dark'; }
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="64" y2="64" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#cdf06f"/>
|
||||
<stop offset="1" stop-color="#7fb524"/>
|
||||
<stop offset="0" stop-color="#56c9f8"/>
|
||||
<stop offset="1" stop-color="#0e86c9"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="64" height="64" rx="15" fill="url(#bg)"/>
|
||||
<g fill="none" stroke="#131507" stroke-width="4.2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<g fill="none" stroke="#061018" stroke-width="4.2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="15" y="18" width="34" height="26" rx="4"/>
|
||||
<path d="M23 18v26M31 18v26M39 18v26"/>
|
||||
<path d="M20 51h24"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 582 B After Width: | Height: | Size: 582 B |
@@ -1,39 +1,50 @@
|
||||
import { lazy, Suspense, type ReactNode } from 'react';
|
||||
import { Link, Navigate, Outlet, Route, Routes, useLocation } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Compass, ShieldCheck } from 'lucide-react';
|
||||
import { PlatformShell } from './layout/PlatformShell';
|
||||
import { ProjectsPage } from '@/features/projects/pages/ProjectsPage';
|
||||
import { ProjectWorkspacePage } from '@/features/workspace/pages/ProjectWorkspacePage';
|
||||
import { ServiceDetailPage } from '@/features/service/pages/ServiceDetailPage';
|
||||
import { ServiceMetricsDashboard } from '@/features/service/pages/ServiceMetricsDashboard';
|
||||
import { BuildsPage } from '@/features/builds/pages/BuildsPage';
|
||||
import { TemplatesPage } from '@/features/templates/pages/TemplatesPage';
|
||||
import { AuditLogsPage } from '@/features/audit/pages/AuditLogsPage';
|
||||
import { WebhooksPage } from '@/features/webhooks/pages/WebhooksPage';
|
||||
import { OperationsPage } from '@/features/operations/pages/OperationsPage';
|
||||
import { ActivityPage } from '@/features/activity/pages/ActivityPage';
|
||||
import { AdminPage } from '@/features/admin/pages/AdminPage';
|
||||
import { LandingPage } from '@/features/landing/LandingPage';
|
||||
import { useBranding } from '@/lib/use-branding';
|
||||
import {
|
||||
DocsPage,
|
||||
PeoplePage,
|
||||
SettingsPage,
|
||||
UsagePage,
|
||||
DatabasesPage,
|
||||
HighAvailabilityPage,
|
||||
SecurityPage,
|
||||
ComponentShowcase,
|
||||
GitHubAppCallbackPage,
|
||||
} from '@/features/secondary/pages';
|
||||
import { NodeDetailPage } from '@/features/nodes/NodeDetailPage';
|
||||
import { AcceptInvitePage, SignInPage, SignUpPage } from '@/features/auth/pages';
|
||||
import { SetupWizardPage } from '@/features/setup/SetupWizardPage';
|
||||
import { useAuthSession } from '@/lib/use-auth-session';
|
||||
import { useDemoMode } from '@/lib/demo-mode';
|
||||
import { getCurrentUserProfile } from '@/lib/api-client';
|
||||
import { ErrorBoundary, LoadingState } from '@/shared/components';
|
||||
|
||||
const LandingPage = lazy(() => import('@/features/landing/LandingPage').then((m) => ({ default: m.LandingPage })));
|
||||
const SignInPage = lazy(() => import('@/features/auth/pages').then((m) => ({ default: m.SignInPage })));
|
||||
const SignUpPage = lazy(() => import('@/features/auth/pages').then((m) => ({ default: m.SignUpPage })));
|
||||
const AcceptInvitePage = lazy(() => import('@/features/auth/pages').then((m) => ({ default: m.AcceptInvitePage })));
|
||||
const SetupWizardPage = lazy(() => import('@/features/setup/SetupWizardPage').then((m) => ({ default: m.SetupWizardPage })));
|
||||
const ProjectWorkspacePage = lazy(() => import('@/features/workspace/pages/ProjectWorkspacePage').then((m) => ({ default: m.ProjectWorkspacePage })));
|
||||
const ServiceDetailPage = lazy(() => import('@/features/service/pages/ServiceDetailPage').then((m) => ({ default: m.ServiceDetailPage })));
|
||||
const ServiceMetricsDashboard = lazy(() => import('@/features/service/pages/ServiceMetricsDashboard').then((m) => ({ default: m.ServiceMetricsDashboard })));
|
||||
const DashboardPage = lazy(() => import('@/features/dashboard/pages/DashboardPage').then((m) => ({ default: m.DashboardPage })));
|
||||
const ProjectsPage = lazy(() => import('@/features/projects/pages/ProjectsPage').then((m) => ({ default: m.ProjectsPage })));
|
||||
const TemplatesPage = lazy(() => import('@/features/templates/pages/TemplatesPage').then((m) => ({ default: m.TemplatesPage })));
|
||||
const DatabasesPage = lazy(() => import('@/features/secondary/pages').then((m) => ({ default: m.DatabasesPage })));
|
||||
const HighAvailabilityPage = lazy(() => import('@/features/secondary/pages').then((m) => ({ default: m.HighAvailabilityPage })));
|
||||
const OperationsPage = lazy(() => import('@/features/operations/pages/OperationsPage').then((m) => ({ default: m.OperationsPage })));
|
||||
const ActivityPage = lazy(() => import('@/features/activity/pages/ActivityPage').then((m) => ({ default: m.ActivityPage })));
|
||||
const SecurityPage = lazy(() => import('@/features/secondary/pages').then((m) => ({ default: m.SecurityPage })));
|
||||
const UsagePage = lazy(() => import('@/features/secondary/pages').then((m) => ({ default: m.UsagePage })));
|
||||
const DocsPage = lazy(() => import('@/features/secondary/pages').then((m) => ({ default: m.DocsPage })));
|
||||
const BuildsPage = lazy(() => import('@/features/builds/pages/BuildsPage').then((m) => ({ default: m.BuildsPage })));
|
||||
const PeoplePage = lazy(() => import('@/features/secondary/pages').then((m) => ({ default: m.PeoplePage })));
|
||||
const SettingsPage = lazy(() => import('@/features/secondary/pages').then((m) => ({ default: m.SettingsPage })));
|
||||
const GitHubAppCallbackPage = lazy(() => import('@/features/secondary/pages').then((m) => ({ default: m.GitHubAppCallbackPage })));
|
||||
const AuditLogsPage = lazy(() => import('@/features/audit/pages/AuditLogsPage').then((m) => ({ default: m.AuditLogsPage })));
|
||||
const WebhooksPage = lazy(() => import('@/features/webhooks/pages/WebhooksPage').then((m) => ({ default: m.WebhooksPage })));
|
||||
const ComponentShowcase = lazy(() => import('@/features/secondary/pages').then((m) => ({ default: m.ComponentShowcase })));
|
||||
const AdminPage = lazy(() => import('@/features/admin/pages/AdminPage').then((m) => ({ default: m.AdminPage })));
|
||||
const NodeDetailPage = lazy(() => import('@/features/nodes/NodeDetailPage').then((m) => ({ default: m.NodeDetailPage })));
|
||||
|
||||
function Page({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<Suspense fallback={<LoadingState message="Loading..." className="min-h-[60vh]" />}>
|
||||
{children}
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthRequired() {
|
||||
const location = useLocation();
|
||||
const isDemoMode = useDemoMode();
|
||||
@@ -92,7 +103,7 @@ function AdminRequired() {
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-[var(--bg-void)] px-6 text-center">
|
||||
<ShieldCheck size={32} className="text-[var(--error)]" />
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-[var(--text-primary)]">Admin access required</h1>
|
||||
<h1 className="font-headline text-lg font-semibold text-[var(--text-primary)]">Admin access required</h1>
|
||||
<p className="mt-1 max-w-sm text-sm text-[var(--text-secondary)]">
|
||||
This area is restricted to the platform administrator.
|
||||
</p>
|
||||
@@ -115,7 +126,7 @@ function NotFoundPage() {
|
||||
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-4 px-6 text-center">
|
||||
<Compass size={32} className="text-[var(--text-tertiary)]" />
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-[var(--text-primary)]">Page not found</h1>
|
||||
<h1 className="font-headline text-lg font-semibold text-[var(--text-primary)]">Page not found</h1>
|
||||
<p className="mt-1 max-w-sm text-sm text-[var(--text-secondary)]">
|
||||
The page you are looking for does not exist or was moved.
|
||||
</p>
|
||||
@@ -135,43 +146,44 @@ export default function App() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<Routes>
|
||||
<Route path="/" element={<LandingPage />} />
|
||||
<Route path="/auth/sign-in" element={<SignInPage />} />
|
||||
<Route path="/auth/sign-up" element={<SignUpPage />} />
|
||||
<Route path="/auth/accept-invite" element={<AcceptInvitePage />} />
|
||||
<Route path="/setup" element={<SetupWizardPage />} />
|
||||
<Route path="/landing" element={<Page><LandingPage /></Page>} />
|
||||
<Route path="/auth/sign-in" element={<Page><SignInPage /></Page>} />
|
||||
<Route path="/auth/sign-up" element={<Page><SignUpPage /></Page>} />
|
||||
<Route path="/auth/accept-invite" element={<Page><AcceptInvitePage /></Page>} />
|
||||
<Route path="/setup" element={<Page><SetupWizardPage /></Page>} />
|
||||
|
||||
{/* Public browsing — approved projects, templates, docs. */}
|
||||
{/* Project workspace renders outside the shell — fullscreen canvas, no nav chrome */}
|
||||
<Route path="/projects/:projectId" element={<ProjectWorkspacePage />} />
|
||||
<Route path="/projects/:projectId/services/:serviceId" element={<ServiceDetailPage />} />
|
||||
<Route path="/projects/:projectId" element={<Page><ProjectWorkspacePage /></Page>} />
|
||||
<Route path="/projects/:projectId/services/:serviceId" element={<Page><ServiceDetailPage /></Page>} />
|
||||
|
||||
<Route element={<PlatformShell />}>
|
||||
<Route path="/projects" element={<ProjectsPage />} />
|
||||
<Route path="/templates" element={<TemplatesPage />} />
|
||||
<Route path="/databases" element={<DatabasesPage />} />
|
||||
<Route path="/ha" element={<HighAvailabilityPage />} />
|
||||
<Route path="/operations" element={<OperationsPage />} />
|
||||
<Route path="/activity" element={<ActivityPage />} />
|
||||
<Route path="/security" element={<SecurityPage />} />
|
||||
<Route path="/usage" element={<UsagePage />} />
|
||||
<Route path="/docs" element={<DocsPage />} />
|
||||
<Route path="/" element={<Page><DashboardPage /></Page>} />
|
||||
<Route path="/projects" element={<Page><ProjectsPage /></Page>} />
|
||||
<Route path="/templates" element={<Page><TemplatesPage /></Page>} />
|
||||
<Route path="/databases" element={<Page><DatabasesPage /></Page>} />
|
||||
<Route path="/ha" element={<Page><HighAvailabilityPage /></Page>} />
|
||||
<Route path="/operations" element={<Page><OperationsPage /></Page>} />
|
||||
<Route path="/activity" element={<Page><ActivityPage /></Page>} />
|
||||
<Route path="/security" element={<Page><SecurityPage /></Page>} />
|
||||
<Route path="/usage" element={<Page><UsagePage /></Page>} />
|
||||
<Route path="/docs" element={<Page><DocsPage /></Page>} />
|
||||
|
||||
{/* Authenticated areas */}
|
||||
<Route element={<AuthRequired />}>
|
||||
<Route path="/builds" element={<BuildsPage />} />
|
||||
<Route path="/people" element={<PeoplePage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/git/github-app/callback" element={<GitHubAppCallbackPage />} />
|
||||
<Route path="/settings/audit-logs" element={<AuditLogsPage />} />
|
||||
<Route path="/settings/webhooks" element={<WebhooksPage />} />
|
||||
<Route path="/showcase" element={<ComponentShowcase />} />
|
||||
<Route path="/builds" element={<Page><BuildsPage /></Page>} />
|
||||
<Route path="/people" element={<Page><PeoplePage /></Page>} />
|
||||
<Route path="/settings" element={<Page><SettingsPage /></Page>} />
|
||||
<Route path="/git/github-app/callback" element={<Page><GitHubAppCallbackPage /></Page>} />
|
||||
<Route path="/settings/audit-logs" element={<Page><AuditLogsPage /></Page>} />
|
||||
<Route path="/settings/webhooks" element={<Page><WebhooksPage /></Page>} />
|
||||
<Route path="/showcase" element={<Page><ComponentShowcase /></Page>} />
|
||||
</Route>
|
||||
|
||||
{/* Platform owner only */}
|
||||
<Route element={<AdminRequired />}>
|
||||
<Route path="/admin" element={<AdminPage />} />
|
||||
<Route path="/nodes/:id" element={<NodeDetailPage />} />
|
||||
<Route path="/admin" element={<Page><AdminPage /></Page>} />
|
||||
<Route path="/nodes/:id" element={<Page><NodeDetailPage /></Page>} />
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
@@ -179,7 +191,7 @@ export default function App() {
|
||||
|
||||
{/* Full-bleed design demo — renders its own chrome, no app shell */}
|
||||
<Route element={<AuthRequired />}>
|
||||
<Route path="/metrics-demo" element={<ServiceMetricsDashboard />} />
|
||||
<Route path="/metrics-demo" element={<Page><ServiceMetricsDashboard /></Page>} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -9,11 +9,13 @@ import {
|
||||
ChartBar,
|
||||
CheckCircle2,
|
||||
ChevronsLeft,
|
||||
ChevronsUpDown,
|
||||
Clock,
|
||||
Container,
|
||||
Database,
|
||||
FileText,
|
||||
FolderKanban,
|
||||
LayoutDashboard,
|
||||
LayoutTemplate,
|
||||
LogIn,
|
||||
LogOut,
|
||||
@@ -41,22 +43,26 @@ import {
|
||||
markAllNotificationsRead,
|
||||
markNotificationRead,
|
||||
pullUpgradeImage,
|
||||
listProjects,
|
||||
} from '@/lib/api-client';
|
||||
import { signOutAuthSession } from '@/lib/auth-client';
|
||||
import { isDemoSearch } from '@/lib/demo-mode';
|
||||
import { demoProjects } from '@/lib/demo-data';
|
||||
import { useAuthSession } from '@/lib/use-auth-session';
|
||||
import { BannerBar, BrandWordmark, useToast } from '@/shared/components';
|
||||
import { BannerBar, BrandWordmark, CommandPalette, useToast } from '@/shared/components';
|
||||
|
||||
type NavItem = { label: string; href: string; icon: typeof FolderKanban; hint: string };
|
||||
type NavSection = { title: string; items: NavItem[] };
|
||||
|
||||
const navSections: NavSection[] = [
|
||||
{
|
||||
title: 'Build',
|
||||
title: 'Platform',
|
||||
items: [
|
||||
{ label: 'Dashboard', href: '/', icon: LayoutDashboard, hint: 'Fleet overview' },
|
||||
{ label: 'Projects', href: '/projects', icon: FolderKanban, hint: 'Services & deployments' },
|
||||
{ label: 'Templates', href: '/templates', icon: LayoutTemplate, hint: 'Deployable presets' },
|
||||
{ label: 'Builds', href: '/builds', icon: FileText, hint: 'Build history' },
|
||||
{ label: 'Databases', href: '/databases', icon: Database, hint: 'Managed data services' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -64,7 +70,6 @@ const navSections: NavSection[] = [
|
||||
items: [
|
||||
{ label: 'Operations', href: '/operations', icon: Activity, hint: 'Live jobs & failures' },
|
||||
{ label: 'Activity', href: '/activity', icon: Clock, hint: 'Platform event feed' },
|
||||
{ label: 'Databases', href: '/databases', icon: Database, hint: 'Managed data services' },
|
||||
{ label: 'High Availability', href: '/ha', icon: ShieldCheck, hint: 'Failover & health' },
|
||||
{ label: 'Security', href: '/security', icon: Shield, hint: 'Scans & compliance' },
|
||||
{ label: 'Usage', href: '/usage', icon: ChartBar, hint: 'Resource consumption' },
|
||||
@@ -164,6 +169,18 @@ export function PlatformShell() {
|
||||
const [theme, setTheme] = useState<ThemeMode>(() => getInitialTheme());
|
||||
const [sidebarExpanded, setSidebarExpanded] = useState<boolean>(() => getInitialSidebar());
|
||||
const [notificationsOpen, setNotificationsOpen] = useState(false);
|
||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const down = (e: KeyboardEvent) => {
|
||||
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
setPaletteOpen((open) => !open);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', down);
|
||||
return () => document.removeEventListener('keydown', down);
|
||||
}, []);
|
||||
|
||||
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
|
||||
const signedIn = isDemoMode || Boolean(sessionQuery.data);
|
||||
@@ -234,8 +251,19 @@ export function PlatformShell() {
|
||||
}
|
||||
};
|
||||
|
||||
const isActiveRoute = (itemHref: string) =>
|
||||
location.pathname === itemHref || location.pathname.startsWith(`${itemHref}/`);
|
||||
// Longest matching href wins so /settings/audit-logs lights only
|
||||
// "Audit Logs", not "Settings" as well.
|
||||
const activeHref = location.pathname === '/'
|
||||
? '/'
|
||||
: visibleNavSections
|
||||
.flatMap((section) => section.items.map((item) => item.href))
|
||||
.filter(
|
||||
(itemHref) =>
|
||||
itemHref !== '/' &&
|
||||
(location.pathname === itemHref || location.pathname.startsWith(`${itemHref}/`)),
|
||||
)
|
||||
.sort((a, b) => b.length - a.length)[0] ?? null;
|
||||
const isActiveRoute = (itemHref: string) => itemHref === activeHref;
|
||||
|
||||
const userName = isDemoMode ? 'Demo workspace' : profileQuery.data?.name ?? sessionQuery.data?.user.name ?? 'Account';
|
||||
const userEmail = isDemoMode ? 'Sample data · read-only' : profileQuery.data?.email ?? sessionQuery.data?.user.email ?? 'Local session';
|
||||
@@ -246,14 +274,20 @@ export function PlatformShell() {
|
||||
.slice(0, 2)
|
||||
.toUpperCase() || 'C';
|
||||
|
||||
const avatar = (
|
||||
<div
|
||||
className="flex h-[30px] w-[30px] shrink-0 items-center justify-center rounded-full text-[11px] font-bold"
|
||||
style={{ background: 'linear-gradient(135deg, var(--accent-primary), var(--accent-secondary))', color: 'var(--accent-on)' }}
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
);
|
||||
const paletteNavigate = (target: string) => {
|
||||
navigate(href(target));
|
||||
};
|
||||
|
||||
// Project deep-links for the palette — only fetched once it opens.
|
||||
const paletteProjectsQuery = useQuery({
|
||||
queryKey: ['palette-projects'],
|
||||
queryFn: () => listProjects(),
|
||||
enabled: paletteOpen && !isDemoMode,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const paletteProjects = isDemoMode
|
||||
? demoProjects
|
||||
: paletteProjectsQuery.data ?? [];
|
||||
|
||||
if (!isDemoMode && signedIn && setupQuery.data?.needs_setup) {
|
||||
return <Navigate to="/setup" replace />;
|
||||
@@ -261,40 +295,46 @@ export function PlatformShell() {
|
||||
|
||||
return (
|
||||
<div className="app-shell min-h-screen">
|
||||
<div className="ambient-glow" />
|
||||
|
||||
<div className="relative flex min-h-screen">
|
||||
{/* ── Sidebar — fixed rail per design/23-sentry ── */}
|
||||
<aside
|
||||
className="hidden h-screen shrink-0 flex-col border-r py-4 transition-[width] duration-200 md:flex"
|
||||
style={{
|
||||
width: sidebarExpanded ? '218px' : '62px',
|
||||
background: 'var(--bg-base)',
|
||||
borderRightColor: 'var(--border-subtle)',
|
||||
}}
|
||||
className="shell-side hidden h-screen shrink-0 flex-col md:flex"
|
||||
style={{ width: sidebarExpanded ? '252px' : '64px' }}
|
||||
>
|
||||
<div className={`flex items-center ${sidebarExpanded ? 'justify-between px-3' : 'flex-col items-center gap-2'}`}>
|
||||
<NavLink to={href('/projects')} title="Containr">
|
||||
<img src="/containr.svg" alt="Containr" className="h-[38px] w-[38px] rounded-xl" />
|
||||
<div
|
||||
className={`flex items-center ${
|
||||
sidebarExpanded ? 'gap-2.5 px-[18px] pb-5 pt-4' : 'flex-col gap-3 px-2 pb-4 pt-4'
|
||||
}`}
|
||||
>
|
||||
<NavLink to={href('/')} title="Containr" className="flex items-center gap-2.5">
|
||||
<span className="shell-mark grid h-[26px] w-[26px] shrink-0 place-items-center">
|
||||
<Container size={14} />
|
||||
</span>
|
||||
{sidebarExpanded ? (
|
||||
<span className="font-headline text-[15px] font-semibold tracking-[-0.01em] text-[var(--text-primary)]">
|
||||
<BrandWordmark />
|
||||
</span>
|
||||
) : null}
|
||||
</NavLink>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSidebarExpanded((v) => !v)}
|
||||
title={sidebarExpanded ? 'Collapse sidebar' : 'Expand sidebar'}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-[var(--radius-sm)] text-[var(--text-tertiary)] transition-colors hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)]"
|
||||
className={`shell-collapse flex items-center justify-center ${
|
||||
sidebarExpanded ? 'ml-auto h-[22px] w-[22px]' : 'h-7 w-7'
|
||||
}`}
|
||||
>
|
||||
{sidebarExpanded ? <ChevronsLeft size={15} /> : <PanelLeft size={15} />}
|
||||
{sidebarExpanded ? <ChevronsLeft size={14} /> : <PanelLeft size={15} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="mt-3 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
|
||||
{visibleNavSections.map((section) => (
|
||||
<div key={section.title}>
|
||||
<nav className={`flex flex-1 flex-col overflow-y-auto ${sidebarExpanded ? 'px-3' : 'gap-4 px-2'}`}>
|
||||
{visibleNavSections.map((section, si) => (
|
||||
<div key={section.title} className={si > 0 && sidebarExpanded ? 'mt-[26px]' : ''}>
|
||||
{sidebarExpanded ? (
|
||||
<p className="mb-1 px-2 text-[10px] font-semibold uppercase tracking-[0.14em] text-[var(--text-tertiary)]">
|
||||
{section.title}
|
||||
</p>
|
||||
<p className="shell-nav-group">{section.title}</p>
|
||||
) : null}
|
||||
<div className={`flex flex-col gap-0.5 ${sidebarExpanded ? '' : 'items-center'}`}>
|
||||
<div className={`flex flex-col ${sidebarExpanded ? '' : 'items-center gap-1'}`}>
|
||||
{section.items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = isActiveRoute(item.href);
|
||||
@@ -303,27 +343,12 @@ export function PlatformShell() {
|
||||
key={item.href}
|
||||
to={href(item.href)}
|
||||
title={sidebarExpanded ? item.hint : `${item.label} — ${item.hint}`}
|
||||
className={`relative flex items-center rounded-[11px] transition-colors ${
|
||||
sidebarExpanded ? 'gap-2.5 px-2 py-2' : 'h-10 w-10 justify-center'
|
||||
} ${
|
||||
isActive
|
||||
? 'bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]'
|
||||
: 'text-[var(--text-tertiary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-secondary)]'
|
||||
className={`shell-ni ${isActive ? 'on' : ''} ${
|
||||
sidebarExpanded ? '' : 'h-10 w-10 justify-center !px-0'
|
||||
}`}
|
||||
>
|
||||
{isActive ? (
|
||||
<span
|
||||
className="absolute -left-2 h-4 w-[2.5px] rounded-full"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
/>
|
||||
) : null}
|
||||
<Icon size={17} className="shrink-0" />
|
||||
{sidebarExpanded ? (
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[13px] font-medium leading-tight">{item.label}</span>
|
||||
<span className="block truncate text-[10px] text-[var(--text-tertiary)]">{item.hint}</span>
|
||||
</span>
|
||||
) : null}
|
||||
<Icon size={16} className="shrink-0" />
|
||||
{sidebarExpanded ? <span className="truncate">{item.label}</span> : null}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
@@ -332,23 +357,26 @@ export function PlatformShell() {
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className={`flex flex-col gap-2 px-2 pt-2 ${sidebarExpanded ? '' : 'items-center'}`}>
|
||||
<div className={`mt-auto ${sidebarExpanded ? 'px-3 pb-3.5' : 'flex justify-center px-2 pb-3'}`}>
|
||||
{signedIn ? (
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
title={sidebarExpanded ? undefined : userEmail}
|
||||
className={`flex items-center gap-2.5 rounded-[11px] text-left transition-colors hover:bg-[var(--surface-muted)] ${
|
||||
sidebarExpanded ? 'w-full px-2 py-2' : 'h-10 w-10 justify-center'
|
||||
className={`shell-user ${
|
||||
sidebarExpanded ? 'w-full' : 'h-10 w-10 justify-center border-transparent bg-transparent'
|
||||
}`}
|
||||
>
|
||||
{avatar}
|
||||
<span className="shell-avatar">{initials}</span>
|
||||
{sidebarExpanded ? (
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[12.5px] font-medium text-[var(--text-primary)]">{userName}</span>
|
||||
<>
|
||||
<span className="min-w-0 flex-1 text-left">
|
||||
<span className="block truncate text-[13px] font-medium text-[var(--text-primary)]">{userName}</span>
|
||||
<span className="block truncate text-[10.5px] text-[var(--text-tertiary)]">{userEmail}</span>
|
||||
</span>
|
||||
<ChevronsUpDown size={13} className="shrink-0 text-[var(--text-tertiary)]" />
|
||||
</>
|
||||
) : null}
|
||||
</button>
|
||||
</DropdownMenu.Trigger>
|
||||
@@ -427,12 +455,12 @@ export function PlatformShell() {
|
||||
<NavLink
|
||||
to="/auth/sign-in"
|
||||
title="Sign in"
|
||||
className={`flex items-center gap-2.5 rounded-[11px] text-[var(--text-tertiary)] transition-colors hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] ${
|
||||
sidebarExpanded ? 'w-full px-2 py-2' : 'h-10 w-10 justify-center'
|
||||
className={`shell-ni ${
|
||||
sidebarExpanded ? 'w-full' : 'h-10 w-10 justify-center !px-0'
|
||||
}`}
|
||||
>
|
||||
<LogIn size={16} className="shrink-0" />
|
||||
{sidebarExpanded ? <span className="text-[12.5px] font-medium">Sign in</span> : null}
|
||||
{sidebarExpanded ? <span className="text-[13px] font-medium">Sign in</span> : null}
|
||||
</NavLink>
|
||||
)}
|
||||
</div>
|
||||
@@ -440,51 +468,31 @@ export function PlatformShell() {
|
||||
|
||||
<div className="relative z-10 flex min-h-screen min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<BannerBar />
|
||||
<header
|
||||
className="hidden h-[46px] shrink-0 items-center border-b px-5 md:flex"
|
||||
style={{
|
||||
background: 'var(--bg-base)',
|
||||
borderBottomColor: 'var(--border-subtle)',
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '11px',
|
||||
}}
|
||||
>
|
||||
<NavLink to={href('/projects')} className="mr-5 text-sm font-extrabold tracking-tight text-[var(--text-primary)]" style={{ fontFamily: 'Inter, sans-serif' }}>
|
||||
<BrandWordmark />
|
||||
</NavLink>
|
||||
|
||||
<div className="flex items-center gap-5">
|
||||
<span className="v-tick"><i className="sig" /><b>{isDemoMode ? 'demo' : 'nominal'}</b></span>
|
||||
<span className="v-tick"><i className="sig" style={{ background: 'var(--text-tertiary)' }} />node <b>local</b></span>
|
||||
</div>
|
||||
|
||||
<div className="search-box ml-6 max-w-[300px]" style={{ height: 30 }}>
|
||||
<Search size={13} />
|
||||
<input type="text" placeholder="search…" className="v-mono" style={{ fontSize: 11.5 }} />
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-4">
|
||||
<span className="v-env">{isDemoMode ? 'env:demo' : 'env:production'}</span>
|
||||
{/* ── Topbar — search left, utility icons right ── */}
|
||||
<header className="shell-top hidden h-[56px] shrink-0 items-center gap-3.5 md:flex">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTheme((current) => (current === 'dark' ? 'light' : 'dark'))}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-[var(--radius-md)] border text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
style={{ borderColor: 'var(--border-subtle)' }}
|
||||
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
onClick={() => setPaletteOpen(true)}
|
||||
className="shell-search"
|
||||
>
|
||||
{theme === 'dark' ? <Sun size={15} /> : <Moon size={15} />}
|
||||
<Search size={13} className="shrink-0" />
|
||||
Search anything
|
||||
<kbd>⌘ K</kbd>
|
||||
</button>
|
||||
|
||||
<span className="v-env">{isDemoMode ? 'env:demo' : `env:${import.meta.env.MODE}`}</span>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{isAdmin && !isDemoMode ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => pullMutation.mutate()}
|
||||
disabled={pullMutation.isPending || !upgradeQuery.data?.imageRef}
|
||||
className="inline-flex h-8 items-center gap-2 rounded-[var(--radius-md)] px-3 text-xs font-semibold text-[var(--accent-on)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
className="s-btn-accent"
|
||||
title={upgradeQuery.data?.message || 'Pull latest configured image'}
|
||||
>
|
||||
{pullMutation.isPending ? <RefreshCw size={14} className="animate-spin" /> : <UploadCloud size={14} />}
|
||||
{pullMutation.isPending ? <RefreshCw size={13} className="animate-spin" /> : <UploadCloud size={13} />}
|
||||
Upgrade
|
||||
</button>
|
||||
) : null}
|
||||
@@ -494,16 +502,11 @@ export function PlatformShell() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNotificationsOpen((open) => !open)}
|
||||
className="relative flex h-8 w-8 items-center justify-center rounded-[var(--radius-md)] border text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
style={{ borderColor: 'var(--border-subtle)' }}
|
||||
className="shell-tic relative"
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<Bell size={15} />
|
||||
{unreadCount > 0 ? (
|
||||
<span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-[var(--accent-primary)] px-1 text-[10px] font-semibold text-[var(--accent-on)]">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
) : null}
|
||||
<Bell size={16} />
|
||||
{unreadCount > 0 ? <span className="shell-tic-dot" /> : null}
|
||||
</button>
|
||||
|
||||
{notificationsOpen ? (
|
||||
@@ -566,13 +569,21 @@ export function PlatformShell() {
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTheme((current) => (current === 'dark' ? 'light' : 'dark'))}
|
||||
className="shell-tic"
|
||||
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
>
|
||||
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<header className="md:hidden sticky top-0 z-50 border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/85 backdrop-blur-2xl">
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<NavLink to={href('/projects')} className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)]">
|
||||
<NavLink to={href('/')} className="flex items-center gap-3">
|
||||
<div className="shell-mark flex h-9 w-9 items-center justify-center">
|
||||
<Container size={16} />
|
||||
</div>
|
||||
<span className="font-headline font-semibold text-[var(--text-primary)]">Containr</span>
|
||||
@@ -613,6 +624,17 @@ export function PlatformShell() {
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CommandPalette
|
||||
open={paletteOpen}
|
||||
onClose={() => setPaletteOpen(false)}
|
||||
onNavigate={paletteNavigate}
|
||||
openTargets={paletteProjects.map((p) => ({
|
||||
label: p.name,
|
||||
target: `/projects/${p.id}`,
|
||||
description: 'Project workspace',
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { type FilterConfig } from '@/lib/dynamic-filter';
|
||||
import { useDynamicFilter, useFilterState } from '@/lib/use-dynamic-filter';
|
||||
import { formatRelative } from '@/lib/time';
|
||||
import { DemoRestricted, FilterBar } from '@/shared/components';
|
||||
import { QuietBtn, SCard, SPageHead, SPill } from '@/shared/components/sentry';
|
||||
import {
|
||||
Activity, AlertCircle, CheckCircle2, Database, GitBranch, Info, Key,
|
||||
Loader2, Package, Projector, Rocket, Shield, User, Users, Wrench,
|
||||
@@ -24,8 +25,12 @@ const SEVERITY_STYLE: Record<string, { icon: typeof Info; cls: string; dot: stri
|
||||
info: { icon: Info, cls: 'text-[var(--accent-primary)]', dot: 'bg-[var(--accent-primary)]' },
|
||||
};
|
||||
|
||||
const SEV_TONE: Record<string, 'err' | 'warn' | 'ok' | 'info'> = {
|
||||
error: 'err', warning: 'warn', success: 'ok', info: 'info',
|
||||
};
|
||||
|
||||
const SCHEMA: FilterConfig<ActivityEntry>[] = [
|
||||
{ key: 'q', label: 'Search', type: 'search', searchFields: ['label', 'action', 'user_email'] },
|
||||
{ key: 'q', label: 'events', type: 'search', searchFields: ['label', 'action', 'user_email'] },
|
||||
{
|
||||
key: 'severity', label: 'Severity', type: 'select',
|
||||
options: [
|
||||
@@ -58,46 +63,33 @@ export function ActivityPage() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<div className="border-b border-[var(--border-subtle)]">
|
||||
<div className="w-full px-8 py-5">
|
||||
<h1 className="v-title">Activity<span className="v-cursor">_</span></h1>
|
||||
<p className="v-mono mt-1.5 text-[11px] text-[var(--text-tertiary)]">
|
||||
platform events across your visible projects
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-full px-4 pt-6 sm:px-8">
|
||||
<SPageHead
|
||||
title="Activity"
|
||||
titleAccent="_"
|
||||
sub="Platform events across your visible projects"
|
||||
actions={query.isFetching ? <Loader2 size={14} className="animate-spin text-[var(--text-muted)]" /> : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="px-8 py-6 space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-1">
|
||||
<div className="px-4 sm:px-8 space-y-4">
|
||||
<SCard icon={<Activity />} title="Event Feed" accent={`${entries.length} events`} pad={false}>
|
||||
<div className="px-4 pt-1 pb-3">
|
||||
<FilterBar {...filter} />
|
||||
</div>
|
||||
{query.isFetching && <Loader2 size={14} className="animate-spin text-[var(--text-muted)] mt-3" />}
|
||||
</div>
|
||||
|
||||
<div className="panel p-0 divide-y divide-[var(--border-subtle)]">
|
||||
<div className="divide-y divide-[var(--border-subtle)] border-t border-[var(--border-subtle)]">
|
||||
{query.isLoading && <p className="px-5 py-8 text-xs text-[var(--text-muted)]">Loading…</p>}
|
||||
{!query.isLoading && entries.length === 0 && (
|
||||
<p className="px-5 py-8 text-xs text-[var(--text-muted)]">No events yet — activity appears here as you deploy, configure, and manage services.</p>
|
||||
)}
|
||||
{entries.map((e) => <Entry key={e.id} entry={e} />)}
|
||||
</div>
|
||||
</SCard>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage(page - 1)}
|
||||
className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--border-subtle)] text-[var(--text-muted)] disabled:opacity-40 hover:text-[var(--text-primary)]"
|
||||
>
|
||||
newer
|
||||
</button>
|
||||
<button
|
||||
disabled={!query.data?.has_more}
|
||||
onClick={() => setPage(page + 1)}
|
||||
className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--border-subtle)] text-[var(--text-muted)] disabled:opacity-40 hover:text-[var(--text-primary)]"
|
||||
>
|
||||
older
|
||||
</button>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<QuietBtn disabled={page <= 1} onClick={() => setPage(page - 1)}>← newer</QuietBtn>
|
||||
<QuietBtn disabled={!query.data?.has_more} onClick={() => setPage(page + 1)}>older →</QuietBtn>
|
||||
<span className="v-mono text-[10.5px] text-[var(--text-muted)]">page {page}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -108,14 +100,14 @@ function Entry({ entry }: { entry: ActivityEntry }) {
|
||||
const Cat = CATEGORY_ICONS[entry.category] ?? Activity;
|
||||
const sev = SEVERITY_STYLE[entry.severity] ?? SEVERITY_STYLE.info;
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-5 py-3">
|
||||
<div className={`w-8 h-8 rounded-md bg-[var(--bg-elevated)] flex items-center justify-center ${sev.cls}`}>
|
||||
<Cat size={15} />
|
||||
<div className="flex items-center gap-3 px-4 py-3 hover:bg-[var(--tint-03)] transition-colors">
|
||||
<div className={`s-ibox ${sev.cls}`} style={{ width: 30, height: 30 }}>
|
||||
<Cat size={14} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] text-[var(--text-primary)] truncate">{entry.label || entry.action}</span>
|
||||
<span className={`v-mono text-[9px] uppercase px-1.5 py-0.5 rounded ${sev.cls} bg-[var(--bg-elevated)]`}>{entry.severity}</span>
|
||||
<span className="text-[13px] font-medium text-[var(--text-primary)] truncate">{entry.label || entry.action}</span>
|
||||
<SPill tone={SEV_TONE[entry.severity] ?? 'info'}>{entry.severity}</SPill>
|
||||
</div>
|
||||
<div className="v-mono text-[10px] text-[var(--text-muted)] mt-0.5">
|
||||
{entry.category} · {entry.action}{entry.user_email ? ` · ${entry.user_email}` : ''}
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
import { useAuthSession } from '@/lib/use-auth-session';
|
||||
import { useDemoMode } from '@/lib/demo-mode';
|
||||
import { DemoRestricted, LoadingState, useToast } from '@/shared/components';
|
||||
import { GhostBtn, QuietBtn, SPageHead, SPill } from '@/shared/components/sentry';
|
||||
|
||||
const statLabels: Record<string, string> = {
|
||||
users: 'Users',
|
||||
@@ -199,7 +200,7 @@ export function AdminPage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl px-5 py-24 text-center">
|
||||
<ShieldCheck size={28} className="mx-auto text-[var(--error)]" />
|
||||
<h1 className="mt-4 text-lg font-semibold text-[var(--text-primary)]">Admin access required</h1>
|
||||
<h1 className="mt-4 font-headline text-lg font-semibold text-[var(--text-primary)]">Admin access required</h1>
|
||||
<p className="mt-2 text-sm text-[var(--text-secondary)]">
|
||||
This area is restricted to platform administrators.
|
||||
</p>
|
||||
@@ -216,74 +217,74 @@ export function AdminPage() {
|
||||
const invites = invitesQuery.data ?? [];
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-5 py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-[24px] font-bold tracking-tight text-[var(--text-primary)]">Admin console</h1>
|
||||
<p className="mt-1 text-sm text-[var(--text-secondary)]">
|
||||
Platform overview, project approvals, and user management.
|
||||
<div className="w-full px-4 py-6 sm:px-8">
|
||||
<SPageHead
|
||||
title="Admin Console"
|
||||
titleAccent="_"
|
||||
sub="Platform overview, project approvals, and user management."
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-4 gap-3.5 max-lg:grid-cols-3 max-sm:grid-cols-2">
|
||||
{Object.entries(statLabels).map(([key, label]) => (
|
||||
<div key={key} className="s-stat">
|
||||
<div className="px-4 pb-1 pt-4">
|
||||
<p className="text-[10.5px] font-medium uppercase tracking-[0.08em] text-[var(--text-tertiary)]">{label}</p>
|
||||
<p className="font-headline mt-3 text-[28px] font-bold leading-none tabular-nums text-[var(--text-primary)]">
|
||||
{stats[key] ?? 0}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-3 max-lg:grid-cols-3 max-sm:grid-cols-2">
|
||||
{Object.entries(statLabels).map(([key, label]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)] px-4 py-3.5"
|
||||
>
|
||||
<p className="text-[20px] font-bold tabular-nums text-[var(--text-primary)]">{stats[key] ?? 0}</p>
|
||||
<p className="mt-0.5 text-[10.5px] uppercase tracking-wider text-[var(--text-tertiary)]">{label}</p>
|
||||
<div className="s-stat-foot mt-3">
|
||||
<span>total</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section className="mt-8">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Clock size={15} className="text-[var(--warning)]" />
|
||||
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Pending approvals</h2>
|
||||
{pending.length > 0 ? (
|
||||
<span className="rounded-full bg-[var(--warning-soft)] px-2 py-0.5 text-[10.5px] font-semibold text-[var(--warning)]">
|
||||
{pending.length}
|
||||
<section className="s-card !p-0 mt-6 overflow-hidden">
|
||||
<div className="s-cardhead border-b border-[var(--border-subtle)]">
|
||||
<span className="s-ibox"><Clock /></span>
|
||||
<h2 className="s-t">Pending Approvals</h2>
|
||||
<span className="s-trail">
|
||||
{pending.length > 0 ? <SPill tone="warn">{pending.length}</SPill> : null}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{pending.length > 0 ? (
|
||||
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)]">
|
||||
<div>
|
||||
{pending.map((project) => (
|
||||
<div
|
||||
key={project.id}
|
||||
className="flex items-center gap-4 border-b border-[var(--border-subtle)] px-4 py-3 last:border-b-0"
|
||||
>
|
||||
<FolderKanban size={15} className="shrink-0 text-[var(--text-tertiary)]" />
|
||||
<span className="s-ibox !h-7 !w-7"><FolderKanban /></span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Link to={`/projects/${project.id}`} className="text-[13.5px] font-medium text-[var(--text-primary)] hover:underline">
|
||||
{project.name}
|
||||
</Link>
|
||||
<p className="truncate text-[11.5px] text-[var(--text-tertiary)]">{project.description || 'No description'}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
<GhostBtn
|
||||
onClick={() => approvalMutation.mutate({ id: project.id, approved: true })}
|
||||
disabled={approvalMutation.isPending}
|
||||
className="inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-3 py-1.5 text-[12px] font-semibold text-[var(--accent-on)] disabled:opacity-50"
|
||||
>
|
||||
<CheckCircle2 size={13} /> Approve
|
||||
</button>
|
||||
</GhostBtn>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)] px-4 py-6 text-center text-[13px] text-[var(--text-tertiary)]">
|
||||
<p className="px-4 py-6 text-center text-[13px] text-[var(--text-tertiary)]">
|
||||
Nothing waiting for approval.
|
||||
</div>
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="mt-8">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<FolderKanban size={15} className="text-[var(--accent-primary)]" />
|
||||
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">All projects</h2>
|
||||
<section className="s-card !p-0 mt-6 overflow-hidden">
|
||||
<div className="s-cardhead border-b border-[var(--border-subtle)]">
|
||||
<span className="s-ibox"><FolderKanban /></span>
|
||||
<h2 className="s-t">All Projects</h2>
|
||||
<span className="s-trail"><span className="s-chip" style={{ cursor: 'default' }}>{allProjects.length}</span></span>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)]">
|
||||
<div>
|
||||
{allProjects.map((project) => (
|
||||
<div
|
||||
key={project.id}
|
||||
@@ -297,22 +298,16 @@ export function AdminPage() {
|
||||
{project.stats?.service_count ?? 0} services · {project.stats?.deployment_count ?? 0} deploys
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`rounded-full px-2.5 py-0.5 text-[10.5px] font-semibold ${
|
||||
project.isApproved
|
||||
? 'bg-[var(--success-soft)] text-[var(--success)]'
|
||||
: 'bg-[var(--warning-soft)] text-[var(--warning)]'
|
||||
}`}
|
||||
>
|
||||
<SPill tone={project.isApproved ? 'ok' : 'warn'}>
|
||||
{project.isApproved ? 'public' : 'pending'}
|
||||
</span>
|
||||
</SPill>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditingProject(project);
|
||||
setEditForm({ name: project.name, description: project.description ?? '' });
|
||||
}}
|
||||
className="inline-flex items-center gap-1 rounded-[var(--radius-md)] border border-[var(--border-default)] px-2.5 py-1.5 text-[12px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
className="s-icon-btn"
|
||||
title="Edit project"
|
||||
>
|
||||
<Pencil size={12} />
|
||||
@@ -325,19 +320,17 @@ export function AdminPage() {
|
||||
}
|
||||
}}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="inline-flex items-center gap-1 rounded-[var(--radius-md)] border border-[var(--error)] px-2.5 py-1.5 text-[12px] font-medium text-[var(--error)] hover:bg-[var(--error-soft)] disabled:opacity-50"
|
||||
className="s-icon-btn !text-[var(--text-tertiary)] hover:!text-[var(--error)] disabled:opacity-40"
|
||||
title="Delete project"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
<QuietBtn
|
||||
onClick={() => approvalMutation.mutate({ id: project.id, approved: !project.isApproved })}
|
||||
disabled={approvalMutation.isPending}
|
||||
className="rounded-[var(--radius-md)] border border-[var(--border-default)] px-3 py-1.5 text-[12px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:opacity-50"
|
||||
>
|
||||
{project.isApproved ? 'Unapprove' : 'Approve'}
|
||||
</button>
|
||||
</QuietBtn>
|
||||
</div>
|
||||
))}
|
||||
{allProjects.length === 0 ? (
|
||||
@@ -346,12 +339,13 @@ export function AdminPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-8">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Megaphone size={15} className="text-[var(--accent-primary)]" />
|
||||
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Announcements</h2>
|
||||
<section className="s-card !p-0 mt-6 overflow-hidden">
|
||||
<div className="s-cardhead border-b border-[var(--border-subtle)]">
|
||||
<span className="s-ibox"><Megaphone /></span>
|
||||
<h2 className="s-t">Announcements</h2>
|
||||
<span className="s-trail"><span className="s-chip" style={{ cursor: 'default' }}>{banners.length}</span></span>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)]">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-[var(--border-subtle)] px-4 py-3">
|
||||
<input
|
||||
value={bannerForm.title}
|
||||
@@ -375,14 +369,12 @@ export function AdminPage() {
|
||||
<option value="warning">warning</option>
|
||||
<option value="error">error</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
<GhostBtn
|
||||
onClick={() => bannerCreateMutation.mutate()}
|
||||
disabled={bannerCreateMutation.isPending || bannerForm.title.trim().length === 0}
|
||||
className="rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-[12px] font-semibold text-[var(--accent-on)] disabled:opacity-50"
|
||||
>
|
||||
{bannerCreateMutation.isPending ? 'Publishing…' : 'Publish'}
|
||||
</button>
|
||||
</GhostBtn>
|
||||
</div>
|
||||
{banners.map((banner) => (
|
||||
<div
|
||||
@@ -396,14 +388,12 @@ export function AdminPage() {
|
||||
{banner.body ? ` · ${banner.body}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
<QuietBtn
|
||||
onClick={() => bannerToggleMutation.mutate({ id: banner.id, active: !banner.active })}
|
||||
disabled={bannerToggleMutation.isPending}
|
||||
className="rounded-[var(--radius-md)] border border-[var(--border-default)] px-3 py-1.5 text-[12px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:opacity-50"
|
||||
>
|
||||
{banner.active ? 'Deactivate' : 'Activate'}
|
||||
</button>
|
||||
</QuietBtn>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -412,7 +402,7 @@ export function AdminPage() {
|
||||
}
|
||||
}}
|
||||
disabled={bannerDeleteMutation.isPending}
|
||||
className="inline-flex items-center gap-1 rounded-[var(--radius-md)] border border-[var(--error)] px-2.5 py-1.5 text-[12px] font-medium text-[var(--error)] hover:bg-[var(--error-soft)] disabled:opacity-50"
|
||||
className="s-icon-btn !text-[var(--text-tertiary)] hover:!text-[var(--error)] disabled:opacity-40"
|
||||
title="Delete banner"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
@@ -425,12 +415,13 @@ export function AdminPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-8">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<UserPlus size={15} className="text-[var(--accent-primary)]" />
|
||||
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Team invites</h2>
|
||||
<section className="s-card !p-0 mt-6 overflow-hidden">
|
||||
<div className="s-cardhead border-b border-[var(--border-subtle)]">
|
||||
<span className="s-ibox"><UserPlus /></span>
|
||||
<h2 className="s-t">Team Invites</h2>
|
||||
<span className="s-trail"><span className="s-chip" style={{ cursor: 'default' }}>{invites.length}</span></span>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)]">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-[var(--border-subtle)] px-4 py-3">
|
||||
<input
|
||||
value={inviteEmail}
|
||||
@@ -439,35 +430,25 @@ export function AdminPage() {
|
||||
type="email"
|
||||
className="h-9 min-w-64 flex-1 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm focus:border-[var(--accent-primary)]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
<GhostBtn
|
||||
onClick={() => inviteCreateMutation.mutate()}
|
||||
disabled={inviteCreateMutation.isPending}
|
||||
className="rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-[12px] font-semibold text-[var(--accent-on)] disabled:opacity-50"
|
||||
>
|
||||
{inviteCreateMutation.isPending ? 'Creating…' : 'Create invite link'}
|
||||
</button>
|
||||
</GhostBtn>
|
||||
</div>
|
||||
{inviteLink ? (
|
||||
<div className="flex items-center gap-2 border-b border-[var(--border-subtle)] bg-[var(--accent-primary-soft)] px-4 py-2.5">
|
||||
<code className="min-w-0 flex-1 truncate text-[12px] text-[var(--accent-primary)]">{inviteLink}</code>
|
||||
<button
|
||||
type="button"
|
||||
<code className="v-mono min-w-0 flex-1 truncate text-[12px] text-[var(--accent-primary)]">{inviteLink}</code>
|
||||
<QuietBtn
|
||||
onClick={() => {
|
||||
navigator.clipboard?.writeText(inviteLink);
|
||||
showToast('success', 'Link copied');
|
||||
}}
|
||||
className="inline-flex items-center gap-1 rounded-[var(--radius-md)] border border-[var(--border-default)] px-2.5 py-1 text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
>
|
||||
<Copy size={11} /> Copy
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setInviteLink(null)}
|
||||
className="text-[11px] text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</QuietBtn>
|
||||
<QuietBtn onClick={() => setInviteLink(null)}>Dismiss</QuietBtn>
|
||||
</div>
|
||||
) : null}
|
||||
{invites.map((invite) => {
|
||||
@@ -486,26 +467,17 @@ export function AdminPage() {
|
||||
expires {new Date(invite.expires_at).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`rounded-full px-2.5 py-0.5 text-[10.5px] font-semibold ${
|
||||
used
|
||||
? 'bg-[var(--surface-muted)] text-[var(--text-tertiary)]'
|
||||
: expired
|
||||
? 'bg-[var(--error-soft)] text-[var(--error)]'
|
||||
: 'bg-[var(--success-soft)] text-[var(--success)]'
|
||||
}`}
|
||||
>
|
||||
<SPill tone={used ? 'off' : expired ? 'err' : 'ok'}>
|
||||
{used ? 'used' : expired ? 'expired' : 'open'}
|
||||
</span>
|
||||
</SPill>
|
||||
{!used ? (
|
||||
<button
|
||||
type="button"
|
||||
<QuietBtn
|
||||
onClick={() => inviteRevokeMutation.mutate(invite.id)}
|
||||
disabled={inviteRevokeMutation.isPending}
|
||||
className="rounded-[var(--radius-md)] border border-[var(--error)] px-2.5 py-1.5 text-[12px] font-medium text-[var(--error)] hover:bg-[var(--error-soft)] disabled:opacity-50"
|
||||
className="hover:!text-[var(--error)] hover:!border-[var(--error)]"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
</QuietBtn>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
@@ -516,12 +488,13 @@ export function AdminPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-8 pb-10">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Users size={15} className="text-[var(--accent-tertiary)]" />
|
||||
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Users</h2>
|
||||
<section className="s-card !p-0 mt-6 mb-10 overflow-hidden">
|
||||
<div className="s-cardhead border-b border-[var(--border-subtle)]">
|
||||
<span className="s-ibox"><Users /></span>
|
||||
<h2 className="s-t">Users</h2>
|
||||
<span className="s-trail"><span className="s-chip" style={{ cursor: 'default' }}>{users.length}</span></span>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)]">
|
||||
<div>
|
||||
{users.map((user) => (
|
||||
<UserRow
|
||||
key={user.id}
|
||||
@@ -540,30 +513,29 @@ export function AdminPage() {
|
||||
|
||||
{impersonation ? (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
|
||||
<div className="w-full max-w-lg overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--bg-base)] shadow-2xl">
|
||||
<div className="border-b border-[var(--border-subtle)] px-5 py-4">
|
||||
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">
|
||||
Impersonating {impersonation.user.email}
|
||||
</h2>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">
|
||||
Token valid until {new Date(impersonation.expires_at).toLocaleString()} — audit-logged.
|
||||
</p>
|
||||
<div className="w-full max-w-lg s-card !p-0 overflow-hidden">
|
||||
<div className="s-cardhead border-b border-[var(--border-subtle)]">
|
||||
<span className="s-ibox"><ShieldCheck /></span>
|
||||
<h2 className="s-t">Impersonating {impersonation.user.email}</h2>
|
||||
<span className="s-trail">
|
||||
<span className="v-mono text-[10.5px] text-[var(--text-muted)]">
|
||||
expires {new Date(impersonation.expires_at).toLocaleString()}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-3 px-5 py-4">
|
||||
<div className="flex items-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2.5">
|
||||
<code className="min-w-0 flex-1 truncate text-[12px] text-[var(--text-primary)]">
|
||||
<div className="s-inset flex items-center gap-2 px-3 py-2.5">
|
||||
<code className="v-mono min-w-0 flex-1 truncate text-[12px] text-[var(--text-primary)]">
|
||||
{impersonation.token}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
<QuietBtn
|
||||
onClick={() => {
|
||||
navigator.clipboard?.writeText(impersonation.token);
|
||||
showToast('success', 'Token copied');
|
||||
}}
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded-[var(--radius-md)] border border-[var(--border-default)] px-2.5 py-1 text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
>
|
||||
<Copy size={11} /> Copy
|
||||
</button>
|
||||
</QuietBtn>
|
||||
</div>
|
||||
<p className="text-[12px] leading-relaxed text-[var(--text-tertiary)]">
|
||||
Use it as a bearer token — e.g. <code className="text-[var(--text-secondary)]">CONTAINR_TOKEN=<token>
|
||||
@@ -572,13 +544,7 @@ export function AdminPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 border-t border-[var(--border-subtle)] px-5 py-3.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setImpersonation(null)}
|
||||
className="rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-xs font-semibold text-[var(--accent-on)]"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
<GhostBtn onClick={() => setImpersonation(null)}>Done</GhostBtn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -586,10 +552,13 @@ export function AdminPage() {
|
||||
|
||||
{editingProject ? (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
|
||||
<div className="w-full max-w-md overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--bg-base)] shadow-2xl">
|
||||
<div className="border-b border-[var(--border-subtle)] px-5 py-4">
|
||||
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Edit project</h2>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Admin edit — changes apply immediately.</p>
|
||||
<div className="w-full max-w-md s-card !p-0 overflow-hidden">
|
||||
<div className="s-cardhead border-b border-[var(--border-subtle)]">
|
||||
<span className="s-ibox"><Pencil /></span>
|
||||
<h2 className="s-t">Edit Project</h2>
|
||||
<span className="s-trail">
|
||||
<span className="v-mono text-[10.5px] text-[var(--text-muted)]">admin edit</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-4 px-5 py-4">
|
||||
<div>
|
||||
@@ -611,21 +580,13 @@ export function AdminPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 border-t border-[var(--border-subtle)] px-5 py-3.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingProject(null)}
|
||||
className="rounded-[var(--radius-md)] px-3.5 py-2 text-xs font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-muted)]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
<QuietBtn onClick={() => setEditingProject(null)}>Cancel</QuietBtn>
|
||||
<GhostBtn
|
||||
onClick={() => editMutation.mutate()}
|
||||
disabled={editMutation.isPending || editForm.name.trim().length < 2}
|
||||
className="rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-xs font-semibold text-[var(--accent-on)] disabled:opacity-50"
|
||||
>
|
||||
{editMutation.isPending ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</GhostBtn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -660,32 +621,22 @@ function UserRow({
|
||||
<p className="truncate text-[11.5px] text-[var(--text-tertiary)]">{user.email}</p>
|
||||
</div>
|
||||
{user.is_admin ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-[var(--accent-primary-soft)] px-2.5 py-0.5 text-[10.5px] font-semibold text-[var(--accent-primary)]">
|
||||
<ShieldCheck size={11} /> admin
|
||||
</span>
|
||||
<SPill tone="info"><ShieldCheck size={10} /> admin</SPill>
|
||||
) : null}
|
||||
{onImpersonate ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onImpersonate}
|
||||
className="rounded-[var(--radius-md)] border border-[var(--border-default)] px-3 py-1.5 text-[12px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
>
|
||||
Impersonate
|
||||
</button>
|
||||
<QuietBtn onClick={onImpersonate}>Impersonate</QuietBtn>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
<QuietBtn
|
||||
onClick={() => onToggle(!user.is_admin)}
|
||||
disabled={pending || isSelf}
|
||||
title={isSelf ? 'You cannot change your own admin flag' : undefined}
|
||||
className="rounded-[var(--radius-md)] border border-[var(--border-default)] px-3 py-1.5 text-[12px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{user.is_admin ? (
|
||||
<span className="inline-flex items-center gap-1"><XCircle size={12} /> Revoke</span>
|
||||
) : (
|
||||
'Make admin'
|
||||
)}
|
||||
</button>
|
||||
</QuietBtn>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,9 @@ import { type FilterConfig } from '@/lib/dynamic-filter';
|
||||
import { useDynamicFilter } from '@/lib/use-dynamic-filter';
|
||||
import { formatDate, formatRelative } from '@/lib/time';
|
||||
import { DemoRestricted, FilterBar } from '@/shared/components';
|
||||
import { QuietBtn, SCard, SPageHead, SPill, STable, type SCol } from '@/shared/components/sentry';
|
||||
import { ScrollText, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';
|
||||
import type { AuditLogEntity } from '@/lib/api-client';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
@@ -72,109 +74,101 @@ export function AuditLogsPage() {
|
||||
const hasNext = logs.length === PAGE_SIZE;
|
||||
const hasFilters = filter.activeCount > 0;
|
||||
|
||||
const cols: SCol<AuditLogEntity>[] = [
|
||||
{
|
||||
key: 'time', label: 'Time', width: '100px', sortable: false,
|
||||
render: (log) => (
|
||||
<span className="v-mono text-[11px] text-[var(--text-tertiary)]" title={log.createdAt ? formatDate(log.createdAt) : undefined}>
|
||||
{log.createdAt ? formatRelative(log.createdAt) : '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actor', label: 'Actor', width: 'minmax(140px,1.1fr)',
|
||||
sortValue: (log) => log.userEmail ?? log.userId ?? 'system',
|
||||
render: (log) => (
|
||||
<span className="text-[12px] text-[var(--text-primary)] truncate">
|
||||
{log.userEmail || (log.userId ? log.userId.slice(0, 8) : 'system')}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'resource', label: 'Resource', width: 'minmax(120px,1fr)',
|
||||
sortValue: (log) => log.resource ?? '',
|
||||
render: (log) => <span className="v-mono text-[11.5px] text-[var(--text-secondary)]">{log.resource}</span>,
|
||||
},
|
||||
{
|
||||
key: 'action', label: 'Action', width: 'minmax(130px,1fr)',
|
||||
sortValue: (log) => log.action ?? '',
|
||||
render: (log) => <SPill tone="info">{log.action}</SPill>,
|
||||
},
|
||||
{
|
||||
key: 'rid', label: 'Resource ID', width: 'minmax(110px,0.8fr)', sortable: false,
|
||||
render: (log) => (
|
||||
<span className="v-mono text-[10.5px] text-[var(--text-tertiary)] truncate block" title={log.resourceId}>
|
||||
{log.resourceId || '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'ip', label: 'IP', width: '110px', sortable: false,
|
||||
render: (log) => <span className="v-mono text-[10.5px] text-[var(--text-tertiary)]">{log.ipAddress?.replace(/\/\d+$/, '') || '—'}</span>,
|
||||
},
|
||||
{
|
||||
key: 'details', label: 'Details', width: 'minmax(160px,1.2fr)', sortable: false,
|
||||
render: (log) => (
|
||||
<span className="v-mono text-[10.5px] text-[var(--text-tertiary)] truncate block" title={log.details}>
|
||||
{log.details && log.details !== '{}' ? log.details : '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<div className="border-b border-[var(--border-subtle)]">
|
||||
<div className="w-full px-8 py-5">
|
||||
<h1 className="v-title">
|
||||
Audit Logs<span className="v-cursor">_</span>
|
||||
</h1>
|
||||
<p className="v-mono mt-1.5 text-[11px] text-[var(--text-tertiary)]">
|
||||
Every authenticated action recorded by the platform
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-full px-4 pt-6 sm:px-8">
|
||||
<SPageHead
|
||||
title="Audit Logs"
|
||||
titleAccent="_"
|
||||
sub="Every authenticated action recorded by the platform"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full px-8 py-6">
|
||||
<section className="panel p-6">
|
||||
<div className="w-full px-4 sm:px-8">
|
||||
<SCard icon={<ScrollText />} title="Event Log" accent={`page ${page}`} pad={false}>
|
||||
<div className="px-4 pt-1 pb-3">
|
||||
<FilterBar {...filter} />
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[var(--border-subtle)] text-left">
|
||||
<th className="px-3 py-2.5 font-medium uppercase tracking-wider text-xs text-[var(--text-muted)]">Time</th>
|
||||
<th className="px-3 py-2.5 font-medium uppercase tracking-wider text-xs text-[var(--text-muted)]">Actor</th>
|
||||
<th className="px-3 py-2.5 font-medium uppercase tracking-wider text-xs text-[var(--text-muted)]">Resource</th>
|
||||
<th className="px-3 py-2.5 font-medium uppercase tracking-wider text-xs text-[var(--text-muted)]">Action</th>
|
||||
<th className="px-3 py-2.5 font-medium uppercase tracking-wider text-xs text-[var(--text-muted)]">Resource ID</th>
|
||||
<th className="px-3 py-2.5 font-medium uppercase tracking-wider text-xs text-[var(--text-muted)]">IP</th>
|
||||
<th className="px-3 py-2.5 font-medium uppercase tracking-wider text-xs text-[var(--text-muted)]">Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--border-subtle)]">
|
||||
{logsQuery.isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-10 text-center text-[var(--text-muted)]">
|
||||
<Loader2 size={18} className="animate-spin inline-block" />
|
||||
</td>
|
||||
</tr>
|
||||
) : logsQuery.isError ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-10 text-center text-[var(--error)]">
|
||||
Failed to load audit logs.
|
||||
</td>
|
||||
</tr>
|
||||
) : logs.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-10 text-center">
|
||||
<ScrollText size={20} className="inline-block mb-2 text-[var(--text-muted)]" />
|
||||
<p className="text-[var(--text-secondary)]">No audit events{hasFilters ? ' match these filters' : ' recorded yet'}.</p>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
logs.map((log) => (
|
||||
<tr key={log.id} className="hover:bg-[var(--surface-muted)]/50 transition-colors">
|
||||
<td className="px-3 py-2.5 whitespace-nowrap text-[var(--text-tertiary)]" title={log.createdAt ? formatDate(log.createdAt) : undefined}>
|
||||
{log.createdAt ? formatRelative(log.createdAt) : '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-[var(--text-primary)]">
|
||||
{log.userEmail || (log.userId ? log.userId.slice(0, 8) : 'system')}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 mono text-[var(--text-secondary)]">{log.resource}</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<span className="px-2 py-0.5 rounded-[var(--radius-sm)] bg-[var(--accent-primary-soft)] text-xs font-medium text-[var(--accent-primary)]">
|
||||
{log.action}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 mono text-xs text-[var(--text-tertiary)] max-w-[140px] truncate" title={log.resourceId}>
|
||||
{log.resourceId || '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 mono text-xs text-[var(--text-tertiary)]">{log.ipAddress || '—'}</td>
|
||||
<td className="px-3 py-2.5 mono text-xs text-[var(--text-tertiary)] max-w-[220px] truncate" title={log.details}>
|
||||
{log.details && log.details !== '{}' ? log.details : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<p className="text-xs text-[var(--text-muted)]">
|
||||
<div className="border-t border-[var(--border-subtle)] px-4 overflow-x-auto">
|
||||
{logsQuery.isLoading ? (
|
||||
<div className="py-10 text-center text-[var(--text-muted)]">
|
||||
<Loader2 size={18} className="animate-spin inline-block" />
|
||||
</div>
|
||||
) : logsQuery.isError ? (
|
||||
<div className="py-10 text-center text-[var(--error)]">Failed to load audit logs.</div>
|
||||
) : logs.length === 0 ? (
|
||||
<div className="py-10 text-center">
|
||||
<div className="s-ibox mx-auto mb-2"><ScrollText /></div>
|
||||
<p className="text-[var(--text-secondary)] text-sm">No audit events{hasFilters ? ' match these filters' : ' recorded yet'}.</p>
|
||||
</div>
|
||||
) : (
|
||||
<STable cols={cols} rows={logs} rowKey={(l) => l.id} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-[var(--border-subtle)]">
|
||||
<p className="v-mono text-[10.5px] text-[var(--text-muted)]">
|
||||
Page {page}{logs.length > 0 ? ` — ${logs.length} entries` : ''}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page <= 1}
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] disabled:opacity-40 transition-colors"
|
||||
>
|
||||
<ChevronLeft size={12} />
|
||||
Prev
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
disabled={!hasNext}
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] disabled:opacity-40 transition-colors"
|
||||
>
|
||||
Next
|
||||
<ChevronRight size={12} />
|
||||
</button>
|
||||
<QuietBtn onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page <= 1}>
|
||||
<ChevronLeft size={12} /> Prev
|
||||
</QuietBtn>
|
||||
<QuietBtn onClick={() => setPage((p) => p + 1)} disabled={!hasNext}>
|
||||
Next <ChevronRight size={12} />
|
||||
</QuietBtn>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</SCard>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -13,15 +13,16 @@ import {
|
||||
} from '@/lib/auth-client';
|
||||
import { acceptInvite, getInvite } from '@/lib/api-client';
|
||||
import { useAuthSession } from '@/lib/use-auth-session';
|
||||
import { GhostBtn, QuietBtn } from '@/shared/components/sentry';
|
||||
|
||||
function sanitizeRedirect(raw: string | null): string {
|
||||
if (!raw) {
|
||||
return '/projects';
|
||||
return '/';
|
||||
}
|
||||
|
||||
const value = raw.trim();
|
||||
if (!value.startsWith('/') || value.startsWith('//')) {
|
||||
return '/projects';
|
||||
return '/';
|
||||
}
|
||||
|
||||
return value;
|
||||
@@ -54,9 +55,11 @@ function AuthCard({
|
||||
}) {
|
||||
return (
|
||||
<div className="w-full max-w-[400px]">
|
||||
<div className="rounded-[var(--radius-xl)] border border-[var(--border-subtle)] bg-[var(--surface-card)]/95 p-8 shadow-2xl shadow-black/50 backdrop-blur-xl">
|
||||
<h2 className="font-headline text-xl font-semibold text-[var(--text-primary)]">{title}</h2>
|
||||
<p className="mb-6 mt-1.5 text-sm text-[var(--text-secondary)]">{subtitle}</p>
|
||||
<div className="s-card shadow-2xl shadow-black/50">
|
||||
<div className="s-cardhead mb-1">
|
||||
<h2 className="font-headline text-[17px] font-semibold text-[var(--text-primary)]">{title}</h2>
|
||||
</div>
|
||||
<p className="mb-6 v-mono text-[11.5px] text-[var(--text-tertiary)]">{subtitle}</p>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
@@ -67,19 +70,14 @@ function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="relative min-h-screen overflow-hidden bg-[var(--bg-void)]">
|
||||
<div className="subtle-grid absolute inset-0" />
|
||||
<div className="ambient-glow" />
|
||||
<div
|
||||
className="absolute left-1/2 top-0 h-[420px] w-[720px] -translate-x-1/2 -translate-y-1/2 rounded-full"
|
||||
style={{ background: 'radial-gradient(ellipse at center, rgba(180,227,74,0.10) 0%, transparent 65%)' }}
|
||||
/>
|
||||
<div className="relative flex min-h-screen flex-col items-center justify-center px-5 py-10">
|
||||
<div className="mb-8 flex flex-col items-center">
|
||||
<img src="/containr.svg" alt="Containr" className="h-14 w-14" />
|
||||
<span className="mt-4 font-headline text-2xl font-bold tracking-tight text-[var(--text-primary)]">Containr</span>
|
||||
<span className="mt-1 text-xs text-[var(--text-muted)]">Self-hosted container platform</span>
|
||||
<span className="mt-1 v-mono text-[11px] uppercase tracking-[0.14em] text-[var(--text-muted)]">Self-hosted container platform</span>
|
||||
</div>
|
||||
{children}
|
||||
<p className="mt-8 text-center text-[11px] text-[var(--text-muted)]">
|
||||
<p className="mt-8 text-center v-mono text-[10.5px] text-[var(--text-muted)]">
|
||||
Sessions are cookie-based · first account becomes platform owner
|
||||
</p>
|
||||
</div>
|
||||
@@ -89,8 +87,8 @@ function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||
|
||||
function AuthErrorNotice({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="mb-4 flex items-start gap-2 rounded-[var(--radius-md)] border border-[var(--error-soft)] bg-[var(--error-soft)] px-3 py-2.5 text-sm text-[var(--error)]">
|
||||
<AlertCircle size={16} className="mt-0.5 shrink-0" />
|
||||
<div className="mb-4 s-inset flex items-start gap-2 !border-[var(--error)]/30 px-3 py-2.5 text-[12.5px] text-[var(--error)]">
|
||||
<AlertCircle size={15} className="mt-0.5 shrink-0" />
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
);
|
||||
@@ -98,7 +96,7 @@ function AuthErrorNotice({ message }: { message: string }) {
|
||||
|
||||
function AuthInfoNotice({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="mb-4 rounded-[var(--radius-md)] border border-[var(--success-soft)] bg-[var(--success-soft)] px-3 py-2.5 text-sm text-[var(--success)]">
|
||||
<div className="mb-4 s-inset !border-[var(--success)]/30 px-3 py-2.5 text-[12.5px] text-[var(--success)]">
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
@@ -206,15 +204,10 @@ export function SignInPage() {
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || sessionQuery.isPending}
|
||||
className="mt-1 inline-flex h-10 w-full items-center justify-center gap-2 rounded-[var(--radius-md)] text-sm font-semibold text-[var(--accent-on)] shadow-lg transition-all disabled:cursor-not-allowed disabled:opacity-60"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
>
|
||||
<GhostBtn type="submit" disabled={isSubmitting || sessionQuery.isPending} className="!h-10 w-full justify-center">
|
||||
{isSubmitting ? <Loader2 size={15} className="animate-spin" /> : <Mail size={15} />}
|
||||
Continue
|
||||
</button>
|
||||
</GhostBtn>
|
||||
</form>
|
||||
|
||||
{providers.length > 0 ? (
|
||||
@@ -227,24 +220,22 @@ export function SignInPage() {
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{providers.includes('github') ? (
|
||||
<button
|
||||
type="button"
|
||||
<QuietBtn
|
||||
onClick={() => void signInWithGitHubProvider()}
|
||||
className="inline-flex h-10 items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm font-medium text-[var(--text-primary)] transition-colors hover:border-[var(--border-default)]"
|
||||
className="!h-10 justify-center text-sm"
|
||||
>
|
||||
<Github size={15} />
|
||||
GitHub
|
||||
</button>
|
||||
</QuietBtn>
|
||||
) : null}
|
||||
{providers.includes('google') ? (
|
||||
<button
|
||||
type="button"
|
||||
<QuietBtn
|
||||
onClick={() => void signInWithGoogleProvider()}
|
||||
className="inline-flex h-10 items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm font-medium text-[var(--text-primary)] transition-colors hover:border-[var(--border-default)]"
|
||||
className="!h-10 justify-center text-sm"
|
||||
>
|
||||
<GoogleMark />
|
||||
Google
|
||||
</button>
|
||||
</QuietBtn>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
@@ -280,7 +271,7 @@ export function AcceptInvitePage() {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
if (sessionQuery.data) {
|
||||
return <Navigate to="/projects" replace />;
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
const boundEmail = inviteQuery.data?.email ?? undefined;
|
||||
@@ -297,7 +288,7 @@ export function AcceptInvitePage() {
|
||||
// account, sign-in creates the browser session.
|
||||
await signInWithEmail(effectiveEmail.trim(), password);
|
||||
await queryClient.invalidateQueries({ queryKey: ['auth-session'] });
|
||||
navigate('/projects', { replace: true });
|
||||
navigate('/', { replace: true });
|
||||
} catch (exception) {
|
||||
setError(exception instanceof Error ? exception.message : 'Failed to accept invite');
|
||||
} finally {
|
||||
@@ -369,15 +360,10 @@ export function AcceptInvitePage() {
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="mt-1 inline-flex h-10 w-full items-center justify-center gap-2 rounded-[var(--radius-md)] text-sm font-semibold text-[var(--accent-on)] shadow-lg transition-all disabled:cursor-not-allowed disabled:opacity-60"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
>
|
||||
<GhostBtn type="submit" disabled={isSubmitting} className="!h-10 w-full justify-center">
|
||||
{isSubmitting ? <Loader2 size={15} className="animate-spin" /> : <User2 size={15} />}
|
||||
Accept invite
|
||||
</button>
|
||||
</GhostBtn>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
@@ -487,15 +473,10 @@ export function SignUpPage() {
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || sessionQuery.isPending}
|
||||
className="mt-1 inline-flex h-10 w-full items-center justify-center gap-2 rounded-[var(--radius-md)] text-sm font-semibold text-[var(--accent-on)] shadow-lg transition-all disabled:cursor-not-allowed disabled:opacity-60"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
>
|
||||
<GhostBtn type="submit" disabled={isSubmitting || sessionQuery.isPending} className="!h-10 w-full justify-center">
|
||||
{isSubmitting ? <Loader2 size={15} className="animate-spin" /> : <User2 size={15} />}
|
||||
Create Account
|
||||
</button>
|
||||
</GhostBtn>
|
||||
</form>
|
||||
|
||||
<div className="mt-5 flex items-center justify-between text-xs text-[var(--text-secondary)]">
|
||||
|
||||
@@ -14,6 +14,18 @@ import { useDynamicFilter, useFilterState } from '@/lib/use-dynamic-filter';
|
||||
import { FilterBar } from '@/shared/components';
|
||||
import { demoBuilds } from '@/lib/demo-data';
|
||||
import { formatRelative } from '@/lib/time';
|
||||
import {
|
||||
SCard,
|
||||
SPageHead,
|
||||
SPill,
|
||||
SStat,
|
||||
STable,
|
||||
IconBtn,
|
||||
QuietBtn,
|
||||
Ticks,
|
||||
type SCol,
|
||||
} from '@/shared/components/sentry';
|
||||
import { statusTone } from '@/shared/components/sentry-utils';
|
||||
import {
|
||||
Check,
|
||||
X,
|
||||
@@ -24,8 +36,6 @@ import {
|
||||
Box,
|
||||
Sparkles,
|
||||
Filter,
|
||||
Circle,
|
||||
HardDrive,
|
||||
} from 'lucide-react';
|
||||
|
||||
const statusOptions: Array<{ value: '' | BuildStatus; label: string }> = [
|
||||
@@ -50,26 +60,11 @@ const BUILD_SCHEMA: FilterConfig<BuildEntity>[] = [
|
||||
},
|
||||
];
|
||||
|
||||
function StatusBadge({ status }: { status: BuildStatus }) {
|
||||
const config = {
|
||||
success: { color: 'var(--success)', bg: 'var(--success-soft)', Icon: Check, animate: false },
|
||||
failed: { color: 'var(--error)', bg: 'var(--error-soft)', Icon: X, animate: false },
|
||||
running: { color: 'var(--warning)', bg: 'var(--warning-soft)', Icon: Loader2, animate: true },
|
||||
pending: { color: 'var(--text-tertiary)', bg: 'var(--surface-muted)', Icon: Clock, animate: false },
|
||||
cancelled: { color: 'var(--text-tertiary)', bg: 'var(--surface-muted)', Icon: X, animate: false },
|
||||
}[status] || { color: 'var(--text-tertiary)', bg: 'var(--surface-muted)', Icon: Circle, animate: false };
|
||||
|
||||
const { Icon } = config;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium"
|
||||
style={{ background: config.bg, color: config.color }}
|
||||
>
|
||||
<Icon size={12} className={config.animate ? 'animate-spin' : ''} />
|
||||
{status}
|
||||
</div>
|
||||
);
|
||||
function BuildIcon({ status }: { status: BuildStatus }) {
|
||||
if (status === 'success') return <Check size={14} className="text-[var(--success)]" />;
|
||||
if (status === 'failed') return <X size={14} className="text-[var(--error)]" />;
|
||||
if (status === 'running') return <Loader2 size={14} className="text-[var(--warning)] animate-spin" />;
|
||||
return <Clock size={14} className="text-[var(--text-tertiary)]" />;
|
||||
}
|
||||
|
||||
function bytesToHumanReadable(bytes: number): string {
|
||||
@@ -149,109 +144,113 @@ export function BuildsPage() {
|
||||
return { running, success, failed, total: builds.length };
|
||||
}, [builds]);
|
||||
|
||||
const buildCols: SCol<BuildEntity>[] = [
|
||||
{
|
||||
key: 'build', label: 'Build', width: 'minmax(220px,1.4fr)',
|
||||
sortValue: (b) => b.id,
|
||||
render: (b) => (
|
||||
<span className="flex items-center gap-2.5 min-w-0">
|
||||
<span className="s-ibox"><BuildIcon status={b.status} /></span>
|
||||
<span className="min-w-0">
|
||||
<span className="block v-mono text-[12px] font-medium text-[var(--text-primary)] truncate">{b.id}</span>
|
||||
<span className="block v-mono text-[10.5px] text-[var(--text-tertiary)] truncate">
|
||||
{b.imageName || '—'}{b.imageTag ? `:${b.imageTag}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status', label: 'Status', width: '110px',
|
||||
sortValue: (b) => b.status,
|
||||
render: (b) => <SPill tone={statusTone(b.status)}>{b.status}</SPill>,
|
||||
},
|
||||
{
|
||||
key: 'progress', label: 'Progress', width: '1fr',
|
||||
sortValue: (b) => b.progress,
|
||||
render: (b) =>
|
||||
b.status === 'running' || b.status === 'pending' ? (
|
||||
<span className="flex items-center gap-2.5">
|
||||
<Ticks pct={b.progress} count={40} />
|
||||
<span className="v-mono text-[10.5px] text-[var(--text-tertiary)]">{b.progress}%</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="v-mono text-[10.5px] text-[var(--text-muted)]">—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'size', label: 'Size', width: '90px',
|
||||
sortValue: (b) => b.size,
|
||||
render: (b) => <span className="v-mono text-[11.5px] text-[var(--text-secondary)]">{bytesToHumanReadable(b.size)}</span>,
|
||||
},
|
||||
{
|
||||
key: 'service', label: 'Service', width: '1fr',
|
||||
sortValue: (b) => b.serviceId ?? '',
|
||||
render: (b) => <span className="v-mono text-[11px] text-[var(--text-tertiary)] truncate">{b.serviceId || '—'}</span>,
|
||||
},
|
||||
{
|
||||
key: 'when', label: 'Started', width: '90px',
|
||||
sortValue: (b) => b.startedAt ?? '',
|
||||
render: (b) => <span className="v-mono text-[11px] text-[var(--text-tertiary)]">{b.startedAt ? formatRelative(b.startedAt) : '—'}</span>,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
{/* Header */}
|
||||
<div className="border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/50 backdrop-blur-sm">
|
||||
<div className="w-full px-8 py-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="v-title">Build Pipeline<span className="v-cursor">_</span></h1>
|
||||
<p className="text-sm text-[var(--text-secondary)]">Monitor build progress and manage jobs</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<div className={`w-2 h-2 rounded-full ${liveStatus === 'live' ? 'bg-[var(--success)] animate-pulse' : 'bg-[var(--text-muted)]'}`} />
|
||||
<div className="w-full px-4 pt-6 sm:px-8">
|
||||
<SPageHead
|
||||
title="Build Pipeline"
|
||||
titleAccent="_"
|
||||
sub="Monitor build progress and manage jobs"
|
||||
actions={
|
||||
<>
|
||||
<div className="flex items-center gap-2 text-[11px] v-mono">
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${liveStatus === 'live' ? 'bg-[var(--success)] animate-pulse' : 'bg-[var(--text-muted)]'}`} />
|
||||
<span className={liveStatus === 'live' ? 'text-[var(--success)]' : 'text-[var(--text-muted)]'}>
|
||||
{liveStatus === 'live' ? 'Live' : liveStatus === 'offline' ? 'Reconnecting...' : 'Polling'}
|
||||
{liveStatus === 'live' ? 'live' : liveStatus === 'offline' ? 'reconnecting' : 'polling'}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => queryClient.invalidateQueries({ queryKey: ['builds-page'] })}
|
||||
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm font-medium hover:border-[var(--border-default)] transition-colors"
|
||||
>
|
||||
<IconBtn onClick={() => queryClient.invalidateQueries({ queryKey: ['builds-page'] })} title="Refresh builds">
|
||||
<RefreshCw size={14} />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</IconBtn>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Demo Mode Banner */}
|
||||
{isDemoMode && (
|
||||
<div className="w-full px-8 py-4">
|
||||
<div className="px-4 py-3 rounded-[var(--radius-md)] border border-[var(--warning-soft)] bg-[var(--warning-soft)]/50">
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--warning)]">
|
||||
<Sparkles size={16} />
|
||||
<span>Demo mode active — using sample data</span>
|
||||
</div>
|
||||
<div className="s-inset flex items-center gap-2 text-xs text-[var(--warning)]">
|
||||
<Sparkles size={13} />
|
||||
Demo mode active — using sample data
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats Overview */}
|
||||
<div className="w-full px-8 py-6">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="panel p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="w-8 h-8 rounded-[var(--radius-sm)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
|
||||
<Box size={16} className="text-[var(--accent-primary)]" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-2xl font-semibold text-[var(--text-primary)]">{stats.total}</p>
|
||||
<p className="text-xs text-[var(--text-muted)] mt-1">Total Builds</p>
|
||||
</div>
|
||||
<div className="panel p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="w-8 h-8 rounded-[var(--radius-sm)] bg-[var(--warning-soft)] flex items-center justify-center">
|
||||
<Loader2 size={16} className="text-[var(--warning)]" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-2xl font-semibold text-[var(--warning)]">{stats.running}</p>
|
||||
<p className="text-xs text-[var(--text-muted)] mt-1">In Progress</p>
|
||||
</div>
|
||||
<div className="panel p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="w-8 h-8 rounded-[var(--radius-sm)] bg-[var(--success-soft)] flex items-center justify-center">
|
||||
<Check size={16} className="text-[var(--success)]" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-2xl font-semibold text-[var(--success)]">{stats.success}</p>
|
||||
<p className="text-xs text-[var(--text-muted)] mt-1">Successful</p>
|
||||
</div>
|
||||
<div className="panel p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="w-8 h-8 rounded-[var(--radius-sm)] bg-[var(--error-soft)] flex items-center justify-center">
|
||||
<X size={16} className="text-[var(--error)]" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-2xl font-semibold text-[var(--error)]">{stats.failed}</p>
|
||||
<p className="text-xs text-[var(--text-muted)] mt-1">Failed</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full px-4 py-6 sm:px-8 space-y-4">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3.5">
|
||||
<SStat icon={<Box />} label="Total Builds" value={stats.total} foot="all jobs" />
|
||||
<SStat icon={<Loader2 />} label="In Progress" value={stats.running} foot="building now" />
|
||||
<SStat icon={<Check />} label="Successful" value={stats.success} foot="completed ok" />
|
||||
<SStat icon={<X />} label="Failed" value={stats.failed} foot="need attention" />
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="w-full px-8">
|
||||
<div className="panel p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter size={16} className="text-[var(--text-tertiary)]" />
|
||||
<span className="text-sm font-medium text-[var(--text-secondary)]">Filters</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-end gap-3 mt-3">
|
||||
<div className="flex-1">
|
||||
<SCard icon={<Filter />} title="Filters"
|
||||
trail={<span className="v-mono text-[10.5px] text-[var(--text-muted)]">{builds.length} jobs shown</span>}
|
||||
>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="flex-1 min-w-[280px]">
|
||||
<FilterBar {...filter} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
|
||||
<label className="block text-[10.5px] font-medium uppercase tracking-wider text-[var(--text-muted)] mb-1.5">
|
||||
Page Size
|
||||
</label>
|
||||
<select
|
||||
value={String(limit)}
|
||||
onChange={(e) => setLimit(Number(e.target.value))}
|
||||
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm focus:border-[var(--accent-primary)] transition-colors"
|
||||
className="s-chip !cursor-pointer appearance-none pr-6"
|
||||
>
|
||||
<option value="20">20</option>
|
||||
<option value="50">50</option>
|
||||
@@ -259,127 +258,41 @@ export function BuildsPage() {
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SCard>
|
||||
|
||||
{/* Build Table */}
|
||||
<div className="w-full px-8 py-6">
|
||||
<div className="panel overflow-hidden">
|
||||
<SCard icon={<Box />} title="Build Queue" accent={`${builds.length} jobs`} pad={false}
|
||||
trail={
|
||||
<IconBtn onClick={() => queryClient.invalidateQueries({ queryKey: ['builds-page'] })} title="Refresh">
|
||||
<RefreshCw size={13} />
|
||||
</IconBtn>
|
||||
}
|
||||
>
|
||||
{!isDemoMode && buildsQuery.isLoading ? (
|
||||
<div className="p-12 text-center">
|
||||
<Loader2 size={24} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
|
||||
<p className="mt-3 text-sm text-[var(--text-muted)]">Loading builds...</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isDemoMode && buildsQuery.isError ? (
|
||||
<div className="p-8 text-center">
|
||||
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-[var(--error-soft)] flex items-center justify-center">
|
||||
<X size={24} className="text-[var(--error)]" />
|
||||
</div>
|
||||
) : !isDemoMode && buildsQuery.isError ? (
|
||||
<div className="p-10 text-center">
|
||||
<p className="text-sm text-[var(--error)]">Failed to load builds</p>
|
||||
<p className="text-xs text-[var(--text-muted)] mt-1">{buildsQuery.error instanceof Error ? buildsQuery.error.message : 'Unknown error'}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{builds.length === 0 && !buildsQuery.isLoading ? (
|
||||
) : builds.length === 0 ? (
|
||||
<div className="p-12 text-center">
|
||||
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-[var(--surface-muted)] flex items-center justify-center">
|
||||
<Box size={24} className="text-[var(--text-tertiary)]" />
|
||||
</div>
|
||||
<div className="s-ibox mx-auto mb-3 !w-10 !h-10"><Box /></div>
|
||||
<p className="text-sm text-[var(--text-muted)]">No builds match current filters</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{builds.length > 0 ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 p-4">
|
||||
{builds.map((build) => (
|
||||
<div
|
||||
key={build.id}
|
||||
className="panel p-4 group hover:border-[var(--accent-primary)]/30 transition-all duration-300 card-lift cursor-pointer"
|
||||
onClick={() => setSelectedBuild(build)}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-xl flex items-center justify-center ${
|
||||
build.status === 'success'
|
||||
? 'bg-[var(--success-soft)]'
|
||||
: build.status === 'failed'
|
||||
? 'bg-[var(--error-soft)]'
|
||||
: build.status === 'running'
|
||||
? 'bg-[var(--warning-soft)]'
|
||||
: 'bg-[var(--surface-muted)]'
|
||||
}`}>
|
||||
{build.status === 'success' && <Check size={18} className="text-[var(--success)]" />}
|
||||
{build.status === 'failed' && <X size={18} className="text-[var(--error)]" />}
|
||||
{build.status === 'running' && <Loader2 size={18} className="text-[var(--warning)] animate-spin" />}
|
||||
{build.status === 'pending' && <Clock size={18} className="text-[var(--text-tertiary)]" />}
|
||||
{build.status === 'cancelled' && <X size={18} className="text-[var(--text-tertiary)]" />}
|
||||
</div>
|
||||
<div>
|
||||
<p className="mono text-sm font-medium text-[var(--text-primary)]">{build.id}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<StatusBadge status={build.status} />
|
||||
<span className="text-xs text-[var(--text-tertiary)]">
|
||||
{build.startedAt ? formatRelative(build.startedAt) : '—'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button className="p-2 rounded-lg hover:bg-[var(--surface-muted)] text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-all">
|
||||
<FileText size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* Image info */}
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-[var(--text-tertiary)]">Image</span>
|
||||
<span className="mono text-[var(--text-secondary)]">
|
||||
{build.imageName || '—'}:{build.imageTag || 'latest'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
{(build.status === 'running' || build.status === 'pending') && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-[var(--text-tertiary)]">Progress</span>
|
||||
<span className="text-[var(--text-secondary)] font-medium">{build.progress}%</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-[var(--surface-muted)] overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-300"
|
||||
style={{ width: `${Math.max(0, Math.min(100, build.progress))}%`, background: 'var(--accent-primary)' }}
|
||||
) : (
|
||||
<div className="px-4">
|
||||
<STable
|
||||
cols={buildCols}
|
||||
rows={builds}
|
||||
rowKey={(b) => b.id}
|
||||
onRowClick={(b) => setSelectedBuild(b)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Size and service */}
|
||||
<div className="flex items-center justify-between text-xs pt-2 border-t border-[var(--border-subtle)]">
|
||||
<div className="flex items-center gap-1.5 text-[var(--text-tertiary)]">
|
||||
<HardDrive size={10} />
|
||||
<span>{bytesToHumanReadable(build.size)}</span>
|
||||
</div>
|
||||
<span className="mono text-[var(--text-tertiary)]">{build.serviceId || '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error message if present */}
|
||||
{build.error && (
|
||||
<div className="mt-3 p-2 rounded-lg bg-[var(--error-soft)]/50 border border-[var(--error)]/20">
|
||||
<p className="text-xs text-[var(--error)] line-clamp-2">{build.error}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</SCard>
|
||||
</div>
|
||||
|
||||
{/* Error Toast */}
|
||||
@@ -393,27 +306,21 @@ export function BuildsPage() {
|
||||
{selectedBuild && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-[var(--bg-void)]/80 backdrop-blur-sm" onClick={() => setSelectedBuild(null)} />
|
||||
<div className="relative w-full max-w-3xl panel p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
|
||||
<FileText size={20} className="text-[var(--accent-primary)]" />
|
||||
</div>
|
||||
<div className="relative w-full max-w-3xl s-card">
|
||||
<div className="s-cardhead">
|
||||
<span className="s-ibox"><FileText /></span>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Build Logs</h2>
|
||||
<p className="mono text-xs text-[var(--text-tertiary)]">{selectedBuild.id}</p>
|
||||
<div className="s-t">Build Logs</div>
|
||||
<div className="v-mono mt-0.5 text-[11px] text-[var(--text-tertiary)]">{selectedBuild.id}</div>
|
||||
</div>
|
||||
<div className="s-trail">
|
||||
<QuietBtn onClick={() => setSelectedBuild(null)}>Close</QuietBtn>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedBuild(null)}
|
||||
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm font-medium hover:border-[var(--border-default)] transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-4 pb-4">
|
||||
|
||||
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-void)] p-4 max-h-[400px] overflow-auto">
|
||||
<pre className="mono text-xs text-[var(--text-secondary)] whitespace-pre-wrap break-all">
|
||||
<pre className="v-mono text-xs text-[var(--text-secondary)] whitespace-pre-wrap break-all">
|
||||
{isDemoMode
|
||||
? selectedBuild.log || '[demo] No logs available.'
|
||||
: logsQuery.isLoading
|
||||
@@ -425,6 +332,7 @@ export function BuildsPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,662 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQueries, useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart as RechartsLineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Cpu,
|
||||
FolderKanban,
|
||||
HardDrive,
|
||||
MemoryStick,
|
||||
RefreshCw,
|
||||
Rocket,
|
||||
Server,
|
||||
Settings,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
getAgentMetrics,
|
||||
getHostMonitoring,
|
||||
listActiveAlerts,
|
||||
listAgents,
|
||||
listProjects,
|
||||
listRecentDeployments,
|
||||
type AgentMetricPoint,
|
||||
type NodeAgentEntity,
|
||||
} from '@/lib/api-client';
|
||||
import { useAuthSession } from '@/lib/use-auth-session';
|
||||
import { useDemoMode } from '@/lib/demo-mode';
|
||||
import { demoHostMonitoring, demoProjects } from '@/lib/demo-data';
|
||||
import {
|
||||
GhostBtn,
|
||||
IconBtn,
|
||||
MiniBars,
|
||||
SAnomaly,
|
||||
SCard,
|
||||
SLegend,
|
||||
SPageHead,
|
||||
SPill,
|
||||
STable,
|
||||
SStat,
|
||||
Ticks,
|
||||
type SCol,
|
||||
} from '@/shared/components/sentry';
|
||||
import { statusTone } from '@/shared/components/sentry-utils';
|
||||
import { QuietBtn } from '@/shared/components/sentry';
|
||||
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Demo fixtures — deterministic, clearly sample data. */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const demoAgents: NodeAgentEntity[] = [
|
||||
{
|
||||
id: 'demo-node-1',
|
||||
name: 'core-01',
|
||||
hostname: 'core-01.containr.local',
|
||||
ipAddress: '10.0.1.11',
|
||||
port: 9090,
|
||||
status: 'online',
|
||||
version: '0.9.2',
|
||||
capabilities: {
|
||||
containerRuntimes: ['docker'],
|
||||
supportedArchitectures: ['amd64'],
|
||||
maxContainers: 64,
|
||||
storageDriver: 'overlay2',
|
||||
networkPlugins: ['bridge'],
|
||||
features: [],
|
||||
},
|
||||
resources: {
|
||||
cpu: { cores: 8, allocation: 8, usage: 2.6 },
|
||||
memory: { total: 16_662_000_000, allocated: 0, used: 9_832_000_000, available: 6_830_000_000 },
|
||||
storage: { total: 240_000_000_000, allocated: 0, used: 141_000_000_000, available: 99_000_000_000 },
|
||||
},
|
||||
autoPrune: true,
|
||||
schedulable: true,
|
||||
tags: ['prod'],
|
||||
metadata: {},
|
||||
lastHeartbeat: new Date(Date.now() - 42_000).toISOString(),
|
||||
},
|
||||
{
|
||||
id: 'demo-node-2',
|
||||
name: 'edge-01',
|
||||
hostname: 'edge-01.containr.local',
|
||||
ipAddress: '10.0.2.4',
|
||||
port: 9090,
|
||||
status: 'degraded',
|
||||
version: '0.9.2',
|
||||
capabilities: {
|
||||
containerRuntimes: ['docker'],
|
||||
supportedArchitectures: ['arm64'],
|
||||
maxContainers: 32,
|
||||
storageDriver: 'overlay2',
|
||||
networkPlugins: ['bridge'],
|
||||
features: [],
|
||||
},
|
||||
resources: {
|
||||
cpu: { cores: 4, allocation: 4, usage: 3.4 },
|
||||
memory: { total: 8_290_000_000, allocated: 0, used: 7_110_000_000, available: 1_180_000_000 },
|
||||
storage: { total: 120_000_000_000, allocated: 0, used: 98_000_000_000, available: 22_000_000_000 },
|
||||
},
|
||||
autoPrune: false,
|
||||
schedulable: true,
|
||||
tags: ['edge'],
|
||||
metadata: {},
|
||||
lastHeartbeat: new Date(Date.now() - 610_000).toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
/* Deterministic pseudo metric series for demo mode (sine + wobble). */
|
||||
function demoMetricSeries(seed: number, points = 48): AgentMetricPoint[] {
|
||||
const now = Date.now();
|
||||
return Array.from({ length: points }, (_, i) => {
|
||||
const t = now - (points - 1 - i) * 30 * 60_000;
|
||||
const wave = Math.sin(i / 5.5 + seed) * 14 + Math.sin(i / 2.3 + seed * 2) * 6;
|
||||
const cpu = Math.max(4, Math.min(96, 42 + wave + seed * 9));
|
||||
const mem = Math.max(20, Math.min(95, 55 + wave / 2 + seed * 6));
|
||||
return {
|
||||
timestamp: new Date(t).toISOString(),
|
||||
cpu: { usage: cpu, usage_percent: cpu, cores: 8 },
|
||||
memory: { usage: mem, usage_percent: mem, limit: 100, available: 100 - mem },
|
||||
system_load: { load_1m: cpu / 12, load_5m: cpu / 14, load_15m: cpu / 16 },
|
||||
container_count: 12 + seed * 5,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const demoDeployRows = [
|
||||
{ id: 'd1', serviceId: 's1', serviceName: 'api-gateway', projectName: 'Core Platform', status: 'deployed', imageName: undefined, startedAt: new Date(Date.now() - 240_000).toISOString(), completedAt: undefined, createdAt: '' },
|
||||
{ id: 'd2', serviceId: 's2', serviceName: 'web-frontend', projectName: 'Core Platform', status: 'building', imageName: undefined, startedAt: new Date(Date.now() - 390_000).toISOString(), completedAt: undefined, createdAt: '' },
|
||||
{ id: 'd3', serviceId: 's3', serviceName: 'batch-evaluator', projectName: 'Inference Lab', status: 'failed', imageName: undefined, startedAt: new Date(Date.now() - 3_900_000).toISOString(), completedAt: undefined, createdAt: '' },
|
||||
{ id: 'd4', serviceId: 's4', serviceName: 'marketing-site', projectName: 'Growth Surface', status: 'deployed', imageName: undefined, startedAt: new Date(Date.now() - 7_400_000).toISOString(), completedAt: undefined, createdAt: '' },
|
||||
];
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const SERIES_COLORS = ['#38bdf8', '#1cd8ec', '#a271d9', '#45e896'];
|
||||
const MAX_CHART_AGENTS = 4;
|
||||
|
||||
type RangeKey = '1h' | '24h' | '7d';
|
||||
|
||||
function formatRelative(date?: string): string {
|
||||
if (!date) return '—';
|
||||
const diff = Date.now() - new Date(date).getTime();
|
||||
if (diff < 0 || diff > 365 * 86_400_000) return '—';
|
||||
if (diff < 60_000) return 'just now';
|
||||
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
|
||||
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
|
||||
return `${Math.floor(diff / 86_400_000)}d ago`;
|
||||
}
|
||||
|
||||
const fmtGB = (v: number) => `${(v / (1024 * 1024 * 1024)).toFixed(0)}G`;
|
||||
const pct = (used: number, total: number) => (total > 0 ? Math.round((used / total) * 100) : 0);
|
||||
|
||||
/* Merge per-agent metric arrays into recharts rows keyed by index. */
|
||||
function mergeSeries(seriesList: { key: string; points: AgentMetricPoint[] }[]) {
|
||||
const len = Math.max(...seriesList.map((s) => s.points.length), 0);
|
||||
return Array.from({ length: len }, (_, i) => {
|
||||
const row: Record<string, number | string> = {};
|
||||
seriesList.forEach((s) => {
|
||||
const p = s.points[Math.min(i, s.points.length - 1)];
|
||||
if (p) {
|
||||
row.t = row.t ?? new Date(p.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
row[s.key] = Math.round(p.cpu.usage_percent * 10) / 10;
|
||||
}
|
||||
});
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
function ChartTooltip({ active, payload, label }: {
|
||||
active?: boolean;
|
||||
payload?: Array<{ name: string; value: number; color: string }>;
|
||||
label?: string;
|
||||
}) {
|
||||
if (!active || !payload?.length) return null;
|
||||
return (
|
||||
<div className="s-tip">
|
||||
<div className="s-tip-t">{label}</div>
|
||||
{payload.map((p) => (
|
||||
<div key={p.name} className="s-tip-r">
|
||||
<span><i style={{ background: p.color }} />{p.name}</span>
|
||||
<b>{p.value}<small>%</small></b>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
const isDemoMode = useDemoMode();
|
||||
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
|
||||
const signedIn = isDemoMode || Boolean(sessionQuery.data);
|
||||
const userName = sessionQuery.data?.user.name;
|
||||
const firstName = isDemoMode ? 'Operator' : userName?.split(' ')[0];
|
||||
const [range, setRange] = useState<RangeKey>('24h');
|
||||
|
||||
const projectsQuery = useQuery({
|
||||
queryKey: ['projects'],
|
||||
enabled: !isDemoMode,
|
||||
queryFn: () => listProjects(),
|
||||
});
|
||||
const hostQuery = useQuery({
|
||||
queryKey: ['host-monitoring'],
|
||||
enabled: !isDemoMode,
|
||||
queryFn: getHostMonitoring,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
// Agents are admin-scoped; failures collapse to an empty list, never an error state.
|
||||
const agentsQuery = useQuery({
|
||||
queryKey: ['agents'],
|
||||
enabled: !isDemoMode && signedIn,
|
||||
queryFn: listAgents,
|
||||
retry: false,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const deploysQuery = useQuery({
|
||||
queryKey: ['recent-deploys', 50],
|
||||
enabled: !isDemoMode,
|
||||
queryFn: () => listRecentDeployments(50),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
const alertsQuery = useQuery({
|
||||
queryKey: ['ha-alerts'],
|
||||
enabled: !isDemoMode && signedIn,
|
||||
queryFn: listActiveAlerts,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const projects = isDemoMode ? demoProjects : projectsQuery.data ?? [];
|
||||
const host = isDemoMode ? demoHostMonitoring : hostQuery.data;
|
||||
const agents = isDemoMode ? demoAgents : agentsQuery.data ?? [];
|
||||
const deployments = isDemoMode ? demoDeployRows : deploysQuery.data ?? [];
|
||||
const alerts = isDemoMode ? [{ id: 'a1', severity: 'warning' as const, message: 'Disk I/O spikes at 03:22' }] : alertsQuery.data ?? [];
|
||||
|
||||
const chartAgents = agents.slice(0, MAX_CHART_AGENTS);
|
||||
const metricQueries = useQueries({
|
||||
queries: chartAgents.map((a) => ({
|
||||
queryKey: ['agent-metrics', a.id, range],
|
||||
queryFn: () => getAgentMetrics(a.id, range),
|
||||
enabled: !isDemoMode && agents.length > 0,
|
||||
staleTime: 60_000,
|
||||
retry: false,
|
||||
})),
|
||||
});
|
||||
|
||||
/* Series: real per-agent CPU%; demo synth; empty otherwise.
|
||||
Plain computation — the compiler memoizes it. */
|
||||
const seriesList = chartAgents.map((a, i) => ({
|
||||
key: a.name || a.hostname || `node-${i + 1}`,
|
||||
points: isDemoMode ? demoMetricSeries(i + 1) : metricQueries[i]?.data ?? [],
|
||||
}));
|
||||
const chartData = {
|
||||
rows: mergeSeries(seriesList),
|
||||
series: seriesList.map((s, i) => ({ key: s.key, color: SERIES_COLORS[i % SERIES_COLORS.length] })),
|
||||
hasData: seriesList.some((s) => s.points.length > 1),
|
||||
};
|
||||
|
||||
const hostLabel = host?.scope === 'docker-desktop-vm' ? 'VM' : 'Host';
|
||||
const cpuPct = host && host.cpu.cores > 0
|
||||
? Math.min(100, Math.round((host.load.load1m / host.cpu.cores) * 100))
|
||||
: null;
|
||||
const memPct = host ? Math.round(host.memory.usagePercent) : null;
|
||||
const diskPct = host ? Math.round(host.storage.usagePercent) : null;
|
||||
|
||||
const totalServices = projects.reduce((sum, p) => sum + p.stats.service_count, 0);
|
||||
const runningServices = projects.reduce((sum, p) => sum + p.stats.running_services, 0);
|
||||
const onlineAgents = agents.filter((a) => a.status === 'online').length;
|
||||
const succeeded = deployments.filter((d) => !['failed', 'error'].includes((d.status ?? '').toLowerCase())).length;
|
||||
|
||||
const anomalies = [
|
||||
...(diskPct !== null && diskPct > 85 ? [`Disk usage at ${diskPct}% — prune images or expand volume`] : []),
|
||||
...(memPct !== null && memPct > 85 ? [`Memory pressure at ${memPct}% — rebalance services`] : []),
|
||||
...alerts.slice(0, 3).map((a) => a.message).filter((m): m is string => Boolean(m)),
|
||||
...(deployments.some((d) => (d.status ?? '').toLowerCase() === 'failed')
|
||||
? [`${deployments.filter((d) => (d.status ?? '').toLowerCase() === 'failed').length} failed deployment${deployments.filter((d) => (d.status ?? '').toLowerCase() === 'failed').length > 1 ? 's' : ''} in the feed`]
|
||||
: []),
|
||||
];
|
||||
|
||||
const statDefs = [
|
||||
{
|
||||
icon: <Server size={13.5} />,
|
||||
label: 'Active Nodes',
|
||||
value: agents.length > 0 ? onlineAgents : host?.dockerAvailable ? 1 : '—',
|
||||
unit: agents.length > 0 ? `of ${agents.length}` : 'local',
|
||||
ticks: null,
|
||||
foot: agents.length > 0 ? `${agents.length - onlineAgents} offline or degraded` : 'single-host install',
|
||||
delta: undefined,
|
||||
},
|
||||
{
|
||||
icon: <Cpu size={13.5} />,
|
||||
label: `${hostLabel} CPU`,
|
||||
value: cpuPct ?? '—',
|
||||
unit: cpuPct !== null ? '%' : undefined,
|
||||
ticks: cpuPct,
|
||||
foot: host ? `${host.cpu.cores} cores · load avg` : 'no telemetry',
|
||||
delta: undefined,
|
||||
},
|
||||
{
|
||||
icon: <MemoryStick size={13.5} />,
|
||||
label: 'Memory Usage',
|
||||
value: memPct ?? '—',
|
||||
unit: memPct !== null ? '%' : undefined,
|
||||
ticks: memPct,
|
||||
foot: host ? `${fmtGB(host.memory.used)} / ${fmtGB(host.memory.total)} used` : 'no telemetry',
|
||||
delta: undefined,
|
||||
},
|
||||
{
|
||||
icon: <Rocket size={13.5} />,
|
||||
label: 'Deployments',
|
||||
value: deployments.length,
|
||||
unit: 'recent',
|
||||
ticks: null,
|
||||
foot: `${succeeded} succeeded`,
|
||||
delta: deployments.length > 0 ? { dir: 'up' as const, text: `+${deployments.length}` } : undefined,
|
||||
},
|
||||
];
|
||||
|
||||
type NodeRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
status: string;
|
||||
cpu: number | null;
|
||||
mem: number | null;
|
||||
disk: number | null;
|
||||
containers: string;
|
||||
heartbeat: string;
|
||||
};
|
||||
|
||||
const nodeRows: NodeRow[] = agents.length > 0
|
||||
? agents.map((a) => ({
|
||||
id: a.id,
|
||||
name: a.name || a.hostname,
|
||||
address: `${a.ipAddress}${a.port ? `:${a.port}` : ''}`,
|
||||
status: a.status,
|
||||
cpu: a.resources.cpu.cores > 0 ? Math.round((a.resources.cpu.usage / a.resources.cpu.cores) * 100) : null,
|
||||
mem: pct(a.resources.memory.used, a.resources.memory.total),
|
||||
disk: pct(a.resources.storage.used, a.resources.storage.total),
|
||||
containers: String(a.capabilities.maxContainers ? `≤${a.capabilities.maxContainers}` : '—'),
|
||||
heartbeat: formatRelative(a.lastHeartbeat),
|
||||
}))
|
||||
: host
|
||||
? [{
|
||||
id: 'local',
|
||||
name: host.hostname || 'local host',
|
||||
address: `${host.os}/${host.architecture}`,
|
||||
status: host.dockerAvailable ? 'online' : 'degraded',
|
||||
cpu: cpuPct,
|
||||
mem: memPct,
|
||||
disk: diskPct,
|
||||
containers: host.docker?.containers !== undefined ? String(host.docker.containers) : '—',
|
||||
heartbeat: 'live',
|
||||
}]
|
||||
: [];
|
||||
|
||||
const metricCell = (v: number | null) =>
|
||||
v === null ? <span className="text-[var(--text-muted)]">—</span> : (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<MiniBars tone={v > 85 ? 'var(--error)' : v > 70 ? 'var(--warning)' : 'var(--accent-primary)'} />
|
||||
<span className="text-[12.5px] font-medium text-[var(--text-primary)]">{v}<small className="text-[var(--text-tertiary)] font-normal">%</small></span>
|
||||
</span>
|
||||
);
|
||||
|
||||
const nodeCols: SCol<NodeRow>[] = [
|
||||
{ key: 'name', label: 'Node', width: '1.7fr', sortValue: (r) => r.name,
|
||||
render: (r) => <span className="text-[var(--text-primary)] font-medium">{r.name}</span> },
|
||||
{ key: 'address', label: 'Address', width: '1.15fr', sortValue: (r) => r.address,
|
||||
render: (r) => <span className="v-mono text-[11.5px] text-[var(--text-secondary)]">{r.address}</span> },
|
||||
{ key: 'status', label: 'Status', width: '0.85fr', sortValue: (r) => r.status,
|
||||
render: (r) => <SPill tone={statusTone(r.status)}>{r.status}</SPill> },
|
||||
{ key: 'cpu', label: 'CPU', width: '0.9fr', sortValue: (r) => r.cpu ?? -1, render: (r) => metricCell(r.cpu) },
|
||||
{ key: 'mem', label: 'Memory', width: '0.9fr', sortValue: (r) => r.mem ?? -1, render: (r) => metricCell(r.mem) },
|
||||
{ key: 'disk', label: 'Disk', width: '0.9fr', sortValue: (r) => r.disk ?? -1, render: (r) => metricCell(r.disk) },
|
||||
{ key: 'containers', label: 'Capacity', width: '0.8fr', sortable: false,
|
||||
render: (r) => <span className="v-mono text-[11.5px] text-[var(--text-secondary)]">{r.containers}</span> },
|
||||
{ key: 'heartbeat', label: 'Heartbeat', width: '0.8fr', sortable: false,
|
||||
render: (r) => <span className="v-mono text-[11.5px] text-[var(--text-tertiary)]">{r.heartbeat}</span> },
|
||||
];
|
||||
|
||||
type DeployRow = { id: string; service: string; project: string; status: string; when: string };
|
||||
const deployRows: DeployRow[] = deployments.slice(0, 7).map((d) => ({
|
||||
id: d.id,
|
||||
service: d.serviceName || d.imageName || d.serviceId || d.id,
|
||||
project: d.projectName || '—',
|
||||
status: d.status || 'queued',
|
||||
when: formatRelative(d.startedAt ?? d.completedAt ?? d.createdAt),
|
||||
}));
|
||||
|
||||
const deployCols: SCol<DeployRow>[] = [
|
||||
{ key: 'service', label: 'Service', width: '1.6fr', sortValue: (r) => r.service,
|
||||
render: (r) => (
|
||||
<span className="text-[var(--text-primary)] font-medium">
|
||||
{r.service}
|
||||
<span className="v-mono ml-2 text-[10.5px] font-normal text-[var(--text-tertiary)]">{r.project}</span>
|
||||
</span>
|
||||
) },
|
||||
{ key: 'status', label: 'Status', width: '0.9fr', sortValue: (r) => r.status,
|
||||
render: (r) => <SPill tone={statusTone(r.status)}>{r.status}</SPill> },
|
||||
{ key: 'when', label: 'When', width: '0.55fr', sortable: false,
|
||||
render: (r) => <span className="v-mono text-[11.5px] text-[var(--text-tertiary)]">{r.when}</span> },
|
||||
];
|
||||
|
||||
const refetchAll = () => {
|
||||
projectsQuery.refetch();
|
||||
hostQuery.refetch();
|
||||
deploysQuery.refetch();
|
||||
agentsQuery.refetch();
|
||||
alertsQuery.refetch();
|
||||
metricQueries.forEach((q) => q.refetch());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<div className="w-full px-4 py-6 sm:px-7">
|
||||
<SPageHead
|
||||
title={firstName ? 'Welcome back,' : 'Fleet'}
|
||||
titleAccent={firstName ?? 'overview'}
|
||||
sub={
|
||||
<>
|
||||
{String(runningServices).padStart(2, '0')} of {String(totalServices).padStart(2, '0')} services online ·{' '}
|
||||
</>
|
||||
}
|
||||
subAccent={agents.length > 0 ? `${onlineAgents}/${agents.length} nodes live` : 'single host'}
|
||||
actions={
|
||||
<>
|
||||
{signedIn && (
|
||||
<GhostBtn onClick={() => navigate('/projects')}>+ New Project</GhostBtn>
|
||||
)}
|
||||
<QuietBtn onClick={() => navigate('/operations')}>
|
||||
<AlertTriangle size={13} />
|
||||
View alerts
|
||||
</QuietBtn>
|
||||
<IconBtn title="Refresh" onClick={refetchAll}>
|
||||
<RefreshCw size={14} />
|
||||
</IconBtn>
|
||||
<IconBtn title="Settings" onClick={() => navigate('/settings')}>
|
||||
<Settings size={14} />
|
||||
</IconBtn>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Stat row */}
|
||||
<div className="mb-4 grid grid-cols-2 gap-3.5 xl:grid-cols-4">
|
||||
{statDefs.map((s) => (
|
||||
<SStat
|
||||
key={s.label}
|
||||
icon={s.icon}
|
||||
label={s.label}
|
||||
value={s.value}
|
||||
unit={s.unit}
|
||||
foot={s.foot}
|
||||
delta={s.delta}
|
||||
extra={s.ticks !== null && s.ticks !== undefined ? <Ticks pct={s.ticks} count={44} warn={s.ticks > 85} /> : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Chart + resource usage */}
|
||||
<div className="mb-4 grid grid-cols-1 gap-3.5 xl:grid-cols-[2.32fr_1fr]">
|
||||
<SCard
|
||||
icon={<Cpu size={13.5} />}
|
||||
title="Node CPU Load"
|
||||
accent={`(${range})`}
|
||||
trail={
|
||||
<IconBtn title="Refresh" onClick={refetchAll}>
|
||||
<RefreshCw size={13} />
|
||||
</IconBtn>
|
||||
}
|
||||
pad={false}
|
||||
>
|
||||
<SLegend items={chartData.series.map((s) => ({ color: s.color, label: s.key }))} />
|
||||
<div className="px-3 pb-3">
|
||||
{chartData.hasData ? (
|
||||
<ResponsiveContainer width="100%" height={292} initialDimension={{ width: 640, height: 292 }}>
|
||||
<RechartsLineChart data={chartData.rows} margin={{ top: 14, right: 14, bottom: 4, left: -14 }}>
|
||||
<CartesianGrid stroke="var(--border-subtle)" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="t"
|
||||
tick={{ fill: 'var(--text-tertiary)', fontSize: 10.5, fontFamily: 'IBM Plex Mono' }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: 'var(--border-subtle)' }}
|
||||
minTickGap={48}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, 100]}
|
||||
tick={{ fill: 'var(--text-tertiary)', fontSize: 10, fontFamily: 'IBM Plex Mono' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(v: number) => `${v}%`}
|
||||
/>
|
||||
<Tooltip content={<ChartTooltip />} cursor={{ stroke: 'var(--border-default)' }} />
|
||||
{chartData.series.map((s, i) => (
|
||||
<Line
|
||||
key={s.key}
|
||||
dataKey={s.key}
|
||||
stroke={s.color}
|
||||
strokeWidth={i === 0 ? 1.6 : 1.3}
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
))}
|
||||
</RechartsLineChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="flex h-[292px] flex-col items-center justify-center gap-2 text-center">
|
||||
<Server size={22} className="text-[var(--text-tertiary)]" />
|
||||
<p className="text-[12.5px] text-[var(--text-secondary)]">No node telemetry yet</p>
|
||||
<p className="max-w-xs text-[11px] text-[var(--text-tertiary)]">
|
||||
Enroll a Containr agent on a host to chart per-node CPU over time.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* range picker rendered as a real control row under the chart */}
|
||||
<div className="flex items-center gap-1.5 border-t border-[var(--border-subtle)] px-4 py-2">
|
||||
{(['1h', '24h', '7d'] as RangeKey[]).map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => setRange(r)}
|
||||
className={`rounded-[5px] px-2 py-1 text-[10.5px] font-medium transition-colors ${
|
||||
range === r
|
||||
? 'bg-[var(--nav-active)] text-[var(--text-primary)]'
|
||||
: 'text-[var(--text-tertiary)] hover:text-[var(--text-primary)]'
|
||||
}`}
|
||||
>
|
||||
{r}
|
||||
</button>
|
||||
))}
|
||||
<span className="v-mono ml-auto text-[10.5px] text-[var(--text-muted)]">
|
||||
{chartAgents.length > 0 ? `${chartAgents.length} node${chartAgents.length > 1 ? 's' : ''} sampled` : 'awaiting agents'}
|
||||
</span>
|
||||
</div>
|
||||
</SCard>
|
||||
|
||||
<SCard
|
||||
icon={<HardDrive size={13.5} />}
|
||||
title="Resource Usage"
|
||||
accent="(host)"
|
||||
pad={false}
|
||||
>
|
||||
<div className="px-4 pt-1">
|
||||
{[
|
||||
{ label: 'CPU Load', v: cpuPct },
|
||||
{ label: 'Memory', v: memPct },
|
||||
{ label: 'Disk I/O', v: diskPct },
|
||||
{ label: 'Services online', v: totalServices > 0 ? Math.round((runningServices / totalServices) * 100) : null },
|
||||
].map((r) => (
|
||||
<div key={r.label} className="py-2">
|
||||
<div className="mb-2 flex items-baseline justify-between text-[12px]">
|
||||
<span className="font-medium text-[var(--text-secondary)]">{r.label}</span>
|
||||
<b className="font-semibold text-[var(--accent-primary)]">{r.v !== null ? `${r.v}%` : '—'}</b>
|
||||
</div>
|
||||
<Ticks pct={r.v ?? 0} count={56} warn={(r.v ?? 0) > 85} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="p-4 pt-2.5">
|
||||
<SAnomaly
|
||||
icon={<AlertTriangle size={12.5} className="text-[var(--accent-primary)]" />}
|
||||
title={
|
||||
<>
|
||||
<b className="font-semibold text-[var(--text-primary)]">{anomalies.length}</b>
|
||||
{anomalies.length === 1 ? ' anomaly' : ' anomalies'} detected
|
||||
</>
|
||||
}
|
||||
items={anomalies.length > 0 ? anomalies : ['All systems nominal']}
|
||||
/>
|
||||
</div>
|
||||
</SCard>
|
||||
</div>
|
||||
|
||||
{/* Node summary */}
|
||||
<SCard
|
||||
icon={<Server size={13.5} />}
|
||||
title="Node Summary"
|
||||
trail={
|
||||
<IconBtn title="Refresh" onClick={() => agentsQuery.refetch()}>
|
||||
<RefreshCw size={13} />
|
||||
</IconBtn>
|
||||
}
|
||||
pad={false}
|
||||
>
|
||||
<STable
|
||||
cols={nodeCols}
|
||||
rows={nodeRows}
|
||||
rowKey={(r) => r.id}
|
||||
selectable
|
||||
onRowClick={(r) => { if (r.id !== 'local') navigate(`/nodes/${r.id}`); }}
|
||||
/>
|
||||
{nodeRows.length === 0 && (
|
||||
<p className="px-7 pb-5 text-[12px] text-[var(--text-tertiary)]">
|
||||
No nodes enrolled. The local host appears here once telemetry arrives.
|
||||
</p>
|
||||
)}
|
||||
</SCard>
|
||||
|
||||
{/* Deployments + projects */}
|
||||
<div className="mt-4 grid grid-cols-1 gap-3.5 xl:grid-cols-[1.6fr_1fr]">
|
||||
<SCard
|
||||
icon={<Rocket size={13.5} />}
|
||||
title="Recent Deployments"
|
||||
accent={deployRows.length > 0 ? `(${deployments.length})` : undefined}
|
||||
trail={
|
||||
<IconBtn title="Refresh" onClick={() => deploysQuery.refetch()}>
|
||||
<RefreshCw size={13} />
|
||||
</IconBtn>
|
||||
}
|
||||
pad={false}
|
||||
>
|
||||
<STable cols={deployCols} rows={deployRows} rowKey={(r) => r.id} />
|
||||
{deployRows.length === 0 && (
|
||||
<p className="px-7 pb-5 text-[12px] text-[var(--text-tertiary)]">No deployments yet.</p>
|
||||
)}
|
||||
</SCard>
|
||||
|
||||
<SCard
|
||||
icon={<FolderKanban size={13.5} />}
|
||||
title="Projects"
|
||||
accent={`(${projects.length})`}
|
||||
pad={false}
|
||||
>
|
||||
<div className="px-2 pb-2">
|
||||
{projects.slice(0, 6).map((p) => {
|
||||
const running = p.stats.running_services;
|
||||
const total = p.stats.service_count;
|
||||
const tone = total === 0 ? 'off' : running === total ? 'ok' : running > 0 ? 'warn' : 'err';
|
||||
return (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => navigate(isDemoMode ? `/projects/${p.id}?demo=1` : `/projects/${p.id}`)}
|
||||
className="flex w-full items-center gap-3 rounded-[8px] px-3 py-2.5 text-left transition-colors hover:bg-[var(--surface-muted)]"
|
||||
>
|
||||
<span className="s-ibox"><FolderKanban size={13.5} /></span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[12.5px] font-medium text-[var(--text-primary)]">{p.name}</span>
|
||||
<span className="v-mono block text-[10.5px] text-[var(--text-tertiary)]">
|
||||
{running}/{total} services · {p.stats.deployment_count} deploys
|
||||
</span>
|
||||
</span>
|
||||
<SPill tone={tone}>{total === 0 ? 'idle' : running === total ? 'healthy' : running > 0 ? 'partial' : 'down'}</SPill>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{projects.length === 0 && (
|
||||
<p className="px-5 py-4 text-[12px] text-[var(--text-tertiary)]">No projects yet.</p>
|
||||
)}
|
||||
</div>
|
||||
</SCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ function renderMarkdown(markdown: string): string {
|
||||
|
||||
function excerpt(markdown: string, length = 140): string {
|
||||
const text = markdown
|
||||
.replace(/^#[^\n]*\n/, ' ')
|
||||
.replace(/```[\s\S]*?```/g, ' ')
|
||||
.replace(/[#>*`\-[\]()]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
@@ -119,7 +120,7 @@ export function DocsBrowser() {
|
||||
|
||||
<div className="min-w-0 flex-1 overflow-y-auto max-md:hidden">
|
||||
{selected ? (
|
||||
<article className="docs-article px-8 py-6">
|
||||
<article className="docs-article px-4 py-6 sm:px-8">
|
||||
<p className="mb-1 text-[11px] font-medium uppercase tracking-wider text-[var(--text-tertiary)]">
|
||||
{selected.section} / {selected.path}
|
||||
</p>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
@@ -8,16 +9,18 @@ import {
|
||||
GitBranch,
|
||||
LayoutTemplate,
|
||||
Rocket,
|
||||
Server,
|
||||
ShieldCheck,
|
||||
Terminal,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import { listProjects, listTemplates } from '@/lib/api-client';
|
||||
import { getHostMonitoring, listProjects, listRecentDeployments, listTemplates } from '@/lib/api-client';
|
||||
import { DocsBrowser } from '@/features/docs/DocsBrowser';
|
||||
import { useAuthSession } from '@/lib/use-auth-session';
|
||||
import { useDemoMode } from '@/lib/demo-mode';
|
||||
import { useBranding } from '@/lib/use-branding';
|
||||
import { BrandWordmark } from '@/shared/components';
|
||||
import { demoProjects, demoTemplates } from '@/lib/demo-data';
|
||||
import { BrandWordmark, MiniBars, SPill, Ticks } from '@/shared/components';
|
||||
import { demoHostMonitoring, demoProjects, demoTemplates } from '@/lib/demo-data';
|
||||
|
||||
const features = [
|
||||
{ icon: Rocket, title: 'Git-push deploys', text: 'Connect a repository and ship. Builds, rollouts, and rollbacks are handled for you.' },
|
||||
@@ -28,7 +31,14 @@ const features = [
|
||||
{ icon: Zap, title: 'Autoscaling', text: 'Metric-driven scaling policies adapt replicas to real load.' },
|
||||
];
|
||||
|
||||
const demoDeploys = [
|
||||
{ id: 'd1', serviceName: 'api-gateway', status: 'deployed', startedAt: new Date(Date.now() - 240_000).toISOString() },
|
||||
{ id: 'd2', serviceName: 'web-frontend', status: 'building', startedAt: new Date(Date.now() - 390_000).toISOString() },
|
||||
{ id: 'd3', serviceName: 'batch-evaluator', status: 'failed', startedAt: new Date(Date.now() - 3_900_000).toISOString() },
|
||||
];
|
||||
|
||||
export function LandingPage() {
|
||||
const [now] = useState(() => Date.now());
|
||||
const isDemoMode = useDemoMode();
|
||||
const { logoUrl, productName } = useBranding();
|
||||
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
|
||||
@@ -37,19 +47,35 @@ export function LandingPage() {
|
||||
|
||||
const projectsQuery = useQuery({ queryKey: ['landing-projects'], queryFn: () => listProjects(), staleTime: 60_000, enabled: !isDemoMode });
|
||||
const templatesQuery = useQuery({ queryKey: ['landing-templates'], queryFn: () => listTemplates(), staleTime: 60_000, enabled: !isDemoMode });
|
||||
const hostQuery = useQuery({ queryKey: ['landing-host'], queryFn: getHostMonitoring, staleTime: 30_000, enabled: !isDemoMode });
|
||||
const deploysQuery = useQuery({ queryKey: ['landing-deploys'], queryFn: () => listRecentDeployments(4), staleTime: 30_000, enabled: !isDemoMode });
|
||||
|
||||
const projects = isDemoMode ? demoProjects : (projectsQuery.data ?? []);
|
||||
const templates = (isDemoMode ? demoTemplates : (templatesQuery.data ?? [])).slice(0, 12);
|
||||
const host = isDemoMode ? demoHostMonitoring : hostQuery.data;
|
||||
const deploys = isDemoMode ? demoDeploys : (deploysQuery.data ?? []).slice(0, 3);
|
||||
|
||||
const cpuPct = host ? Math.min(100, Math.round((host.load.load1m / Math.max(1, host.cpu.cores)) * 100)) : null;
|
||||
const memPct = host ? Math.round(host.memory.usagePercent) : null;
|
||||
const diskPct = host ? Math.round(host.storage.usagePercent) : null;
|
||||
|
||||
const rel = (d?: string) => {
|
||||
if (!d) return '—';
|
||||
const s = Math.floor((now - new Date(d).getTime()) / 1000);
|
||||
if (s < 60) return 'just now';
|
||||
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
||||
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
|
||||
return `${Math.floor(s / 86400)}d ago`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--bg-void)] text-[var(--text-secondary)]">
|
||||
<div className="ambient-glow" />
|
||||
|
||||
{/* ── Top bar ── */}
|
||||
<header className="sticky top-0 z-40 border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/80 backdrop-blur-xl">
|
||||
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-5">
|
||||
<Link to="/" className="flex items-center gap-2.5">
|
||||
<img src={logoUrl || '/containr.svg'} alt={productName} className="h-7 w-7 rounded-lg object-contain" />
|
||||
<span className="text-[15px] font-extrabold tracking-tight text-[var(--text-primary)]">
|
||||
<span className="font-headline text-[15px] font-semibold tracking-tight text-[var(--text-primary)]">
|
||||
<BrandWordmark />
|
||||
</span>
|
||||
</Link>
|
||||
@@ -57,112 +83,160 @@ export function LandingPage() {
|
||||
<a href="#features" className="rounded-[var(--radius-md)] px-3 py-1.5 text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] max-sm:hidden">Features</a>
|
||||
<a href="#templates" className="rounded-[var(--radius-md)] px-3 py-1.5 text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] max-sm:hidden">Templates</a>
|
||||
<a href="#docs" className="rounded-[var(--radius-md)] px-3 py-1.5 text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] max-sm:hidden">Docs</a>
|
||||
<Link
|
||||
to={`/projects${demo}`}
|
||||
className="rounded-[var(--radius-md)] px-3 py-1.5 font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)]"
|
||||
>
|
||||
<Link to={`/projects${demo}`} className="rounded-[var(--radius-md)] px-3 py-1.5 font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)]">
|
||||
Browse
|
||||
</Link>
|
||||
{signedIn ? (
|
||||
<Link
|
||||
to="/projects"
|
||||
className="ml-1 inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-3.5 py-1.5 font-semibold text-[var(--accent-on)]"
|
||||
>
|
||||
{signedIn || isDemoMode ? (
|
||||
<Link to={`/${demo}`} className="ml-1 s-btn-accent">
|
||||
Console <ArrowRight size={13} />
|
||||
</Link>
|
||||
) : (
|
||||
<Link
|
||||
to={isDemoMode ? `/projects${demo}` : '/auth/sign-in'}
|
||||
className="ml-1 inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-3.5 py-1.5 font-semibold text-[var(--accent-on)]"
|
||||
>
|
||||
{isDemoMode ? 'Live demo' : 'Sign in'} <ArrowRight size={13} />
|
||||
<Link to="/auth/sign-in" className="ml-1 s-btn-accent">
|
||||
Sign in <ArrowRight size={13} />
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="relative">
|
||||
<section className="mx-auto max-w-6xl px-5 pb-16 pt-20 text-center">
|
||||
<p className="mx-auto mb-5 inline-flex items-center gap-2 rounded-full border border-[var(--border-subtle)] bg-[var(--surface-card)] px-3.5 py-1 text-[11px] font-medium uppercase tracking-[0.16em] text-[var(--text-tertiary)]">
|
||||
<Container size={12} className="text-[var(--accent-primary)]" />
|
||||
<main>
|
||||
{/* ── Hero + live instance window ── */}
|
||||
<section className="border-b border-[var(--border-subtle)]">
|
||||
<div className="mx-auto grid max-w-6xl gap-10 px-5 pb-16 pt-16 lg:grid-cols-[1.1fr_1fr] lg:items-center">
|
||||
<div>
|
||||
<p className="s-chip mb-6 inline-flex">
|
||||
<Container size={11} />
|
||||
self-hosted PaaS
|
||||
</p>
|
||||
<h1 className="mx-auto max-w-3xl text-[42px] font-extrabold leading-[1.08] tracking-[-0.03em] text-[var(--text-primary)] max-sm:text-[32px]">
|
||||
<h1 className="font-headline text-[44px] font-bold leading-[1.06] tracking-[-0.03em] text-[var(--text-primary)] max-sm:text-[32px]">
|
||||
Deploy containers like it's{' '}
|
||||
<span style={{ color: 'var(--accent-primary)' }}>your own cloud</span>
|
||||
<span className="text-[var(--accent-primary)]">your own cloud</span>
|
||||
</h1>
|
||||
<p className="mx-auto mt-5 max-w-xl text-[15px] leading-relaxed text-[var(--text-secondary)]">
|
||||
<p className="mt-5 max-w-lg text-[14px] leading-relaxed text-[var(--text-secondary)]">
|
||||
Containr turns any Docker host into a full deployment platform — git-push builds,
|
||||
managed databases, preview environments, HA failover, and a searchable knowledge base.
|
||||
</p>
|
||||
<div className="mt-8 flex items-center justify-center gap-3">
|
||||
<Link
|
||||
to={`/projects${demo}`}
|
||||
className="inline-flex items-center gap-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-5 py-2.5 text-sm font-semibold text-[var(--accent-on)] transition-transform hover:scale-[1.02]"
|
||||
>
|
||||
{isDemoMode ? 'Explore the live demo' : 'Browse projects'} <ArrowRight size={15} />
|
||||
<div className="mt-8 flex items-center gap-3">
|
||||
<Link to={`/${demo}`} className="s-btn-accent !px-5 !h-10 text-sm">
|
||||
{isDemoMode ? 'Explore the live demo' : 'Open the console'} <ArrowRight size={15} />
|
||||
</Link>
|
||||
<a
|
||||
href="#docs"
|
||||
className="inline-flex items-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-default)] px-5 py-2.5 text-sm font-medium text-[var(--text-secondary)] hover:border-[var(--border-strong)] hover:text-[var(--text-primary)]"
|
||||
>
|
||||
<a href="#docs" className="s-btn-quiet !px-5 !h-10 text-sm">
|
||||
<BookOpen size={15} /> Read the docs
|
||||
</a>
|
||||
</div>
|
||||
<div className="v-mono mt-8 flex items-center gap-5 text-[11px] text-[var(--text-tertiary)]">
|
||||
<span className="flex items-center gap-2">
|
||||
<i className="inline-block h-1.5 w-1.5 rounded-full bg-[var(--success)]" />
|
||||
{projects.length} public projects
|
||||
</span>
|
||||
<span>{projects.reduce((s, p) => s + (p.stats?.running_services ?? 0), 0)} services online</span>
|
||||
<span className="max-sm:hidden">{projects.reduce((s, p) => s + (p.stats?.deployment_count ?? 0), 0)} deployments</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{projects.length > 0 ? (
|
||||
<div className="mx-auto mt-12 grid max-w-3xl grid-cols-3 gap-3 text-left max-sm:grid-cols-1">
|
||||
<Stat label="Public projects" value={projects.length} />
|
||||
<Stat
|
||||
label="Running services"
|
||||
value={projects.reduce((sum, p) => sum + (p.stats?.running_services ?? 0), 0)}
|
||||
/>
|
||||
<Stat
|
||||
label="Deployments"
|
||||
value={projects.reduce((sum, p) => sum + (p.stats?.deployment_count ?? 0), 0)}
|
||||
/>
|
||||
{/* Live window — real telemetry from this instance */}
|
||||
<div className="s-card !p-0 overflow-hidden">
|
||||
<div className="s-cardhead border-b border-[var(--border-subtle)]">
|
||||
<span className="s-ibox"><Server size={13.5} /></span>
|
||||
<span className="s-t">{host?.hostname ?? 'this instance'} <span className="s-ta">live</span></span>
|
||||
<span className="s-trail">
|
||||
<SPill tone={host?.dockerAvailable ? 'ok' : 'off'}>{host?.dockerAvailable ? 'online' : 'offline'}</SPill>
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-4 pb-2 pt-3">
|
||||
{[
|
||||
{ label: 'CPU', v: cpuPct },
|
||||
{ label: 'Memory', v: memPct },
|
||||
{ label: 'Disk', v: diskPct },
|
||||
].map((r) => (
|
||||
<div key={r.label} className="py-1.5">
|
||||
<div className="mb-1.5 flex items-baseline justify-between text-[11.5px]">
|
||||
<span className="text-[var(--text-secondary)]">{r.label}</span>
|
||||
<b className="font-semibold text-[var(--accent-primary)]">{r.v !== null ? `${r.v}%` : '—'}</b>
|
||||
</div>
|
||||
<Ticks pct={r.v ?? 0} count={48} warn={(r.v ?? 0) > 85} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t border-[var(--border-subtle)] px-4 py-3">
|
||||
<p className="v-mono mb-2 text-[10px] uppercase tracking-[0.12em] text-[var(--text-tertiary)]">latest deploys</p>
|
||||
{deploys.map((d) => (
|
||||
<div key={d.id} className="flex items-center gap-2.5 py-1.5 text-[12px]">
|
||||
<MiniBars tone={(d.status ?? '').toLowerCase() === 'failed' ? 'var(--error)' : 'var(--accent-primary)'} />
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-[var(--text-primary)]">{d.serviceName || d.id}</span>
|
||||
<span className="v-mono text-[10.5px] text-[var(--text-tertiary)]">{rel(d.startedAt)}</span>
|
||||
</div>
|
||||
))}
|
||||
{deploys.length === 0 && (
|
||||
<p className="py-1.5 text-[11.5px] text-[var(--text-tertiary)]">No deployments yet.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section id="features" className="border-t border-[var(--border-subtle)] bg-[var(--bg-base)]/60">
|
||||
{/* ── Install strip ── */}
|
||||
<section className="border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/60">
|
||||
<div className="mx-auto flex max-w-6xl flex-wrap items-center gap-4 px-5 py-5">
|
||||
<span className="s-ibox"><Terminal size={13.5} /></span>
|
||||
<code className="v-mono flex-1 text-[12.5px] text-[var(--text-secondary)]">
|
||||
<span className="text-[var(--accent-primary)]">$</span> docker compose up -d
|
||||
</code>
|
||||
<span className="v-mono text-[11px] text-[var(--text-tertiary)]">one host · one command · full platform</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Features ── */}
|
||||
<section id="features" className="border-b border-[var(--border-subtle)]">
|
||||
<div className="mx-auto max-w-6xl px-5 py-16">
|
||||
<h2 className="text-center text-[26px] font-bold tracking-tight text-[var(--text-primary)]">
|
||||
<div className="flex items-end justify-between gap-6 max-sm:flex-col max-sm:items-start">
|
||||
<div>
|
||||
<p className="v-mono mb-2 flex items-center gap-2 text-[10.5px] uppercase tracking-[0.1em] text-[var(--text-tertiary)]">
|
||||
<i className="inline-block h-[5px] w-[5px] rounded-[1px] bg-[var(--accent-primary)]" />
|
||||
platform
|
||||
</p>
|
||||
<h2 className="font-headline text-[26px] font-bold tracking-tight text-[var(--text-primary)]">
|
||||
Everything a deployment platform needs
|
||||
</h2>
|
||||
<p className="mx-auto mt-2 max-w-lg text-center text-sm text-[var(--text-secondary)]">
|
||||
<p className="mt-1.5 max-w-lg text-[13px] text-[var(--text-secondary)]">
|
||||
One install, one dashboard, full control over your own infrastructure.
|
||||
</p>
|
||||
<div className="mt-10 grid grid-cols-3 gap-4 max-md:grid-cols-2 max-sm:grid-cols-1">
|
||||
</div>
|
||||
<span className="v-mono text-[11px] text-[var(--text-muted)]">06 capabilities</span>
|
||||
</div>
|
||||
<div className="mt-9 grid grid-cols-3 gap-3.5 max-md:grid-cols-2 max-sm:grid-cols-1">
|
||||
{features.map((feature) => (
|
||||
<div
|
||||
key={feature.title}
|
||||
className="rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)] p-5 transition-colors hover:border-[var(--border-default)]"
|
||||
>
|
||||
<feature.icon size={18} className="text-[var(--accent-primary)]" />
|
||||
<h3 className="mt-3 text-[14.5px] font-semibold text-[var(--text-primary)]">{feature.title}</h3>
|
||||
<p className="mt-1.5 text-[12.5px] leading-relaxed text-[var(--text-tertiary)]">{feature.text}</p>
|
||||
<div key={feature.title} className="s-card">
|
||||
<div className="s-cardhead !mb-0">
|
||||
<span className="s-ibox"><feature.icon size={13.5} /></span>
|
||||
<h3 className="s-t">{feature.title}</h3>
|
||||
</div>
|
||||
<p className="mt-2 text-[12.5px] leading-relaxed text-[var(--text-tertiary)]">{feature.text}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="templates" className="border-t border-[var(--border-subtle)]">
|
||||
{/* ── Templates ── */}
|
||||
<section id="templates" className="border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/60">
|
||||
<div className="mx-auto max-w-6xl px-5 py-16">
|
||||
<div className="flex items-end justify-between">
|
||||
<div className="flex items-end justify-between gap-6">
|
||||
<div>
|
||||
<h2 className="text-[26px] font-bold tracking-tight text-[var(--text-primary)]">Template catalog</h2>
|
||||
<p className="mt-1 text-sm text-[var(--text-secondary)]">
|
||||
<p className="v-mono mb-2 flex items-center gap-2 text-[10.5px] uppercase tracking-[0.1em] text-[var(--text-tertiary)]">
|
||||
<i className="inline-block h-[5px] w-[5px] rounded-[1px] bg-[var(--accent-primary)]" />
|
||||
catalog
|
||||
</p>
|
||||
<h2 className="font-headline text-[26px] font-bold tracking-tight text-[var(--text-primary)]">Template catalog</h2>
|
||||
<p className="mt-1.5 text-[13px] text-[var(--text-secondary)]">
|
||||
Production-ready presets — deploy in one click from the console.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
to={`/templates${demo}`}
|
||||
className="inline-flex items-center gap-1.5 text-[13px] font-medium text-[var(--accent-primary)] hover:underline"
|
||||
className="s-chip !normal-case !tracking-normal"
|
||||
>
|
||||
Browse all <ArrowRight size={13} />
|
||||
Browse all <ArrowRight size={11} />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-8 grid grid-cols-4 gap-3 max-lg:grid-cols-3 max-sm:grid-cols-2">
|
||||
@@ -170,22 +244,20 @@ export function LandingPage() {
|
||||
<Link
|
||||
key={template.id}
|
||||
to={`/templates?template=${template.id}${isDemoMode ? '&demo=1' : ''}`}
|
||||
className="group rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)] p-4 transition-colors hover:border-[var(--border-strong)]"
|
||||
className="s-card group"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
{template.logo ? (
|
||||
<img src={template.logo} alt="" className="h-7 w-7 rounded-md" />
|
||||
<img src={template.logo} alt="" className="h-7 w-7 rounded-[6px]" />
|
||||
) : (
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-md bg-[var(--surface-muted)] text-[var(--accent-primary)]">
|
||||
<LayoutTemplate size={14} />
|
||||
</span>
|
||||
<span className="s-ibox !h-7 !w-7"><LayoutTemplate size={13.5} /></span>
|
||||
)}
|
||||
<span className="truncate text-[13px] font-semibold text-[var(--text-primary)]">{template.name}</span>
|
||||
</div>
|
||||
<p className="mt-2 line-clamp-2 text-[11.5px] leading-relaxed text-[var(--text-tertiary)]">
|
||||
{template.description}
|
||||
</p>
|
||||
<span className="mt-2 inline-block rounded-full bg-[var(--surface-muted)] px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-[var(--text-tertiary)]">
|
||||
<span className="s-chip mt-2" style={{ cursor: 'default' }}>
|
||||
{template.category || 'app'}
|
||||
</span>
|
||||
</Link>
|
||||
@@ -199,10 +271,15 @@ export function LandingPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="docs" className="border-t border-[var(--border-subtle)] bg-[var(--bg-base)]/60">
|
||||
{/* ── Docs ── */}
|
||||
<section id="docs" className="border-b border-[var(--border-subtle)]">
|
||||
<div className="mx-auto max-w-6xl px-5 py-16">
|
||||
<h2 className="text-[26px] font-bold tracking-tight text-[var(--text-primary)]">Documentation</h2>
|
||||
<p className="mt-1 text-sm text-[var(--text-secondary)]">
|
||||
<p className="v-mono mb-2 flex items-center gap-2 text-[10.5px] uppercase tracking-[0.1em] text-[var(--text-tertiary)]">
|
||||
<i className="inline-block h-[5px] w-[5px] rounded-[1px] bg-[var(--accent-primary)]" />
|
||||
reference
|
||||
</p>
|
||||
<h2 className="font-headline text-[26px] font-bold tracking-tight text-[var(--text-primary)]">Documentation</h2>
|
||||
<p className="mt-1.5 text-[13px] text-[var(--text-secondary)]">
|
||||
Guides and references — synced from GitHub, cached locally, always available.
|
||||
</p>
|
||||
<div className="mt-8">
|
||||
@@ -213,10 +290,10 @@ export function LandingPage() {
|
||||
</main>
|
||||
|
||||
<footer className="border-t border-[var(--border-subtle)]">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between px-5 py-6 text-[12px] text-[var(--text-tertiary)]">
|
||||
<div className="v-mono mx-auto flex max-w-6xl items-center justify-between px-5 py-6 text-[11.5px] text-[var(--text-tertiary)]">
|
||||
<span className="flex items-center gap-2">
|
||||
<img src="/containr.svg" alt="" className="h-4 w-4 rounded" />
|
||||
Containr — self-hosted container platform
|
||||
containr — self-hosted container platform
|
||||
</span>
|
||||
<div className="flex items-center gap-4">
|
||||
<Link to={`/projects${demo}`} className="hover:text-[var(--text-secondary)]">Projects</Link>
|
||||
@@ -228,12 +305,3 @@ export function LandingPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div className="rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)] px-4 py-3.5">
|
||||
<p className="text-[22px] font-bold tabular-nums text-[var(--text-primary)]">{value}</p>
|
||||
<p className="mt-0.5 text-[11px] uppercase tracking-wider text-[var(--text-tertiary)]">{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,8 +19,10 @@ import { useAuthSession } from '@/lib/use-auth-session';
|
||||
import { useDemoMode } from '@/lib/demo-mode';
|
||||
import { formatBytes, formatRelative } from '@/lib/time';
|
||||
import { DemoRestricted } from '@/shared/components';
|
||||
import { GhostBtn, QuietBtn, SPill } from '@/shared/components/sentry';
|
||||
import {
|
||||
ArrowLeft, CheckCircle2, ChevronRight, Loader2, Pencil, XCircle,
|
||||
Activity, Cpu, ListOrdered, Server,
|
||||
} from 'lucide-react';
|
||||
|
||||
const TIME_RANGES = [
|
||||
@@ -129,8 +131,7 @@ export function NodeDetailPage() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<div className="border-b border-[var(--border-subtle)]">
|
||||
<div className="w-full px-8 py-5">
|
||||
<div className="w-full px-4 pt-6 sm:px-8">
|
||||
<div className="flex items-center gap-2 v-mono text-[11px] text-[var(--text-tertiary)] mb-2">
|
||||
<Link to="/usage" className="hover:text-[var(--text-primary)] flex items-center gap-1">
|
||||
<ArrowLeft size={12} /> usage
|
||||
@@ -140,7 +141,7 @@ export function NodeDetailPage() {
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 className="v-title">
|
||||
<h1 className="v-title font-headline">
|
||||
{rename === null ? agent.name : rename}
|
||||
{isAdmin && rename === null && (
|
||||
<button
|
||||
@@ -163,7 +164,7 @@ export function NodeDetailPage() {
|
||||
if (e.key === 'Escape') setRename(null);
|
||||
}}
|
||||
autoFocus
|
||||
className="rounded-md border border-[var(--border-subtle)] bg-[var(--bg-tertiary)] px-2 py-1 text-sm text-[var(--text-primary)]"
|
||||
className="rounded-md border border-[var(--border-subtle)] bg-[var(--bg-elevated)] px-2 py-1 text-sm text-[var(--text-primary)]"
|
||||
/>
|
||||
<button onClick={() => updateMutation.mutate({ name: rename })} className="v-mono text-[11px] text-[var(--accent-primary)] hover:underline">save</button>
|
||||
<button onClick={() => setRename(null)} className="v-mono text-[11px] text-[var(--text-muted)] hover:underline">cancel</button>
|
||||
@@ -209,7 +210,7 @@ export function NodeDetailPage() {
|
||||
}}
|
||||
autoFocus
|
||||
placeholder="eu-west, gpu"
|
||||
className="rounded-md border border-[var(--border-subtle)] bg-[var(--bg-tertiary)] px-2 py-0.5 text-[11px] text-[var(--text-primary)] w-52"
|
||||
className="rounded-md border border-[var(--border-subtle)] bg-[var(--bg-elevated)] px-2 py-0.5 text-[11px] text-[var(--text-primary)] w-52"
|
||||
/>
|
||||
<button
|
||||
onClick={() => updateMutation.mutate({ tags: tagDraft.split(',').map((t) => t.trim()).filter(Boolean) })}
|
||||
@@ -247,7 +248,7 @@ export function NodeDetailPage() {
|
||||
}}
|
||||
autoFocus
|
||||
placeholder="apps.node1.example.com — empty clears"
|
||||
className="rounded-md border border-[var(--border-subtle)] bg-[var(--bg-tertiary)] px-2 py-0.5 text-[11px] text-[var(--text-primary)] w-64"
|
||||
className="rounded-md border border-[var(--border-subtle)] bg-[var(--bg-elevated)] px-2 py-0.5 text-[11px] text-[var(--text-primary)] w-64"
|
||||
/>
|
||||
<button
|
||||
onClick={() => updateMutation.mutate({ default_domain: domainDraft.trim().toLowerCase() })}
|
||||
@@ -258,17 +259,9 @@ export function NodeDetailPage() {
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`v-mono text-[11px] px-2 py-1 rounded-md border ${online
|
||||
? 'border-[var(--success)]/40 text-[var(--success)]'
|
||||
: 'border-[var(--error)]/40 text-[var(--error)]'}`}>
|
||||
{agent.status}
|
||||
</span>
|
||||
{!agent.schedulable && (
|
||||
<span className="v-mono text-[11px] px-2 py-1 rounded-md border border-[var(--warning)]/40 text-[var(--warning)]">
|
||||
cordoned
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<SPill tone={online ? 'ok' : 'err'}>{agent.status}</SPill>
|
||||
{!agent.schedulable && <SPill tone="warn">cordoned</SPill>}
|
||||
{isAdmin && (
|
||||
<>
|
||||
<label className="flex items-center gap-1.5 v-mono text-[11px] text-[var(--text-tertiary)] cursor-pointer" title="Daily automatic docker prune">
|
||||
@@ -280,81 +273,79 @@ export function NodeDetailPage() {
|
||||
/>
|
||||
auto-prune
|
||||
</label>
|
||||
<button
|
||||
<QuietBtn
|
||||
onClick={() => cordonMutation.mutate(agent.schedulable)}
|
||||
disabled={cordonMutation.isPending}
|
||||
title={agent.schedulable
|
||||
? 'Block new placements — running replicas stay'
|
||||
: 'Re-open for placement'}
|
||||
className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--border-subtle)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:opacity-50"
|
||||
>
|
||||
{agent.schedulable ? 'cordon' : 'uncordon'}
|
||||
</button>
|
||||
<button
|
||||
</QuietBtn>
|
||||
<QuietBtn
|
||||
onClick={() => setConfirmDrain(true)}
|
||||
disabled={drainMutation.isPending || !online}
|
||||
title={online
|
||||
? 'Cordon + remove all service containers + unpin services (they redeploy locally)'
|
||||
: 'Node must be online to drain'}
|
||||
className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--warning)]/40 text-[var(--warning)] hover:bg-[var(--warning)]/10 disabled:opacity-50"
|
||||
className="!text-[var(--warning)] hover:!border-[var(--warning)]/60"
|
||||
>
|
||||
drain
|
||||
</button>
|
||||
<button
|
||||
</QuietBtn>
|
||||
<GhostBtn
|
||||
onClick={() => upgradeMutation.mutate()}
|
||||
disabled={upgradeMutation.isPending || !online}
|
||||
title={online
|
||||
? `Upgrade agent to the server's bundled binary (server ${upgradeMutation.data?.version ?? 'build'}) — the agent restarts itself`
|
||||
: 'Node must be online to upgrade'}
|
||||
className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--border-subtle)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:opacity-50"
|
||||
>
|
||||
{upgradeMutation.isPending ? 'upgrading…' : 'upgrade agent'}
|
||||
</button>
|
||||
<button
|
||||
</GhostBtn>
|
||||
<QuietBtn
|
||||
onClick={() => setConfirmVolumes(true)}
|
||||
disabled={pruneMutation.isPending}
|
||||
className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--border-subtle)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:opacity-50"
|
||||
>
|
||||
prune now
|
||||
</button>
|
||||
<button
|
||||
</QuietBtn>
|
||||
<QuietBtn
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--error)]/40 text-[var(--error)] hover:bg-[var(--error)]/10"
|
||||
className="!text-[var(--error)] hover:!border-[var(--error)]/60"
|
||||
>
|
||||
remove
|
||||
</button>
|
||||
</QuietBtn>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-8 py-6 space-y-6">
|
||||
<div className="px-4 py-6 sm:px-8 space-y-6">
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Stat label="cpu" value={latest ? `${latest.cpu.usage_percent.toFixed(0)}%` : '—'} sub={latest ? `${latest.cpu.cores} cores` : ''} />
|
||||
<Stat label="memory" value={latest ? `${latest.memory.usage_percent.toFixed(0)}%` : '—'} sub={latest ? `${formatBytes(latest.memory.available)} free of ${formatBytes(latest.memory.limit)}` : ''} />
|
||||
<Stat label="load 1m" value={latest ? latest.system_load.load_1m.toFixed(2) : '—'} sub={latest ? `5m ${latest.system_load.load_5m.toFixed(2)} · 15m ${latest.system_load.load_15m.toFixed(2)}` : ''} />
|
||||
<Stat label="containers" value={latest ? String(latest.container_count) : String(containers.length)} sub="reported by agent" />
|
||||
<Stat label="cpu" icon={<Cpu />} value={latest ? `${latest.cpu.usage_percent.toFixed(0)}%` : '—'} sub={latest ? `${latest.cpu.cores} cores` : ''} />
|
||||
<Stat label="memory" icon={<Server />} value={latest ? `${latest.memory.usage_percent.toFixed(0)}%` : '—'} sub={latest ? `${formatBytes(latest.memory.available)} free of ${formatBytes(latest.memory.limit)}` : ''} />
|
||||
<Stat label="load 1m" icon={<Activity />} value={latest ? latest.system_load.load_1m.toFixed(2) : '—'} sub={latest ? `5m ${latest.system_load.load_5m.toFixed(2)} · 15m ${latest.system_load.load_15m.toFixed(2)}` : ''} />
|
||||
<Stat label="containers" icon={<ListOrdered />} value={latest ? String(latest.container_count) : String(containers.length)} sub="reported by agent" />
|
||||
</div>
|
||||
|
||||
<section className="panel p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="v-mono text-[11px] uppercase tracking-wider text-[var(--text-tertiary)]">telemetry</h2>
|
||||
<div className="flex gap-1">
|
||||
<section className="s-card !p-0">
|
||||
<div className="s-cardhead border-b border-[var(--border-subtle)]">
|
||||
<span className="s-ibox"><Activity /></span>
|
||||
<h2 className="s-t">Telemetry</h2>
|
||||
<span className="s-trail">
|
||||
<span className="flex gap-1">
|
||||
{TIME_RANGES.map((r) => (
|
||||
<button
|
||||
key={r.value}
|
||||
onClick={() => setTimeRange(r.value)}
|
||||
className={`v-mono text-[11px] px-2.5 py-1 rounded-md border ${timeRange === r.value
|
||||
? 'border-[var(--accent-primary)]/50 text-[var(--accent-primary)]'
|
||||
: 'border-[var(--border-subtle)] text-[var(--text-muted)] hover:text-[var(--text-primary)]'}`}
|
||||
className={`s-chip v-mono ${timeRange === r.value ? 'on' : ''}`}
|
||||
>
|
||||
{r.label}
|
||||
</button>
|
||||
))}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
{metricsQuery.isLoading ? (
|
||||
<p className="text-xs text-[var(--text-muted)]">Loading metrics…</p>
|
||||
) : metrics.length < 2 ? (
|
||||
@@ -365,12 +356,17 @@ export function NodeDetailPage() {
|
||||
<Sparkline label="memory %" points={metrics} value={(m) => m.memory.usage_percent} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<section className="panel p-0">
|
||||
<h2 className="v-mono text-[11px] uppercase tracking-wider text-[var(--text-tertiary)] px-5 pt-4 pb-3">containers</h2>
|
||||
<div className="divide-y divide-[var(--border-subtle)] border-t border-[var(--border-subtle)]">
|
||||
<section className="s-card !p-0">
|
||||
<div className="s-cardhead border-b border-[var(--border-subtle)]">
|
||||
<span className="s-ibox"><Server /></span>
|
||||
<h2 className="s-t">Containers</h2>
|
||||
<span className="s-trail"><span className="s-chip" style={{ cursor: 'default' }}>{containers.length}</span></span>
|
||||
</div>
|
||||
<div className="divide-y divide-[var(--border-subtle)]">
|
||||
{containers.length === 0 && (
|
||||
<p className="px-5 py-6 text-xs text-[var(--text-muted)]">No containers reported on this node.</p>
|
||||
)}
|
||||
@@ -380,15 +376,24 @@ export function NodeDetailPage() {
|
||||
<p className="truncate text-sm text-[var(--text-primary)]">{c.name ?? c.id}</p>
|
||||
<p className="truncate v-mono text-[11px] text-[var(--text-tertiary)]">{c.image}</p>
|
||||
</div>
|
||||
<span className="v-mono text-[11px] text-[var(--text-secondary)]">{c.status ?? '—'}</span>
|
||||
{/* Persisted query cache can replay the raw status object — flatten defensively. */}
|
||||
<span className="v-mono text-[11px] text-[var(--text-secondary)]">
|
||||
{typeof c.status === 'string'
|
||||
? c.status
|
||||
: (c.status as { state?: string } | undefined)?.state ?? '—'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel p-0">
|
||||
<h2 className="v-mono text-[11px] uppercase tracking-wider text-[var(--text-tertiary)] px-5 pt-4 pb-3">command history</h2>
|
||||
<div className="divide-y divide-[var(--border-subtle)] border-t border-[var(--border-subtle)]">
|
||||
<section className="s-card !p-0">
|
||||
<div className="s-cardhead border-b border-[var(--border-subtle)]">
|
||||
<span className="s-ibox"><ListOrdered /></span>
|
||||
<h2 className="s-t">Command History</h2>
|
||||
<span className="s-trail"><span className="s-chip" style={{ cursor: 'default' }}>{commands.length}</span></span>
|
||||
</div>
|
||||
<div className="divide-y divide-[var(--border-subtle)]">
|
||||
{commands.length === 0 && (
|
||||
<p className="px-5 py-6 text-xs text-[var(--text-muted)]">No commands sent to this node yet.</p>
|
||||
)}
|
||||
@@ -466,12 +471,19 @@ function meshAddresses(agent: { metadata: Record<string, unknown> }): [string, s
|
||||
.filter((entry): entry is [string, string] => typeof entry[1] === 'string' && entry[1] !== '');
|
||||
}
|
||||
|
||||
function Stat({ label, value, sub }: { label: string; value: string; sub?: string }) {
|
||||
function Stat({ label, icon, value, sub }: { label: string; icon: React.ReactNode; value: string; sub?: string }) {
|
||||
return (
|
||||
<div className="panel p-4">
|
||||
<p className="v-mono text-[10px] uppercase tracking-wider text-[var(--text-tertiary)]">{label}</p>
|
||||
<p className="mt-1 text-xl font-semibold text-[var(--text-primary)]">{value}</p>
|
||||
{sub && <p className="mt-0.5 v-mono text-[11px] text-[var(--text-muted)]">{sub}</p>}
|
||||
<div className="s-stat">
|
||||
<div className="px-4 pb-1 pt-4">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="s-ibox">{icon}</span>
|
||||
<span className="text-[11.5px] font-medium text-[var(--text-secondary)]">{label}</span>
|
||||
</div>
|
||||
<p className="mt-3 font-headline text-[24px] font-bold leading-none tabular-nums text-[var(--text-primary)]">{value}</p>
|
||||
</div>
|
||||
<div className="s-stat-foot mt-3">
|
||||
<span>{sub ?? '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -510,24 +522,20 @@ function ConfirmModal({ title, body, confirmLabel, danger, extra, onCancel, onCo
|
||||
pending: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onCancel}>
|
||||
<div className="panel w-full max-w-sm p-5" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="text-sm font-semibold text-[var(--text-primary)]">{title}</h3>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm" onClick={onCancel}>
|
||||
<div className="s-card w-full max-w-sm" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="s-t">{title}</h3>
|
||||
<p className="mt-2 text-xs text-[var(--text-secondary)]">{body}</p>
|
||||
{extra && <div className="mt-3">{extra}</div>}
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<button onClick={onCancel} className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--border-subtle)] text-[var(--text-secondary)]">
|
||||
cancel
|
||||
</button>
|
||||
<button
|
||||
<QuietBtn onClick={onCancel}>cancel</QuietBtn>
|
||||
<GhostBtn
|
||||
onClick={onConfirm}
|
||||
disabled={pending}
|
||||
className={`v-mono text-[11px] px-3 py-1.5 rounded-md border disabled:opacity-50 ${danger
|
||||
? 'border-[var(--error)]/50 text-[var(--error)] hover:bg-[var(--error)]/10'
|
||||
: 'border-[var(--accent-primary)]/50 text-[var(--accent-primary)] hover:bg-[var(--accent-primary)]/10'}`}
|
||||
className={danger ? '!text-[var(--error)] !border-[var(--error)]/40 !bg-[var(--error)]/10' : undefined}
|
||||
>
|
||||
{pending ? 'working…' : confirmLabel}
|
||||
</button>
|
||||
</GhostBtn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,28 +4,10 @@ import { getOperations, type OperationsView } from '@/lib/api-client';
|
||||
import { useDemoMode } from '@/lib/demo-mode';
|
||||
import { formatRelative } from '@/lib/time';
|
||||
import { DemoRestricted } from '@/shared/components';
|
||||
import { SPageHead, SPill, SStat, Ticks } from '@/shared/components/sentry';
|
||||
import { statusTone } from '@/shared/components/sentry-utils';
|
||||
import { Activity, AlertTriangle, Clock, Database, Loader2, Timer } from 'lucide-react';
|
||||
|
||||
function statusClass(status: string): string {
|
||||
switch (status) {
|
||||
case 'deployed':
|
||||
case 'completed':
|
||||
case 'success':
|
||||
return 'text-[var(--success)]';
|
||||
case 'failed':
|
||||
case 'cancelled':
|
||||
case 'rolled_back':
|
||||
return 'text-[var(--error)]';
|
||||
case 'building':
|
||||
case 'deploying':
|
||||
case 'in_progress':
|
||||
case 'running':
|
||||
return 'text-[var(--accent-primary)]';
|
||||
default:
|
||||
return 'text-[var(--warning)]';
|
||||
}
|
||||
}
|
||||
|
||||
function Section({ title, icon: Icon, count, children }: {
|
||||
title: string;
|
||||
icon: typeof Activity;
|
||||
@@ -33,13 +15,15 @@ function Section({ title, icon: Icon, count, children }: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="panel p-5">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Icon size={16} className="text-[var(--accent-primary)]" />
|
||||
<h2 className="text-sm font-semibold text-[var(--text-primary)]">{title}</h2>
|
||||
<span className="text-xs text-[var(--text-muted)] mono">({count})</span>
|
||||
<section className="s-card overflow-hidden">
|
||||
<div className="s-cardhead border-b border-[var(--border-subtle)]">
|
||||
<span className="s-ibox"><Icon /></span>
|
||||
<h2 className="s-t">{title}</h2>
|
||||
<span className="s-trail">
|
||||
<span className="s-chip" style={{ cursor: 'default' }}>{count}</span>
|
||||
</span>
|
||||
</div>
|
||||
{children}
|
||||
<div className="p-3">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -74,13 +58,8 @@ export function OperationsPage() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<div className="border-b border-[var(--border-subtle)]">
|
||||
<div className="w-full px-8 py-5">
|
||||
<h1 className="v-title">Operations<span className="v-cursor">_</span></h1>
|
||||
<p className="v-mono mt-1.5 text-[11px] text-[var(--text-tertiary)]">
|
||||
Live view of deploys, queues, cron runs, and backups
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-full px-4 pt-6 sm:px-8">
|
||||
<SPageHead title="Operations" titleAccent="_" sub="Live view of deploys, queues, cron runs, and backups" />
|
||||
</div>
|
||||
|
||||
{opsQuery.isLoading ? (
|
||||
@@ -88,24 +67,12 @@ export function OperationsPage() {
|
||||
<Loader2 size={20} className="animate-spin text-[var(--text-muted)]" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full px-8 py-6 space-y-6">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="panel-soft p-4">
|
||||
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">In flight</p>
|
||||
<p className="mt-1 text-2xl font-semibold text-[var(--accent-primary)]">{active.length}</p>
|
||||
</div>
|
||||
<div className="panel-soft p-4">
|
||||
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Queued</p>
|
||||
<p className="mt-1 text-2xl font-semibold text-[var(--warning)]">{queuedTotal}</p>
|
||||
</div>
|
||||
<div className="panel-soft p-4">
|
||||
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Failed 24h</p>
|
||||
<p className="mt-1 text-2xl font-semibold text-[var(--error)]">{failed.length}</p>
|
||||
</div>
|
||||
<div className="panel-soft p-4">
|
||||
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Backups 24h</p>
|
||||
<p className="mt-1 text-2xl font-semibold text-[var(--text-secondary)]">{backups.length}</p>
|
||||
</div>
|
||||
<div className="w-full px-4 py-6 sm:px-8 space-y-6">
|
||||
<div className="grid grid-cols-2 gap-3.5 md:grid-cols-4">
|
||||
<SStat icon={<Activity />} label="In Flight" value={active.length} foot="deploying now" />
|
||||
<SStat icon={<Clock />} label="Queued" value={queuedTotal} foot="waiting in queue" />
|
||||
<SStat icon={<AlertTriangle />} label="Failed 24h" value={failed.length} foot="need attention" />
|
||||
<SStat icon={<Database />} label="Backups 24h" value={backups.length} foot="completed runs" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||
@@ -125,7 +92,7 @@ export function OperationsPage() {
|
||||
<span className="text-sm text-[var(--text-primary)]">{d.service_name}</span>
|
||||
<span className="text-xs text-[var(--text-muted)]"> · {d.project_name}</span>
|
||||
</div>
|
||||
<span className={`text-xs mono ${statusClass(d.status)}`}>{d.status}</span>
|
||||
<SPill tone={statusTone(d.status)}>{d.status}</SPill>
|
||||
<span className="text-xs text-[var(--text-muted)]">{formatRelative(d.created_at)}</span>
|
||||
</button>
|
||||
))}
|
||||
@@ -152,7 +119,7 @@ export function OperationsPage() {
|
||||
<p className="text-[11px] text-[var(--error)]/80 truncate">{d.error}</p>
|
||||
)}
|
||||
</div>
|
||||
<span className={`text-xs mono ${statusClass(d.status)}`}>{d.status}</span>
|
||||
<SPill tone={statusTone(d.status)}>{d.status}</SPill>
|
||||
<span className="text-xs text-[var(--text-muted)]">{formatRelative(d.created_at)}</span>
|
||||
</button>
|
||||
))}
|
||||
@@ -167,9 +134,9 @@ export function OperationsPage() {
|
||||
<div className="space-y-1.5">
|
||||
{cron.map((r) => (
|
||||
<div key={r.id} className="flex items-center gap-3 px-3 py-1.5 text-xs">
|
||||
<span className={`mono ${statusClass(r.status)}`}>{r.status}</span>
|
||||
<SPill tone={statusTone(r.status)}>{r.status}</SPill>
|
||||
<span className="text-[var(--text-primary)]">{r.job_name}</span>
|
||||
<span className="mono text-[var(--text-muted)]">{r.schedule}</span>
|
||||
<span className="v-mono text-[var(--text-muted)]">{r.schedule}</span>
|
||||
{r.error && <span className="text-[var(--error)]/80 truncate">{r.error}</span>}
|
||||
<span className="ml-auto text-[var(--text-muted)]">{formatRelative(r.started_at)}</span>
|
||||
</div>
|
||||
@@ -185,7 +152,7 @@ export function OperationsPage() {
|
||||
<div className="space-y-1.5">
|
||||
{backups.map((b) => (
|
||||
<div key={b.id} className="flex items-center gap-3 px-3 py-1.5 text-xs">
|
||||
<span className={`mono ${statusClass(b.status)}`}>{b.status}</span>
|
||||
<SPill tone={statusTone(b.status)}>{b.status}</SPill>
|
||||
<span className="text-[var(--text-primary)]">{b.database_name}</span>
|
||||
<span className="text-[var(--text-muted)]">{b.size}</span>
|
||||
<span className="ml-auto text-[var(--text-muted)]">{formatRelative(b.created_at)}</span>
|
||||
@@ -198,14 +165,15 @@ export function OperationsPage() {
|
||||
|
||||
{queue.length > 0 && (
|
||||
<Section title="Deploy queue depth" icon={Clock} count={queue.length}>
|
||||
<div className="space-y-1.5">
|
||||
<div className="space-y-2">
|
||||
{queue.map((q) => (
|
||||
<div key={q.service_id} className="flex items-center gap-3 px-3 py-1.5 text-xs mono">
|
||||
<span className="text-[var(--text-secondary)]">{q.service_id.slice(0, 8)}</span>
|
||||
<span className={q.running ? 'text-[var(--accent-primary)]' : 'text-[var(--text-muted)]'}>
|
||||
{q.running ? 'running' : 'idle'}
|
||||
<div key={q.service_id} className="flex items-center gap-3 px-3 py-1.5 text-xs">
|
||||
<span className="v-mono text-[var(--text-secondary)]">{q.service_id.slice(0, 8)}</span>
|
||||
<SPill tone={q.running ? 'info' : 'off'}>{q.running ? 'running' : 'idle'}</SPill>
|
||||
<span className="flex-1 max-w-[220px]">
|
||||
<Ticks pct={Math.min(100, q.queued * 25)} count={32} warn={!q.running} />
|
||||
</span>
|
||||
<span className="text-[var(--warning)]">{q.queued} queued</span>
|
||||
<span className="v-mono text-[var(--warning)]">{q.queued} queued</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -13,10 +13,31 @@ import {
|
||||
Search,
|
||||
FolderOpen,
|
||||
X,
|
||||
Cpu,
|
||||
MemoryStick,
|
||||
HardDrive,
|
||||
Rocket,
|
||||
RefreshCw,
|
||||
Server,
|
||||
} from 'lucide-react';
|
||||
import { useAuthSession } from '@/lib/use-auth-session';
|
||||
import { useDemoMode } from '@/lib/demo-mode';
|
||||
import { demoProjects } from '@/lib/demo-data';
|
||||
import {
|
||||
GhostBtn,
|
||||
IconBtn,
|
||||
QuietBtn,
|
||||
SAnomaly,
|
||||
SCard,
|
||||
SPageHead,
|
||||
SPill,
|
||||
SSection,
|
||||
SStat,
|
||||
STable,
|
||||
Ticks,
|
||||
type SCol,
|
||||
} from '@/shared/components/sentry';
|
||||
import { statusTone } from '@/shared/components/sentry-utils';
|
||||
|
||||
const demoDeploys = [
|
||||
{ id: 'd1', name: 'API Gateway', project: 'Core Platform', status: 'DEPLOYED', when: '4m' },
|
||||
@@ -25,6 +46,8 @@ const demoDeploys = [
|
||||
{ id: 'd4', name: 'Marketing Site', project: 'Growth Surface', status: 'DEPLOYED', when: '2h' },
|
||||
];
|
||||
|
||||
type DeployRow = { id: string; name: string; project?: string; status: string; when: string };
|
||||
|
||||
function getHealthStatus(stats: ProjectStats): 'healthy' | 'idle' | 'degraded' | 'critical' {
|
||||
if (stats.service_count === 0) return 'idle';
|
||||
if (stats.running_services === stats.service_count) return 'healthy';
|
||||
@@ -32,19 +55,6 @@ function getHealthStatus(stats: ProjectStats): 'healthy' | 'idle' | 'degraded' |
|
||||
return 'critical';
|
||||
}
|
||||
|
||||
function healthBadge(health: ReturnType<typeof getHealthStatus>) {
|
||||
switch (health) {
|
||||
case 'healthy':
|
||||
return { label: 'RUNNING', cls: 'v-st-ok' };
|
||||
case 'idle':
|
||||
return { label: 'IDLE', cls: '' };
|
||||
case 'degraded':
|
||||
return { label: 'DEGRADED', cls: 'v-st-warn' };
|
||||
case 'critical':
|
||||
return { label: 'DOWN', cls: 'v-st-fail' };
|
||||
}
|
||||
}
|
||||
|
||||
function formatRelative(date?: string): string {
|
||||
if (!date) return '—';
|
||||
const diff = Date.now() - new Date(date).getTime();
|
||||
@@ -54,6 +64,7 @@ function formatRelative(date?: string): string {
|
||||
return `${Math.floor(diff / 86_400_000)}d ago`;
|
||||
}
|
||||
|
||||
/* Mini service topology preview inside each project card. */
|
||||
function MiniCanvas({ project }: { project: ProjectEntity }) {
|
||||
const count = project.stats.service_count;
|
||||
const running = project.stats.running_services;
|
||||
@@ -84,40 +95,61 @@ function MiniCanvas({ project }: { project: ProjectEntity }) {
|
||||
function ProjectCard({ project, href }: { project: ProjectEntity; href: string }) {
|
||||
const navigate = useNavigate();
|
||||
const health = getHealthStatus(project.stats);
|
||||
const badge = healthBadge(health);
|
||||
const envOk = health === 'healthy';
|
||||
const envLabel = envOk ? 'production' : health === 'idle' ? 'idle' : health === 'critical' ? 'down' : 'degraded';
|
||||
const envLabel =
|
||||
health === 'healthy' ? 'production' : health === 'idle' ? 'idle' : health === 'critical' ? 'down' : 'degraded';
|
||||
|
||||
return (
|
||||
<article
|
||||
className="group panel p-4 cursor-pointer transition-all duration-200 hover:-translate-y-0.5 hover:border-[var(--border-strong)]"
|
||||
className="s-card group cursor-pointer transition-all duration-200 hover:-translate-y-0.5 hover:border-[var(--border-strong)]"
|
||||
onClick={() => navigate(href)}
|
||||
>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<h3 className="truncate text-[14.5px] font-bold tracking-tight text-[var(--text-primary)]">
|
||||
{project.name}
|
||||
</h3>
|
||||
<span className={`v-st ml-auto ${badge.cls}`}>{badge.label}</span>
|
||||
<div className="s-cardhead">
|
||||
<IboxStub />
|
||||
<h3 className="s-t truncate">{project.name}</h3>
|
||||
<span className="s-trail">
|
||||
<SPill
|
||||
tone={
|
||||
health === 'healthy' ? 'ok' : health === 'idle' ? 'off' : health === 'critical' ? 'err' : 'warn'
|
||||
}
|
||||
>
|
||||
{health === 'healthy' ? 'Running' : health === 'idle' ? 'Idle' : health === 'critical' ? 'Down' : 'Degraded'}
|
||||
</SPill>
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-4">
|
||||
<MiniCanvas project={project} />
|
||||
<div className="v-mono mt-3 flex items-center gap-2 text-[10.5px] text-[var(--text-tertiary)]">
|
||||
</div>
|
||||
<div className="s-stat-foot mt-3.5">
|
||||
<span className="flex items-center gap-2">
|
||||
<i
|
||||
className="inline-block h-1.5 w-1.5 rounded-full"
|
||||
style={{ background: envOk ? 'var(--success)' : health === 'idle' ? 'var(--text-tertiary)' : health === 'critical' ? 'var(--error)' : 'var(--warning)' }}
|
||||
style={{
|
||||
background:
|
||||
health === 'healthy'
|
||||
? 'var(--success)'
|
||||
: health === 'idle'
|
||||
? 'var(--text-tertiary)'
|
||||
: health === 'critical'
|
||||
? 'var(--error)'
|
||||
: 'var(--warning)',
|
||||
}}
|
||||
/>
|
||||
<span className="text-[var(--text-secondary)]">
|
||||
{envLabel}
|
||||
{envLabel} · {project.stats.running_services}/{project.stats.service_count} online
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span>
|
||||
{project.stats.running_services}/{project.stats.service_count} online
|
||||
</span>
|
||||
<span className="ml-auto">{formatRelative(project.updatedAt)}</span>
|
||||
<span>{formatRelative(project.updatedAt)}</span>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function IboxStub() {
|
||||
return (
|
||||
<span className="s-ibox">
|
||||
<Server size={13.5} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectsPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -181,7 +213,7 @@ export function ProjectsPage() {
|
||||
const totalServices = projects.reduce((sum, p) => sum + p.stats.service_count, 0);
|
||||
const runningServices = projects.reduce((sum, p) => sum + p.stats.running_services, 0);
|
||||
const host = hostQuery.data;
|
||||
const hostLabel = host?.scope === 'docker-desktop-vm' ? 'VM' : 'HOST';
|
||||
const hostLabel = host?.scope === 'docker-desktop-vm' ? 'VM' : 'Host';
|
||||
const cpuPct = isDemoMode ? 14 : host && host.cpu.cores > 0
|
||||
? Math.min(100, Math.round((host.load.load1m / host.cpu.cores) * 100))
|
||||
: null;
|
||||
@@ -190,9 +222,9 @@ export function ProjectsPage() {
|
||||
const fmtGB = (v: number) => `${(v / (1024 * 1024 * 1024)).toFixed(0)}G`;
|
||||
const deployments = isDemoMode ? [] : deploysQuery.data ?? [];
|
||||
const deployCount = deployments.length;
|
||||
const feed = isDemoMode
|
||||
const feed: DeployRow[] = isDemoMode
|
||||
? demoDeploys
|
||||
: deployments.slice(0, 5).map((d) => ({
|
||||
: deployments.slice(0, 6).map((d) => ({
|
||||
id: d.id,
|
||||
name: d.serviceName || d.imageName || d.serviceId || d.id,
|
||||
project: d.projectName,
|
||||
@@ -200,120 +232,188 @@ export function ProjectsPage() {
|
||||
when: formatRelative(d.startedAt ?? d.completedAt ?? d.createdAt),
|
||||
}));
|
||||
|
||||
const statusClass = (status: string) =>
|
||||
status === 'DEPLOYED' || status === 'SUCCESS' || status === 'SUCCEEDED'
|
||||
? 'v-st-ok'
|
||||
: status === 'BUILDING' || status === 'RUNNING' || status === 'QUEUED'
|
||||
? 'v-st-run'
|
||||
: status === 'FAILED' || status === 'CANCELLED'
|
||||
? 'v-st-fail'
|
||||
: 'v-st-off';
|
||||
const statDefs = [
|
||||
{
|
||||
icon: <Cpu size={13.5} />,
|
||||
label: `${hostLabel} CPU`,
|
||||
value: cpuPct ?? '—',
|
||||
unit: cpuPct !== null ? '%' : undefined,
|
||||
ticks: cpuPct,
|
||||
foot: isDemoMode || host ? `${host?.cpu.cores ?? 16} cores · load avg` : 'no telemetry',
|
||||
},
|
||||
{
|
||||
icon: <MemoryStick size={13.5} />,
|
||||
label: `${hostLabel} Memory`,
|
||||
value: memPct ?? '—',
|
||||
unit: memPct !== null ? '%' : undefined,
|
||||
ticks: memPct,
|
||||
foot: isDemoMode
|
||||
? '9.8G / 13G used'
|
||||
: host
|
||||
? `${fmtGB(host.memory.used)} / ${fmtGB(host.memory.total)} used`
|
||||
: 'no telemetry',
|
||||
},
|
||||
{
|
||||
icon: <HardDrive size={13.5} />,
|
||||
label: `${hostLabel} Disk`,
|
||||
value: diskPct ?? '—',
|
||||
unit: diskPct !== null ? '%' : undefined,
|
||||
ticks: diskPct,
|
||||
foot: isDemoMode
|
||||
? '338G of 465G'
|
||||
: host
|
||||
? `${fmtGB(host.storage.used)} of ${fmtGB(host.storage.total)}`
|
||||
: 'no telemetry',
|
||||
},
|
||||
{
|
||||
icon: <Rocket size={13.5} />,
|
||||
label: 'Deployments',
|
||||
value: isDemoMode ? 18 : deployCount,
|
||||
unit: 'recent',
|
||||
ticks: null,
|
||||
foot: isDemoMode
|
||||
? '17 succeeded'
|
||||
: `${deployments.filter((d) => (d.status ?? '').toLowerCase() !== 'failed').length} succeeded`,
|
||||
},
|
||||
];
|
||||
|
||||
const deployCols: SCol<DeployRow>[] = [
|
||||
{
|
||||
key: 'service',
|
||||
label: 'Service',
|
||||
width: '2.4fr',
|
||||
sortValue: (r) => r.name,
|
||||
render: (r) => (
|
||||
<span className="text-[var(--text-primary)] font-medium">
|
||||
{r.name}
|
||||
{r.project ? (
|
||||
<span className="v-mono ml-2 text-[10.5px] font-normal text-[var(--text-tertiary)]">{r.project}</span>
|
||||
) : null}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
width: '1fr',
|
||||
sortValue: (r) => r.status,
|
||||
render: (r) => <SPill tone={statusTone(r.status)}>{r.status}</SPill>,
|
||||
},
|
||||
{
|
||||
key: 'when',
|
||||
label: 'When',
|
||||
width: '0.6fr',
|
||||
sortable: false,
|
||||
render: (r) => <span className="v-mono text-[11.5px] text-[var(--text-tertiary)]">{r.when}</span>,
|
||||
},
|
||||
];
|
||||
|
||||
const anomalies =
|
||||
(diskPct ?? 0) > 85 || (memPct ?? 0) > 85
|
||||
? [
|
||||
...(diskPct ?? 0) > 85 ? [`Disk usage at ${diskPct}% — prune images or expand volume`] : [],
|
||||
...(memPct ?? 0) > 85 ? [`Memory pressure at ${memPct}% — consider rebalancing services`] : [],
|
||||
]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<div className="w-full px-8 py-6">
|
||||
{/* Page head */}
|
||||
<div className="mb-6 flex items-center justify-between gap-5">
|
||||
<div>
|
||||
<h1 className="v-title">Projects<span className="v-cursor">_</span></h1>
|
||||
<p className="v-mono mt-1.5 text-[11px] text-[var(--text-tertiary)]">
|
||||
<b className="text-[var(--text-secondary)]">{String(projects.length).padStart(2, '0')}</b> projects ·{' '}
|
||||
<b className="text-[var(--text-secondary)]">{String(totalServices).padStart(2, '0')}</b> services ·{' '}
|
||||
<b className="text-[var(--text-secondary)]">{runningServices}</b> online
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="search-box" style={{ width: 'min(240px, 42vw)' }}>
|
||||
<div className="w-full px-4 py-6 sm:px-7">
|
||||
<SPageHead
|
||||
title="Projects"
|
||||
titleAccent="_"
|
||||
sub={
|
||||
<>
|
||||
{String(projects.length).padStart(2, '0')} projects · {String(totalServices).padStart(2, '0')} services ·{' '}
|
||||
</>
|
||||
}
|
||||
subAccent={`${runningServices} online`}
|
||||
actions={
|
||||
<>
|
||||
<div className="search-box" style={{ width: 'min(230px, 40vw)' }}>
|
||||
<Search size={13} />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="search projects…"
|
||||
className="v-mono"
|
||||
style={{ fontSize: 11.5 }}
|
||||
placeholder="Search projects"
|
||||
/>
|
||||
</div>
|
||||
<IconBtn title="Refresh" onClick={() => { projectsQuery.refetch(); deploysQuery.refetch(); hostQuery.refetch(); }}>
|
||||
<RefreshCw size={14} />
|
||||
</IconBtn>
|
||||
{signedIn && (
|
||||
<button onClick={openCreate} className="v-btn">
|
||||
+ new project
|
||||
</button>
|
||||
<GhostBtn onClick={openCreate}>+ New Project</GhostBtn>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Host stat row */}
|
||||
<div className="mb-7 grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<div className="v-stat">
|
||||
<div className="v-k"><span>{hostLabel}.CPU</span><span className="v-live" /></div>
|
||||
<div className="v-v">{cpuPct ?? '—'}<span>%</span></div>
|
||||
{isDemoMode ? (
|
||||
<div className="v-spark">
|
||||
{[28, 44, 36, 58, 42, 66, 50, 34, 56, 40, 30, 20].map((h, i) => (
|
||||
<i key={i} style={{ height: `${h}%` }} />
|
||||
{/* Host telemetry */}
|
||||
<div className="mb-4 grid grid-cols-2 gap-3.5 lg:grid-cols-4">
|
||||
{statDefs.map((s) => (
|
||||
<SStat
|
||||
key={s.label}
|
||||
icon={s.icon}
|
||||
label={s.label}
|
||||
value={s.value}
|
||||
unit={s.unit}
|
||||
foot={s.foot}
|
||||
delta={s.label === 'Deployments' && feed.length > 0 ? { dir: 'up', text: `+${feed.length}` } : undefined}
|
||||
extra={s.ticks !== null && s.ticks !== undefined ? <Ticks pct={s.ticks} count={44} warn={s.ticks > 85} /> : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="v-d">
|
||||
{isDemoMode || host ? (
|
||||
<><b style={{ color: 'var(--success)' }}>healthy</b> · {host?.cpu.cores ?? 16} cores</>
|
||||
) : 'no telemetry'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="v-stat">
|
||||
<div className="v-k"><span>{hostLabel}.MEM</span><span className="v-live" /></div>
|
||||
<div className="v-v">{memPct ?? '—'}<span>%</span></div>
|
||||
<div className="v-meter"><i className={memPct !== null && memPct > 85 ? 'r' : 'y'} style={{ width: `${memPct ?? 0}%` }} /></div>
|
||||
<div className="v-d">{isDemoMode ? '9.8G / 13G' : host ? `${fmtGB(host.memory.used)} / ${fmtGB(host.memory.total)}` : 'no telemetry'}</div>
|
||||
</div>
|
||||
<div className="v-stat">
|
||||
<div className="v-k"><span>{hostLabel}.DISK</span><span className="v-live" /></div>
|
||||
<div className="v-v">{diskPct ?? '—'}<span>%</span></div>
|
||||
<div className="v-meter"><i className="r" style={{ width: `${diskPct ?? 0}%` }} /></div>
|
||||
<div className="v-d">{isDemoMode ? '338G of 465G · high' : host ? `${fmtGB(host.storage.used)} of ${fmtGB(host.storage.total)}` : 'no telemetry'}</div>
|
||||
</div>
|
||||
<div className="v-stat">
|
||||
<div className="v-k"><span>DEPLOYS</span></div>
|
||||
<div className="v-v">{isDemoMode ? 18 : deployCount}</div>
|
||||
<div className="v-meter"><i style={{ width: '94%' }} /></div>
|
||||
<div className="v-d"><b style={{ color: 'var(--success)' }}>{isDemoMode ? '17 ok' : `${deployments.filter((d) => (d.status ?? '').toLowerCase() !== 'failed').length} recent`}</b></div>
|
||||
</div>
|
||||
|
||||
{anomalies.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<SAnomaly
|
||||
icon={
|
||||
<svg width="12.5" height="12.5" viewBox="0 0 16 16" fill="none" stroke="var(--warning)" strokeWidth="1.5">
|
||||
<path d="M8 2.4 2.2 13h11.6z" />
|
||||
<path d="M8 6.6v3M8 11.4v.3" />
|
||||
</svg>
|
||||
}
|
||||
title={<><b className="text-[var(--text-primary)] font-semibold">{anomalies.length}</b> resource anomaly{anomalies.length > 1 ? 's' : ''} detected</>}
|
||||
items={anomalies}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Projects grid */}
|
||||
<p className="v-sect">PROJECTS <span className="v-sect-r">{filteredProjects.length} records</span></p>
|
||||
<SSection right={`${filteredProjects.length} records`}>Projects</SSection>
|
||||
|
||||
{!isDemoMode && projectsQuery.isLoading && (
|
||||
<div className="grid grid-cols-1 gap-3.5 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="panel h-[210px] animate-pulse" />
|
||||
<div key={i} className="s-card h-[210px] animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isDemoMode && projectsQuery.isError && (
|
||||
<div className="panel p-8 text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-[var(--error-soft)]">
|
||||
<X size={24} className="text-[var(--error)]" />
|
||||
<div className="s-card p-8 text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-[var(--error-soft)]">
|
||||
<X size={22} className="text-[var(--error)]" />
|
||||
</div>
|
||||
<p className="text-lg font-medium text-[var(--text-primary)]">Failed to load projects</p>
|
||||
<p className="mt-2 text-sm text-[var(--text-secondary)]">Check API connectivity and try again</p>
|
||||
<p className="text-[15px] font-semibold text-[var(--text-primary)]">Failed to load projects</p>
|
||||
<p className="mt-1.5 text-[12px] text-[var(--text-tertiary)]">Check API connectivity and try again</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredProjects.length === 0 && !projectsQuery.isLoading && !projectsQuery.isError ? (
|
||||
<div className="panel p-12 text-center">
|
||||
<div className="mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-[var(--accent-primary-soft)]">
|
||||
<FolderOpen size={28} className="text-[var(--accent-primary)]" />
|
||||
<div className="s-card p-12 text-center">
|
||||
<div className="mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-xl border border-[var(--border-default)] bg-[var(--bg-overlay)]">
|
||||
<FolderOpen size={24} className="text-[var(--accent-primary)]" />
|
||||
</div>
|
||||
<p className="text-xl font-semibold text-[var(--text-primary)]">No projects yet</p>
|
||||
<p className="mx-auto mt-2 max-w-md text-[var(--text-secondary)]">
|
||||
<p className="font-headline text-[17px] font-bold text-[var(--text-primary)]">No projects yet</p>
|
||||
<p className="mx-auto mt-2 max-w-md text-[12.5px] text-[var(--text-secondary)]">
|
||||
{signedIn
|
||||
? 'Create your first project to start deploying services with visual topology management.'
|
||||
: 'No public projects yet. Sign in to create one — an admin approves it before it goes public.'}
|
||||
</p>
|
||||
<button onClick={openCreate} className="v-btn mt-6">
|
||||
{signedIn ? '+ create project' : 'sign in to create'}
|
||||
</button>
|
||||
<GhostBtn onClick={openCreate} className="mt-6">
|
||||
{signedIn ? '+ New Project' : 'Sign in to create'}
|
||||
</GhostBtn>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -326,24 +426,23 @@ export function ProjectsPage() {
|
||||
)}
|
||||
|
||||
{/* Deploy feed */}
|
||||
<p className="v-sect mt-7">RECENT DEPLOYMENTS <span className="v-sect-r">latest</span></p>
|
||||
<div className="v-tbl">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>SERVICE</th><th>STATUS</th><th style={{ textAlign: 'right' }}>WHEN</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{feed.length === 0 ? (
|
||||
<tr><td colSpan={3} className="text-center" style={{ color: 'var(--text-tertiary)' }}>no deployments yet</td></tr>
|
||||
) : feed.map((d) => (
|
||||
<tr key={d.id}>
|
||||
<td className="v-lead">{d.name}{d.project ? <span className="v-mono ml-2 text-[10.5px] font-normal text-[var(--text-tertiary)]">{d.project}</span> : null}</td>
|
||||
<td><span className={`v-st ${statusClass(d.status)}`}>{d.status}</span></td>
|
||||
<td style={{ textAlign: 'right' }}>{d.when}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="mt-6">
|
||||
<SCard
|
||||
icon={<Rocket size={13.5} />}
|
||||
title="Recent Deployments"
|
||||
accent={feed.length > 0 ? `(${feed.length})` : undefined}
|
||||
trail={
|
||||
<IconBtn title="Refresh" onClick={() => deploysQuery.refetch()}>
|
||||
<RefreshCw size={13} />
|
||||
</IconBtn>
|
||||
}
|
||||
pad={false}
|
||||
>
|
||||
<STable cols={deployCols} rows={feed} rowKey={(r) => r.id} />
|
||||
{feed.length === 0 && (
|
||||
<p className="px-7 pb-5 text-[12px] text-[var(--text-tertiary)]">No deployments yet.</p>
|
||||
)}
|
||||
</SCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -351,58 +450,55 @@ export function ProjectsPage() {
|
||||
{isCreateOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-[var(--bg-void)]/80 backdrop-blur-sm" onClick={() => setCreateOpen(false)} />
|
||||
<div className="relative w-full max-w-lg panel p-6">
|
||||
<h2 className="text-xl font-semibold text-[var(--text-primary)]">Create new project</h2>
|
||||
<p className="mt-1 text-sm text-[var(--text-secondary)]">
|
||||
<div className="s-card relative w-full max-w-lg p-6">
|
||||
<h2 className="font-headline text-[19px] font-bold tracking-tight text-[var(--text-primary)]">New project</h2>
|
||||
<p className="mt-1 text-[12.5px] text-[var(--text-secondary)]">
|
||||
Projects organize your services and provide a visual canvas for topology management.
|
||||
</p>
|
||||
<p className="mt-2 rounded-[var(--radius-md)] bg-[var(--warning-soft)] px-3 py-2 text-xs text-[var(--warning)]">
|
||||
<div className="s-inset mt-3 text-[11.5px] text-[var(--warning)]">
|
||||
New projects stay private until a platform admin approves them for public listing.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-4">
|
||||
<div className="mt-5 space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)]">
|
||||
<label className="mb-2 block text-[11px] font-medium uppercase tracking-wider text-[var(--text-tertiary)]">
|
||||
Project Name
|
||||
</label>
|
||||
<input
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))}
|
||||
placeholder="my-awesome-project"
|
||||
className="h-11 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-4 text-[var(--text-primary)] transition-all placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)]"
|
||||
className="h-10 w-full rounded-lg border border-[var(--border-default)] bg-[var(--bg-overlay)] px-3.5 text-[13px] text-[var(--text-primary)] transition-all placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)]">
|
||||
<label className="mb-2 block text-[11px] font-medium uppercase tracking-wider text-[var(--text-tertiary)]">
|
||||
Description <span className="normal-case text-[var(--text-muted)]">(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={form.description}
|
||||
onChange={(e) => setForm((p) => ({ ...p, description: e.target.value }))}
|
||||
placeholder="Describe your project..."
|
||||
placeholder="Describe your project…"
|
||||
rows={3}
|
||||
className="w-full resize-none rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-4 py-3 text-[var(--text-primary)] transition-all placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)]"
|
||||
className="w-full resize-none rounded-lg border border-[var(--border-default)] bg-[var(--bg-overlay)] px-3.5 py-3 text-[13px] text-[var(--text-primary)] transition-all placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{createProjectMutation.isError && (
|
||||
<div className="mt-4 rounded-[var(--radius-md)] bg-[var(--error-soft)] px-4 py-3 text-sm text-[var(--error)]">
|
||||
<div className="s-inset mt-4 text-[12px] text-[var(--error)]">
|
||||
{(createProjectMutation.error as Error).message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<button onClick={() => setCreateOpen(false)} className="v-btn-ghost">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
<div className="mt-6 flex justify-end gap-2.5">
|
||||
<QuietBtn onClick={() => setCreateOpen(false)}>Cancel</QuietBtn>
|
||||
<GhostBtn
|
||||
onClick={() => createProjectMutation.mutate()}
|
||||
disabled={!form.name.trim() || createProjectMutation.isPending}
|
||||
className="v-btn"
|
||||
>
|
||||
{createProjectMutation.isPending ? 'Creating...' : 'Create Project'}
|
||||
</button>
|
||||
{createProjectMutation.isPending ? 'Creating…' : 'Create Project'}
|
||||
</GhostBtn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
@@ -104,6 +104,7 @@ import { formatRelative } from '@/lib/time';
|
||||
import { getAuthBaseUrl, signOutAuthSession } from '@/lib/auth-client';
|
||||
import { useBuildUpdates } from '@/lib/use-build-updates';
|
||||
import { EnhancedMetricCard, LineAreaChart, DonutChart, SegmentedBar, BarChart, DemoRestricted } from '@/shared/components';
|
||||
import { SPageHead } from '@/shared/components/sentry';
|
||||
import {
|
||||
Clock,
|
||||
Activity,
|
||||
@@ -148,13 +149,10 @@ import {
|
||||
Send,
|
||||
} from 'lucide-react';
|
||||
|
||||
function SecondaryPageHeader({ title, description }: { title: string; description: string }) {
|
||||
function SecondaryPageHeader({ title, description, actions }: { title: string; description: string; actions?: ReactNode }) {
|
||||
return (
|
||||
<div className="border-b border-[var(--border-subtle)]">
|
||||
<div className="w-full px-8 py-5">
|
||||
<h1 className="v-title">{title}<span className="v-cursor">_</span></h1>
|
||||
<p className="v-mono mt-1.5 text-[11px] text-[var(--text-tertiary)]">{description}</p>
|
||||
</div>
|
||||
<div className="w-full px-4 pt-6 sm:px-8">
|
||||
<SPageHead title={title} titleAccent="_" sub={description} actions={actions} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -180,15 +178,17 @@ function StatCard({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="panel p-5">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--surface-muted)] flex items-center justify-center">
|
||||
<Icon size={18} className="text-[var(--text-tertiary)]" />
|
||||
<div className="s-stat">
|
||||
<div className="px-4 pb-1 pt-4">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="s-ibox"><Icon /></span>
|
||||
<span className="text-[11.5px] font-medium text-[var(--text-secondary)]">{title}</span>
|
||||
</div>
|
||||
<p className={`font-headline mt-4 text-[30px] font-bold leading-none tracking-tight ${colorClasses[color]}`}>{value}</p>
|
||||
</div>
|
||||
<div className="s-stat-foot mt-4">
|
||||
<span>{description}</span>
|
||||
</div>
|
||||
<p className={`text-2xl font-semibold ${colorClasses[color]}`}>{value}</p>
|
||||
<p className="text-xs font-medium text-[var(--text-muted)] mt-1">{title}</p>
|
||||
<p className="text-xs text-[var(--text-tertiary)] mt-2">{description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -436,7 +436,7 @@ export function UsagePage() {
|
||||
title="Usage"
|
||||
description="Platform usage metrics and operational summaries"
|
||||
/>
|
||||
<div className="w-full px-8 py-6">
|
||||
<div className="w-full px-4 py-6 sm:px-8">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<div className={`w-2 h-2 rounded-full ${liveStatus === 'live' ? 'bg-[var(--success)] animate-pulse' : 'bg-[var(--text-muted)]'}`} />
|
||||
<span className={`text-sm ${liveStatus === 'live' ? 'text-[var(--success)]' : 'text-[var(--text-muted)]'}`}>
|
||||
@@ -450,14 +450,14 @@ export function UsagePage() {
|
||||
<p className="mt-3 text-sm text-[var(--text-muted)]">Loading usage data...</p>
|
||||
</div>
|
||||
) : !isDemoMode && buildsQuery.isError ? (
|
||||
<div className="panel p-8 text-center">
|
||||
<div className="s-card p-8 text-center">
|
||||
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-[var(--error-soft)] flex items-center justify-center">
|
||||
<AlertCircle size={24} className="text-[var(--error)]" />
|
||||
</div>
|
||||
<p className="text-sm text-[var(--error)]">Failed to load usage data</p>
|
||||
</div>
|
||||
) : !signedIn ? (
|
||||
<div className="panel p-8 text-center">
|
||||
<div className="s-card p-8 text-center">
|
||||
<p className="text-sm text-[var(--text-secondary)]">Sign in to view build activity and capacity.</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -490,14 +490,12 @@ export function UsagePage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="panel p-6">
|
||||
<section className="s-card p-5">
|
||||
<div className="mb-5 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)]">
|
||||
<Server size={18} className="text-[var(--accent-primary)]" />
|
||||
</div>
|
||||
<span className="s-ibox !w-9 !h-9"><Server /></span>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Host Monitoring</h2>
|
||||
<h2 className="s-t">Host Monitoring</h2>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">
|
||||
{host
|
||||
? `${host.hostname} · ${host.os}/${host.architecture} · uptime ${formatUptime(host.uptimeSeconds)}${host.dockerAvailable ? '' : ' · Docker unavailable'}`
|
||||
@@ -578,13 +576,11 @@ export function UsagePage() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="panel p-6">
|
||||
<section className="s-card p-5">
|
||||
<div className="mb-5 flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-[var(--radius-md)] bg-[var(--surface-muted)]">
|
||||
<Radio size={18} className="text-[var(--text-tertiary)]" />
|
||||
</div>
|
||||
<span className="s-ibox !w-9 !h-9"><Radio /></span>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Node Agents</h2>
|
||||
<h2 className="s-t">Node Agents</h2>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Remote VPS, VM, and LXC capacity for placement and autoscaling.</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -678,8 +674,8 @@ export function UsagePage() {
|
||||
<button
|
||||
onClick={() => createTokenMutation.mutate()}
|
||||
disabled={createTokenMutation.isPending}
|
||||
className="h-8 px-3 rounded-[var(--radius-md)] text-xs font-medium text-[var(--accent-on)] transition-all disabled:opacity-50"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
className="s-btn-accent h-8 disabled:opacity-50"
|
||||
|
||||
>
|
||||
{createTokenMutation.isPending ? 'Issuing…' : 'Issue token'}
|
||||
</button>
|
||||
@@ -688,7 +684,7 @@ export function UsagePage() {
|
||||
{issuedToken && (
|
||||
<div className="mb-3 rounded-[var(--radius-md)] border border-[var(--success)]/30 bg-[var(--success-soft)] p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="mono text-xs text-[var(--text-primary)] break-all">{issuedToken.token}</span>
|
||||
<span className="v-mono text-xs text-[var(--text-primary)] break-all">{issuedToken.token}</span>
|
||||
<button
|
||||
onClick={() => void navigator.clipboard.writeText(issuedToken.token ?? '')}
|
||||
className="flex items-center gap-1 text-xs text-[var(--accent-primary)] hover:underline shrink-0"
|
||||
@@ -706,11 +702,11 @@ export function UsagePage() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
<pre className="mono text-xs text-[var(--text-secondary)] whitespace-pre-wrap">{installCommand}</pre>
|
||||
<pre className="mono text-xs text-[var(--text-secondary)] whitespace-pre-wrap mt-2">{sshCommand}</pre>
|
||||
<pre className="v-mono text-xs text-[var(--text-secondary)] whitespace-pre-wrap">{installCommand}</pre>
|
||||
<pre className="v-mono text-xs text-[var(--text-secondary)] whitespace-pre-wrap mt-2">{sshCommand}</pre>
|
||||
<div className="mt-4 space-y-2 text-xs text-[var(--text-tertiary)]">
|
||||
<p>Agent endpoint: <span className="mono text-[var(--text-primary)]">{agentEndpoint}</span></p>
|
||||
<p>Use an issued token above, or set <span className="mono text-[var(--text-primary)]">CONTAINR_AGENT_AUTH_TOKENS</span> on the backend.</p>
|
||||
<p>Agent endpoint: <span className="v-mono text-[var(--text-primary)]">{agentEndpoint}</span></p>
|
||||
<p>Use an issued token above, or set <span className="v-mono text-[var(--text-primary)]">CONTAINR_AGENT_AUTH_TOKENS</span> on the backend.</p>
|
||||
<p>Agent sends host resources, polls pending Docker commands, and reports command results.</p>
|
||||
</div>
|
||||
|
||||
@@ -844,7 +840,7 @@ export function PeoplePage() {
|
||||
title="People"
|
||||
description="Team management and access control"
|
||||
/>
|
||||
<div className="w-full px-8 py-6">
|
||||
<div className="w-full px-4 py-6 sm:px-8">
|
||||
{profileQuery.isLoading ? (
|
||||
<div className="py-16 text-center">
|
||||
<Loader2 size={24} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
|
||||
@@ -876,19 +872,17 @@ export function PeoplePage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="panel p-6">
|
||||
<section className="s-card p-5">
|
||||
<div className="mb-5 flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)]">
|
||||
<UserPlus size={18} className="text-[var(--accent-primary)]" />
|
||||
</div>
|
||||
<span className="s-ibox !w-9 !h-9"><UserPlus /></span>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Manual User Creation</h2>
|
||||
<h2 className="s-t">Manual User Creation</h2>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Public registration is closed after bootstrap.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="grid gap-3 lg:grid-cols-[1fr_1fr_1fr_auto]"
|
||||
className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_auto]"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
createUserMutation.mutate();
|
||||
@@ -921,8 +915,8 @@ export function PeoplePage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createUserMutation.isPending}
|
||||
className="inline-flex h-10 items-center justify-center gap-2 rounded-[var(--radius-md)] px-4 text-sm font-semibold text-[var(--accent-on)] disabled:opacity-60"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
className="s-btn-accent h-10 justify-center disabled:opacity-60"
|
||||
|
||||
>
|
||||
{createUserMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : <UserPlus size={14} />}
|
||||
Create
|
||||
@@ -1057,14 +1051,12 @@ function GitProvidersSection() {
|
||||
const selectedType = gitProviderTypes.find((t) => t.value === form.name)!;
|
||||
|
||||
return (
|
||||
<section className="panel p-6">
|
||||
<section className="s-card p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
|
||||
<GitBranch size={18} className="text-[var(--accent-primary)]" />
|
||||
</div>
|
||||
<span className="s-ibox !w-9 !h-9"><GitBranch /></span>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Git Providers</h2>
|
||||
<h2 className="s-t">Git Providers</h2>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Connect accounts to deploy services from repositories</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1073,8 +1065,8 @@ function GitProvidersSection() {
|
||||
<button
|
||||
onClick={() => provisionMutation.mutate()}
|
||||
disabled={provisionMutation.isPending}
|
||||
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-[var(--accent-on)] text-sm font-medium shadow-lg transition-all disabled:opacity-50"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
className="s-btn-accent h-9 disabled:opacity-50"
|
||||
|
||||
>
|
||||
<GitBranch size={14} />
|
||||
{provisionMutation.isPending ? 'Opening GitHub…' : 'Set up GitHub App'}
|
||||
@@ -1090,8 +1082,8 @@ function GitProvidersSection() {
|
||||
)}
|
||||
<button
|
||||
onClick={() => setFormOpen((open) => !open)}
|
||||
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-[var(--accent-on)] text-sm font-medium shadow-lg transition-all"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
className="s-btn-accent h-9"
|
||||
|
||||
>
|
||||
<Link2 size={14} />
|
||||
{formOpen ? 'Close' : 'Connect'}
|
||||
@@ -1140,12 +1132,10 @@ function GitProvidersSection() {
|
||||
{providers.map((provider) => (
|
||||
<div
|
||||
key={provider.id}
|
||||
className="flex items-center justify-between p-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]"
|
||||
className="s-inset flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-[var(--radius-sm)] bg-[var(--surface-card)] flex items-center justify-center">
|
||||
<GitBranch size={14} className="text-[var(--text-tertiary)]" />
|
||||
</div>
|
||||
<span className="s-ibox"><GitBranch /></span>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">{provider.display_name}</p>
|
||||
<p className="text-xs text-[var(--text-muted)] capitalize">{provider.name.replace('_', ' ')}</p>
|
||||
@@ -1201,7 +1191,7 @@ function GitProvidersSection() {
|
||||
type="password"
|
||||
value={form.accessToken}
|
||||
onChange={(e) => setForm((p) => ({ ...p, accessToken: e.target.value }))}
|
||||
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm mono focus:border-[var(--accent-primary)] transition-colors"
|
||||
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm v-mono focus:border-[var(--accent-primary)] transition-colors"
|
||||
placeholder="••••••••••••••••"
|
||||
/>
|
||||
<p className="text-xs text-[var(--text-muted)] mt-1.5">{selectedType.tokenHint}</p>
|
||||
@@ -1214,7 +1204,7 @@ function GitProvidersSection() {
|
||||
<input
|
||||
value={form.apiUrl}
|
||||
onChange={(e) => setForm((p) => ({ ...p, apiUrl: e.target.value }))}
|
||||
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm mono focus:border-[var(--accent-primary)] transition-colors"
|
||||
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm v-mono focus:border-[var(--accent-primary)] transition-colors"
|
||||
placeholder="https://gitea.example.com"
|
||||
/>
|
||||
</div>
|
||||
@@ -1228,8 +1218,8 @@ function GitProvidersSection() {
|
||||
<button
|
||||
onClick={() => createMutation.mutate()}
|
||||
disabled={!form.accessToken.trim() || createMutation.isPending}
|
||||
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-[var(--accent-on)] text-sm font-medium shadow-lg disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
className="s-btn-accent h-9 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
|
||||
>
|
||||
{createMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />}
|
||||
Connect Provider
|
||||
@@ -1274,7 +1264,7 @@ export function GitHubAppCallbackPage() {
|
||||
{!code ? (
|
||||
<>
|
||||
<AlertCircle size={28} className="mx-auto text-[var(--error)]" />
|
||||
<h1 className="mt-4 text-lg font-semibold text-[var(--text-primary)]">Missing code</h1>
|
||||
<h1 className="mt-4 font-headline text-lg font-semibold text-[var(--text-primary)]">Missing code</h1>
|
||||
<p className="mt-2 text-sm text-[var(--text-tertiary)]">
|
||||
GitHub did not return an app code. Start the setup again from Git providers.
|
||||
</p>
|
||||
@@ -1282,14 +1272,14 @@ export function GitHubAppCallbackPage() {
|
||||
) : convertMutation.isError ? (
|
||||
<>
|
||||
<AlertCircle size={28} className="mx-auto text-[var(--error)]" />
|
||||
<h1 className="mt-4 text-lg font-semibold text-[var(--text-primary)]">Setup failed</h1>
|
||||
<h1 className="mt-4 font-headline text-lg font-semibold text-[var(--text-primary)]">Setup failed</h1>
|
||||
<p className="mt-2 text-sm text-[var(--text-tertiary)]">
|
||||
{convertMutation.error instanceof Error ? convertMutation.error.message : 'Could not provision the GitHub App'}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/settings', { replace: true })}
|
||||
className="mt-6 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-sm font-semibold text-[var(--accent-on)]"
|
||||
className="s-btn-accent mt-6"
|
||||
>
|
||||
Back to settings
|
||||
</button>
|
||||
@@ -1297,7 +1287,7 @@ export function GitHubAppCallbackPage() {
|
||||
) : (
|
||||
<>
|
||||
<Loader2 size={28} className="mx-auto animate-spin text-[var(--accent-primary)]" />
|
||||
<h1 className="mt-4 text-lg font-semibold text-[var(--text-primary)]">Setting up GitHub App</h1>
|
||||
<h1 className="mt-4 font-headline text-lg font-semibold text-[var(--text-primary)]">Setting up GitHub App</h1>
|
||||
<p className="mt-2 text-sm text-[var(--text-tertiary)]">Exchanging the app credentials…</p>
|
||||
</>
|
||||
)}
|
||||
@@ -1353,13 +1343,11 @@ function PlatformSettingsSection() {
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="panel p-6">
|
||||
<section className="s-card p-5">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
|
||||
<Cloud size={18} className="text-[var(--accent-primary)]" />
|
||||
</div>
|
||||
<span className="s-ibox !w-9 !h-9"><Cloud /></span>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Platform</h2>
|
||||
<h2 className="s-t">Platform</h2>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Owner only - values set here override environment variables</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1412,8 +1400,8 @@ function PlatformSettingsSection() {
|
||||
<button
|
||||
onClick={saveToken}
|
||||
disabled={saveMutation.isPending || !tokenDraft.trim()}
|
||||
className="h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
className="s-btn-accent h-9 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
@@ -1475,8 +1463,8 @@ function PlatformSettingsSection() {
|
||||
saveMutation.mutate({ branding: b });
|
||||
}}
|
||||
disabled={saveMutation.isPending || Object.keys(brandDraft).length === 0}
|
||||
className="h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
className="s-btn-accent h-9 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
|
||||
>
|
||||
Save branding
|
||||
</button>
|
||||
@@ -1548,13 +1536,11 @@ function UserTokensSection({ isAdmin }: { isAdmin: boolean }) {
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="panel p-6">
|
||||
<section className="s-card p-5">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
|
||||
<Key size={18} className="text-[var(--accent-primary)]" />
|
||||
</div>
|
||||
<span className="s-ibox !w-9 !h-9"><Key /></span>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Personal Access Tokens</h2>
|
||||
<h2 className="s-t">Personal Access Tokens</h2>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Authenticate the CLI, MCP server, and scripts - same rights as you, bounded by scope</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1588,8 +1574,8 @@ function UserTokensSection({ isAdmin }: { isAdmin: boolean }) {
|
||||
<button
|
||||
onClick={() => createMutation.mutate()}
|
||||
disabled={createMutation.isPending || !tokenName.trim()}
|
||||
className="h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
className="s-btn-accent h-9 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
|
||||
>
|
||||
{createMutation.isPending ? 'Creating…' : 'Create token'}
|
||||
</button>
|
||||
@@ -1598,7 +1584,7 @@ function UserTokensSection({ isAdmin }: { isAdmin: boolean }) {
|
||||
{issuedToken ? (
|
||||
<div className="rounded-[var(--radius-md)] border border-[var(--success)]/30 bg-[var(--success-soft)] p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="mono text-xs text-[var(--text-primary)] break-all">{issuedToken.token}</span>
|
||||
<span className="v-mono text-xs text-[var(--text-primary)] break-all">{issuedToken.token}</span>
|
||||
<button
|
||||
onClick={() => void navigator.clipboard.writeText(issuedToken.token ?? '')}
|
||||
className="flex items-center gap-1 text-xs text-[var(--accent-primary)] hover:underline shrink-0"
|
||||
@@ -1608,7 +1594,7 @@ function UserTokensSection({ isAdmin }: { isAdmin: boolean }) {
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-[var(--text-tertiary)]">
|
||||
Shown once - store it now. Use with <span className="mono">containr auth login --token</span> or as a Bearer token.
|
||||
Shown once - store it now. Use with <span className="v-mono">containr auth login --token</span> or as a Bearer token.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -1630,7 +1616,7 @@ function UserTokensSection({ isAdmin }: { isAdmin: boolean }) {
|
||||
<div key={token.id} className="flex items-center justify-between gap-2 text-xs">
|
||||
<div className="min-w-0">
|
||||
<span className="text-[var(--text-primary)]">{token.name}</span>
|
||||
<span className="ml-2 mono text-[var(--text-muted)]">{token.key_prefix}…</span>
|
||||
<span className="ml-2 v-mono text-[var(--text-muted)]">{token.key_prefix}…</span>
|
||||
<span className="ml-2 rounded px-1.5 py-0.5 bg-[var(--surface-muted)] text-[var(--text-secondary)]">{token.scope}</span>
|
||||
<span className="ml-2 text-[var(--text-muted)]">
|
||||
{token.last_used_at ? `used ${formatRelative(token.last_used_at)}` : 'unused'}
|
||||
@@ -1692,13 +1678,11 @@ function RegistriesSection() {
|
||||
const registries = registriesQuery.data ?? [];
|
||||
|
||||
return (
|
||||
<section className="panel p-6">
|
||||
<section className="s-card p-5">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
|
||||
<Archive size={18} className="text-[var(--accent-primary)]" />
|
||||
</div>
|
||||
<span className="s-ibox !w-9 !h-9"><Archive /></span>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Registry credentials</h2>
|
||||
<h2 className="s-t">Registry credentials</h2>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Pull private images on deploy - matched by image host, stored encrypted</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1733,8 +1717,8 @@ function RegistriesSection() {
|
||||
<button
|
||||
onClick={() => createMutation.mutate()}
|
||||
disabled={createMutation.isPending || !name.trim() || !host.trim()}
|
||||
className="h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
className="s-btn-accent h-9 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
|
||||
>
|
||||
{createMutation.isPending ? 'Adding…' : 'Add registry'}
|
||||
</button>
|
||||
@@ -1760,7 +1744,7 @@ function RegistriesSection() {
|
||||
<div key={r.id} className="flex items-center justify-between gap-2 text-xs">
|
||||
<div className="min-w-0">
|
||||
<span className="text-[var(--text-primary)]">{r.name}</span>
|
||||
<span className="ml-2 mono text-[var(--text-muted)]">{r.host}</span>
|
||||
<span className="ml-2 v-mono text-[var(--text-muted)]">{r.host}</span>
|
||||
{r.username ? <span className="ml-2 text-[var(--text-secondary)]">{r.username}</span> : null}
|
||||
<span className="ml-2 rounded px-1.5 py-0.5 bg-[var(--surface-muted)] text-[var(--text-secondary)]">
|
||||
{r.has_password ? 'password set' : 'anonymous'}
|
||||
@@ -1828,13 +1812,11 @@ function BackupTargetsSection() {
|
||||
const fieldCls = 'h-9 flex-1 min-w-[130px] rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all';
|
||||
|
||||
return (
|
||||
<section className="panel p-6">
|
||||
<section className="s-card p-5">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
|
||||
<HardDrive size={18} className="text-[var(--accent-primary)]" />
|
||||
</div>
|
||||
<span className="s-ibox !w-9 !h-9"><HardDrive /></span>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Backup targets</h2>
|
||||
<h2 className="s-t">Backup targets</h2>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">S3-compatible destinations for database archives — keys stored encrypted</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1853,8 +1835,8 @@ function BackupTargetsSection() {
|
||||
<button
|
||||
onClick={() => createMutation.mutate()}
|
||||
disabled={createMutation.isPending || !form.name.trim() || !form.endpoint.trim() || !form.bucket.trim()}
|
||||
className="h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
className="s-btn-accent h-9 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
|
||||
>
|
||||
{createMutation.isPending ? 'Checking…' : 'Add target'}
|
||||
</button>
|
||||
@@ -1880,7 +1862,7 @@ function BackupTargetsSection() {
|
||||
<div key={t.id} className="flex items-center justify-between gap-2 text-xs">
|
||||
<div className="min-w-0">
|
||||
<span className="text-[var(--text-primary)]">{t.name}</span>
|
||||
<span className="ml-2 mono text-[var(--text-muted)]">{t.endpoint}/{t.bucket}{t.prefix ? `/${t.prefix}` : ''}</span>
|
||||
<span className="ml-2 v-mono text-[var(--text-muted)]">{t.endpoint}/{t.bucket}{t.prefix ? `/${t.prefix}` : ''}</span>
|
||||
<span className="ml-2 rounded px-1.5 py-0.5 bg-[var(--surface-muted)] text-[var(--text-secondary)]">
|
||||
{t.has_credentials ? 'keys set' : 'anonymous'}{t.use_tls ? '' : ' · no TLS'}
|
||||
</span>
|
||||
@@ -1961,13 +1943,11 @@ function NotificationChannelsSection() {
|
||||
const channels = channelsQuery.data ?? [];
|
||||
|
||||
return (
|
||||
<section className="panel p-6">
|
||||
<section className="s-card p-5">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
|
||||
<Bell size={18} className="text-[var(--accent-primary)]" />
|
||||
</div>
|
||||
<span className="s-ibox !w-9 !h-9"><Bell /></span>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Push notifications</h2>
|
||||
<h2 className="s-t">Push notifications</h2>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Relay notifications to a self-hosted ntfy or Gotify server</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1998,8 +1978,8 @@ function NotificationChannelsSection() {
|
||||
<button
|
||||
onClick={() => createMutation.mutate()}
|
||||
disabled={createMutation.isPending || !endpoint.trim()}
|
||||
className="h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
className="s-btn-accent h-9 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
|
||||
>
|
||||
{createMutation.isPending ? 'Adding…' : 'Add channel'}
|
||||
</button>
|
||||
@@ -2027,7 +2007,7 @@ function NotificationChannelsSection() {
|
||||
<span className="rounded px-1.5 py-0.5 bg-[var(--surface-muted)] text-[var(--text-secondary)] uppercase">
|
||||
{ch.kind}
|
||||
</span>
|
||||
<span className="ml-2 mono text-[var(--text-primary)] break-all">{ch.endpoint}</span>
|
||||
<span className="ml-2 v-mono text-[var(--text-primary)] break-all">{ch.endpoint}</span>
|
||||
{testResult[ch.id] === 'ok' ? (
|
||||
<span className="ml-2 text-[var(--success)]">delivered</span>
|
||||
) : testResult[ch.id] ? (
|
||||
@@ -2145,16 +2125,14 @@ export function SettingsPage() {
|
||||
title="Settings"
|
||||
description="Manage account profile and local configuration"
|
||||
/>
|
||||
<div className="w-full px-8 py-6">
|
||||
<div className="w-full px-4 py-6 sm:px-8">
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||
{/* Profile Section */}
|
||||
<section className="panel p-6">
|
||||
<section className="s-card p-5">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
|
||||
<User size={18} className="text-[var(--accent-primary)]" />
|
||||
</div>
|
||||
<span className="s-ibox !w-9 !h-9"><User /></span>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Profile</h2>
|
||||
<h2 className="s-t">Profile</h2>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Backed by GET/PUT /user/profile</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2196,8 +2174,8 @@ export function SettingsPage() {
|
||||
<button
|
||||
onClick={() => updateProfileMutation.mutate()}
|
||||
disabled={!hasProfileChanges || updateProfileMutation.isPending}
|
||||
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-[var(--accent-on)] text-sm font-medium shadow-lg disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
className="s-btn-accent h-9 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
|
||||
>
|
||||
{updateProfileMutation.isPending ? (
|
||||
<>
|
||||
@@ -2248,13 +2226,11 @@ export function SettingsPage() {
|
||||
<GitProvidersSection />
|
||||
|
||||
{/* Audit Logs Section */}
|
||||
<section className="panel p-6">
|
||||
<section className="s-card p-5">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
|
||||
<ScrollText size={18} className="text-[var(--accent-primary)]" />
|
||||
</div>
|
||||
<span className="s-ibox !w-9 !h-9"><ScrollText /></span>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Audit Logs</h2>
|
||||
<h2 className="s-t">Audit Logs</h2>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Every authenticated action recorded by the platform</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2271,13 +2247,11 @@ export function SettingsPage() {
|
||||
</section>
|
||||
|
||||
{/* Runtime & Local State Section */}
|
||||
<section className="panel p-6">
|
||||
<section className="s-card p-5">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--surface-muted)] flex items-center justify-center">
|
||||
<Settings size={18} className="text-[var(--text-tertiary)]" />
|
||||
</div>
|
||||
<span className="s-ibox !w-9 !h-9"><Settings /></span>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Runtime & Local State</h2>
|
||||
<h2 className="s-t">Runtime & Local State</h2>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">API endpoints, cookie-session mode, and storage diagnostics</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2288,7 +2262,7 @@ export function SettingsPage() {
|
||||
<Key size={14} className="text-[var(--text-tertiary)]" />
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">API Base</span>
|
||||
</div>
|
||||
<p className="mono text-xs text-[var(--text-primary)] break-all">{getApiBaseUrl()}</p>
|
||||
<p className="v-mono text-xs text-[var(--text-primary)] break-all">{getApiBaseUrl()}</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]">
|
||||
@@ -2296,7 +2270,7 @@ export function SettingsPage() {
|
||||
<Key size={14} className="text-[var(--text-tertiary)]" />
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Auth Base</span>
|
||||
</div>
|
||||
<p className="mono text-xs text-[var(--text-primary)] break-all">{getAuthBaseUrl()}</p>
|
||||
<p className="v-mono text-xs text-[var(--text-primary)] break-all">{getAuthBaseUrl()}</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]">
|
||||
@@ -2350,7 +2324,7 @@ export function SettingsPage() {
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">Cached Keys</p>
|
||||
<ul className="space-y-1">
|
||||
{storageSummary.canvasKeys.slice(0, 5).map((key) => (
|
||||
<li key={key} className="mono text-xs text-[var(--text-tertiary)] truncate">
|
||||
<li key={key} className="v-mono text-xs text-[var(--text-tertiary)] truncate">
|
||||
{key}
|
||||
</li>
|
||||
))}
|
||||
@@ -2376,7 +2350,7 @@ export function DocsPage() {
|
||||
title="Docs"
|
||||
description="Guides and references — synced from GitHub, cached offline"
|
||||
/>
|
||||
<div className="w-full px-8 py-6">
|
||||
<div className="w-full px-4 py-6 sm:px-8">
|
||||
<DocsBrowser />
|
||||
</div>
|
||||
</div>
|
||||
@@ -2513,7 +2487,7 @@ export function DatabasesPage() {
|
||||
title="Databases"
|
||||
description="Managed database services — connection info, runtime actions and backups"
|
||||
/>
|
||||
<div className="w-full px-8 py-6">
|
||||
<div className="w-full px-4 py-6 sm:px-8">
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<p className="text-xs text-[var(--text-tertiary)]">
|
||||
{databases.length} {databases.length === 1 ? 'database' : 'databases'}
|
||||
@@ -2522,7 +2496,7 @@ export function DatabasesPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
className="inline-flex h-9 items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 text-[12.5px] font-semibold text-[var(--accent-on)]"
|
||||
className="s-btn-accent h-9 text-[12.5px]"
|
||||
>
|
||||
<Database size={13} /> New database
|
||||
</button>
|
||||
@@ -2540,7 +2514,7 @@ export function DatabasesPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
className="mt-4 inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-[12.5px] font-semibold text-[var(--accent-on)]"
|
||||
className="s-btn-accent mt-4 text-[12.5px]"
|
||||
>
|
||||
<Database size={13} /> Create your first database
|
||||
</button>
|
||||
@@ -2574,7 +2548,7 @@ export function DatabasesPage() {
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-[var(--text-primary)]">{db.name}</span>
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-[var(--surface-muted)] text-[var(--text-secondary)] mono">{db.type}{db.version ? ` ${db.version}` : ''}</span>
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-[var(--surface-muted)] text-[var(--text-secondary)] v-mono">{db.type}{db.version ? ` ${db.version}` : ''}</span>
|
||||
{db.provider === 'external' && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]">external</span>
|
||||
)}
|
||||
@@ -2594,7 +2568,7 @@ export function DatabasesPage() {
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Connection URL</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="mono flex-1 truncate rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2 text-xs text-[var(--text-primary)]">
|
||||
<code className="v-mono flex-1 truncate rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2 text-xs text-[var(--text-primary)]">
|
||||
{db.connection_url || '—'}
|
||||
</code>
|
||||
{db.connection_url && (
|
||||
@@ -2617,7 +2591,7 @@ export function DatabasesPage() {
|
||||
<button
|
||||
onClick={() => testByIdMutation.mutate(db.id ?? '')}
|
||||
disabled={testByIdMutation.isPending}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] disabled:opacity-40 transition-colors"
|
||||
className="s-btn-quiet"
|
||||
>
|
||||
<RefreshCw size={11} /> Test connection
|
||||
</button>
|
||||
@@ -2634,21 +2608,21 @@ export function DatabasesPage() {
|
||||
<button
|
||||
onClick={() => actionMutation.mutate({ id: db.id ?? '', action: 'start' })}
|
||||
disabled={running || actionMutation.isPending}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] disabled:opacity-40 transition-colors"
|
||||
className="s-btn-quiet"
|
||||
>
|
||||
<Play size={11} /> Start
|
||||
</button>
|
||||
<button
|
||||
onClick={() => actionMutation.mutate({ id: db.id ?? '', action: 'stop' })}
|
||||
disabled={!running || actionMutation.isPending}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] disabled:opacity-40 transition-colors"
|
||||
className="s-btn-quiet"
|
||||
>
|
||||
<Square size={11} /> Stop
|
||||
</button>
|
||||
<button
|
||||
onClick={() => actionMutation.mutate({ id: db.id ?? '', action: 'restart' })}
|
||||
disabled={!running || actionMutation.isPending}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] disabled:opacity-40 transition-colors"
|
||||
className="s-btn-quiet"
|
||||
>
|
||||
<RefreshCw size={11} /> Restart
|
||||
</button>
|
||||
@@ -2665,7 +2639,7 @@ export function DatabasesPage() {
|
||||
onClick={() => setBindDb(bindDb?.id === db.id ? null : db)}
|
||||
disabled={!db.connection_url}
|
||||
title={db.connection_url ? 'Inject connection URL into a service' : 'No connection URL available'}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] disabled:opacity-40 transition-colors"
|
||||
className="s-btn-quiet"
|
||||
>
|
||||
<Link2 size={11} /> Bind
|
||||
</button>
|
||||
@@ -2697,7 +2671,7 @@ export function DatabasesPage() {
|
||||
] as const).map(([label, value]) => (
|
||||
<div key={label} className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2">
|
||||
<p className="text-xs text-[var(--text-muted)]">{label}</p>
|
||||
<p className="mono text-sm text-[var(--text-primary)]">{value}</p>
|
||||
<p className="v-mono text-sm text-[var(--text-primary)]">{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -2743,8 +2717,8 @@ export function DatabasesPage() {
|
||||
<button
|
||||
onClick={() => backupMutation.mutate(db.id ?? '')}
|
||||
disabled={!running || backupMutation.isPending}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] text-xs font-medium text-[var(--accent-on)] disabled:opacity-40 transition-all"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
className="s-btn-accent disabled:opacity-40"
|
||||
|
||||
>
|
||||
<Archive size={11} />
|
||||
{backupMutation.isPending ? 'Starting…' : 'New backup'}
|
||||
@@ -2764,7 +2738,7 @@ export function DatabasesPage() {
|
||||
{backups.map((b) => (
|
||||
<div key={b.id} className="flex items-center gap-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2 text-xs">
|
||||
<span className="text-[var(--text-secondary)]">{formatRelative(b.created_at)}</span>
|
||||
<span className="mono text-[var(--text-muted)]">{b.size}</span>
|
||||
<span className="v-mono text-[var(--text-muted)]">{b.size}</span>
|
||||
<span className={`${b.status === 'completed' ? 'text-[var(--success)]' : b.status === 'in_progress' ? 'text-[var(--warning)]' : 'text-[var(--error)]'}`}>{b.status}</span>
|
||||
{b.remote && <span className="rounded bg-[var(--surface-card)] px-1.5 py-0.5 text-[var(--accent-primary)]" title="Also shipped to the configured backup target">offsite</span>}
|
||||
{b.status === 'completed' && (
|
||||
@@ -2994,7 +2968,7 @@ export function DatabasesPage() {
|
||||
(createMode === 'managed' ? createMutation.isPending : registerExternalMutation.isPending) ||
|
||||
(createMode === 'managed' ? createForm.name.trim().length === 0 : externalForm.name.trim().length === 0 || externalForm.host.trim().length === 0)
|
||||
}
|
||||
className="inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-xs font-semibold text-[var(--accent-on)] disabled:opacity-50"
|
||||
className="s-btn-accent disabled:opacity-50"
|
||||
>
|
||||
{(createMode === 'managed' ? createMutation.isPending : registerExternalMutation.isPending) ? <Loader2 size={13} className="animate-spin" /> : null}
|
||||
{createMode === 'managed' ? 'Create database' : 'Register database'}
|
||||
@@ -3041,10 +3015,10 @@ function BindDatabasePanel({
|
||||
const services = servicesQuery.data ?? [];
|
||||
|
||||
return (
|
||||
<div className="rounded-[var(--radius-md)] border border-[var(--border-default)] bg-[var(--surface-card)] p-4 space-y-3">
|
||||
<div className="s-card space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-medium text-[var(--text-primary)]">Bind to service</p>
|
||||
<button onClick={onClose} className="text-xs text-[var(--text-muted)] hover:text-[var(--text-primary)]">Close</button>
|
||||
<p className="s-t">Bind to service</p>
|
||||
<button onClick={onClose} className="v-mono text-[11px] text-[var(--text-muted)] hover:text-[var(--text-primary)]">close</button>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--text-muted)]">
|
||||
Injects this database's connection URL as an environment variable on the selected service.
|
||||
@@ -3082,13 +3056,13 @@ function BindDatabasePanel({
|
||||
<input
|
||||
value={varKey}
|
||||
onChange={(e) => setVarKey(e.target.value)}
|
||||
className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-2 py-1.5 text-xs mono text-[var(--text-primary)] w-40"
|
||||
className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-2 py-1.5 text-xs v-mono text-[var(--text-primary)] w-40"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
onClick={() => bindMutation.mutate()}
|
||||
disabled={!serviceId || !varKey.trim() || bindMutation.isPending}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-xs font-medium disabled:opacity-40"
|
||||
className="s-btn-accent disabled:opacity-40"
|
||||
>
|
||||
{bindMutation.isPending ? <Loader2 size={11} className="animate-spin" /> : <Link2 size={11} />} Bind
|
||||
</button>
|
||||
@@ -3129,7 +3103,7 @@ function BackupScheduleRow({
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder="cron schedule, e.g. 0 3 * * *"
|
||||
className="w-44 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-2 py-1.5 text-xs mono text-[var(--text-primary)]"
|
||||
className="w-44 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-2 py-1.5 text-xs v-mono text-[var(--text-primary)]"
|
||||
/>
|
||||
<button
|
||||
onClick={() => mutation.mutate(value.trim())}
|
||||
@@ -3251,7 +3225,7 @@ export function HighAvailabilityPage() {
|
||||
title="High availability"
|
||||
description="Failover manager, policies, and active alerts"
|
||||
/>
|
||||
<div className="p-8 space-y-8">
|
||||
<div className="p-4 space-y-8 sm:p-8">
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--text-tertiary)]">
|
||||
<ShieldCheck size={13} className="text-[var(--accent-primary)]" />
|
||||
<span>HA state feeds the platform security posture.</span>
|
||||
@@ -3297,17 +3271,17 @@ export function HighAvailabilityPage() {
|
||||
</div>
|
||||
|
||||
{isAdmin ? (
|
||||
<div className="panel p-6">
|
||||
<div className="s-card p-5">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Zap size={18} className="text-[var(--accent-primary)]" />
|
||||
<h2 className="text-base font-semibold text-[var(--text-primary)]">Manager controls</h2>
|
||||
<h2 className="s-t">Manager controls</h2>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={toggleMutation.isPending || statusQuery.isLoading}
|
||||
onClick={() => toggleMutation.mutate(!status?.enabled)}
|
||||
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50"
|
||||
className="s-btn-accent disabled:opacity-50"
|
||||
>
|
||||
{status?.enabled ? 'Disable HA manager' : 'Enable HA manager'}
|
||||
</button>
|
||||
@@ -3338,10 +3312,10 @@ export function HighAvailabilityPage() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="panel p-6">
|
||||
<div className="s-card p-5">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<AlertCircle size={18} className="text-[var(--accent-primary)]" />
|
||||
<h2 className="text-base font-semibold text-[var(--text-primary)]">Active alerts</h2>
|
||||
<h2 className="s-t">Active alerts</h2>
|
||||
</div>
|
||||
{!isDemoMode && alertsQuery.isLoading ? (
|
||||
<p className="text-sm text-[var(--text-secondary)]">Loading…</p>
|
||||
@@ -3365,7 +3339,7 @@ export function HighAvailabilityPage() {
|
||||
type="button"
|
||||
disabled={resolveMutation.isPending}
|
||||
onClick={() => alert.id && resolveMutation.mutate(alert.id)}
|
||||
className="ml-4 shrink-0 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs text-[var(--text-primary)] disabled:opacity-50"
|
||||
className="s-btn-quiet"
|
||||
>
|
||||
Resolve
|
||||
</button>
|
||||
@@ -3376,17 +3350,17 @@ export function HighAvailabilityPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="panel p-6">
|
||||
<div className="s-card p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<ShieldCheck size={18} className="text-[var(--accent-primary)]" />
|
||||
<h2 className="text-base font-semibold text-[var(--text-primary)]">Failover policies</h2>
|
||||
<h2 className="s-t">Failover policies</h2>
|
||||
</div>
|
||||
{policyForm === null && isAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPolicyForm({ serviceId: '', strategy: 'active_passive', minHealthyNodes: '1', maxFailures: '3', enabled: true, editing: false })}
|
||||
className="px-3 py-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-xs font-medium"
|
||||
className="s-btn-accent "
|
||||
>
|
||||
New policy
|
||||
</button>
|
||||
@@ -3480,14 +3454,14 @@ export function HighAvailabilityPage() {
|
||||
type="button"
|
||||
disabled={savePolicyMutation.isPending || !policyForm.serviceId}
|
||||
onClick={() => savePolicyMutation.mutate()}
|
||||
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50"
|
||||
className="s-btn-accent disabled:opacity-50"
|
||||
>
|
||||
{savePolicyMutation.isPending ? 'Saving…' : 'Save policy'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPolicyForm(null)}
|
||||
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
|
||||
className="s-btn-quiet"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@@ -3526,7 +3500,7 @@ export function HighAvailabilityPage() {
|
||||
enabled: policy.enabled ?? true,
|
||||
editing: true,
|
||||
})}
|
||||
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs text-[var(--text-primary)]"
|
||||
className="s-btn-quiet"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
@@ -3553,10 +3527,10 @@ export function HighAvailabilityPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="panel p-6">
|
||||
<div className="s-card p-5">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<HeartPulse size={18} className="text-[var(--accent-primary)]" />
|
||||
<h2 className="text-base font-semibold text-[var(--text-primary)]">Health check results</h2>
|
||||
<h2 className="s-t">Health check results</h2>
|
||||
</div>
|
||||
{!isDemoMode && healthQuery.isLoading ? (
|
||||
<p className="text-sm text-[var(--text-secondary)]">Loading…</p>
|
||||
@@ -3615,39 +3589,43 @@ export function SecurityPage() {
|
||||
|
||||
const projectsQuery = useQuery({ queryKey: ['projects'], queryFn: () => listProjects(), enabled: !isDemoMode });
|
||||
const projects = isDemoMode ? demoProjects : projectsQuery.data ?? [];
|
||||
|
||||
// Auto-select the first project so the page never renders empty — derived, not
|
||||
// stored, so clearing the select still shows the empty state explicitly.
|
||||
const effectiveProjectId = projectId || projects[0]?.id || '';
|
||||
const servicesQuery = useQuery({
|
||||
queryKey: ['services', projectId],
|
||||
queryFn: () => listServicesByProject(projectId),
|
||||
enabled: !isDemoMode && Boolean(projectId),
|
||||
queryKey: ['services', effectiveProjectId],
|
||||
queryFn: () => listServicesByProject(effectiveProjectId),
|
||||
enabled: !isDemoMode && Boolean(effectiveProjectId),
|
||||
});
|
||||
const services = isDemoMode ? getDemoServicesByProject(projectId) : servicesQuery.data ?? [];
|
||||
const services = isDemoMode ? getDemoServicesByProject(effectiveProjectId) : servicesQuery.data ?? [];
|
||||
const metricsQuery = useQuery({
|
||||
queryKey: ['security-metrics', projectId],
|
||||
queryFn: () => getSecurityMetrics(projectId),
|
||||
enabled: !isDemoMode && Boolean(projectId),
|
||||
queryKey: ['security-metrics', effectiveProjectId],
|
||||
queryFn: () => getSecurityMetrics(effectiveProjectId),
|
||||
enabled: !isDemoMode && Boolean(effectiveProjectId),
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
const historyQuery = useQuery({
|
||||
queryKey: ['security-history', projectId],
|
||||
queryFn: () => getSecurityHistory(projectId),
|
||||
enabled: !isDemoMode && Boolean(projectId),
|
||||
queryKey: ['security-history', effectiveProjectId],
|
||||
queryFn: () => getSecurityHistory(effectiveProjectId),
|
||||
enabled: !isDemoMode && Boolean(effectiveProjectId),
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
const vulnsQuery = useQuery({
|
||||
queryKey: ['security-vulns', projectId],
|
||||
queryFn: () => listVulnerabilities(projectId),
|
||||
enabled: !isDemoMode && Boolean(projectId),
|
||||
queryKey: ['security-vulns', effectiveProjectId],
|
||||
queryFn: () => listVulnerabilities(effectiveProjectId),
|
||||
enabled: !isDemoMode && Boolean(effectiveProjectId),
|
||||
});
|
||||
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['security-metrics', projectId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['security-history', projectId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['security-vulns', projectId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['security-metrics', effectiveProjectId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['security-history', effectiveProjectId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['security-vulns', effectiveProjectId] });
|
||||
};
|
||||
|
||||
const scanMutation = useMutation({
|
||||
mutationFn: () => startSecurityScan({
|
||||
project_id: projectId,
|
||||
project_id: effectiveProjectId,
|
||||
service_id: scanServiceId || undefined,
|
||||
scan_type: scanType as 'dependency' | 'configuration' | 'comprehensive',
|
||||
}),
|
||||
@@ -3661,10 +3639,10 @@ export function SecurityPage() {
|
||||
|
||||
const metrics = isDemoMode ? demoSecurityMetrics : metricsQuery.data;
|
||||
const scanHistory = isDemoMode
|
||||
? demoSecurityScans.filter((s) => s.project_id === projectId)
|
||||
? demoSecurityScans.filter((s) => s.project_id === effectiveProjectId)
|
||||
: historyQuery.data ?? [];
|
||||
const vulns = (isDemoMode
|
||||
? demoVulnerabilities.filter((v) => v.project_id === projectId)
|
||||
? demoVulnerabilities.filter((v) => v.project_id === effectiveProjectId)
|
||||
: vulnsQuery.data ?? []
|
||||
).filter((v) => v.status !== 'resolved');
|
||||
const pageError =
|
||||
@@ -3677,7 +3655,7 @@ export function SecurityPage() {
|
||||
title="Security"
|
||||
description="Scan findings, security posture, and compliance status"
|
||||
/>
|
||||
<div className="p-8 space-y-8">
|
||||
<div className="p-4 space-y-8 sm:p-8">
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--text-tertiary)]">
|
||||
<HeartPulse size={13} className="text-[var(--accent-primary)]" />
|
||||
<span>Platform availability and failover live under High availability.</span>
|
||||
@@ -3695,7 +3673,7 @@ export function SecurityPage() {
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Project</label>
|
||||
<select
|
||||
value={projectId}
|
||||
value={effectiveProjectId}
|
||||
onChange={(e) => { setProjectId(e.target.value); setScanServiceId(''); }}
|
||||
className="px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)] w-56"
|
||||
>
|
||||
@@ -3705,7 +3683,7 @@ export function SecurityPage() {
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{projectId && (
|
||||
{effectiveProjectId && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Scan type</label>
|
||||
@@ -3737,7 +3715,7 @@ export function SecurityPage() {
|
||||
type="button"
|
||||
disabled={scanMutation.isPending}
|
||||
onClick={() => scanMutation.mutate()}
|
||||
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50"
|
||||
className="s-btn-accent disabled:opacity-50"
|
||||
>
|
||||
{scanMutation.isPending ? 'Starting…' : 'Run scan'}
|
||||
</button>
|
||||
@@ -3748,7 +3726,7 @@ export function SecurityPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{projectId && metrics && (
|
||||
{effectiveProjectId && metrics && (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
title="Security score"
|
||||
@@ -3779,11 +3757,11 @@ export function SecurityPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{projectId && (
|
||||
<div className="panel p-6">
|
||||
{effectiveProjectId && (
|
||||
<div className="s-card p-5">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Shield size={18} className="text-[var(--accent-primary)]" />
|
||||
<h2 className="text-base font-semibold text-[var(--text-primary)]">Findings</h2>
|
||||
<h2 className="s-t">Findings</h2>
|
||||
</div>
|
||||
{!isDemoMode && vulnsQuery.isLoading ? (
|
||||
<p className="text-sm text-[var(--text-secondary)]">Loading…</p>
|
||||
@@ -3822,7 +3800,7 @@ export function SecurityPage() {
|
||||
type="button"
|
||||
disabled={vulnMutation.isPending}
|
||||
onClick={() => vulnMutation.mutate({ id: v.id!, status: 'ignored' })}
|
||||
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs text-[var(--text-secondary)] disabled:opacity-50"
|
||||
className="s-btn-quiet"
|
||||
>
|
||||
Ignore
|
||||
</button>
|
||||
@@ -3835,11 +3813,11 @@ export function SecurityPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{projectId && (
|
||||
<div className="panel p-6">
|
||||
{effectiveProjectId && (
|
||||
<div className="s-card p-5">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Clock size={18} className="text-[var(--accent-primary)]" />
|
||||
<h2 className="text-base font-semibold text-[var(--text-primary)]">Scan history</h2>
|
||||
<h2 className="s-t">Scan history</h2>
|
||||
</div>
|
||||
{!isDemoMode && historyQuery.isLoading ? (
|
||||
<p className="text-sm text-[var(--text-secondary)]">Loading…</p>
|
||||
|
||||
@@ -34,8 +34,7 @@ export function ComponentShowcase() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen p-8" style={{ background: '#16171c' }}>
|
||||
<div className="ambient-glow" />
|
||||
<div className="min-h-screen p-8" style={{ background: 'var(--bg-void)' }}>
|
||||
|
||||
<div className="w-full relative z-10">
|
||||
{/* Header */}
|
||||
@@ -91,25 +90,25 @@ export function ComponentShowcase() {
|
||||
Line Charts
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="panel p-6">
|
||||
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4">
|
||||
<div className="s-card p-6">
|
||||
<h3 className="s-t mb-4">
|
||||
Basic Line Chart (CPU Style)
|
||||
</h3>
|
||||
<LineChart
|
||||
data={sampleLineData}
|
||||
color="#b4e34a"
|
||||
color="#38bdf8"
|
||||
height={120}
|
||||
showDots={true}
|
||||
smooth={true}
|
||||
/>
|
||||
</div>
|
||||
<div className="panel p-6">
|
||||
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4">
|
||||
<div className="s-card p-6">
|
||||
<h3 className="s-t mb-4">
|
||||
Line Chart (No Dots)
|
||||
</h3>
|
||||
<LineChart
|
||||
data={sampleLineData}
|
||||
color="#7ab8ff"
|
||||
color="var(--accent-primary)"
|
||||
height={120}
|
||||
showDots={false}
|
||||
smooth={true}
|
||||
@@ -123,8 +122,8 @@ export function ComponentShowcase() {
|
||||
Area Charts
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="panel p-6">
|
||||
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4">
|
||||
<div className="s-card p-6">
|
||||
<h3 className="s-t mb-4">
|
||||
Area Chart (Active Users Style)
|
||||
</h3>
|
||||
<LineAreaChart
|
||||
@@ -134,8 +133,8 @@ export function ComponentShowcase() {
|
||||
height={150}
|
||||
/>
|
||||
</div>
|
||||
<div className="panel p-6">
|
||||
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4">
|
||||
<div className="s-card p-6">
|
||||
<h3 className="s-t mb-4">
|
||||
Area Chart (Success Color)
|
||||
</h3>
|
||||
<LineAreaChart
|
||||
@@ -153,20 +152,20 @@ export function ComponentShowcase() {
|
||||
Multi-Line & Bar Charts
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="panel p-6">
|
||||
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4">
|
||||
<div className="s-card p-6">
|
||||
<h3 className="s-t mb-4">
|
||||
Multi-Line Chart (Performance Style)
|
||||
</h3>
|
||||
<MultiLineChart
|
||||
datasets={[
|
||||
{ data: sampleLineData.map(v => v + 10), color: '#7ab8ff', fillOpacity: 0.15 },
|
||||
{ data: sampleLineData, color: '#f2c94c', fillOpacity: 0.15 }
|
||||
{ data: sampleLineData.map(v => v + 10), color: 'var(--accent-primary)', fillOpacity: 0.15 },
|
||||
{ data: sampleLineData, color: 'var(--warning)', fillOpacity: 0.15 }
|
||||
]}
|
||||
height={120}
|
||||
/>
|
||||
</div>
|
||||
<div className="panel p-6">
|
||||
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4">
|
||||
<div className="s-card p-6">
|
||||
<h3 className="s-t mb-4">
|
||||
Bar Chart (Timeline Style)
|
||||
</h3>
|
||||
<BarChart
|
||||
@@ -184,23 +183,23 @@ export function ComponentShowcase() {
|
||||
Donut Charts
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="panel p-6 flex flex-col items-center">
|
||||
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4">
|
||||
<div className="s-card p-6 flex flex-col items-center">
|
||||
<h3 className="s-t mb-4">
|
||||
RAM Usage (65%)
|
||||
</h3>
|
||||
<DonutChart percentage={65} color="#f2c94c" size={160} />
|
||||
<DonutChart percentage={65} color="var(--warning)" size={160} />
|
||||
</div>
|
||||
<div className="panel p-6 flex flex-col items-center">
|
||||
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4">
|
||||
<div className="s-card p-6 flex flex-col items-center">
|
||||
<h3 className="s-t mb-4">
|
||||
Disk Usage (82%)
|
||||
</h3>
|
||||
<DonutChart percentage={82} color="#b4e34a" size={160} />
|
||||
<DonutChart percentage={82} color="#38bdf8" size={160} />
|
||||
</div>
|
||||
<div className="panel p-6 flex flex-col items-center">
|
||||
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4">
|
||||
<div className="s-card p-6 flex flex-col items-center">
|
||||
<h3 className="s-t mb-4">
|
||||
Network (45%)
|
||||
</h3>
|
||||
<DonutChart percentage={45} color="#7ab8ff" size={160} />
|
||||
<DonutChart percentage={45} color="var(--accent-primary)" size={160} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -222,7 +221,7 @@ export function ComponentShowcase() {
|
||||
status="good"
|
||||
statusText="Good"
|
||||
icon={<Cpu size={16} />}
|
||||
chart={<LineChart data={sampleLineData} color="#b4e34a" height={76} />}
|
||||
chart={<LineChart data={sampleLineData} color="#38bdf8" height={76} />}
|
||||
onClick={() => handleCardClick('CPU Usage')}
|
||||
animationDelay={0.04}
|
||||
/>
|
||||
@@ -233,7 +232,7 @@ export function ComponentShowcase() {
|
||||
status="average"
|
||||
statusText="Average"
|
||||
icon={<MemoryStick size={16} />}
|
||||
chart={<LineChart data={sampleLineData.map(v => v + 20)} color="#f2c94c" height={76} />}
|
||||
chart={<LineChart data={sampleLineData.map(v => v + 20)} color="var(--warning)" height={76} />}
|
||||
onClick={() => handleCardClick('Memory')}
|
||||
animationDelay={0.09}
|
||||
/>
|
||||
@@ -276,7 +275,7 @@ export function ComponentShowcase() {
|
||||
value="12.5K"
|
||||
subtitle="Queries per minute"
|
||||
icon={<Database size={16} />}
|
||||
chart={<LineAreaChart data={sampleAreaData.map(v => v + 10)} color="#7ab8ff" height={130} />}
|
||||
chart={<LineAreaChart data={sampleAreaData.map(v => v + 10)} color="var(--accent-primary)" height={130} />}
|
||||
details={
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
@@ -313,8 +312,8 @@ export function ComponentShowcase() {
|
||||
upSpeed={10.4}
|
||||
downSpeed={5.2}
|
||||
datasets={[
|
||||
{ data: sampleLineData.map(v => v + 20), color: '#7ab8ff', fillOpacity: 0.15 },
|
||||
{ data: sampleLineData.map(v => v + 10), color: '#f2c94c', fillOpacity: 0.15 }
|
||||
{ data: sampleLineData.map(v => v + 20), color: 'var(--accent-primary)', fillOpacity: 0.15 },
|
||||
{ data: sampleLineData.map(v => v + 10), color: 'var(--warning)', fillOpacity: 0.15 }
|
||||
]}
|
||||
onClick={() => handleCardClick('Performance')}
|
||||
animationDelay={0.09}
|
||||
@@ -331,7 +330,7 @@ export function ComponentShowcase() {
|
||||
<h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4">
|
||||
Badges & Indicators
|
||||
</h2>
|
||||
<div className="panel p-6 space-y-4">
|
||||
<div className="s-card p-6 space-y-4">
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<span className="badge-active">
|
||||
<span className="live-dot" />
|
||||
@@ -358,7 +357,7 @@ export function ComponentShowcase() {
|
||||
<h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4">
|
||||
Buttons
|
||||
</h2>
|
||||
<div className="panel p-6 space-y-4">
|
||||
<div className="s-card p-6 space-y-4">
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<button className="btn-stop">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
|
||||
@@ -387,7 +386,7 @@ export function ComponentShowcase() {
|
||||
<h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4">
|
||||
Pills & Tabs
|
||||
</h2>
|
||||
<div className="panel p-6 space-y-6">
|
||||
<div className="s-card p-6 space-y-6">
|
||||
<div>
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-3">Pill Group</p>
|
||||
<div className="pill-group">
|
||||
@@ -420,9 +419,9 @@ export function ComponentShowcase() {
|
||||
<h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4">
|
||||
Search & Input
|
||||
</h2>
|
||||
<div className="panel p-6 space-y-4">
|
||||
<div className="s-card p-6 space-y-4">
|
||||
<div className="search-box">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#6b6e7d" strokeWidth="2" strokeLinecap="round">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2" strokeLinecap="round">
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</svg>
|
||||
@@ -435,7 +434,7 @@ export function ComponentShowcase() {
|
||||
<h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4">
|
||||
Toast Notifications
|
||||
</h2>
|
||||
<div className="panel p-6">
|
||||
<div className="s-card p-6">
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
<button
|
||||
onClick={() => showToast('Operation completed successfully!', 'success')}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -78,8 +78,8 @@ export function ServiceMetricsDashboard() {
|
||||
userData,
|
||||
perf: perfPercent,
|
||||
perfData: [
|
||||
{ data: perfData1, color: '#7ab8ff', fillOpacity: 0.15 },
|
||||
{ data: perfData2, color: '#f2c94c', fillOpacity: 0.15 }
|
||||
{ data: perfData1, color: 'var(--accent-primary)', fillOpacity: 0.15 },
|
||||
{ data: perfData2, color: 'var(--warning)', fillOpacity: 0.15 }
|
||||
],
|
||||
upSpeed: seededRandom(`${serviceId}:upspeed`, 8, 13).toFixed(1),
|
||||
downSpeed: seededRandom(`${serviceId}:downspeed`, 4, 7).toFixed(1),
|
||||
@@ -107,9 +107,8 @@ export function ServiceMetricsDashboard() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen" style={{ background: '#16171c' }}>
|
||||
<div className="min-h-screen" style={{ background: 'var(--bg-void)' }}>
|
||||
{/* Ambient glow */}
|
||||
<div className="ambient-glow" />
|
||||
|
||||
<div className="flex min-h-screen relative">
|
||||
{/* Sidebar - self.html exact match */}
|
||||
@@ -117,8 +116,8 @@ export function ServiceMetricsDashboard() {
|
||||
className="hidden md:flex shrink-0 flex-col items-center border-r"
|
||||
style={{
|
||||
width: '64px',
|
||||
background: '#111217',
|
||||
borderRight: '1px solid rgba(255,255,255,0.07)',
|
||||
background: 'var(--bg-surface)',
|
||||
borderRight: '1px solid var(--border-subtle)',
|
||||
padding: '16px 0',
|
||||
gap: '5px'
|
||||
}}
|
||||
@@ -169,10 +168,10 @@ export function ServiceMetricsDashboard() {
|
||||
style={{
|
||||
width: '34px',
|
||||
height: '34px',
|
||||
background: '#22233a',
|
||||
background: 'var(--accent-primary-soft)',
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
color: '#9295a4',
|
||||
color: 'var(--text-tertiary)',
|
||||
marginTop: '4px',
|
||||
letterSpacing: '-0.3px'
|
||||
}}
|
||||
@@ -188,8 +187,8 @@ export function ServiceMetricsDashboard() {
|
||||
className="shrink-0 flex items-center"
|
||||
style={{
|
||||
height: '52px',
|
||||
background: '#111217',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.07)',
|
||||
background: 'var(--bg-surface)',
|
||||
borderBottom: '1px solid var(--border-subtle)',
|
||||
padding: '0 22px',
|
||||
gap: '14px'
|
||||
}}
|
||||
@@ -201,22 +200,22 @@ export function ServiceMetricsDashboard() {
|
||||
|
||||
<div className="ml-auto flex items-center" style={{ gap: '8px' }}>
|
||||
<button
|
||||
className="rounded-[9px] border bg-transparent text-[#9295a4] font-medium cursor-pointer"
|
||||
className="rounded-[9px] border bg-transparent text-[var(--text-tertiary)] font-medium cursor-pointer"
|
||||
style={{
|
||||
height: '32px',
|
||||
padding: '0 14px',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
border: '1px solid var(--border-default)',
|
||||
fontSize: '13px'
|
||||
}}
|
||||
>
|
||||
Support
|
||||
</button>
|
||||
<button
|
||||
className="rounded-[9px] border-none flex items-center text-[#e8e9f0] font-medium cursor-pointer"
|
||||
className="rounded-[9px] border-none flex items-center text-[var(--text-primary)] font-medium cursor-pointer"
|
||||
style={{
|
||||
height: '32px',
|
||||
padding: '0 14px',
|
||||
background: 'rgba(255,255,255,0.08)',
|
||||
background: 'var(--bg-overlay)',
|
||||
fontSize: '13px',
|
||||
gap: '6px'
|
||||
}}
|
||||
@@ -229,10 +228,10 @@ export function ServiceMetricsDashboard() {
|
||||
style={{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
border: '1px solid rgba(255,255,255,0.1)'
|
||||
border: '1px solid var(--border-default)'
|
||||
}}
|
||||
>
|
||||
<Bell size={15} color="#9295a4" />
|
||||
<Bell size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -245,22 +244,22 @@ export function ServiceMetricsDashboard() {
|
||||
style={{
|
||||
gap: '6px',
|
||||
padding: '14px 0 10px',
|
||||
color: '#6b6e7d',
|
||||
color: 'var(--text-tertiary)',
|
||||
fontSize: '13px'
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#6b6e7d" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="15 18 9 12 15 6"/>
|
||||
</svg>
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
style={{ color: '#6b6e7d' }}
|
||||
className="hover:text-[#9295a4] transition-colors"
|
||||
style={{ color: 'var(--text-tertiary)' }}
|
||||
className="hover:text-[var(--text-tertiary)] transition-colors"
|
||||
>
|
||||
Servers
|
||||
</button>
|
||||
<span style={{ opacity: 0.4 }}>/</span>
|
||||
<span style={{ color: '#9295a4' }}>[NuFest] - App Project</span>
|
||||
<span style={{ color: 'var(--text-tertiary)' }}>[NuFest] - App Project</span>
|
||||
</div>
|
||||
|
||||
{/* Project Header */}
|
||||
@@ -280,7 +279,7 @@ export function ServiceMetricsDashboard() {
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center" style={{ gap: '10px' }}>
|
||||
<span style={{ fontSize: '20px', fontWeight: 800, letterSpacing: '-0.5px', color: '#e8e9f0' }}>
|
||||
<span style={{ fontSize: '20px', fontWeight: 800, letterSpacing: '-0.5px', color: 'var(--text-primary)' }}>
|
||||
[NuFest] - App Project
|
||||
</span>
|
||||
<span className={`badge-${isSystemRunning ? 'active' : 'stopped'}`}>
|
||||
@@ -293,8 +292,8 @@ export function ServiceMetricsDashboard() {
|
||||
href="https://nufest-dth.app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center hover:text-[#9295a4] transition-colors"
|
||||
style={{ color: '#6b6e7d', fontSize: '12.5px', textDecoration: 'none', gap: '4px' }}
|
||||
className="flex items-center hover:text-[var(--text-tertiary)] transition-colors"
|
||||
style={{ color: 'var(--text-tertiary)', fontSize: '12.5px', textDecoration: 'none', gap: '4px' }}
|
||||
>
|
||||
https://nufest-dth.app
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
@@ -304,8 +303,8 @@ export function ServiceMetricsDashboard() {
|
||||
</svg>
|
||||
</a>
|
||||
<button
|
||||
className="flex items-center hover:text-[#9295a4] transition-colors"
|
||||
style={{ color: '#6b6e7d', fontSize: '12.5px', gap: '4px' }}
|
||||
className="flex items-center hover:text-[var(--text-tertiary)] transition-colors"
|
||||
style={{ color: 'var(--text-tertiary)', fontSize: '12.5px', gap: '4px' }}
|
||||
>
|
||||
Project Information
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
@@ -344,7 +343,7 @@ export function ServiceMetricsDashboard() {
|
||||
<div
|
||||
className="flex"
|
||||
style={{
|
||||
borderBottom: '1px solid rgba(255,255,255,0.07)',
|
||||
borderBottom: '1px solid var(--border-subtle)',
|
||||
marginBottom: '18px'
|
||||
}}
|
||||
>
|
||||
@@ -365,16 +364,16 @@ export function ServiceMetricsDashboard() {
|
||||
|
||||
{/* Metrics Header */}
|
||||
<div className="flex items-center justify-between" style={{ marginBottom: '16px' }}>
|
||||
<span style={{ fontSize: '15px', fontWeight: 700, color: '#e8e9f0' }}>Metrics</span>
|
||||
<span style={{ fontSize: '15px', fontWeight: 700, color: 'var(--text-primary)' }}>Metrics</span>
|
||||
<div className="flex items-center" style={{ gap: '8px' }}>
|
||||
<button
|
||||
className="rounded-[9px] border flex items-center font-medium cursor-pointer"
|
||||
style={{
|
||||
height: '32px',
|
||||
padding: '0 12px',
|
||||
border: '1px solid rgba(255,255,255,0.09)',
|
||||
background: 'rgba(255,255,255,0.04)',
|
||||
color: '#9295a4',
|
||||
border: '1px solid var(--border-default)',
|
||||
background: 'var(--bg-elevated)',
|
||||
color: 'var(--text-tertiary)',
|
||||
fontSize: '12.5px',
|
||||
gap: '6px'
|
||||
}}
|
||||
@@ -408,7 +407,7 @@ export function ServiceMetricsDashboard() {
|
||||
status="good"
|
||||
statusText="Good"
|
||||
icon={<Cpu size={16} />}
|
||||
chart={<LineChart data={metrics.cpuData} color="#b4e34a" height={76} />}
|
||||
chart={<LineChart data={metrics.cpuData} color="#38bdf8" height={76} />}
|
||||
animationDelay={0.04}
|
||||
/>
|
||||
|
||||
@@ -418,23 +417,23 @@ export function ServiceMetricsDashboard() {
|
||||
<div className="card-icon">
|
||||
<MemoryStick size={16} />
|
||||
</div>
|
||||
<span style={{ fontSize: 14, fontWeight: 600, color: '#e8e9f0' }}>RAM Usage</span>
|
||||
<span style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)' }}>RAM Usage</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 38, fontWeight: 900, letterSpacing: '-1.5px', lineHeight: 1, color: '#e8e9f0' }}>
|
||||
<div style={{ fontSize: 38, fontWeight: 900, letterSpacing: '-1.5px', lineHeight: 1, color: 'var(--text-primary)' }}>
|
||||
{metrics.ram}%
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#6b6e7d', marginTop: 4 }}>
|
||||
<span style={{ color: '#f2c94c', fontWeight: 700 }}>Average</span> Daily usage
|
||||
<div style={{ fontSize: 12, color: 'var(--text-tertiary)', marginTop: 4 }}>
|
||||
<span style={{ color: 'var(--warning)', fontWeight: 700 }}>Average</span> Daily usage
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', margin: '10px 0 4px', position: 'relative' }}>
|
||||
<DonutChart percentage={metrics.ram} color="#f2c94c" />
|
||||
<DonutChart percentage={metrics.ram} color="var(--warning)" />
|
||||
<div style={{ position: 'absolute', bottom: 14, textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 10.5, color: '#6b6e7d', marginBottom: 1 }}>Used</div>
|
||||
<div style={{ fontSize: 12.5, fontWeight: 700, color: '#e8e9f0' }}>{metrics.ramUsedGB} GB / 8GB</div>
|
||||
<div style={{ fontSize: 10.5, color: 'var(--text-tertiary)', marginBottom: 1 }}>Used</div>
|
||||
<div style={{ fontSize: 12.5, fontWeight: 700, color: 'var(--text-primary)' }}>{metrics.ramUsedGB} GB / 8GB</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingTop: 4 }}>
|
||||
<span style={{ fontSize: 13, color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}>Details</span>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-tertiary)', fontWeight: 500, cursor: 'pointer' }}>Details</span>
|
||||
<div className="arrow-btn">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
getSetupStatus,
|
||||
updatePlatformSettings,
|
||||
} from '@/lib/api-client';
|
||||
import { GhostBtn, QuietBtn } from '@/shared/components/sentry';
|
||||
|
||||
// Marker the GitHub App callback reads so the wizard flow resumes here
|
||||
// instead of the settings page.
|
||||
@@ -39,7 +40,7 @@ export function SetupWizardPage() {
|
||||
mutationFn: completeSetup,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['setup-status'] });
|
||||
navigate('/projects', { replace: true });
|
||||
navigate('/', { replace: true });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -53,7 +54,7 @@ export function SetupWizardPage() {
|
||||
|
||||
// Wizard is optional once the instance is already configured.
|
||||
if (status && !status.needs_setup && !override) {
|
||||
return <Navigate to="/projects" replace />;
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
const steps: { key: Step; label: string }[] = [
|
||||
@@ -92,9 +93,12 @@ export function SetupWizardPage() {
|
||||
</div>
|
||||
|
||||
{step === 'account' ? (
|
||||
<section className="panel p-6">
|
||||
<h1 className="text-xl font-bold text-[var(--text-primary)]">Welcome to Containr</h1>
|
||||
<p className="mt-2 text-sm text-[var(--text-secondary)]">
|
||||
<section className="s-card">
|
||||
<div className="s-cardhead mb-1">
|
||||
<span className="s-ibox"><Rocket /></span>
|
||||
<h1 className="s-t">Welcome to Containr</h1>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-[var(--text-secondary)]">
|
||||
First boot detected. Create the admin account — registration closes to the public
|
||||
right after, so this account owns the instance.
|
||||
</p>
|
||||
@@ -105,8 +109,7 @@ export function SetupWizardPage() {
|
||||
</p>
|
||||
<Link
|
||||
to="/auth/sign-in?redirect=/setup"
|
||||
className="mt-4 inline-flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)]"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
className="mt-4 s-btn-accent inline-flex items-center gap-2"
|
||||
>
|
||||
Sign in <ChevronRight size={14} />
|
||||
</Link>
|
||||
@@ -114,8 +117,7 @@ export function SetupWizardPage() {
|
||||
) : !status?.has_users ? (
|
||||
<Link
|
||||
to="/auth/sign-up?redirect=/setup"
|
||||
className="mt-4 inline-flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)]"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
className="mt-4 s-btn-accent inline-flex items-center gap-2"
|
||||
>
|
||||
Create admin account <ChevronRight size={14} />
|
||||
</Link>
|
||||
@@ -127,9 +129,9 @@ export function SetupWizardPage() {
|
||||
{step === 'tunnel' ? <TunnelStep onNext={() => setOverride('done')} /> : null}
|
||||
|
||||
{step === 'done' ? (
|
||||
<section className="panel p-6 text-center">
|
||||
<Rocket size={28} className="mx-auto text-[var(--accent-primary)]" />
|
||||
<h1 className="mt-4 text-xl font-bold text-[var(--text-primary)]">All set</h1>
|
||||
<section className="s-card text-center">
|
||||
<div className="s-ibox mx-auto !h-10 !w-10"><ShieldCheck /></div>
|
||||
<h1 className="mt-4 font-headline text-lg font-bold text-[var(--text-primary)]">All set</h1>
|
||||
<p className="mt-2 text-sm text-[var(--text-secondary)]">
|
||||
Deploy your first service from a git repo, an image, or a template — the dashboard is
|
||||
ready.
|
||||
@@ -141,12 +143,10 @@ export function SetupWizardPage() {
|
||||
: 'Failed to save setup state'}
|
||||
</p>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
<GhostBtn
|
||||
onClick={() => completeMutation.mutate()}
|
||||
disabled={completeMutation.isPending}
|
||||
className="mt-5 inline-flex items-center gap-2 h-10 px-6 rounded-[var(--radius-md)] text-sm font-semibold text-[var(--accent-on)] disabled:opacity-50"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
className="mt-5 !h-10 !px-6"
|
||||
>
|
||||
{completeMutation.isPending ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
@@ -154,7 +154,7 @@ export function SetupWizardPage() {
|
||||
<ShieldCheck size={15} />
|
||||
)}
|
||||
Enter Containr
|
||||
</button>
|
||||
</GhostBtn>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -185,12 +185,12 @@ function GitHubStep({ onNext }: { onNext: () => void }) {
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="panel p-6">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Github size={20} className="text-[var(--text-primary)]" />
|
||||
<h1 className="text-xl font-bold text-[var(--text-primary)]">Connect a git provider</h1>
|
||||
<section className="s-card">
|
||||
<div className="s-cardhead mb-1">
|
||||
<span className="s-ibox"><Github /></span>
|
||||
<h1 className="s-t">Connect a Git Provider</h1>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
<p className="mt-1 text-sm text-[var(--text-secondary)]">
|
||||
Provision the instance GitHub App — repo push events then trigger deployments and preview
|
||||
environments automatically. Self-hosted GitLab/Gitea can be added later in Settings.
|
||||
</p>
|
||||
@@ -201,24 +201,15 @@ function GitHubStep({ onNext }: { onNext: () => void }) {
|
||||
: 'Could not start GitHub App provisioning'}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-5 flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
<div className="mt-5 flex items-center gap-2">
|
||||
<GhostBtn
|
||||
onClick={() => provisionMutation.mutate()}
|
||||
disabled={provisionMutation.isPending}
|
||||
className="inline-flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
>
|
||||
{provisionMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : null}
|
||||
{provisionMutation.isPending ? <Loader2 size={13} className="animate-spin" /> : null}
|
||||
Set up GitHub App
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNext}
|
||||
className="h-9 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
>
|
||||
Skip for now
|
||||
</button>
|
||||
</GhostBtn>
|
||||
<QuietBtn onClick={onNext}>Skip for now</QuietBtn>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
@@ -237,9 +228,12 @@ function TunnelStep({ onNext }: { onNext: () => void }) {
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="panel p-6">
|
||||
<h1 className="text-xl font-bold text-[var(--text-primary)]">Public access (optional)</h1>
|
||||
<p className="mt-2 text-sm text-[var(--text-secondary)]">
|
||||
<section className="s-card">
|
||||
<div className="s-cardhead mb-1">
|
||||
<span className="s-ibox"><ShieldCheck /></span>
|
||||
<h1 className="s-t">Public Access (optional)</h1>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-[var(--text-secondary)]">
|
||||
Paste a Cloudflare Tunnel token to expose this instance on the internet. Without one,
|
||||
Containr stays reachable on this network — you can add it later in Settings.
|
||||
</p>
|
||||
@@ -248,31 +242,22 @@ function TunnelStep({ onNext }: { onNext: () => void }) {
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
placeholder="eyJhIjo… tunnel token"
|
||||
className="mt-4 w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-colors"
|
||||
className="mt-4 w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] v-mono text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-colors"
|
||||
/>
|
||||
{saveMutation.isError ? (
|
||||
<p className="mt-3 text-xs text-[var(--error)]">
|
||||
{saveMutation.error instanceof Error ? saveMutation.error.message : 'Failed to save token'}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-5 flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
<div className="mt-5 flex items-center gap-2">
|
||||
<GhostBtn
|
||||
onClick={() => (token.trim() ? saveMutation.mutate() : onNext())}
|
||||
disabled={saveMutation.isPending}
|
||||
className="inline-flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
>
|
||||
{saveMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : null}
|
||||
{saveMutation.isPending ? <Loader2 size={13} className="animate-spin" /> : null}
|
||||
{token.trim() ? 'Save tunnel token' : 'Continue'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNext}
|
||||
className="h-9 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
>
|
||||
Skip
|
||||
</button>
|
||||
</GhostBtn>
|
||||
<QuietBtn onClick={onNext}>Skip</QuietBtn>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import { Database, GitBranch, Package, Timer, Trash2, X } from 'lucide-react';
|
||||
import { GhostBtn, QuietBtn } from '@/shared/components/sentry';
|
||||
import {
|
||||
canvasFromConfig,
|
||||
configFromCanvas,
|
||||
@@ -57,7 +58,7 @@ function ServiceBuilderNodeView({ data, selected }: NodeProps<ServiceBuilderNode
|
||||
</div>
|
||||
<div className="mt-1 flex items-center justify-between gap-2">
|
||||
<span className="text-[10.5px] uppercase tracking-wide text-[var(--text-tertiary)]">{meta.label}</span>
|
||||
<span className="mono max-w-[120px] truncate text-[10.5px] text-[var(--text-secondary)]">
|
||||
<span className="v-mono max-w-[120px] truncate text-[10.5px] text-[var(--text-secondary)]">
|
||||
{data.svc.runtime || data.svc.repo || '—'}
|
||||
</span>
|
||||
</div>
|
||||
@@ -120,8 +121,8 @@ function BuilderInner({
|
||||
id: `${dep}->${svc.key}`,
|
||||
source: dep,
|
||||
target: svc.key,
|
||||
markerEnd: { type: MarkerType.ArrowClosed, width: 14, height: 14, color: 'rgba(180,227,74,0.55)' },
|
||||
style: { stroke: 'rgba(180,227,74,0.42)', strokeWidth: 1.6 },
|
||||
markerEnd: { type: MarkerType.ArrowClosed, width: 14, height: 14, color: 'rgba(56, 189, 248,0.55)' },
|
||||
style: { stroke: 'rgba(56, 189, 248,0.42)', strokeWidth: 1.6 },
|
||||
className: 'edge-premium',
|
||||
})),
|
||||
),
|
||||
@@ -239,7 +240,7 @@ function BuilderInner({
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => addService(t)}
|
||||
className="canvas-pill inline-flex items-center gap-1.5 px-2.5 py-1.5 text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
className="s-chip !py-1.5"
|
||||
>
|
||||
<Icon size={12} className={meta.cls} />
|
||||
{meta.label}
|
||||
@@ -263,7 +264,7 @@ function BuilderInner({
|
||||
deleteKeyCode={['Backspace', 'Delete']}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={22} size={1.2} color="rgba(255,255,255,0.07)" />
|
||||
<Background variant={BackgroundVariant.Dots} gap={22} size={1.2} color="var(--tint-07)" />
|
||||
</ReactFlow>
|
||||
<div className="canvas-pill absolute bottom-3 left-3 z-10 px-3 py-1.5 text-[10.5px] text-[var(--text-tertiary)]">
|
||||
drag = move · connect handles = depends_on · double-click = add web · Del = remove
|
||||
@@ -295,21 +296,16 @@ function BuilderInner({
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="flex-1 rounded-[var(--radius-md)] px-3 py-2 text-xs font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-muted)]"
|
||||
>
|
||||
<QuietBtn onClick={onCancel} className="flex-1 justify-center">
|
||||
Back to JSON
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
</QuietBtn>
|
||||
<GhostBtn
|
||||
disabled={errors.length > 0 || services.size === 0}
|
||||
onClick={() => onApply(configFromCanvas(builderNodes, config))}
|
||||
className="flex-1 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-3 py-2 text-xs font-semibold text-[var(--accent-on)] disabled:opacity-50"
|
||||
className="flex-1 justify-center"
|
||||
>
|
||||
Apply to JSON
|
||||
</button>
|
||||
</GhostBtn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -409,7 +405,7 @@ function Inspector({
|
||||
|
||||
<label className={`${LABEL_CLS} mt-3`}>Environment (KEY=value, one per line)</label>
|
||||
<textarea
|
||||
className={`${FIELD_CLS} mono min-h-[70px] resize-y`}
|
||||
className={`${FIELD_CLS} v-mono min-h-[70px] resize-y`}
|
||||
defaultValue={envText}
|
||||
spellCheck={false}
|
||||
onBlur={(e) => {
|
||||
@@ -425,7 +421,7 @@ function Inspector({
|
||||
|
||||
<label className={`${LABEL_CLS} mt-3`}>Volumes (source:target[:ro], one per line)</label>
|
||||
<textarea
|
||||
className={`${FIELD_CLS} mono min-h-[50px] resize-y`}
|
||||
className={`${FIELD_CLS} v-mono min-h-[50px] resize-y`}
|
||||
defaultValue={volText}
|
||||
spellCheck={false}
|
||||
onBlur={(e) => {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { useAuthSession } from '@/lib/use-auth-session';
|
||||
import { useDemoMode } from '@/lib/demo-mode';
|
||||
import { demoProjects, demoTemplates, demoTemplateDetails } from '@/lib/demo-data';
|
||||
import { GhostBtn, QuietBtn, SPageHead } from '@/shared/components/sentry';
|
||||
import {
|
||||
Search,
|
||||
Filter,
|
||||
@@ -489,67 +490,51 @@ export function TemplatesPage() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
{/* Header */}
|
||||
<div className="border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/50 backdrop-blur-sm">
|
||||
<div className="w-full px-8 py-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="v-title">Template Catalog<span className="v-cursor">_</span></h1>
|
||||
<p className="text-sm text-[var(--text-secondary)]">Deploy services from pre-configured templates</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm text-[var(--text-tertiary)]">
|
||||
<Layers size={16} />
|
||||
<span>{filteredTemplates.length} templates</span>
|
||||
{signedIn && !isDemoMode ? (
|
||||
<div className="w-full px-4 pt-6 sm:px-8">
|
||||
<SPageHead
|
||||
title="Template Catalog"
|
||||
titleAccent="_"
|
||||
sub={`${filteredTemplates.length} templates · deploy services from pre-configured stacks`}
|
||||
actions={signedIn && !isDemoMode ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
<QuietBtn
|
||||
onClick={() => {
|
||||
setComposeYaml('');
|
||||
setComposeWarnings([]);
|
||||
setComposeError(null);
|
||||
setComposeOpen(true);
|
||||
}}
|
||||
className="inline-flex h-9 items-center gap-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-3.5 text-[12.5px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
>
|
||||
<FileCode size={14} /> Import Compose
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCreateEditor}
|
||||
className="inline-flex h-9 items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-3.5 text-[12.5px] font-semibold text-[var(--accent-on)]"
|
||||
>
|
||||
<Plus size={14} /> New template
|
||||
</button>
|
||||
<FileCode size={13} /> Import Compose
|
||||
</QuietBtn>
|
||||
<GhostBtn onClick={openCreateEditor}>
|
||||
<Plus size={13} /> New template
|
||||
</GhostBtn>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Demo Mode Banner */}
|
||||
{isDemoMode && (
|
||||
<div className="w-full px-8 py-4">
|
||||
<div className="px-4 py-3 rounded-[var(--radius-md)] border border-[var(--warning-soft)] bg-[var(--warning-soft)]/50">
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--warning)]">
|
||||
<Sparkles size={16} />
|
||||
<span>Demo mode active — using sample data</span>
|
||||
</div>
|
||||
<div className="s-inset flex items-center gap-2 text-xs text-[var(--warning)]">
|
||||
<Sparkles size={13} />
|
||||
Demo mode active — using sample data
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="w-full px-8 py-6">
|
||||
<div className="w-full px-4 py-6 sm:px-8">
|
||||
<div className="grid grid-cols-1 xl:grid-cols-[380px_1fr] gap-6">
|
||||
{/* Template List */}
|
||||
<section className="panel overflow-hidden">
|
||||
<div className="p-4 border-b border-[var(--border-subtle)]">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Filter size={16} className="text-[var(--text-tertiary)]" />
|
||||
<span className="text-sm font-medium text-[var(--text-secondary)]">Filters</span>
|
||||
<section className="s-card overflow-hidden">
|
||||
<div className="s-cardhead border-b border-[var(--border-subtle)]">
|
||||
<span className="s-ibox"><Filter /></span>
|
||||
<span className="s-t">Filters</span>
|
||||
</div>
|
||||
<div className="p-4 border-b border-[var(--border-subtle)]">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
|
||||
@@ -614,21 +599,21 @@ export function TemplatesPage() {
|
||||
{filteredTemplates.map((template) => {
|
||||
const selected = template.id === selectedTemplateId;
|
||||
const Icon = categoryIcon(template.category);
|
||||
const categoryColor = template.category === 'database' ? '#7ab8ff' : template.category === 'frontend' ? '#f2c94c' : template.category === 'web' ? 'var(--accent-primary)' : '#9295a4';
|
||||
const categoryColor = template.category === 'database' ? 'var(--info)' : template.category === 'frontend' ? 'var(--warning)' : template.category === 'web' ? 'var(--accent-primary)' : 'var(--text-tertiary)';
|
||||
return (
|
||||
<button
|
||||
key={template.id}
|
||||
onClick={() => selectTemplate(template.id)}
|
||||
className={`w-full p-4 rounded-[var(--radius-lg)] border text-left transition-all duration-300 group ${
|
||||
selected
|
||||
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] shadow-lg shadow-[var(--accent-primary-glow)]'
|
||||
? 'border-[var(--accent-primary)]/60 bg-[var(--accent-primary-soft)]'
|
||||
: 'border-[var(--border-subtle)] hover:border-[var(--border-default)] hover:bg-[var(--surface-muted)]/50 card-lift'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className={`w-11 h-11 rounded-xl flex items-center justify-center transition-all duration-300 ${
|
||||
selected ? 'ring-2 ring-white/20' : ''
|
||||
selected ? 'ring-1 ring-[var(--accent-primary)]/40' : ''
|
||||
}`}
|
||||
style={{
|
||||
background: selected ? categoryColor : `${categoryColor}20`,
|
||||
@@ -691,12 +676,10 @@ export function TemplatesPage() {
|
||||
</section>
|
||||
|
||||
{/* Template Detail */}
|
||||
<section className="panel p-6">
|
||||
<section className="s-card p-5">
|
||||
{!selectedTemplateId ? (
|
||||
<div className="py-16 text-center">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-[var(--surface-muted)] flex items-center justify-center">
|
||||
<Layers size={28} className="text-[var(--text-tertiary)]" />
|
||||
</div>
|
||||
<div className="s-ibox mx-auto mb-4 !w-14 !h-14"><Layers size={22} /></div>
|
||||
<p className="text-sm text-[var(--text-muted)]">Select a template to view details and deploy</p>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -722,15 +705,15 @@ export function TemplatesPage() {
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-4 mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-14 h-14 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
|
||||
<div className="s-ibox !w-14 !h-14 !rounded-xl text-[var(--accent-primary)]">
|
||||
{(() => {
|
||||
const Icon = categoryIcon(selectedDetail.template.category);
|
||||
return <Icon size={24} className="text-[var(--accent-primary)]" />;
|
||||
return <Icon size={24} />;
|
||||
})()}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-xl font-semibold text-[var(--text-primary)]">{selectedDetail.template.name}</h2>
|
||||
<h2 className="font-headline text-lg font-semibold text-[var(--text-primary)]">{selectedDetail.template.name}</h2>
|
||||
{selectedDetail.template.isOfficial && (
|
||||
<span className="flex items-center gap-1 px-2 py-0.5 rounded-full bg-[var(--warning-soft)] text-[var(--warning)] text-xs font-medium">
|
||||
<Star size={10} className="fill-[var(--warning)]" />
|
||||
@@ -738,7 +721,7 @@ export function TemplatesPage() {
|
||||
</span>
|
||||
)}
|
||||
{!selectedDetail.template.isOfficial && selectedDetail.template.isPublic && (
|
||||
<span className="px-2 py-0.5 rounded-full bg-[var(--info-soft)] text-[var(--info)] text-xs font-medium">
|
||||
<span className="s-chip !text-[var(--info)] !border-[color-mix(in_srgb,var(--info)_30%,transparent)] !bg-[var(--info-soft)] v-mono !text-[10.5px]">
|
||||
Public
|
||||
</span>
|
||||
)}
|
||||
@@ -747,10 +730,10 @@ export function TemplatesPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-3 py-1 rounded-full border border-[var(--border-subtle)] text-xs text-[var(--text-tertiary)]">
|
||||
<span className="s-chip v-mono !text-[10.5px]">
|
||||
{categoryLabel(selectedDetail.template.category)}
|
||||
</span>
|
||||
<span className="px-3 py-1 rounded-full border border-[var(--border-subtle)] text-xs text-[var(--text-tertiary)]">
|
||||
<span className="s-chip v-mono !text-[10.5px]">
|
||||
{selectedDetail.config.runtime || 'n/a'}
|
||||
</span>
|
||||
{canManageTemplate(selectedDetail.template) ? (
|
||||
@@ -777,25 +760,25 @@ export function TemplatesPage() {
|
||||
|
||||
{/* Config Grid */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<div className="panel-soft p-4">
|
||||
<div className="s-inset">
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Type</p>
|
||||
<p className="mono text-sm text-[var(--text-primary)] mt-2">{selectedDetail.config.type || '—'}</p>
|
||||
<p className="v-mono text-sm text-[var(--text-primary)] mt-2">{selectedDetail.config.type || '—'}</p>
|
||||
</div>
|
||||
<div className="panel-soft p-4">
|
||||
<div className="s-inset">
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Port</p>
|
||||
<p className="mono text-sm text-[var(--text-primary)] mt-2">{selectedDetail.config.port || '—'}</p>
|
||||
<p className="v-mono text-sm text-[var(--text-primary)] mt-2">{selectedDetail.config.port || '—'}</p>
|
||||
</div>
|
||||
<div className="panel-soft p-4">
|
||||
<div className="s-inset">
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Build</p>
|
||||
<p className="mono text-xs text-[var(--text-primary)] mt-2 truncate">{selectedDetail.config.buildCommand || '—'}</p>
|
||||
<p className="v-mono text-xs text-[var(--text-primary)] mt-2 truncate">{selectedDetail.config.buildCommand || '—'}</p>
|
||||
</div>
|
||||
<div className="panel-soft p-4">
|
||||
<div className="s-inset">
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Start</p>
|
||||
<p className="mono text-xs text-[var(--text-primary)] mt-2 truncate">{selectedDetail.config.startCommand || '—'}</p>
|
||||
<p className="v-mono text-xs text-[var(--text-primary)] mt-2 truncate">{selectedDetail.config.startCommand || '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel-soft p-4 mb-6">
|
||||
<div className="s-inset mb-6">
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">How Templates Work</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
Template defaults are merged with your variable inputs. Creating from template saves a stopped service in the selected project, then you deploy it from the service detail page.
|
||||
@@ -808,7 +791,7 @@ export function TemplatesPage() {
|
||||
</div>
|
||||
|
||||
{isGraphTemplate ? (
|
||||
<div className="panel-soft p-4 mb-6">
|
||||
<div className="s-inset mb-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Network size={14} className="text-[var(--accent-primary)]" />
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
|
||||
@@ -824,14 +807,14 @@ export function TemplatesPage() {
|
||||
) : (
|
||||
<Box size={13} className="text-[var(--text-tertiary)] shrink-0" />
|
||||
)}
|
||||
<span className="mono text-[var(--text-primary)] truncate">{member.name || member.key}</span>
|
||||
<span className="v-mono text-[var(--text-primary)] truncate">{member.name || member.key}</span>
|
||||
{member.dependsOn && member.dependsOn.length > 0 ? (
|
||||
<span className="text-[10px] text-[var(--text-tertiary)]">
|
||||
after {member.dependsOn.join(', ')}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="mono text-[11px] text-[var(--text-tertiary)] truncate">
|
||||
<span className="v-mono text-[11px] text-[var(--text-tertiary)] truncate">
|
||||
{member.type === 'database' ? `managed ${member.runtime}` : member.runtime || member.repo || '—'}
|
||||
</span>
|
||||
</div>
|
||||
@@ -845,26 +828,28 @@ export function TemplatesPage() {
|
||||
|
||||
{/* Deploy Section */}
|
||||
<div className="border-t border-[var(--border-subtle)] pt-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--success-soft)] flex items-center justify-center">
|
||||
<Play size={18} className="text-[var(--success)]" />
|
||||
</div>
|
||||
<div className="s-cardhead !mb-4">
|
||||
<span className="s-ibox"><Play /></span>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">Deploy from Template</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
<span className="s-t">Deploy from Template</span>
|
||||
<p className="v-mono text-[10.5px] text-[var(--text-tertiary)] mt-0.5">
|
||||
{isGraphTemplate ? 'Deploy all members into a project' : 'Configure and create a new service'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!signedIn ? (
|
||||
{sessionQuery.isPending && !isDemoMode ? (
|
||||
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-4 py-5 text-center">
|
||||
<Loader2 size={16} className="mx-auto animate-spin text-[var(--text-muted)]" />
|
||||
</div>
|
||||
) : !signedIn ? (
|
||||
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-4 py-5 text-center">
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
Sign in to deploy this template into one of your projects.
|
||||
</p>
|
||||
<Link
|
||||
to="/auth/sign-in"
|
||||
className="mt-3 inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-[12.5px] font-semibold text-[var(--accent-on)]"
|
||||
className="s-btn-accent mt-3 text-[12.5px]"
|
||||
>
|
||||
Sign in <ArrowRight size={13} />
|
||||
</Link>
|
||||
@@ -970,7 +955,7 @@ export function TemplatesPage() {
|
||||
<span>
|
||||
{lastDeployment.created
|
||||
? `Stack deployed — ${lastDeployment.created.length} resources`
|
||||
: <>Service <span className="mono font-medium">{lastDeployment.serviceName}</span> created</>}
|
||||
: <>Service <span className="v-mono font-medium">{lastDeployment.serviceName}</span> created</>}
|
||||
</span>
|
||||
<Link
|
||||
to={lastDeployment.created
|
||||
@@ -986,7 +971,7 @@ export function TemplatesPage() {
|
||||
{lastDeployment.created.map((m) => (
|
||||
<div key={m.id} className="flex items-center gap-2 text-xs">
|
||||
{m.kind === 'database' ? <Database size={11} /> : <Box size={11} />}
|
||||
<span className="mono">{m.name}</span>
|
||||
<span className="v-mono">{m.name}</span>
|
||||
<span className="opacity-70">({m.kind})</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -999,8 +984,8 @@ export function TemplatesPage() {
|
||||
<button
|
||||
onClick={() => deployMutation.mutate()}
|
||||
disabled={isDeployDisabled}
|
||||
className="flex items-center gap-2 h-11 px-6 rounded-[var(--radius-md)] text-[var(--accent-on)] text-sm font-medium shadow-lg disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
className="s-btn-accent h-11 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
|
||||
>
|
||||
{deployMutation.isPending ? (
|
||||
<>
|
||||
@@ -1133,7 +1118,7 @@ export function TemplatesPage() {
|
||||
value={editorJson}
|
||||
onChange={(e) => setEditorJson(e.target.value)}
|
||||
spellCheck={false}
|
||||
className="mono min-h-[380px] flex-1 resize-none bg-[var(--bg-void)] p-4 text-[12.5px] leading-relaxed text-[var(--text-secondary)] outline-none"
|
||||
className="v-mono min-h-[380px] flex-1 resize-none bg-[var(--bg-void)] p-4 text-[12.5px] leading-relaxed text-[var(--text-secondary)] outline-none"
|
||||
/>
|
||||
)}
|
||||
{composeWarnings.length > 0 ? (
|
||||
@@ -1171,7 +1156,7 @@ export function TemplatesPage() {
|
||||
type="button"
|
||||
onClick={submitEditor}
|
||||
disabled={saveTemplateMutation.isPending}
|
||||
className="inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-xs font-semibold text-[var(--accent-on)] disabled:opacity-50"
|
||||
className="s-btn-accent disabled:opacity-50"
|
||||
>
|
||||
{saveTemplateMutation.isPending ? <Loader2 size={13} className="animate-spin" /> : null}
|
||||
{editingTemplate ? 'Save changes' : 'Create template'}
|
||||
@@ -1223,7 +1208,7 @@ export function TemplatesPage() {
|
||||
onChange={(e) => setComposeYaml(e.target.value)}
|
||||
spellCheck={false}
|
||||
placeholder={'services:\n db:\n image: postgres:16\n web:\n image: myapp:latest\n depends_on: [db]'}
|
||||
className="mono min-h-[320px] flex-1 resize-none bg-[var(--bg-void)] p-4 text-[12.5px] leading-relaxed text-[var(--text-secondary)] outline-none"
|
||||
className="v-mono min-h-[320px] flex-1 resize-none bg-[var(--bg-void)] p-4 text-[12.5px] leading-relaxed text-[var(--text-secondary)] outline-none"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex min-h-[320px] flex-1 flex-col gap-3 bg-[var(--bg-void)] p-4">
|
||||
@@ -1234,7 +1219,7 @@ export function TemplatesPage() {
|
||||
onChange={(e) => setGitRepo(e.target.value)}
|
||||
spellCheck={false}
|
||||
placeholder="owner/repo or https://github.com/owner/repo.git"
|
||||
className="mono mt-1 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-base)] px-3 py-2 text-[12.5px] text-[var(--text-secondary)] outline-none focus:border-[var(--accent-primary)]"
|
||||
className="v-mono mt-1 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-base)] px-3 py-2 text-[12.5px] text-[var(--text-secondary)] outline-none focus:border-[var(--accent-primary)]"
|
||||
/>
|
||||
</label>
|
||||
<label className="text-xs text-[var(--text-tertiary)]">
|
||||
@@ -1244,7 +1229,7 @@ export function TemplatesPage() {
|
||||
onChange={(e) => setGitPath(e.target.value)}
|
||||
spellCheck={false}
|
||||
placeholder="deploy/compose.yml"
|
||||
className="mono mt-1 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-base)] px-3 py-2 text-[12.5px] text-[var(--text-secondary)] outline-none focus:border-[var(--accent-primary)]"
|
||||
className="v-mono mt-1 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-base)] px-3 py-2 text-[12.5px] text-[var(--text-secondary)] outline-none focus:border-[var(--accent-primary)]"
|
||||
/>
|
||||
</label>
|
||||
<label className="text-xs text-[var(--text-tertiary)]">
|
||||
@@ -1254,7 +1239,7 @@ export function TemplatesPage() {
|
||||
onChange={(e) => setGitRef(e.target.value)}
|
||||
spellCheck={false}
|
||||
placeholder="main"
|
||||
className="mono mt-1 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-base)] px-3 py-2 text-[12.5px] text-[var(--text-secondary)] outline-none focus:border-[var(--accent-primary)]"
|
||||
className="v-mono mt-1 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-base)] px-3 py-2 text-[12.5px] text-[var(--text-secondary)] outline-none focus:border-[var(--accent-primary)]"
|
||||
/>
|
||||
</label>
|
||||
<p className="text-[11px] text-[var(--text-tertiary)]">
|
||||
@@ -1279,7 +1264,7 @@ export function TemplatesPage() {
|
||||
type="button"
|
||||
onClick={() => composeImportMutation.mutate()}
|
||||
disabled={(composeMode === 'paste' ? !composeYaml.trim() : !gitRepo.trim()) || composeImportMutation.isPending}
|
||||
className="inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-xs font-semibold text-[var(--accent-on)] disabled:opacity-50"
|
||||
className="s-btn-accent disabled:opacity-50"
|
||||
>
|
||||
{composeImportMutation.isPending ? <Loader2 size={13} className="animate-spin" /> : <FileCode size={13} />}
|
||||
Convert to template
|
||||
|
||||
@@ -8,11 +8,12 @@ import {
|
||||
testWebhook,
|
||||
updateWebhook,
|
||||
type OutboundWebhook,
|
||||
type WebhookDelivery,
|
||||
} from '@/lib/api-client';
|
||||
import { useDemoMode } from '@/lib/demo-mode';
|
||||
import { formatRelative } from '@/lib/time';
|
||||
import { DemoRestricted, useToast } from '@/shared/components';
|
||||
import { GhostBtn, QuietBtn, SPageHead, SPill } from '@/shared/components/sentry';
|
||||
import { statusTone } from '@/shared/components/sentry-utils';
|
||||
import {
|
||||
Webhook,
|
||||
Plus,
|
||||
@@ -30,17 +31,6 @@ const inputClass =
|
||||
|
||||
const eventSuggestions = ['*', 'service.*', 'project.*', 'database.*', 'deployment.*', 'template.*'];
|
||||
|
||||
function statusClass(status: WebhookDelivery['status']): string {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return 'text-[var(--success)]';
|
||||
case 'failed':
|
||||
return 'text-[var(--error)]';
|
||||
default:
|
||||
return 'text-[var(--warning)]';
|
||||
}
|
||||
}
|
||||
|
||||
function WebhookRow({
|
||||
webhook,
|
||||
onDelete,
|
||||
@@ -63,59 +53,50 @@ function WebhookRow({
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="panel p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={() => setExpanded((v) => !v)} className="text-[var(--text-tertiary)]">
|
||||
{expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
</button>
|
||||
<div className="s-card !p-0">
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
<span className="s-ibox shrink-0"><Webhook /></span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-[var(--text-primary)]">{webhook.name}</span>
|
||||
{!webhook.enabled && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--surface-muted)] text-[var(--text-muted)]">
|
||||
disabled
|
||||
</span>
|
||||
)}
|
||||
{!webhook.enabled && <SPill tone="off">disabled</SPill>}
|
||||
</div>
|
||||
<p className="text-xs text-[var(--text-muted)] mono truncate">{webhook.url}</p>
|
||||
<p className="v-mono text-[10.5px] text-[var(--text-muted)] truncate">{webhook.url}</p>
|
||||
</div>
|
||||
<div className="hidden md:flex gap-1">
|
||||
<div className="hidden md:flex gap-1.5">
|
||||
{webhook.events.map((ev) => (
|
||||
<span
|
||||
key={ev}
|
||||
className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--accent-primary-soft)] text-[var(--accent-primary)] mono"
|
||||
>
|
||||
{ev}
|
||||
</span>
|
||||
<span key={ev} className="s-chip">{ev}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="s-icon-btn"
|
||||
title={expanded ? 'Collapse' : 'Deliveries'}
|
||||
>
|
||||
{expanded ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={onTest}
|
||||
disabled={testing || !webhook.enabled}
|
||||
className="p-2 rounded-[var(--radius-md)] text-[var(--text-tertiary)] hover:text-[var(--accent-primary)] disabled:opacity-40 transition-colors"
|
||||
className="s-icon-btn disabled:opacity-40"
|
||||
title="Send test ping"
|
||||
>
|
||||
{testing ? <Loader2 size={15} className="animate-spin" /> : <Send size={15} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="p-2 rounded-[var(--radius-md)] text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-colors text-xs"
|
||||
>
|
||||
{webhook.enabled ? 'Disable' : 'Enable'}
|
||||
{testing ? <Loader2 size={13} className="animate-spin" /> : <Send size={13} />}
|
||||
</button>
|
||||
<QuietBtn onClick={onToggle}>{webhook.enabled ? 'Disable' : 'Enable'}</QuietBtn>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="p-2 rounded-[var(--radius-md)] text-[var(--text-tertiary)] hover:text-[var(--error)] transition-colors"
|
||||
className="s-icon-btn !text-[var(--text-tertiary)] hover:!text-[var(--error)]"
|
||||
title="Delete webhook"
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className="mt-3 border-t border-[var(--border-subtle)] pt-3">
|
||||
<p className="text-[11px] uppercase tracking-wider text-[var(--text-muted)] mb-2">
|
||||
<div className="border-t border-[var(--border-subtle)] bg-[var(--surface-muted)]/40 px-4 py-3">
|
||||
<p className="v-mono text-[10px] uppercase tracking-[0.12em] text-[var(--text-muted)] mb-2">
|
||||
Recent deliveries
|
||||
</p>
|
||||
{deliveriesQuery.isLoading ? (
|
||||
@@ -123,10 +104,10 @@ function WebhookRow({
|
||||
) : (deliveriesQuery.data ?? []).length === 0 ? (
|
||||
<p className="text-xs text-[var(--text-muted)]">No deliveries yet.</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-1.5">
|
||||
{(deliveriesQuery.data ?? []).slice(0, 20).map((d) => (
|
||||
<div key={d.id} className="flex items-center gap-3 text-xs mono">
|
||||
<span className={statusClass(d.status)}>{d.status}</span>
|
||||
<div key={d.id} className="flex items-center gap-3 text-[11px] v-mono">
|
||||
<SPill tone={statusTone(d.status)}>{d.status}</SPill>
|
||||
<span className="text-[var(--text-secondary)]">{d.event}</span>
|
||||
{d.response_status != null && (
|
||||
<span className="text-[var(--text-muted)]">HTTP {d.response_status}</span>
|
||||
@@ -207,46 +188,41 @@ export function WebhooksPage() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<div className="border-b border-[var(--border-subtle)]">
|
||||
<div className="w-full px-8 py-5 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="v-title">Webhooks<span className="v-cursor">_</span></h1>
|
||||
<p className="v-mono mt-1.5 text-[11px] text-[var(--text-tertiary)]">
|
||||
Signed HTTP delivery for every platform event — deploys, restarts, deletes
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setCreating(true)}
|
||||
className="flex items-center gap-1.5 h-9 px-4 rounded-[var(--radius-md)] text-sm font-semibold text-[var(--accent-on)]"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
>
|
||||
<Plus size={15} />
|
||||
New webhook
|
||||
</button>
|
||||
</div>
|
||||
<div className="w-full px-4 pt-6 sm:px-8">
|
||||
<SPageHead
|
||||
title="Webhooks"
|
||||
titleAccent="_"
|
||||
sub="Signed HTTP delivery for every platform event — deploys, restarts, deletes"
|
||||
actions={
|
||||
<GhostBtn onClick={() => setCreating(true)}>
|
||||
<Plus size={13} /> New webhook
|
||||
</GhostBtn>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full px-8 py-6 space-y-4 max-w-4xl">
|
||||
<div className="w-full px-4 sm:px-8 space-y-4 max-w-4xl">
|
||||
{secret && (
|
||||
<div className="panel-soft p-4 flex items-center gap-3">
|
||||
<div className="s-inset p-4 flex items-center gap-3">
|
||||
<span className="s-ibox shrink-0"><Webhook /></span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">Signing secret — shown once</p>
|
||||
<p className="text-xs text-[var(--text-muted)]">
|
||||
Verify deliveries with <span className="mono">X-Containr-Signature</span>
|
||||
<p className="v-mono text-[11px] text-[var(--text-muted)]">
|
||||
Verify deliveries with <span className="v-mono">X-Containr-Signature</span>
|
||||
</p>
|
||||
<code className="block mt-2 text-xs mono text-[var(--accent-primary)] break-all">{secret}</code>
|
||||
<code className="block mt-2 v-mono text-xs text-[var(--accent-primary)] break-all">{secret}</code>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard?.writeText(secret);
|
||||
toast.showToast('Secret copied', 'success');
|
||||
}}
|
||||
className="p-2 text-[var(--text-tertiary)] hover:text-[var(--text-primary)]"
|
||||
className="s-icon-btn"
|
||||
>
|
||||
<Copy size={15} />
|
||||
<Copy size={13} />
|
||||
</button>
|
||||
<button onClick={() => setSecret(null)} className="p-2 text-[var(--text-tertiary)]">
|
||||
<X size={15} />
|
||||
<button onClick={() => setSecret(null)} className="s-icon-btn">
|
||||
<X size={13} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -254,12 +230,12 @@ export function WebhooksPage() {
|
||||
{webhooksQuery.isLoading ? (
|
||||
<Loader2 size={18} className="animate-spin text-[var(--text-muted)]" />
|
||||
) : (webhooksQuery.data ?? []).length === 0 ? (
|
||||
<div className="panel p-8 text-center">
|
||||
<Webhook size={28} className="mx-auto text-[var(--text-muted)] mb-3" />
|
||||
<div className="s-card py-10 text-center">
|
||||
<div className="s-ibox mx-auto mb-3"><Webhook /></div>
|
||||
<p className="text-sm text-[var(--text-secondary)]">No webhooks yet.</p>
|
||||
<p className="text-xs text-[var(--text-muted)] mt-1">
|
||||
Subscribe an HTTP endpoint to platform events like <span className="mono">service.*</span> or{' '}
|
||||
<span className="mono">deployment.fail</span>.
|
||||
<p className="v-mono text-[11px] text-[var(--text-muted)] mt-1">
|
||||
Subscribe an HTTP endpoint to platform events like <span className="v-mono">service.*</span> or{' '}
|
||||
<span className="v-mono">deployment.fail</span>.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -283,8 +259,11 @@ export function WebhooksPage() {
|
||||
{creating && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-[var(--bg-void)]/80 backdrop-blur-sm" onClick={() => setCreating(false)} />
|
||||
<div className="relative w-full max-w-md panel p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">New webhook</h3>
|
||||
<div className="relative w-full max-w-md s-card">
|
||||
<div className="s-cardhead mb-4">
|
||||
<span className="s-ibox"><Webhook /></span>
|
||||
<h3 className="s-t">New Webhook</h3>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
|
||||
@@ -304,7 +283,7 @@ export function WebhooksPage() {
|
||||
<input
|
||||
value={form.url}
|
||||
onChange={(e) => setForm((f) => ({ ...f, url: e.target.value }))}
|
||||
className={`${inputClass} w-full mono`}
|
||||
className={`${inputClass} w-full v-mono`}
|
||||
placeholder="https://example.com/hooks/containr"
|
||||
/>
|
||||
</div>
|
||||
@@ -317,30 +296,21 @@ export function WebhooksPage() {
|
||||
<button
|
||||
key={ev}
|
||||
onClick={() => toggleEvent(ev)}
|
||||
className={`px-2.5 py-1 rounded-[var(--radius-md)] border text-xs mono transition-colors ${
|
||||
form.events.includes(ev)
|
||||
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]'
|
||||
: 'border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-tertiary)]'
|
||||
}`}
|
||||
className={`s-chip v-mono ${form.events.includes(ev) ? 'on' : ''}`}
|
||||
>
|
||||
{ev}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 text-[11px] text-[var(--text-muted)]">
|
||||
Events are <span className="mono">resource.action</span> — wildcards like{' '}
|
||||
<span className="mono">service.*</span> match the whole resource.
|
||||
<p className="mt-2 v-mono text-[10.5px] text-[var(--text-muted)]">
|
||||
Events are <span className="v-mono">resource.action</span> — wildcards like{' '}
|
||||
<span className="v-mono">service.*</span> match the whole resource.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setCreating(false)}
|
||||
className="h-10 px-4 rounded-[var(--radius-md)] text-sm text-[var(--text-secondary)]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
<QuietBtn onClick={() => setCreating(false)}>Cancel</QuietBtn>
|
||||
<GhostBtn
|
||||
onClick={() => createMutation.mutate()}
|
||||
disabled={
|
||||
createMutation.isPending ||
|
||||
@@ -348,12 +318,10 @@ export function WebhooksPage() {
|
||||
!form.url.trim() ||
|
||||
form.events.length === 0
|
||||
}
|
||||
className="h-10 px-5 rounded-[var(--radius-md)] text-sm font-semibold text-[var(--accent-on)] disabled:opacity-50 flex items-center gap-2"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
>
|
||||
{createMutation.isPending && <Loader2 size={14} className="animate-spin" />}
|
||||
{createMutation.isPending && <Loader2 size={13} className="animate-spin" />}
|
||||
Create
|
||||
</button>
|
||||
</GhostBtn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -167,12 +167,12 @@ function toFlowEdges(
|
||||
},
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
color: 'rgba(180, 227, 74, 0.55)',
|
||||
color: 'color-mix(in srgb, var(--accent-primary) 55%, transparent)',
|
||||
width: 13,
|
||||
height: 13,
|
||||
},
|
||||
style: {
|
||||
stroke: 'rgba(180, 227, 74, 0.42)',
|
||||
stroke: 'color-mix(in srgb, var(--accent-primary) 42%, transparent)',
|
||||
strokeWidth: 1.6,
|
||||
},
|
||||
className: 'edge-premium',
|
||||
@@ -198,7 +198,7 @@ function ZoomControls({ onTidy }: { onTidy: () => void }) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewport({ x: 0, y: 0, zoom: 1 }, { duration: 180 })}
|
||||
className="canvas-pill-btn mono w-12 text-[11px]"
|
||||
className="canvas-pill-btn v-mono w-12 text-[11px]"
|
||||
title="Reset zoom (0)"
|
||||
>
|
||||
{Math.round(zoom * 100)}%
|
||||
@@ -873,7 +873,7 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
||||
const minimapNodeColor = useCallback(
|
||||
(node: CanvasNode) => {
|
||||
if (node.type === 'groupNode') {
|
||||
return 'rgba(255,255,255,0.08)';
|
||||
return 'var(--tint-08)';
|
||||
}
|
||||
return serviceAccent((node.data as ServiceNodeData).service);
|
||||
},
|
||||
@@ -922,14 +922,14 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
||||
selectionKeyCode="Shift"
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={26} size={1.2} color="rgba(255,255,255,0.055)" />
|
||||
<Background variant={BackgroundVariant.Dots} gap={26} size={1.2} color="var(--tint-05)" />
|
||||
<MiniMap
|
||||
position="bottom-right"
|
||||
pannable
|
||||
zoomable
|
||||
nodeColor={minimapNodeColor}
|
||||
bgColor="rgba(14, 15, 18, 0.92)"
|
||||
maskColor="rgba(12, 13, 15, 0.72)"
|
||||
bgColor="var(--surface-glass)"
|
||||
maskColor="var(--surface-glass)"
|
||||
className="canvas-minimap hidden md:block"
|
||||
/>
|
||||
</ReactFlow>
|
||||
@@ -937,21 +937,19 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
||||
{/* Floating: env filter + group (top-left) */}
|
||||
<div className="absolute left-4 top-4 flex items-center gap-2">
|
||||
{environments.length > 1 && (
|
||||
<div className="canvas-pill flex items-center gap-0.5 p-1">
|
||||
<div className="canvas-pill !rounded-[9px] p-1">
|
||||
<span className="s-seg">
|
||||
{['all', ...environments].map((env) => (
|
||||
<button
|
||||
key={env}
|
||||
type="button"
|
||||
onClick={() => setEnvFilter(env)}
|
||||
className={`px-2.5 py-1 rounded-full text-[11px] font-medium capitalize transition-all ${
|
||||
envFilter === env
|
||||
? 'bg-[var(--surface-card)] text-[var(--text-primary)] shadow-sm'
|
||||
: 'text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]'
|
||||
}`}
|
||||
className={`!w-auto !h-auto px-2.5 py-1 capitalize v-mono text-[10.5px] ${envFilter === env ? 'on' : ''}`}
|
||||
>
|
||||
{env}
|
||||
</button>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!readOnly && (
|
||||
@@ -1018,12 +1016,12 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
||||
{/* Empty state */}
|
||||
{services.length === 0 && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div className="text-center pointer-events-auto">
|
||||
<div className="w-14 h-14 mx-auto mb-4 rounded-2xl bg-[var(--surface-card)] border border-[var(--border-subtle)] flex items-center justify-center">
|
||||
<Rocket size={24} className="text-[var(--accent-primary)]" />
|
||||
<div className="text-center pointer-events-auto s-card !py-8 !px-10">
|
||||
<div className="s-ibox mx-auto mb-4 !h-11 !w-11">
|
||||
<Rocket />
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-[var(--text-primary)]">Deploy your first service</h3>
|
||||
<p className="text-sm text-[var(--text-tertiary)] mt-1 mb-4 max-w-xs">
|
||||
<h3 className="font-headline text-base font-semibold text-[var(--text-primary)]">Deploy your first service</h3>
|
||||
<p className="v-mono text-[11.5px] text-[var(--text-tertiary)] mt-1.5 mb-5 max-w-xs">
|
||||
Add a service from a git repo or Docker image - it runs on a private network with the rest of this
|
||||
project.
|
||||
</p>
|
||||
@@ -1031,10 +1029,9 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAddService}
|
||||
className="inline-flex items-center gap-2 h-9 px-4 rounded-lg text-[var(--accent-on)] text-sm font-medium shadow-lg"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
className="s-btn-accent inline-flex items-center gap-2"
|
||||
>
|
||||
<Plus size={15} />
|
||||
<Plus size={13} />
|
||||
Add Service
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ServiceEntity } from '@/lib/api-client';
|
||||
import { serviceStatusClass } from '@/lib/api-client';
|
||||
import type { ServiceVariable } from '../auto-connections';
|
||||
import { GhostBtn, QuietBtn, SPill } from '@/shared/components/sentry';
|
||||
import { statusTone } from '@/shared/components/sentry-utils';
|
||||
import { ServiceIcon } from './ServiceIcon';
|
||||
import { serviceAccent } from './service-visuals';
|
||||
import { formatRelative } from '@/lib/time';
|
||||
@@ -63,28 +64,11 @@ function statusLabel(status: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function statusColor(status: string): string {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
case 'deployed':
|
||||
return 'var(--success)';
|
||||
case 'failed':
|
||||
return 'var(--error)';
|
||||
case 'building':
|
||||
case 'deploying':
|
||||
case 'pending':
|
||||
case 'rolling_back':
|
||||
return 'var(--warning)';
|
||||
default:
|
||||
return 'var(--text-tertiary)';
|
||||
}
|
||||
}
|
||||
|
||||
function Section({ title, children, action }: { title: string; children: React.ReactNode; action?: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<p className="text-[10px] uppercase tracking-wider text-[var(--text-muted)]">{title}</p>
|
||||
<p className="v-mono text-[10px] uppercase tracking-[0.14em] text-[var(--text-muted)]">{title}</p>
|
||||
{action}
|
||||
</div>
|
||||
{children}
|
||||
@@ -163,48 +147,49 @@ export function ServiceInspector({
|
||||
|
||||
return (
|
||||
<aside className="canvas-inspector absolute right-4 top-4 bottom-4 w-[312px] panel-glass flex flex-col overflow-hidden">
|
||||
<div className="flex items-center gap-3 px-4 pt-4 pb-3 border-b border-[var(--border-subtle)]">
|
||||
<div
|
||||
className="w-9 h-9 rounded-[var(--radius-md)] flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: `${accent}1c`, color: accent }}
|
||||
<div className="s-cardhead border-b border-[var(--border-subtle)]">
|
||||
<span
|
||||
className="s-ibox flex-shrink-0"
|
||||
style={{ background: `${accent}14`, color: accent, borderColor: `${accent}33` }}
|
||||
>
|
||||
<ServiceIcon service={service} size={16} />
|
||||
</div>
|
||||
<ServiceIcon service={service} size={15} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-sm font-semibold text-[var(--text-primary)] truncate">{service.name}</h3>
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-[var(--text-tertiary)]">
|
||||
<span className={`status-dot ${serviceStatusClass(service.status)}`} />
|
||||
<span style={{ color: statusColor(service.status) }}>{statusLabel(service.status)}</span>
|
||||
<span>· {service.type}</span>
|
||||
{service.environment && service.environment !== 'production' && <span>· {service.environment}</span>}
|
||||
<h3 className="text-[13px] font-semibold text-[var(--text-primary)] truncate leading-tight">{service.name}</h3>
|
||||
<div className="flex items-center gap-1.5 mt-1">
|
||||
<SPill tone={statusTone(service.status)}>{statusLabel(service.status)}</SPill>
|
||||
<span className="v-mono text-[10px] text-[var(--text-tertiary)]">
|
||||
{service.type}
|
||||
{service.environment && service.environment !== 'production' ? ` · ${service.environment}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center text-[var(--text-tertiary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-muted)]"
|
||||
className="s-icon-btn"
|
||||
title="Close (Esc)"
|
||||
>
|
||||
<X size={14} />
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-4">
|
||||
{(publicUrl || service.port) && (
|
||||
<Section title="Address">
|
||||
<div className="flex items-center gap-1.5 group">
|
||||
<div className="s-inset flex items-center gap-1.5 px-2.5 py-2 group">
|
||||
<Globe size={11} className="text-[var(--text-tertiary)] flex-shrink-0" />
|
||||
{publicUrl ? (
|
||||
<a
|
||||
href={publicUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mono text-[11px] text-[var(--accent-primary)] hover:underline break-all"
|
||||
className="v-mono text-[11px] text-[var(--accent-primary)] hover:underline break-all"
|
||||
>
|
||||
{publicUrl.replace(/^https?:\/\//, '')}
|
||||
</a>
|
||||
) : (
|
||||
<span className="mono text-[11px] text-[var(--text-secondary)]">
|
||||
<span className="v-mono text-[11px] text-[var(--text-secondary)]">
|
||||
{service.name}:{service.port}
|
||||
</span>
|
||||
)}
|
||||
@@ -218,7 +203,7 @@ export function ServiceInspector({
|
||||
</button>
|
||||
</div>
|
||||
{publicUrl && service.port ? (
|
||||
<p className="mono text-[10.5px] text-[var(--text-tertiary)] mt-0.5 pl-4">
|
||||
<p className="v-mono text-[10.5px] text-[var(--text-tertiary)] mt-1 pl-1">
|
||||
{service.name}:{service.port}
|
||||
</p>
|
||||
) : null}
|
||||
@@ -266,13 +251,13 @@ export function ServiceInspector({
|
||||
{variables.slice(0, 6).map((variable) => (
|
||||
<div
|
||||
key={variable.key}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded-[var(--radius-sm)] bg-[var(--surface-muted)]/50"
|
||||
className="s-inset !rounded-[6px] flex items-center gap-2 px-2 py-1.5"
|
||||
>
|
||||
<KeyRound size={9} className="text-[var(--text-tertiary)] flex-shrink-0" />
|
||||
<span className="mono text-[10px] text-[var(--text-primary)] truncate font-medium">
|
||||
<span className="v-mono text-[10px] text-[var(--text-primary)] truncate font-medium">
|
||||
{variable.key}
|
||||
</span>
|
||||
<span className="mono text-[10px] text-[var(--text-tertiary)] truncate ml-auto max-w-[130px]" title={variable.isSecret ? 'Secret' : variable.value}>
|
||||
<span className="v-mono text-[10px] text-[var(--text-tertiary)] truncate ml-auto max-w-[130px]" title={variable.isSecret ? 'Secret' : variable.value}>
|
||||
{variable.isSecret ? '••••••••' : variable.value}
|
||||
</span>
|
||||
</div>
|
||||
@@ -299,7 +284,7 @@ export function ServiceInspector({
|
||||
{visibleConfig.map(([label, value]) => (
|
||||
<div key={label} className="flex items-baseline gap-2">
|
||||
<dt className="text-[10px] text-[var(--text-muted)] w-[70px] flex-shrink-0">{label}</dt>
|
||||
<dd className="mono text-[10.5px] text-[var(--text-secondary)] truncate" title={value}>
|
||||
<dd className="v-mono text-[10.5px] text-[var(--text-secondary)] truncate" title={value}>
|
||||
{value}
|
||||
</dd>
|
||||
</div>
|
||||
@@ -307,7 +292,7 @@ export function ServiceInspector({
|
||||
{(service.replicas ?? 0) > 0 && (
|
||||
<div className="flex items-baseline gap-2">
|
||||
<dt className="text-[10px] text-[var(--text-muted)] w-[70px] flex-shrink-0">Replicas</dt>
|
||||
<dd className="mono text-[10.5px] text-[var(--text-secondary)]">{service.replicas}</dd>
|
||||
<dd className="v-mono text-[10.5px] text-[var(--text-secondary)]">{service.replicas}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
@@ -318,7 +303,7 @@ export function ServiceInspector({
|
||||
{connections.length === 0 ? (
|
||||
<p className="text-[11px] text-[var(--text-muted)]">
|
||||
No inferred connections. Reference another service via{' '}
|
||||
<span className="mono">{'{{variable}}'}</span> placeholders in env vars.
|
||||
<span className="v-mono">{'{{variable}}'}</span> placeholders in env vars.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
@@ -327,13 +312,13 @@ export function ServiceInspector({
|
||||
key={conn.id}
|
||||
type="button"
|
||||
onClick={() => onSelectPeer(conn.peerId)}
|
||||
className="w-full text-left px-2.5 py-2 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]/40 hover:bg-[var(--surface-muted)] transition-colors"
|
||||
className="s-inset !rounded-[6px] w-full text-left px-2.5 py-2 hover:border-[var(--border-default)] transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-[var(--text-primary)]">
|
||||
<Link2 size={10} className="text-[var(--accent-primary)] flex-shrink-0" />
|
||||
<span className="truncate font-medium">{conn.outbound ? `→ ${conn.peer}` : `← ${conn.peer}`}</span>
|
||||
</div>
|
||||
<p className="mono text-[9.5px] text-[var(--text-tertiary)] truncate mt-0.5 pl-4">
|
||||
<p className="v-mono text-[9.5px] text-[var(--text-tertiary)] truncate mt-0.5 pl-4">
|
||||
{conn.reasons.join(' · ')}
|
||||
</p>
|
||||
</button>
|
||||
@@ -350,65 +335,58 @@ export function ServiceInspector({
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[var(--border-subtle)] px-4 py-3 space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
<GhostBtn
|
||||
onClick={() => onOpenService(service.id)}
|
||||
className="w-full flex items-center justify-center gap-2 h-9 rounded-[var(--radius-md)] text-[var(--accent-on)] text-[13px] font-medium"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
className="w-full justify-center !h-9"
|
||||
>
|
||||
<ArrowUpRight size={14} />
|
||||
Open service
|
||||
</button>
|
||||
</GhostBtn>
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
<QuietBtn
|
||||
disabled={actions.pending || readOnly}
|
||||
onClick={actions.deploy}
|
||||
className="flex items-center justify-center gap-1 h-8 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors disabled:opacity-40"
|
||||
className="justify-center"
|
||||
>
|
||||
{actions.deployPending ? <Loader2 size={11} className="animate-spin" /> : <Rocket size={11} />}
|
||||
Deploy
|
||||
</button>
|
||||
</QuietBtn>
|
||||
{service.status !== 'running' ? (
|
||||
<button
|
||||
type="button"
|
||||
<QuietBtn
|
||||
disabled={actions.pending || readOnly}
|
||||
onClick={actions.start}
|
||||
className="flex items-center justify-center gap-1 h-8 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors disabled:opacity-40"
|
||||
className="justify-center"
|
||||
>
|
||||
<Play size={11} />
|
||||
Start
|
||||
</button>
|
||||
</QuietBtn>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
<QuietBtn
|
||||
disabled={actions.pending || readOnly}
|
||||
onClick={actions.restart}
|
||||
className="flex items-center justify-center gap-1 h-8 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors disabled:opacity-40"
|
||||
className="justify-center"
|
||||
>
|
||||
{actions.restartPending ? <Loader2 size={11} className="animate-spin" /> : <Play size={11} />}
|
||||
Restart
|
||||
</button>
|
||||
</QuietBtn>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
<QuietBtn
|
||||
disabled={actions.pending || readOnly || service.status !== 'running'}
|
||||
onClick={actions.stop}
|
||||
className="flex items-center justify-center gap-1 h-8 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors disabled:opacity-40"
|
||||
className="justify-center"
|
||||
>
|
||||
<Square size={11} />
|
||||
Stop
|
||||
</button>
|
||||
</QuietBtn>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
<QuietBtn
|
||||
disabled={actions.pending || readOnly}
|
||||
onClick={actions.remove}
|
||||
className="w-full flex items-center justify-center gap-1.5 h-8 rounded-[var(--radius-sm)] text-[11px] font-medium text-[var(--error)] hover:bg-[var(--error-soft)] transition-colors disabled:opacity-40"
|
||||
className="w-full justify-center !text-[var(--error)] hover:!border-[var(--error)]/50"
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
Delete service
|
||||
</button>
|
||||
</QuietBtn>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Handle, Position, type Node, type NodeProps } from '@xyflow/react';
|
||||
import type { ServiceEntity } from '@/lib/api-client';
|
||||
import { serviceStatusClass } from '@/lib/api-client';
|
||||
import { Box, ExternalLink, Copy, Layers, Loader2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { serviceIcon, serviceAccent } from './service-visuals';
|
||||
import { ServiceIcon } from './ServiceIcon';
|
||||
import { SPill } from '@/shared/components/sentry';
|
||||
import { statusTone } from '@/shared/components/sentry-utils';
|
||||
|
||||
export type ServiceNodeData = {
|
||||
service: ServiceEntity;
|
||||
@@ -65,10 +66,10 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`w-full rounded-[var(--radius-lg)] border transition-[border-color,box-shadow,transform] duration-150 group relative overflow-hidden ${
|
||||
className={`w-full rounded-[10px] border transition-[border-color,box-shadow] duration-150 group relative overflow-hidden ${
|
||||
selected
|
||||
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] shadow-[0_0_0_1px_var(--accent-primary),0_12px_36px_rgba(0,0,0,0.5)]'
|
||||
: 'border-[var(--border-subtle)] bg-[var(--surface-card)] hover:border-[var(--border-default)] hover:shadow-[0_12px_32px_rgba(0,0,0,0.45)] hover:-translate-y-px'
|
||||
? 'border-[var(--accent-primary)] bg-[var(--surface-card)] shadow-[0_0_0_1px_var(--accent-primary),0_12px_36px_rgba(0,0,0,0.5)]'
|
||||
: 'border-[var(--border-subtle)] bg-[var(--surface-card)] hover:border-[var(--border-default)]'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
@@ -86,39 +87,39 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
|
||||
style={{ background: stateColor }}
|
||||
/>
|
||||
|
||||
<div className="relative px-3.5 pt-3.5 pb-3">
|
||||
<div className="flex items-center gap-3 mb-2.5">
|
||||
<div className="relative">
|
||||
<div className="flex items-center gap-2.5 px-3.5 pt-3 pb-2.5">
|
||||
<div
|
||||
className="w-9 h-9 rounded-[var(--radius-md)] flex items-center justify-center flex-shrink-0 transition-all duration-200"
|
||||
className="w-8 h-8 rounded-[7px] border border-[var(--border-subtle)] flex items-center justify-center flex-shrink-0 transition-all duration-200"
|
||||
style={{
|
||||
background: selected && !brand ? typeColor : `${typeColor}1c`,
|
||||
background: selected && !brand ? typeColor : `${typeColor}14`,
|
||||
color: selected && !brand ? 'var(--bg-void)' : typeColor,
|
||||
}}
|
||||
>
|
||||
{isTransient ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<ServiceIcon service={service} size={17} />
|
||||
<ServiceIcon service={service} size={15} />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="font-semibold text-[13px] leading-tight text-[var(--text-primary)] truncate tracking-tight">
|
||||
<h4 className="font-semibold text-[12.5px] leading-tight text-[var(--text-primary)] truncate tracking-tight">
|
||||
{service.name}
|
||||
</h4>
|
||||
<p className="text-[10.5px] text-[var(--text-tertiary)] truncate mt-0.5">
|
||||
<p className="v-mono text-[10px] text-[var(--text-tertiary)] truncate mt-0.5">
|
||||
{service.type}
|
||||
{service.environment && service.environment !== 'production' ? ` · ${service.environment}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
{replicas > 1 && (
|
||||
<span className="flex items-center gap-1 px-1.5 py-0.5 rounded bg-[var(--surface-muted)] text-[10px] font-semibold text-[var(--text-secondary)]">
|
||||
<span className="s-chip">
|
||||
<Layers size={9} />
|
||||
{replicas}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mb-2.5 px-2.5 py-1.5 rounded-[var(--radius-sm)] bg-[var(--surface-muted)]/60 border border-[var(--border-subtle)]/60 group/addr">
|
||||
<div className="mx-3 mb-2.5 s-inset !rounded-[6px] flex items-center gap-2 px-2.5 py-1.5 group/addr">
|
||||
<ExternalLink size={10} className="text-[var(--text-tertiary)] flex-shrink-0" />
|
||||
{publicUrl ? (
|
||||
<a
|
||||
@@ -126,12 +127,12 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-[10.5px] text-[var(--accent-primary)] truncate mono hover:underline"
|
||||
className="v-mono text-[10.5px] text-[var(--accent-primary)] truncate hover:underline"
|
||||
>
|
||||
{publicUrl.replace(/^https?:\/\//, '')}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-[10.5px] text-[var(--text-secondary)] truncate mono" title="Internal network address">
|
||||
<span className="v-mono text-[10.5px] text-[var(--text-secondary)] truncate" title="Internal network address">
|
||||
{internalAddr}
|
||||
</span>
|
||||
)}
|
||||
@@ -148,15 +149,10 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`status-dot ${serviceStatusClass(service.status)}`} />
|
||||
<span className="text-[10px] font-semibold tracking-[0.08em] uppercase" style={{ color: stateColor }}>
|
||||
{statusLabel(service.status)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-3.5 py-2 border-t border-[var(--border-subtle)]">
|
||||
<SPill tone={statusTone(service.status)}>{statusLabel(service.status)}</SPill>
|
||||
{service.image && (
|
||||
<span className="text-[9.5px] text-[var(--text-muted)] truncate mono max-w-[110px]" title={service.image}>
|
||||
<span className="v-mono text-[9.5px] text-[var(--text-muted)] truncate max-w-[110px]" title={service.image}>
|
||||
{service.image}
|
||||
</span>
|
||||
)}
|
||||
@@ -191,25 +187,25 @@ export function GroupNode({ id, data }: NodeProps<GroupNodeType>) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-full w-full rounded-[var(--radius-xl)] border-2 border-dashed transition-all duration-200 relative"
|
||||
className="h-full w-full rounded-[12px] border border-dashed transition-all duration-200 relative"
|
||||
style={{
|
||||
borderColor: isHovered ? 'var(--border-strong)' : 'var(--border-default)',
|
||||
background: isHovered ? 'var(--accent-primary-soft)' : 'rgba(255,255,255,0.02)',
|
||||
background: isHovered ? 'var(--accent-primary-soft)' : 'var(--tint-03)',
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<div className="relative p-4">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div
|
||||
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors duration-200"
|
||||
<span
|
||||
className="s-ibox !h-7 !w-7 transition-colors duration-200"
|
||||
style={{
|
||||
background: isHovered ? 'var(--accent-primary-soft)' : 'var(--surface-card)',
|
||||
color: isHovered ? 'var(--accent-primary)' : 'var(--text-tertiary)',
|
||||
background: isHovered ? 'var(--accent-primary-soft)' : undefined,
|
||||
color: isHovered ? 'var(--accent-primary)' : undefined,
|
||||
}}
|
||||
>
|
||||
<Box size={14} />
|
||||
</div>
|
||||
<Box />
|
||||
</span>
|
||||
{editing ? (
|
||||
<input
|
||||
autoFocus
|
||||
@@ -227,7 +223,7 @@ export function GroupNode({ id, data }: NodeProps<GroupNodeType>) {
|
||||
/>
|
||||
) : (
|
||||
<h3
|
||||
className="text-sm font-semibold text-[var(--text-primary)] tracking-tight"
|
||||
className="font-headline text-sm font-semibold text-[var(--text-primary)] tracking-tight"
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDraft(data.title);
|
||||
@@ -241,7 +237,7 @@ export function GroupNode({ id, data }: NodeProps<GroupNodeType>) {
|
||||
</div>
|
||||
|
||||
<p
|
||||
className="text-[10px] text-[var(--text-tertiary)] mt-2 transition-opacity duration-200"
|
||||
className="v-mono text-[10px] uppercase tracking-[0.12em] text-[var(--text-tertiary)] mt-2 transition-opacity duration-200"
|
||||
style={{ opacity: isHovered ? 1 : 0 }}
|
||||
>
|
||||
Drag services here to group them
|
||||
|
||||
@@ -18,15 +18,15 @@ export function serviceTypeIcon(type: string, size = 18) {
|
||||
export function serviceTypeColor(type: string): string {
|
||||
switch (type) {
|
||||
case 'web':
|
||||
return '#7ab8ff';
|
||||
return 'var(--info)';
|
||||
case 'database':
|
||||
return '#b4e34a';
|
||||
return '#38bdf8';
|
||||
case 'worker':
|
||||
return '#f2c94c';
|
||||
return 'var(--warning)';
|
||||
case 'cron':
|
||||
return '#f2994a';
|
||||
return 'var(--accent-secondary)';
|
||||
default:
|
||||
return '#9295a4';
|
||||
return 'var(--text-tertiary)';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,306 +1,198 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
LineMetricChart,
|
||||
DualLineChart,
|
||||
DonutChart,
|
||||
} from '@/shared/components';
|
||||
|
||||
interface MetricData {
|
||||
cpu: number[];
|
||||
ram: number;
|
||||
ramUsed: string;
|
||||
cache: number;
|
||||
cacheBreakdown: { cache: number; nonCache: number; total: number };
|
||||
users: number[];
|
||||
performance: number[];
|
||||
performanceAlt: number[];
|
||||
upSpeed: number;
|
||||
downSpeed: number;
|
||||
}
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useQueries } from '@tanstack/react-query';
|
||||
import { Cpu, HardDrive, ArrowDownUp, Boxes } from 'lucide-react';
|
||||
import { LineMetricChart, DonutChart } from '@/shared/components';
|
||||
import { getServiceMetrics, type ServiceMetrics } from '@/lib/api-client';
|
||||
import { formatBytes } from '@/lib/time';
|
||||
|
||||
interface MetricsDashboardProps {
|
||||
projectId?: string;
|
||||
isRunning?: boolean;
|
||||
onStop?: () => void;
|
||||
onRestart?: () => void;
|
||||
services: { id: string; name: string; status: string }[];
|
||||
isDemoMode?: boolean;
|
||||
}
|
||||
|
||||
function getRandom(min: number, max: number) {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
function healthLabel(pct: number): { text: string; color: string } {
|
||||
if (pct < 50) return { text: 'Good', color: 'var(--success)' };
|
||||
if (pct < 80) return { text: 'Average', color: 'var(--warning)' };
|
||||
return { text: 'High', color: 'var(--error)' };
|
||||
}
|
||||
|
||||
function generateInitialData(): MetricData {
|
||||
return {
|
||||
cpu: Array.from({ length: 24 }, () => getRandom(10, 45)),
|
||||
ram: getRandom(50, 85),
|
||||
ramUsed: '5.4 GB',
|
||||
cache: 352,
|
||||
cacheBreakdown: { cache: 212, nonCache: 85.5, total: 1750 },
|
||||
users: Array.from({ length: 15 }, () => getRandom(40, 110)),
|
||||
performance: Array.from({ length: 12 }, () => getRandom(70, 95)),
|
||||
performanceAlt: Array.from({ length: 12 }, () => getRandom(60, 85)),
|
||||
upSpeed: 10.4,
|
||||
downSpeed: 5.2,
|
||||
};
|
||||
}
|
||||
|
||||
export function MetricsDashboard({
|
||||
isRunning = true,
|
||||
}: MetricsDashboardProps) {
|
||||
const [data, setData] = useState<MetricData>(generateInitialData);
|
||||
const [timeRange, setTimeRange] = useState<'day' | 'month' | 'year'>('day');
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRunning) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setData((prev) => {
|
||||
const newCpu = [...prev.cpu.slice(1), getRandom(10, 45)];
|
||||
const newUsers = [...prev.users.slice(1), getRandom(60, 110)];
|
||||
const newPerf = [...prev.performance.slice(1), getRandom(82, 99)];
|
||||
const newPerfAlt = [...prev.performanceAlt.slice(1), getRandom(60, 85)];
|
||||
|
||||
return {
|
||||
...prev,
|
||||
cpu: newCpu,
|
||||
ram: getRandom(50, 85),
|
||||
ramUsed: `${(8 * (prev.ram / 100)).toFixed(1)} GB`,
|
||||
users: newUsers,
|
||||
performance: newPerf,
|
||||
performanceAlt: newPerfAlt,
|
||||
upSpeed: parseFloat((Math.random() * 5 + 8).toFixed(1)),
|
||||
downSpeed: parseFloat((Math.random() * 3 + 4).toFixed(1)),
|
||||
};
|
||||
});
|
||||
}, 2000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isRunning]);
|
||||
|
||||
const currentCpu = data.cpu[data.cpu.length - 1];
|
||||
const currentUsers = data.users[data.users.length - 1];
|
||||
const currentPerf = data.performance[data.performance.length - 1];
|
||||
|
||||
function Tile({ icon: Icon, label, children }: {
|
||||
icon: React.ElementType;
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
{/* Metrics Header - self.html exact match */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '16px' }}>
|
||||
<span style={{ fontSize: '15px', fontWeight: 700, color: '#e8e9f0' }}>Metrics</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<button style={{
|
||||
height: '32px',
|
||||
padding: '0 12px',
|
||||
borderRadius: '9px',
|
||||
border: '1px solid rgba(255,255,255,0.09)',
|
||||
background: 'rgba(255,255,255,0.04)',
|
||||
color: '#9295a4',
|
||||
fontSize: '12.5px',
|
||||
fontWeight: 500,
|
||||
fontFamily: 'inherit',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
}}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" />
|
||||
</svg>
|
||||
Filter
|
||||
</button>
|
||||
<div className="pill-group">
|
||||
{(['day', 'month', 'year'] as const).map((range) => (
|
||||
<div
|
||||
key={range}
|
||||
className={`pill ${timeRange === range ? 'active' : ''}`}
|
||||
onClick={() => setTimeRange(range)}
|
||||
>
|
||||
{range.charAt(0).toUpperCase() + range.slice(1)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 1: CPU, RAM, Cache - self.html exact: 13px gap */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1.95fr', gap: '13px', marginBottom: '13px' }}>
|
||||
{/* CPU Card */}
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '12px' }}>
|
||||
<div className="card-icon"><svg viewBox="0 0 24 24"><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><line x1="9" y1="1" x2="9" y2="4"/><line x1="15" y1="1" x2="15" y2="4"/><line x1="9" y1="20" x2="9" y2="23"/><line x1="15" y1="20" x2="15" y2="23"/><line x1="20" y1="9" x2="23" y2="9"/><line x1="20" y1="14" x2="23" y2="14"/><line x1="1" y1="9" x2="4" y2="9"/><line x1="1" y1="14" x2="4" y2="14"/></svg></div>
|
||||
<span style={{ fontSize: '14px', fontWeight: 600, color: '#e8e9f0' }}>CPU Usage</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '38px', fontWeight: 900, letterSpacing: '-1.5px', lineHeight: 1, color: '#e8e9f0' }}>{currentCpu}%</div>
|
||||
<div style={{ fontSize: '12px', color: '#6b6e7d', marginTop: '4px' }}>
|
||||
<span style={{ color: currentCpu < 50 ? '#5ee6a0' : currentCpu < 75 ? '#f2c94c' : '#b4e34a', fontWeight: 700 }}>
|
||||
{currentCpu < 50 ? 'Good' : currentCpu < 75 ? 'Average' : 'High'}
|
||||
</span>{' '}
|
||||
Daily usage
|
||||
</div>
|
||||
<div style={{ height: 76, margin: '12px 0 6px' }}>
|
||||
<LineMetricChart data={data.cpu} color="#b4e34a" height={76} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingTop: '4px' }}>
|
||||
<span style={{ fontSize: '13px', color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}>Details</span>
|
||||
<div className="arrow-btn">
|
||||
<svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RAM Card */}
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '12px' }}>
|
||||
<div className="card-icon"><svg viewBox="0 0 24 24"><rect x="2" y="8" width="20" height="8" rx="2"/><path d="M6 8V6M10 8V6M14 8V6M18 8V6M6 16v2M18 16v2"/></svg></div>
|
||||
<span style={{ fontSize: '14px', fontWeight: 600, color: '#e8e9f0' }}>RAM Usage</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '38px', fontWeight: 900, letterSpacing: '-1.5px', lineHeight: 1, color: '#e8e9f0' }}>{data.ram}%</div>
|
||||
<div style={{ fontSize: '12px', color: '#6b6e7d', marginTop: '4px' }}>
|
||||
<span style={{ color: data.ram < 60 ? '#5ee6a0' : '#f2c94c', fontWeight: 700 }}>
|
||||
{data.ram < 60 ? 'Good' : 'Average'}
|
||||
</span>{' '}
|
||||
Daily usage
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', margin: '10px 0 4px', position: 'relative' }}>
|
||||
<DonutChart percent={data.ram} color="#f2c94c" size={160} thickness={16} />
|
||||
<div style={{ position: 'absolute', bottom: 14, textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '10.5px', color: '#6b6e7d', marginBottom: 1 }}>Used</div>
|
||||
<div style={{ fontSize: '12.5px', fontWeight: 700, color: '#e8e9f0' }}>{data.ramUsed} / 8GB</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingTop: '4px' }}>
|
||||
<span style={{ fontSize: '13px', color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}>Details</span>
|
||||
<div className="arrow-btn">
|
||||
<svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cache Card */}
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '12px' }}>
|
||||
<div className="card-icon"><svg viewBox="0 0 24 24"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg></div>
|
||||
<span style={{ fontSize: '14px', fontWeight: 600, color: '#e8e9f0' }}>Cache</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '38px', fontWeight: 900, letterSpacing: '-1.5px', lineHeight: 1, color: '#e8e9f0' }}>{data.cache} MB</div>
|
||||
<div style={{ fontSize: '12px', color: '#6b6e7d', marginTop: '4px' }}>
|
||||
<span style={{ color: '#f2c94c', fontWeight: 700 }}>220MB Average</span>{' '}
|
||||
cached images and files
|
||||
</div>
|
||||
|
||||
{/* Segmented Bar */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '5px', margin: '16px 0 15px', height: '32px' }}>
|
||||
<div className="cache-seg" style={{ width: '43%', background: '#ff6b5b', borderRadius: '10px 4px 4px 10px' }} />
|
||||
<div className="cache-seg" style={{ width: '13%', background: '#8c6ef0', borderRadius: '5px' }} />
|
||||
<div className="cache-seg" style={{ flex: 1, background: 'rgba(255,255,255,0.07)', borderRadius: '4px 10px 10px 4px' }} />
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto 1fr auto 1fr', gap: 0, alignItems: 'stretch' }}>
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '5px', fontSize: '11px', color: '#6b6e7d', marginBottom: '5px' }}>
|
||||
<div className="stat-dot" style={{ background: '#ff6b5b' }} />
|
||||
Cache
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: '4px' }}>
|
||||
<span style={{ fontSize: '15px', fontWeight: 800, color: '#e8e9f0' }}>212 MB</span>
|
||||
<span style={{ fontSize: '11px', color: '#6b6e7d' }}>12%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ width: '1px', background: 'rgba(255,255,255,0.08)', margin: '0 16px' }} />
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '5px', fontSize: '11px', color: '#6b6e7d', marginBottom: '5px' }}>
|
||||
<div className="stat-dot" style={{ background: '#8c6ef0' }} />
|
||||
Non-Cache
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: '4px' }}>
|
||||
<span style={{ fontSize: '15px', fontWeight: 800, color: '#e8e9f0' }}>85.5 MB</span>
|
||||
<span style={{ fontSize: '11px', color: '#6b6e7d' }}>4%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ width: '1px', background: 'rgba(255,255,255,0.08)', margin: '0 16px' }} />
|
||||
<div>
|
||||
<div style={{ fontSize: '11px', color: '#6b6e7d', marginBottom: '5px' }}>Total</div>
|
||||
<div style={{ fontSize: '15px', fontWeight: 800, color: '#e8e9f0' }}>1.75 GB</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingTop: '16px' }}>
|
||||
<span style={{ fontSize: '13px', color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}>Details</span>
|
||||
<div className="arrow-btn">
|
||||
<svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Active Users, Performance - self.html exact: 13px gap */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '13px' }}>
|
||||
{/* Active Users - horizontal layout */}
|
||||
<div className="card" style={{ flexDirection: 'row', padding: 0, overflow: 'hidden' }}>
|
||||
<div style={{ flex: 1, padding: '20px 18px 18px 20px', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '12px' }}>
|
||||
<div className="card-icon"><svg viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/></svg></div>
|
||||
<span style={{ fontSize: '14px', fontWeight: 600, color: '#e8e9f0' }}>Active User</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '36px', fontWeight: 900, letterSpacing: '-1.5px', lineHeight: 1, color: '#e8e9f0' }}>{currentUsers} K</div>
|
||||
<div style={{ fontSize: '12px', color: '#6b6e7d', marginTop: '4px' }}>User active right now</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '5px', marginTop: '12px', flexWrap: 'wrap' }}>
|
||||
{['🇨🇳', '🇮🇩', '🇲🇲', '🇲🇾', '🇯🇵', '🇮🇳', '🇰🇷', '🇵🇭'].map((flag, i) => (
|
||||
<span key={i} className="flag">{flag}</span>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: 'auto', paddingTop: '14px' }}>
|
||||
<span style={{ fontSize: '13px', color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}>Details</span>
|
||||
<div className="arrow-btn">
|
||||
<svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ width: '50%', padding: '16px 14px 46px 0', display: 'flex', alignItems: 'flex-end' }}>
|
||||
<LineMetricChart data={data.users} color="var(--accent-primary)" fillOpacity={0.15} showArea height={130} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Performance Card */}
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '12px' }}>
|
||||
<div className="card-icon"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></div>
|
||||
<span style={{ fontSize: '14px', fontWeight: 600, color: '#e8e9f0' }}>Performance</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '16px', flex: 1 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: '36px', fontWeight: 900, letterSpacing: '-1.5px', lineHeight: 1, color: '#e8e9f0' }}>{currentPerf}%</div>
|
||||
<div style={{ fontSize: '12px', color: '#6b6e7d', marginTop: '4px' }}>
|
||||
<span style={{ color: currentPerf > 85 ? '#5ee6a0' : '#f2c94c', fontWeight: 700 }}>
|
||||
{currentPerf > 85 ? 'Good' : 'Average'}
|
||||
</span>{' '}
|
||||
Last scan
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: '8px' }}>
|
||||
<div style={{ width: 134, height: 58 }}>
|
||||
<DualLineChart data1={data.performance} data2={data.performanceAlt} height={58} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px', alignItems: 'flex-end' }}>
|
||||
<div className="speed-row" style={{ color: '#7ab8ff' }}>
|
||||
<svg viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 19 19 12"/></svg>
|
||||
<span>{data.upSpeed}</span> Mbps
|
||||
</div>
|
||||
<div className="speed-row" style={{ color: 'var(--accent-primary)' }}>
|
||||
<svg viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 5 5 12"/></svg>
|
||||
<span>{data.downSpeed}</span> Mbps
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingTop: '14px' }}>
|
||||
<span style={{ fontSize: '13px', color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}>Check Speed</span>
|
||||
<div className="arrow-btn">
|
||||
<svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="s-card !p-0 overflow-hidden">
|
||||
<div className="s-cardhead">
|
||||
<span className="s-ibox"><Icon /></span>
|
||||
<span className="s-t">{label}</span>
|
||||
</div>
|
||||
<div className="px-4 pb-4 pt-3">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MetricsDashboard({ services, isDemoMode = false }: MetricsDashboardProps) {
|
||||
const liveServices = services.filter((s) => s.status === 'running');
|
||||
|
||||
const metricsQueries = useQueries({
|
||||
queries: liveServices.map((service) => ({
|
||||
queryKey: ['service-metrics', service.id],
|
||||
queryFn: () => getServiceMetrics(service.id),
|
||||
enabled: !isDemoMode,
|
||||
refetchInterval: 5000,
|
||||
retry: false,
|
||||
})),
|
||||
});
|
||||
|
||||
const samples = metricsQueries
|
||||
.map((q) => q.data)
|
||||
.filter((m): m is ServiceMetrics => Boolean(m) && m!.status === 'ok');
|
||||
|
||||
const totalCpu = samples.reduce((sum, m) => sum + m.cpu_percent, 0);
|
||||
const avgCpu = samples.length > 0 ? totalCpu / samples.length : 0;
|
||||
const memUsed = samples.reduce((sum, m) => sum + m.memory_usage_bytes, 0);
|
||||
const memLimit = samples.reduce((sum, m) => sum + m.memory_limit_bytes, 0);
|
||||
const memPct = memLimit > 0 ? (memUsed / memLimit) * 100 : 0;
|
||||
const netRx = samples.reduce((sum, m) => sum + m.network_rx_bytes, 0);
|
||||
const netTx = samples.reduce((sum, m) => sum + m.network_tx_bytes, 0);
|
||||
const instanceCount = samples.reduce((sum, m) => sum + m.instances.length, 0);
|
||||
|
||||
// Rolling history for the sparklines — accumulated from real samples.
|
||||
const [cpuHistory, setCpuHistory] = useState<number[]>([]);
|
||||
const [netHistory, setNetHistory] = useState<{ rx: number[]; tx: number[] }>({ rx: [], tx: [] });
|
||||
const lastNetRef = useRef<{ rx: number; tx: number; at: number } | null>(null);
|
||||
|
||||
const samplesKey = samples.map((s) => `${s.service_id}:${s.collected_at}`).join('|');
|
||||
const avgCpuRef = avgCpu;
|
||||
const netRxRef = netRx;
|
||||
const netTxRef = netTx;
|
||||
useEffect(() => {
|
||||
if (!samplesKey) return;
|
||||
const now = Date.now();
|
||||
const prev = lastNetRef.current;
|
||||
const elapsed = prev ? Math.max(1, (now - prev.at) / 1000) : 0;
|
||||
const rxRate = prev && elapsed > 0 ? Math.max(0, (netRxRef - prev.rx) / elapsed) : 0;
|
||||
const txRate = prev && elapsed > 0 ? Math.max(0, (netTxRef - prev.tx) / elapsed) : 0;
|
||||
lastNetRef.current = { rx: netRxRef, tx: netTxRef, at: now };
|
||||
setCpuHistory((h) => [...h, Math.min(100, avgCpuRef)].slice(-24));
|
||||
setNetHistory((h) => ({ rx: [...h.rx, rxRate].slice(-24), tx: [...h.tx, txRate].slice(-24) }));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the samples' collected_at signature
|
||||
}, [samplesKey]);
|
||||
|
||||
const cpuHealth = healthLabel(avgCpu);
|
||||
const memHealth = healthLabel(memPct);
|
||||
const reporting = samples.length;
|
||||
const noData = !isDemoMode && liveServices.length > 0 && reporting === 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="v-mono text-[10.5px] uppercase tracking-[0.1em] text-[var(--text-tertiary)]">Metrics</span>
|
||||
<span className="v-mono text-[10.5px] text-[var(--text-muted)]">
|
||||
{isDemoMode
|
||||
? 'sample data'
|
||||
: reporting > 0
|
||||
? `${reporting} service${reporting === 1 ? '' : 's'} reporting · every 5s`
|
||||
: 'no running services'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{/* CPU */}
|
||||
<Tile icon={Cpu} label="CPU Usage">
|
||||
<div className="font-headline text-[30px] font-bold leading-none tracking-[-0.02em] text-[var(--text-primary)]">
|
||||
{avgCpu.toFixed(1)}<span className="text-[13px] font-medium text-[var(--text-tertiary)]">%</span>
|
||||
</div>
|
||||
<div className="mt-1.5 v-mono text-[10.5px] text-[var(--text-tertiary)]">
|
||||
<span style={{ color: cpuHealth.color }}>{cpuHealth.text}</span>
|
||||
{' '}· avg across running services
|
||||
</div>
|
||||
<div style={{ height: 56, marginTop: 12 }}>
|
||||
{cpuHistory.length > 1 ? (
|
||||
<LineMetricChart data={cpuHistory} color="var(--accent-primary)" fillOpacity={0.15} showArea height={56} />
|
||||
) : (
|
||||
<div className="h-full rounded bg-[var(--surface-muted)] opacity-40" />
|
||||
)}
|
||||
</div>
|
||||
</Tile>
|
||||
|
||||
{/* Memory */}
|
||||
<Tile icon={HardDrive} label="Memory">
|
||||
<div className="font-headline text-[30px] font-bold leading-none tracking-[-0.02em] text-[var(--text-primary)]">
|
||||
{memPct.toFixed(0)}<span className="text-[13px] font-medium text-[var(--text-tertiary)]">%</span>
|
||||
</div>
|
||||
<div className="mt-1.5 v-mono text-[10.5px] text-[var(--text-tertiary)]">
|
||||
<span style={{ color: memHealth.color }}>{memHealth.text}</span>
|
||||
{' '}· {formatBytes(memUsed)} used
|
||||
</div>
|
||||
<div className="mt-2 flex justify-center">
|
||||
<DonutChart percentage={memPct} size={100} thickness={11} />
|
||||
</div>
|
||||
<div className="mt-1 text-center v-mono text-[10.5px] text-[var(--text-tertiary)]">
|
||||
{formatBytes(memUsed)} / {memLimit > 0 ? formatBytes(memLimit) : '∞'}
|
||||
</div>
|
||||
</Tile>
|
||||
|
||||
{/* Network */}
|
||||
<Tile icon={ArrowDownUp} label="Network">
|
||||
<div className="font-headline text-[30px] font-bold leading-none tracking-[-0.02em] text-[var(--text-primary)]">
|
||||
{formatBytes(netRx)}
|
||||
</div>
|
||||
<div className="mt-1.5 v-mono text-[10.5px] text-[var(--text-tertiary)]">
|
||||
<span className="text-[var(--accent-primary)]">RX</span>
|
||||
{' '}total · TX {formatBytes(netTx)}
|
||||
</div>
|
||||
<div style={{ height: 56, marginTop: 12 }}>
|
||||
{netHistory.rx.length > 1 ? (
|
||||
<LineMetricChart data={netHistory.rx} color="var(--accent-primary)" fillOpacity={0.12} showArea height={56} />
|
||||
) : (
|
||||
<div className="h-full rounded bg-[var(--surface-muted)] opacity-40" />
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 v-mono text-[10.5px] text-[var(--text-muted)]">
|
||||
{netHistory.rx.length > 0
|
||||
? `↓ ${formatBytes(netHistory.rx[netHistory.rx.length - 1])}/s · ↑ ${formatBytes(netHistory.tx[netHistory.tx.length - 1])}/s`
|
||||
: 'rate accumulating…'}
|
||||
</div>
|
||||
</Tile>
|
||||
|
||||
{/* Instances */}
|
||||
<Tile icon={Boxes} label="Instances">
|
||||
<div className="font-headline text-[30px] font-bold leading-none tracking-[-0.02em] text-[var(--text-primary)]">
|
||||
{isDemoMode ? liveServices.length : instanceCount}
|
||||
</div>
|
||||
<div className="mt-1.5 v-mono text-[10.5px] text-[var(--text-tertiary)]">
|
||||
<span className="text-[var(--success)]">Running</span>
|
||||
{' '}across {liveServices.length} service{liveServices.length === 1 ? '' : 's'}
|
||||
</div>
|
||||
<ul className="mt-3 space-y-1.5">
|
||||
{liveServices.slice(0, 4).map((service, i) => {
|
||||
const m = samples.find((s) => s.service_id === service.id);
|
||||
return (
|
||||
<li key={service.id} className="flex items-center justify-between text-[11.5px]">
|
||||
<span className="truncate text-[var(--text-secondary)]">
|
||||
<span className="mr-1.5 inline-block h-1.5 w-1.5 rounded-full bg-[var(--success)] align-middle" />
|
||||
{service.name}
|
||||
</span>
|
||||
<span className="v-mono text-[10.5px] text-[var(--text-muted)]">
|
||||
{m ? `${m.cpu_percent.toFixed(1)}%` : i < 3 ? '—' : ''}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{liveServices.length > 4 && (
|
||||
<li className="v-mono text-[10.5px] text-[var(--text-muted)]">+{liveServices.length - 4} more</li>
|
||||
)}
|
||||
</ul>
|
||||
</Tile>
|
||||
</div>
|
||||
|
||||
{noData && (
|
||||
<p className="mt-3 v-mono text-[11px] text-[var(--text-muted)]">
|
||||
Metrics collector is not reporting yet — check that the node agent is online.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { listProjectVariables, updateProjectVariables } from '@/lib/api-client';
|
||||
import { Loader2, Plus, Save, Trash2, KeyRound } from 'lucide-react';
|
||||
import { QuietBtn } from '@/shared/components/sentry';
|
||||
|
||||
type Row = { key: string; value: string; isSecret: boolean };
|
||||
|
||||
@@ -42,26 +43,23 @@ export function SharedVariablesSection({ projectId }: { projectId: string }) {
|
||||
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-[var(--text-secondary)]">Shared Variables</h3>
|
||||
<h3 className="s-t">Shared Variables</h3>
|
||||
<p className="mt-1 text-xs text-[var(--text-muted)]">
|
||||
Reference from any service as{' '}
|
||||
<code className="mono text-[var(--accent-primary)]">{'${{shared.KEY}}'}</code>. Applied on next
|
||||
<code className="v-mono text-[var(--accent-primary)]">{'${{shared.KEY}}'}</code>. Applied on next
|
||||
deploy/redeploy — not live-updated.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setDrafts([...rows, { key: '', value: '', isSecret: false }])}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] transition-colors"
|
||||
>
|
||||
<QuietBtn onClick={() => setDrafts([...rows, { key: '', value: '', isSecret: false }])}>
|
||||
<Plus size={12} />
|
||||
Add
|
||||
</button>
|
||||
</QuietBtn>
|
||||
<button
|
||||
onClick={() => saveMutation.mutate(rows)}
|
||||
disabled={!dirty || saveMutation.isPending}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-[var(--radius-md)] text-xs font-medium text-[var(--accent-on)] disabled:opacity-50 transition-all"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
className="s-btn-accent disabled:opacity-50"
|
||||
|
||||
>
|
||||
{saveMutation.isPending ? <Loader2 size={12} className="animate-spin" /> : <Save size={12} />}
|
||||
Save
|
||||
@@ -78,12 +76,12 @@ export function SharedVariablesSection({ projectId }: { projectId: string }) {
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{rows.map((row, i) => (
|
||||
<div key={i} className="grid grid-cols-[1fr_1fr_auto_auto] gap-2 items-center">
|
||||
<div key={i} className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto_auto] gap-2 items-center">
|
||||
<input
|
||||
value={row.key}
|
||||
onChange={(e) => updateRow(i, { key: e.target.value })}
|
||||
placeholder="KEY"
|
||||
className="h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] mono text-xs text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all"
|
||||
className="h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] v-mono text-xs text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all"
|
||||
/>
|
||||
<input
|
||||
value={row.value}
|
||||
@@ -91,13 +89,13 @@ export function SharedVariablesSection({ projectId }: { projectId: string }) {
|
||||
type={row.isSecret ? 'password' : 'text'}
|
||||
placeholder="value"
|
||||
autoComplete="off"
|
||||
className="h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] mono text-xs text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all"
|
||||
className="h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] v-mono text-xs text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
title={row.isSecret ? 'Secret — masked on save' : 'Mark as secret'}
|
||||
onClick={() => updateRow(i, { isSecret: !row.isSecret })}
|
||||
className={`w-9 h-9 rounded-[var(--radius-md)] border flex items-center justify-center transition-colors ${
|
||||
className={`w-9 h-9 rounded-lg border flex items-center justify-center transition-colors ${
|
||||
row.isSecret
|
||||
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]'
|
||||
: 'border-[var(--border-subtle)] text-[var(--text-muted)] hover:border-[var(--border-default)]'
|
||||
@@ -108,7 +106,7 @@ export function SharedVariablesSection({ projectId }: { projectId: string }) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDrafts(rows.filter((_, idx) => idx !== i))}
|
||||
className="w-9 h-9 rounded-[var(--radius-md)] border border-[var(--border-subtle)] flex items-center justify-center text-[var(--text-muted)] hover:border-[var(--error)] hover:text-[var(--error)] transition-colors"
|
||||
className="w-9 h-9 rounded-lg border border-[var(--border-subtle)] flex items-center justify-center text-[var(--text-muted)] hover:border-[var(--error)] hover:text-[var(--error)] transition-colors"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
|
||||
@@ -50,7 +50,9 @@ import {
|
||||
GitBranch,
|
||||
Container,
|
||||
FileUp,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import { GhostBtn, QuietBtn } from '@/shared/components/sentry';
|
||||
|
||||
type WorkspaceView = 'canvas' | 'observability' | 'logs' | 'activity' | 'settings';
|
||||
|
||||
@@ -146,12 +148,10 @@ function ServiceCreateDialog(props: {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-[var(--bg-void)]/80 backdrop-blur-sm" onClick={props.onClose} />
|
||||
<div className="relative w-full max-w-lg panel p-6">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
|
||||
<Plus size={20} className="text-[var(--accent-primary)]" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-[var(--text-primary)]">Add Service</h3>
|
||||
<div className="relative w-full max-w-lg s-card !p-6">
|
||||
<div className="flex items-center gap-2.5 mb-1">
|
||||
<span className="s-ibox !w-9 !h-9"><Plus /></span>
|
||||
<h3 className="font-headline text-lg font-semibold text-[var(--text-primary)]">Add Service</h3>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
Deploy a new service to this project. Configure runtime settings after creation.
|
||||
@@ -202,7 +202,7 @@ function ServiceCreateDialog(props: {
|
||||
</div>
|
||||
|
||||
{isDatabase ? (
|
||||
<div className="space-y-3 p-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]/50">
|
||||
<div className="s-inset space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
|
||||
@@ -287,12 +287,12 @@ function ServiceCreateDialog(props: {
|
||||
<input
|
||||
value={form.image ?? ''}
|
||||
onChange={(e) => setForm((p) => ({ ...p, image: e.target.value }))}
|
||||
className="w-full h-11 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all mono text-sm"
|
||||
className="w-full h-11 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all v-mono text-sm"
|
||||
placeholder="ghcr.io/org/app:latest"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 p-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]/50">
|
||||
<div className="s-inset space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
|
||||
Provider
|
||||
@@ -326,7 +326,7 @@ function ServiceCreateDialog(props: {
|
||||
<input
|
||||
value={repoSearch}
|
||||
onChange={(e) => setRepoSearch(e.target.value)}
|
||||
className="w-full h-9 px-3 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm mono mb-2"
|
||||
className="w-full h-9 px-3 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm v-mono mb-2"
|
||||
placeholder="Search repositories..."
|
||||
/>
|
||||
{repositoriesQuery.isLoading ? (
|
||||
@@ -357,7 +357,7 @@ function ServiceCreateDialog(props: {
|
||||
: 'text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)]'
|
||||
}`}
|
||||
>
|
||||
<span className="mono text-xs truncate">{repo.full_name}</span>
|
||||
<span className="v-mono text-xs truncate">{repo.full_name}</span>
|
||||
<span className="text-[10px] text-[var(--text-muted)] ml-2 shrink-0">{repo.default_branch}</span>
|
||||
</button>
|
||||
))}
|
||||
@@ -380,7 +380,7 @@ function ServiceCreateDialog(props: {
|
||||
<select
|
||||
value={form.git_branch ?? ''}
|
||||
onChange={(e) => setForm((p) => ({ ...p, git_branch: e.target.value }))}
|
||||
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm mono"
|
||||
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm v-mono"
|
||||
>
|
||||
<option value="">Select branch</option>
|
||||
{branches.map((branch) => (
|
||||
@@ -391,7 +391,7 @@ function ServiceCreateDialog(props: {
|
||||
<input
|
||||
value={form.git_branch ?? ''}
|
||||
onChange={(e) => setForm((p) => ({ ...p, git_branch: e.target.value }))}
|
||||
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm mono"
|
||||
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm v-mono"
|
||||
placeholder={selectedRepo?.default_branch ?? 'main'}
|
||||
/>
|
||||
)}
|
||||
@@ -403,7 +403,7 @@ function ServiceCreateDialog(props: {
|
||||
<input
|
||||
value={form.build_path ?? ''}
|
||||
onChange={(e) => setForm((p) => ({ ...p, build_path: e.target.value }))}
|
||||
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm mono"
|
||||
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm v-mono"
|
||||
placeholder="/ (repo root)"
|
||||
/>
|
||||
</div>
|
||||
@@ -419,7 +419,7 @@ function ServiceCreateDialog(props: {
|
||||
<input
|
||||
value={form.command ?? ''}
|
||||
onChange={(e) => setForm((p) => ({ ...p, command: e.target.value }))}
|
||||
className="w-full h-11 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all mono text-sm"
|
||||
className="w-full h-11 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all v-mono text-sm"
|
||||
placeholder="npm run start"
|
||||
/>
|
||||
</div>
|
||||
@@ -434,7 +434,7 @@ function ServiceCreateDialog(props: {
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={props.onClose}
|
||||
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-[var(--text-secondary)] text-sm font-medium hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors"
|
||||
className="s-btn-quiet"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@@ -454,8 +454,8 @@ function ServiceCreateDialog(props: {
|
||||
database: isDatabase ? { engine: dbEngine, plan: dbPlan } : undefined,
|
||||
})
|
||||
}
|
||||
className="px-5 py-2 rounded-[var(--radius-md)] text-[var(--accent-on)] text-sm font-medium shadow-lg disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
className="s-btn-accent disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
|
||||
>
|
||||
{props.loading ? 'Creating...' : isDatabase ? 'Create Database' : 'Create Service'}
|
||||
</button>
|
||||
@@ -689,17 +689,17 @@ export function ProjectWorkspacePage() {
|
||||
if ((!isDemoMode && projectQuery.isError) || !project) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-8">
|
||||
<div className="panel p-8 text-center max-w-md">
|
||||
<div className="s-card p-8 text-center max-w-md">
|
||||
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-[var(--error-soft)] flex items-center justify-center">
|
||||
<X size={24} className="text-[var(--error)]" />
|
||||
</div>
|
||||
<p className="text-lg font-medium text-[var(--text-primary)]">Project not found</p>
|
||||
<p className="font-headline text-lg font-medium text-[var(--text-primary)]">Project not found</p>
|
||||
<p className="mt-2 text-sm text-[var(--text-secondary)]">
|
||||
This project may have been deleted or you don't have access.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate(isDemoMode ? '/projects?demo=1' : '/projects')}
|
||||
className="mt-6 px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm font-medium hover:border-[var(--border-default)] transition-colors"
|
||||
className="s-btn-quiet"
|
||||
>
|
||||
Back to Projects
|
||||
</button>
|
||||
@@ -724,13 +724,13 @@ export function ProjectWorkspacePage() {
|
||||
<span className="hidden sm:inline">Projects</span>
|
||||
</button>
|
||||
<div className="w-px h-4 bg-[var(--border-subtle)] shrink-0" />
|
||||
<h1 className="v-title text-sm shrink-0 min-w-0 truncate">{project.name}<span className="v-cursor">_</span></h1>
|
||||
<h1 className="v-title font-headline text-sm shrink-0 min-w-0 truncate">{project.name}<span className="v-cursor">_</span></h1>
|
||||
<span className="hidden lg:flex items-center gap-1.5 text-[11px] text-[var(--text-tertiary)] shrink-0">
|
||||
{services.length} service{services.length !== 1 ? 's' : ''} · {runningServices} running
|
||||
</span>
|
||||
|
||||
{/* View tabs */}
|
||||
<nav className="flex items-center gap-0.5 rounded-full bg-[var(--surface-muted)] border border-[var(--border-subtle)] p-0.5 mx-2 overflow-x-auto min-w-0">
|
||||
<nav className="flex items-center gap-0.5 mx-2 overflow-x-auto min-w-0 self-stretch [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
{viewItems.filter((item) => signedIn || !item.authOnly).map((item) => {
|
||||
const active = activeView === item.key;
|
||||
const Icon = item.icon;
|
||||
@@ -742,11 +742,7 @@ export function ProjectWorkspacePage() {
|
||||
next.set('view', item.key);
|
||||
return next;
|
||||
})}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-all whitespace-nowrap ${
|
||||
active
|
||||
? 'bg-[var(--surface-card)] text-[var(--text-primary)] shadow-sm'
|
||||
: 'text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]'
|
||||
}`}
|
||||
className={`tab ${active ? 'active' : ''} !py-0 h-full !text-[11.5px]`}
|
||||
>
|
||||
<Icon size={13} />
|
||||
<span className="hidden md:inline">{item.label}</span>
|
||||
@@ -765,29 +761,21 @@ export function ProjectWorkspacePage() {
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setCommandPaletteOpen(true)}
|
||||
className="hidden md:flex items-center gap-2 h-8 px-2.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-muted)] text-xs hover:border-[var(--border-default)] hover:text-[var(--text-secondary)] transition-colors"
|
||||
className="s-chip hidden lg:flex"
|
||||
>
|
||||
<Search size={12} />
|
||||
<kbd className="px-1 py-0.5 rounded bg-[var(--surface-card)] text-[10px] font-mono">⌘K</kbd>
|
||||
<kbd className="px-1 py-px rounded border border-[var(--border-subtle)] bg-[var(--bg-surface)] text-[10px] font-mono">⌘K</kbd>
|
||||
</button>
|
||||
{!isDemoMode && signedIn && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setImportOpen(true)}
|
||||
className="flex items-center gap-1.5 h-8 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] hover:text-[var(--text-primary)] transition-colors"
|
||||
title="Deploy a docker-compose file into this project"
|
||||
>
|
||||
<FileUp size={14} />
|
||||
<QuietBtn onClick={() => setImportOpen(true)} title="Deploy a docker-compose file into this project">
|
||||
<FileUp size={13} />
|
||||
<span className="hidden sm:inline">Compose</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={openAddService}
|
||||
className="flex items-center gap-1.5 h-8 px-3 rounded-[var(--radius-md)] text-[var(--accent-on)] text-xs font-semibold shadow-lg hover:shadow-xl transition-all"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</QuietBtn>
|
||||
<GhostBtn onClick={openAddService}>
|
||||
<Plus size={13} />
|
||||
<span className="hidden sm:inline">Add Service</span>
|
||||
</button>
|
||||
</GhostBtn>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -818,67 +806,48 @@ export function ProjectWorkspacePage() {
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
{isDemoMode && (
|
||||
<div className="w-full px-8 pt-6">
|
||||
<div className="px-4 py-3 rounded-[var(--radius-md)] border border-[var(--warning-soft)] bg-[var(--warning-soft)]/50">
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--warning)]">
|
||||
<Sparkles size={16} />
|
||||
<span>Demo mode active - using sample data for preview</span>
|
||||
</div>
|
||||
<div className="s-inset flex items-center gap-2 text-xs text-[var(--warning)]">
|
||||
<Sparkles size={13} />
|
||||
Demo mode active — using sample data for preview
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full px-8 py-6">
|
||||
<div className="w-full px-4 py-6 sm:px-8">
|
||||
<section className="min-w-0">
|
||||
{activeView === 'observability' && (
|
||||
<div className="space-y-6">
|
||||
{/* Health Summary */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="panel-soft p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-[var(--success)] live-pulse" />
|
||||
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Healthy</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3.5">
|
||||
{([
|
||||
{ label: 'Healthy', color: 'var(--success)', value: runningServices, pulse: true },
|
||||
{ label: 'Failed', color: 'var(--error)', value: services.filter((s) => s.status === 'failed').length, pulse: false },
|
||||
{ label: 'Building', color: 'var(--warning)', value: services.filter((s) => s.status === 'building').length, pulse: true },
|
||||
]).map((s) => (
|
||||
<div key={s.label} className="s-card">
|
||||
<div className="s-cardhead !py-2.5">
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${s.pulse ? 'animate-pulse' : ''}`} style={{ background: s.color }} />
|
||||
<span className="text-[11px] text-[var(--text-tertiary)]">{s.label}</span>
|
||||
</div>
|
||||
<p className="mt-2 text-3xl font-semibold text-[var(--success)]">{runningServices}</p>
|
||||
<div className="px-4 pb-3.5">
|
||||
<span className="font-headline text-[28px] font-bold leading-none" style={{ color: s.color }}>{s.value}</span>
|
||||
</div>
|
||||
<div className="panel-soft p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-[var(--error)]" />
|
||||
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Failed</p>
|
||||
</div>
|
||||
<p className="mt-2 text-3xl font-semibold text-[var(--error)]">
|
||||
{services.filter((s) => s.status === 'failed').length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="panel-soft p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-[var(--warning)] animate-pulse" />
|
||||
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Building</p>
|
||||
</div>
|
||||
<p className="mt-2 text-3xl font-semibold text-[var(--warning)]">
|
||||
{services.filter((s) => s.status === 'building').length}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Metrics Dashboard */}
|
||||
<MetricsDashboard
|
||||
isRunning={runningServices > 0}
|
||||
onStop={() => toast.showToast('Services stopped', 'warning')}
|
||||
onRestart={() => toast.showToast('Services restarted', 'success')}
|
||||
/>
|
||||
<MetricsDashboard services={visibleServices} isDemoMode={isDemoMode} />
|
||||
|
||||
{/* Service List */}
|
||||
<div className="panel p-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium text-[var(--text-secondary)]">Services</h3>
|
||||
<div className="s-card">
|
||||
<div className="s-cardhead">
|
||||
<span className="s-ibox"><Layers /></span>
|
||||
<span className="s-t">Services</span>
|
||||
{!isDemoMode && environments.length > 0 && (
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<div className="s-trail flex-wrap">
|
||||
<button
|
||||
onClick={() => setEnvFilter(null)}
|
||||
className={`px-2.5 py-1 rounded-full text-xs font-medium transition-colors ${
|
||||
envFilter === null
|
||||
? 'bg-[var(--accent-primary)] text-white'
|
||||
: 'bg-[var(--surface-muted)] text-[var(--text-secondary)] hover:bg-[var(--surface-card)]'
|
||||
}`}
|
||||
className={`s-chip !py-1 !px-2.5 ${envFilter === null ? '!text-[var(--accent-primary)] !border-[var(--accent-primary)]/40' : ''}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
@@ -886,11 +855,7 @@ export function ProjectWorkspacePage() {
|
||||
<button
|
||||
key={env.id}
|
||||
onClick={() => setEnvFilter(envFilter === env.name ? null : env.name)}
|
||||
className={`px-2.5 py-1 rounded-full text-xs font-medium transition-colors ${
|
||||
envFilter === env.name
|
||||
? 'bg-[var(--accent-primary)] text-white'
|
||||
: 'bg-[var(--surface-muted)] text-[var(--text-secondary)] hover:bg-[var(--surface-card)]'
|
||||
}`}
|
||||
className={`s-chip !py-1 !px-2.5 ${envFilter === env.name ? '!text-[var(--accent-primary)] !border-[var(--accent-primary)]/40' : ''}`}
|
||||
>
|
||||
{env.name} ({env.service_count})
|
||||
</button>
|
||||
@@ -899,6 +864,7 @@ export function ProjectWorkspacePage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-4 pb-4">
|
||||
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] overflow-hidden divide-y divide-[var(--border-subtle)]">
|
||||
{visibleServices.length === 0 ? (
|
||||
<div className="p-8 text-center">
|
||||
@@ -918,9 +884,7 @@ export function ProjectWorkspacePage() {
|
||||
className="w-full flex items-center justify-between px-4 py-3 hover:bg-[var(--surface-muted)]/50 transition-colors text-left"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-[var(--radius-sm)] bg-[var(--surface-card)] border border-[var(--border-subtle)] flex items-center justify-center">
|
||||
<Box size={14} className="text-[var(--text-tertiary)]" />
|
||||
</div>
|
||||
<span className="s-ibox"><Box /></span>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">{service.name}</p>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">{service.type}</p>
|
||||
@@ -933,31 +897,29 @@ export function ProjectWorkspacePage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeView === 'logs' && (
|
||||
<div className="panel p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
|
||||
<FileText size={20} className="text-[var(--accent-primary)]" />
|
||||
</div>
|
||||
<div className="s-card">
|
||||
<div className="s-cardhead">
|
||||
<span className="s-ibox"><FileText /></span>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Logs</h2>
|
||||
<p className="text-sm text-[var(--text-secondary)]">Aggregated service output</p>
|
||||
</div>
|
||||
<div className="s-t">Logs</div>
|
||||
<div className="mt-0.5 text-[11px] text-[var(--text-tertiary)]">Aggregated service output</div>
|
||||
</div>
|
||||
{!isDemoMode && (
|
||||
<button
|
||||
onClick={() => workspaceLogsQuery.refetch()}
|
||||
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors"
|
||||
>
|
||||
<div className="s-trail">
|
||||
<QuietBtn onClick={() => workspaceLogsQuery.refetch()}>
|
||||
<RefreshCw size={13} />
|
||||
Refresh
|
||||
</button>
|
||||
</QuietBtn>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-4 pb-4">
|
||||
|
||||
<div className="mono rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-void)] p-4 text-xs text-[var(--text-secondary)] overflow-x-auto max-h-[600px] overflow-y-auto">
|
||||
<div className="v-mono rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-void)] p-4 text-xs text-[var(--text-secondary)] overflow-x-auto max-h-[600px] overflow-y-auto">
|
||||
{services.length === 0 ? (
|
||||
<p className="text-[var(--text-muted)]">No services available.</p>
|
||||
) : isDemoMode ? (
|
||||
@@ -999,19 +961,19 @@ export function ProjectWorkspacePage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeView === 'activity' && (
|
||||
<div className="panel p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
|
||||
<Clock size={20} className="text-[var(--accent-primary)]" />
|
||||
</div>
|
||||
<div className="s-card">
|
||||
<div className="s-cardhead">
|
||||
<span className="s-ibox"><Clock /></span>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Activity</h2>
|
||||
<p className="text-sm text-[var(--text-secondary)]">Project timeline — deploys, config, membership</p>
|
||||
<div className="s-t">Activity</div>
|
||||
<div className="mt-0.5 text-[11px] text-[var(--text-tertiary)]">Project timeline — deploys, config, membership</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-4 pb-4">
|
||||
<div className="divide-y divide-[var(--border-subtle)] border border-[var(--border-subtle)] rounded-[var(--radius-md)]">
|
||||
{activityQuery.isLoading ? (
|
||||
<p className="px-4 py-6 text-xs text-[var(--text-muted)]">Loading…</p>
|
||||
@@ -1035,39 +997,39 @@ export function ProjectWorkspacePage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeView === 'settings' && (
|
||||
<div className="panel p-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
|
||||
<Settings size={20} className="text-[var(--accent-primary)]" />
|
||||
</div>
|
||||
<div className="s-card">
|
||||
<div className="s-cardhead">
|
||||
<span className="s-ibox"><Settings /></span>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Settings</h2>
|
||||
<p className="text-sm text-[var(--text-secondary)]">Project configuration</p>
|
||||
<div className="s-t">Settings</div>
|
||||
<div className="mt-0.5 text-[11px] text-[var(--text-tertiary)]">Project configuration</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-4 pb-4">
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="panel-soft p-4">
|
||||
<div className="s-inset">
|
||||
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Project ID</p>
|
||||
<p className="mono mt-2 text-sm text-[var(--text-primary)] break-all">{project.id}</p>
|
||||
<p className="v-mono mt-2 text-sm text-[var(--text-primary)] break-all">{project.id}</p>
|
||||
</div>
|
||||
<div className="panel-soft p-4">
|
||||
<div className="s-inset">
|
||||
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Created</p>
|
||||
<p className="mt-2 text-sm text-[var(--text-primary)]">{formatDate(project.createdAt)}</p>
|
||||
</div>
|
||||
<div className="panel-soft p-4 md:col-span-2">
|
||||
<div className="s-inset md:col-span-2">
|
||||
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Canvas Storage Key</p>
|
||||
<p className="mono mt-2 text-xs text-[var(--text-secondary)] break-all">{canvasStorageKey(project.id)}</p>
|
||||
<p className="v-mono mt-2 text-xs text-[var(--text-secondary)] break-all">{canvasStorageKey(project.id)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isDemoMode && signedIn && <SharedVariablesSection projectId={project.id} />}
|
||||
|
||||
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
|
||||
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-3">Local Data</h3>
|
||||
<h3 className="s-t mb-3">Local Data</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
clearCanvasMetadata(project.id);
|
||||
@@ -1080,6 +1042,7 @@ export function ProjectWorkspacePage() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
@@ -1105,12 +1068,10 @@ export function ProjectWorkspacePage() {
|
||||
{importOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-[var(--bg-void)]/80 backdrop-blur-sm" onClick={() => setImportOpen(false)} />
|
||||
<div className="relative w-full max-w-2xl panel p-6">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
|
||||
<FileUp size={20} className="text-[var(--accent-primary)]" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-[var(--text-primary)]">Import docker-compose</h3>
|
||||
<div className="relative w-full max-w-2xl s-card !p-6">
|
||||
<div className="flex items-center gap-2.5 mb-1">
|
||||
<span className="s-ibox !w-9 !h-9"><FileUp /></span>
|
||||
<h3 className="font-headline text-lg font-semibold text-[var(--text-primary)]">Import docker-compose</h3>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
Paste a docker-compose.yml. Services deploy in dependency order; postgres, mysql, redis, mongo,
|
||||
@@ -1120,7 +1081,7 @@ export function ProjectWorkspacePage() {
|
||||
value={composeYaml}
|
||||
onChange={(e) => setComposeYaml(e.target.value)}
|
||||
spellCheck={false}
|
||||
className="mt-5 w-full h-72 px-4 py-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all mono text-sm resize-none"
|
||||
className="mt-5 w-full h-72 px-4 py-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all v-mono text-sm resize-none"
|
||||
placeholder={'services:\n web:\n image: nginx:alpine\n ports:\n - "80"\n db:\n image: postgres:16-alpine\n environment:\n POSTGRES_PASSWORD: secret'}
|
||||
/>
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
@@ -1133,8 +1094,8 @@ export function ProjectWorkspacePage() {
|
||||
<button
|
||||
onClick={() => composeImportMutation.mutate(composeYaml)}
|
||||
disabled={composeImportMutation.isPending || composeYaml.trim().length === 0}
|
||||
className="h-10 px-5 rounded-[var(--radius-md)] text-sm font-semibold text-[var(--accent-on)] disabled:opacity-50 transition-all flex items-center gap-2"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
className="s-btn-accent h-10 disabled:opacity-50"
|
||||
|
||||
>
|
||||
{composeImportMutation.isPending && <Loader2 size={14} className="animate-spin" />}
|
||||
Deploy stack
|
||||
@@ -1163,6 +1124,9 @@ export function ProjectWorkspacePage() {
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
navTargets={viewItems
|
||||
.filter((item) => signedIn || !item.authOnly)
|
||||
.map((item) => ({ label: `Go to ${item.label}`, target: item.key }))}
|
||||
/>
|
||||
|
||||
{/* Error Banner */}
|
||||
|
||||
+715
-194
File diff suppressed because it is too large.
Load diff
Loaded 100 of 116 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user