mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
549790c611 |
No files matched your search
@@ -124,6 +124,3 @@ ref/
|
||||
.playwright-mcp/
|
||||
canvas-*.png
|
||||
.vercel
|
||||
|
||||
# Local design previews (not shipped)
|
||||
design/
|
||||
+4
-14
@@ -1,6 +1,6 @@
|
||||
# Containr — Roadmap 2: The dflow Convergence
|
||||
|
||||
Last verified: **2026-10-10** — deep pass over the full dflow codebase
|
||||
Last verified: **2026-10-08** — 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,9 +337,8 @@ 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. Build-on-node and remote
|
||||
sleep/wake both shipped (see below). Remaining: spread weighting
|
||||
by real capacity.
|
||||
service-detail placement-tags input. Remaining: build-on-node,
|
||||
remote sleep/wake, 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/
|
||||
@@ -538,18 +537,9 @@ 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.
|
||||
- [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.
|
||||
- [ ] **sqlc port** — ~265 raw `database/sql` sites → `sqlc/queries/`. Done in tranches (dynamic queries stay raw). Tranches merged: metrics storage + service domains (#75), service clone/move ops (#76), preview environments (#77), deployments + rollback (#78). Remaining: runtime, compliance, cron, git, security, auth, sleeper, scanner, gateway, and smaller files.
|
||||
- [ ] **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,7 +12,6 @@ import (
|
||||
"time"
|
||||
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
@@ -181,13 +180,13 @@ func handleGetProjectActivity(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Access check: approved / owner / member / admin.
|
||||
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 {
|
||||
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 {
|
||||
respondError(c, http.StatusNotFound, "NOT_FOUND", "project not found")
|
||||
return
|
||||
}
|
||||
@@ -238,12 +237,12 @@ func StartAuditRetention(ctx context.Context, db *database.DB) {
|
||||
}
|
||||
|
||||
prune := func() {
|
||||
n, err := sqlcdb.New(db.DB).PruneAuditLogs(context.Background(), int32(days))
|
||||
res, err := db.Exec(`DELETE FROM audit_logs WHERE created_at < NOW() - ($1::int * INTERVAL '1 day')`, days)
|
||||
if err != nil {
|
||||
log.Printf("audit retention prune failed: %v", err)
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
if n, _ := res.RowsAffected(); n > 0 {
|
||||
log.Printf("audit retention: pruned %d rows older than %d days", n, days)
|
||||
}
|
||||
}
|
||||
@@ -266,24 +265,34 @@ 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) {
|
||||
q := sqlcdb.New(db.DB)
|
||||
cursor := uuid.Nil
|
||||
cursor := uuid.Nil.String()
|
||||
for {
|
||||
rows, err := q.ListAuditBackfillBatch(context.Background(), cursor)
|
||||
rows, err := db.Query(
|
||||
`SELECT id::text, resource, action FROM audit_logs
|
||||
WHERE label = '' AND id > $1::uuid ORDER BY id LIMIT 500`, cursor)
|
||||
if err != nil {
|
||||
log.Printf("audit backfill scan failed: %v", err)
|
||||
return
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
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 {
|
||||
return
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
log.Printf("audit backfill update failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,11 +52,15 @@ func EnsureAdminAccount(ctx context.Context, db *database.DB, cfg *config.Config
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := sqlcdb.New(db.DB).UpsertAdminUser(ctx, sqlcdb.UpsertAdminUserParams{
|
||||
Email: email,
|
||||
PasswordHash: string(hashed),
|
||||
Name: name,
|
||||
}); err != nil {
|
||||
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 {
|
||||
log.Printf("Admin bootstrap failed to upsert user %s: %v", email, err)
|
||||
return
|
||||
}
|
||||
@@ -181,18 +185,24 @@ func handleAdminOverview(c *gin.Context) {
|
||||
func handleAdminListUsers(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
|
||||
rows, err := sqlcdb.New(db.DB).ListAdminUsers(c.Request.Context())
|
||||
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
|
||||
`)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list users"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
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,
|
||||
})
|
||||
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)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"users": users})
|
||||
}
|
||||
@@ -219,16 +229,15 @@ func handleAdminSetUserAdmin(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
targetUUID, _ := uuid.Parse(targetID)
|
||||
n, err := sqlcdb.New(db.DB).SetUserAdmin(c.Request.Context(), sqlcdb.SetUserAdminParams{
|
||||
IsAdmin: req.IsAdmin,
|
||||
ID: targetUUID,
|
||||
})
|
||||
result, err := db.ExecContext(c.Request.Context(),
|
||||
`UPDATE users SET is_admin = $1, updated_at = NOW() WHERE id = $2`,
|
||||
req.IsAdmin, targetID,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user"})
|
||||
return
|
||||
}
|
||||
if n == 0 {
|
||||
if n, _ := result.RowsAffected(); n == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
||||
return
|
||||
}
|
||||
@@ -249,13 +258,15 @@ func handleAdminImpersonateUser(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
userUUID, _ := uuid.Parse(targetID)
|
||||
info, err := sqlcdb.New(db.DB).GetAdminUserInfo(c.Request.Context(), userUUID)
|
||||
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)
|
||||
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
|
||||
@@ -275,9 +286,9 @@ func handleAdminImpersonateUser(c *gin.Context) {
|
||||
}
|
||||
|
||||
LogAuditWithRequest(c, "user", targetID, "impersonate", map[string]interface{}{
|
||||
"actor_id": actorID,
|
||||
"actor_id": actorID,
|
||||
"target_email": email,
|
||||
"expires_at": expiresAt.Format(time.RFC3339),
|
||||
"expires_at": expiresAt.Format(time.RFC3339),
|
||||
})
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"token": token,
|
||||
|
||||
@@ -2,7 +2,6 @@ package api
|
||||
|
||||
import (
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
@@ -15,7 +14,6 @@ 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"
|
||||
)
|
||||
@@ -162,22 +160,21 @@ func handleAPwhyServiceValidate(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
ctx := c.Request.Context()
|
||||
|
||||
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 {
|
||||
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 {
|
||||
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 svc.RequestTimeoutMs.Valid && svc.RequestTimeoutMs.Int32 > 0 {
|
||||
timeout = int(svc.RequestTimeoutMs.Int32)
|
||||
if timeoutMs.Valid && timeoutMs.Int64 > 0 {
|
||||
timeout = int(timeoutMs.Int64)
|
||||
}
|
||||
target := strings.TrimRight(upstreamURL, "/") + "/" + strings.TrimLeft(healthPath, "/")
|
||||
|
||||
@@ -206,19 +203,17 @@ func handleAPwhyServiceValidate(c *gin.Context) {
|
||||
validationStatus = "healthy"
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
_, _ = 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)
|
||||
|
||||
if !ok {
|
||||
_ = q.InsertServiceIncident(ctx, sqlcdb.InsertServiceIncidentParams{
|
||||
ServiceID: uuid.NullUUID{UUID: svcUUID, Valid: true},
|
||||
Message: message,
|
||||
HttpStatus: sql.NullInt32{Int32: int32(status), Valid: true},
|
||||
})
|
||||
_, _ = 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)
|
||||
}
|
||||
|
||||
sendSuccessResponse(c, http.StatusOK, map[string]interface{}{
|
||||
@@ -236,24 +231,36 @@ func handleAPwhyServicesList(c *gin.Context) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Query services from database
|
||||
rows, err := sqlcdb.New(db.DB).ListAPServices(ctx)
|
||||
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
|
||||
`)
|
||||
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": 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,
|
||||
"id": id,
|
||||
"name": name,
|
||||
"slug": slug,
|
||||
"upstreamUrl": upstreamURL,
|
||||
"routePrefix": routePrefix,
|
||||
"enabled": enabled != 0,
|
||||
"createdAt": createdAt,
|
||||
"updatedAt": updatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -292,14 +299,14 @@ func handleAPwhyServicesCreate(c *gin.Context) {
|
||||
}
|
||||
|
||||
// id defaults to gen_random_uuid(); enabled defaults to 1.
|
||||
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},
|
||||
})
|
||||
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)
|
||||
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
@@ -313,7 +320,7 @@ func handleAPwhyServicesCreate(c *gin.Context) {
|
||||
}
|
||||
|
||||
serviceData := map[string]interface{}{
|
||||
"id": svcID.String(),
|
||||
"id": id,
|
||||
"name": req.Name,
|
||||
"slug": slug,
|
||||
"upstreamUrl": req.UpstreamURL,
|
||||
@@ -349,15 +356,13 @@ func handleAPwhyServicesPatch(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
|
||||
if input.Enabled != nil {
|
||||
enabled := int32(0)
|
||||
enabled := 0
|
||||
if *input.Enabled {
|
||||
enabled = 1
|
||||
}
|
||||
svcUUID, _ := uuid.Parse(serviceID)
|
||||
err := sqlcdb.New(db.DB).SetAPServiceEnabled(context.Background(), sqlcdb.SetAPServiceEnabledParams{
|
||||
Enabled: enabled,
|
||||
ID: svcUUID,
|
||||
})
|
||||
_, err := db.ExecContext(context.Background(),
|
||||
"UPDATE api_services SET enabled = $1, updated_at = NOW() WHERE id = $2",
|
||||
enabled, serviceID)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
@@ -378,7 +383,11 @@ func handleAPwhyServicesPatch(c *gin.Context) {
|
||||
func handleAPwhyKeysList(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
|
||||
rows, err := sqlcdb.New(db.DB).ListAPKeys(context.Background())
|
||||
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
|
||||
`)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"ok": true,
|
||||
@@ -386,24 +395,34 @@ 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": r.ID.String(),
|
||||
"name": r.Name,
|
||||
"keyPrefix": r.KeyPrefix,
|
||||
"plan": r.Plan,
|
||||
"enabled": r.Enabled != 0,
|
||||
"createdAt": r.CreatedAt,
|
||||
"updatedAt": r.UpdatedAt,
|
||||
"id": id,
|
||||
"name": name,
|
||||
"keyPrefix": keyPrefix,
|
||||
"plan": plan,
|
||||
"enabled": enabled != 0,
|
||||
"createdAt": createdAt,
|
||||
"updatedAt": updatedAt,
|
||||
}
|
||||
|
||||
if r.RpmLimit.Valid {
|
||||
key["rpmLimit"] = int64(r.RpmLimit.Int32)
|
||||
if rpmLimit.Valid {
|
||||
key["rpmLimit"] = rpmLimit.Int64
|
||||
}
|
||||
if r.MonthlyQuota.Valid {
|
||||
key["monthlyQuota"] = int64(r.MonthlyQuota.Int32)
|
||||
if monthlyQuota.Valid {
|
||||
key["monthlyQuota"] = monthlyQuota.Int64
|
||||
}
|
||||
|
||||
keys = append(keys, key)
|
||||
@@ -462,14 +481,14 @@ func handleAPwhyKeysCreate(c *gin.Context) {
|
||||
}
|
||||
|
||||
// id defaults to gen_random_uuid(); enabled defaults to 1.
|
||||
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},
|
||||
})
|
||||
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)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
@@ -482,7 +501,7 @@ func handleAPwhyKeysCreate(c *gin.Context) {
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"ok": true,
|
||||
"data": gin.H{
|
||||
"id": keyID.String(),
|
||||
"id": id,
|
||||
"name": input.Name,
|
||||
"plan": plan,
|
||||
"key": apiKey, // Only return the actual key once
|
||||
@@ -513,15 +532,13 @@ func handleAPwhyKeysPatch(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
|
||||
if input.Enabled != nil {
|
||||
enabled := int32(0)
|
||||
enabled := 0
|
||||
if *input.Enabled {
|
||||
enabled = 1
|
||||
}
|
||||
keyUUID, _ := uuid.Parse(keyID)
|
||||
err := sqlcdb.New(db.DB).SetAPKeyEnabled(context.Background(), sqlcdb.SetAPKeyEnabledParams{
|
||||
Enabled: enabled,
|
||||
ID: keyUUID,
|
||||
})
|
||||
_, err := db.ExecContext(context.Background(),
|
||||
"UPDATE api_keys SET enabled = $1, updated_at = NOW() WHERE id = $2",
|
||||
enabled, keyID)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
@@ -543,27 +560,23 @@ func handleAPwhyAnalyticsOps(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
|
||||
// Get counts from database
|
||||
q := sqlcdb.New(db.DB)
|
||||
ctx := context.Background()
|
||||
var totalServices, totalKeys, totalUsers int
|
||||
|
||||
totalServices, err := q.CountAPServices(ctx)
|
||||
if err != nil {
|
||||
if err := db.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM api_services").Scan(&totalServices); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"ok": false,
|
||||
"error": "Failed to count services: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
totalKeys, err := q.CountAPKeys(ctx)
|
||||
if err != nil {
|
||||
if err := db.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM api_keys").Scan(&totalKeys); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"ok": false,
|
||||
"error": "Failed to count api keys: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
totalUsers, err := q.CountUsers(ctx)
|
||||
if err != nil {
|
||||
if err := db.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM users").Scan(&totalUsers); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"ok": false,
|
||||
"error": "Failed to count users: " + err.Error(),
|
||||
@@ -571,8 +584,8 @@ func handleAPwhyAnalyticsOps(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
totalRequests, err := q.SumUsageCounters(ctx)
|
||||
if err != nil {
|
||||
var totalRequests int
|
||||
if err := db.QueryRowContext(context.Background(), "SELECT COALESCE(SUM(request_count), 0) FROM usage_counters").Scan(&totalRequests); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"ok": false,
|
||||
"error": "Failed to aggregate request counters: " + err.Error(),
|
||||
@@ -580,8 +593,12 @@ func handleAPwhyAnalyticsOps(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
requestsToday, err := q.SumRequestMetricSince(ctx, "day")
|
||||
if err != nil {
|
||||
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 {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"ok": false,
|
||||
"error": "Failed to aggregate today's requests: " + err.Error(),
|
||||
@@ -589,8 +606,12 @@ func handleAPwhyAnalyticsOps(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
requestsThisMonth, err := q.SumRequestMetricSince(ctx, "month")
|
||||
if err != nil {
|
||||
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 {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"ok": false,
|
||||
"error": "Failed to aggregate monthly requests: " + err.Error(),
|
||||
@@ -615,47 +636,94 @@ 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)
|
||||
if serviceRows, err := q.ListTopAPServices(ctx); err == nil {
|
||||
for _, r := range serviceRows {
|
||||
topServices = append(topServices, map[string]interface{}{
|
||||
"service_id": r.ID.String(),
|
||||
"name": r.Name,
|
||||
"requests": r.TotalRequests,
|
||||
})
|
||||
serviceRows, err := db.QueryContext(context.Background(), `
|
||||
SELECT s.id, s.name, COALESCE(SUM(u.request_count), 0) AS total_requests
|
||||
FROM api_services s
|
||||
LEFT JOIN usage_counters u ON u.service_id = s.id
|
||||
GROUP BY s.id, s.name
|
||||
ORDER BY total_requests DESC, s.name ASC
|
||||
LIMIT 10
|
||||
`)
|
||||
if err == nil {
|
||||
defer serviceRows.Close()
|
||||
for serviceRows.Next() {
|
||||
var serviceID, serviceName string
|
||||
var totalRequests int
|
||||
if scanErr := serviceRows.Scan(&serviceID, &serviceName, &totalRequests); scanErr == nil {
|
||||
topServices = append(topServices, map[string]interface{}{
|
||||
"service_id": serviceID,
|
||||
"name": serviceName,
|
||||
"requests": totalRequests,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
requestsByDay := make([]map[string]interface{}, 0)
|
||||
if trafficRows, err := q.ListRequestsByDay(ctx); err == nil {
|
||||
for _, r := range trafficRows {
|
||||
requestsByDay = append(requestsByDay, map[string]interface{}{
|
||||
"day": r.DayBucket,
|
||||
"requests": r.Total,
|
||||
})
|
||||
trafficRows, err := db.QueryContext(context.Background(), `
|
||||
SELECT TO_CHAR(DATE_TRUNC('day', occurred_at), 'YYYY-MM-DD') AS day_bucket, COUNT(*) AS total
|
||||
FROM metrics_timeseries
|
||||
WHERE metric = 'request_total' AND occurred_at >= NOW() - INTERVAL '7 days'
|
||||
GROUP BY DATE_TRUNC('day', occurred_at)
|
||||
ORDER BY DATE_TRUNC('day', occurred_at) ASC
|
||||
`)
|
||||
if err == nil {
|
||||
defer trafficRows.Close()
|
||||
for trafficRows.Next() {
|
||||
var day string
|
||||
var count int
|
||||
if scanErr := trafficRows.Scan(&day, &count); scanErr == nil {
|
||||
requestsByDay = append(requestsByDay, map[string]interface{}{
|
||||
"day": day,
|
||||
"requests": count,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
statusCodes := make([]map[string]interface{}, 0)
|
||||
if statusRows, err := q.ListIncidentStatusCodes(ctx); err == nil {
|
||||
for _, r := range statusRows {
|
||||
statusCodes = append(statusCodes, map[string]interface{}{
|
||||
"status_code": r.StatusCode,
|
||||
"count": r.Total,
|
||||
})
|
||||
statusRows, err := db.QueryContext(context.Background(), `
|
||||
SELECT COALESCE(http_status, 0) AS status_code, COALESCE(SUM(count), 0) AS total
|
||||
FROM incident_events
|
||||
WHERE occurred_at >= NOW() - INTERVAL '7 days'
|
||||
GROUP BY COALESCE(http_status, 0)
|
||||
ORDER BY total DESC, status_code ASC
|
||||
`)
|
||||
if err == nil {
|
||||
defer statusRows.Close()
|
||||
for statusRows.Next() {
|
||||
var code int
|
||||
var total int
|
||||
if scanErr := statusRows.Scan(&code, &total); scanErr == nil {
|
||||
statusCodes = append(statusCodes, map[string]interface{}{
|
||||
"status_code": code,
|
||||
"count": total,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clientEvents := make([]map[string]interface{}, 0)
|
||||
if eventRows, err := q.ListClientEventPaths(ctx); err == nil {
|
||||
for _, r := range eventRows {
|
||||
clientEvents = append(clientEvents, map[string]interface{}{
|
||||
"path": r.Path,
|
||||
"count": r.Total,
|
||||
})
|
||||
eventRows, err := db.QueryContext(context.Background(), `
|
||||
SELECT COALESCE((labels_json::jsonb ->> 'path'), 'unknown') AS path, COUNT(*) AS total
|
||||
FROM metrics_timeseries
|
||||
WHERE metric = 'client_event' AND occurred_at >= NOW() - INTERVAL '7 days'
|
||||
GROUP BY COALESCE((labels_json::jsonb ->> 'path'), 'unknown')
|
||||
ORDER BY total DESC, path ASC
|
||||
LIMIT 20
|
||||
`)
|
||||
if err == nil {
|
||||
defer eventRows.Close()
|
||||
for eventRows.Next() {
|
||||
var path string
|
||||
var total int
|
||||
if scanErr := eventRows.Scan(&path, &total); scanErr == nil {
|
||||
clientEvents = append(clientEvents, map[string]interface{}{
|
||||
"path": path,
|
||||
"count": total,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,6 @@ package api
|
||||
|
||||
import (
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -14,7 +11,6 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/sqlc-dev/pqtype"
|
||||
)
|
||||
|
||||
type AuditLog struct {
|
||||
@@ -102,20 +98,16 @@ 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)
|
||||
|
||||
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(),
|
||||
})
|
||||
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(),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
}
|
||||
@@ -139,24 +131,16 @@ 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)
|
||||
|
||||
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(),
|
||||
})
|
||||
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(),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
}
|
||||
@@ -271,29 +255,30 @@ func handleGetResourceAuditLogs(c *gin.Context) {
|
||||
resource := c.Param("resource")
|
||||
resourceID := c.Param("id")
|
||||
|
||||
rows, err := sqlcdb.New(db.DB).ListResourceAuditLogs(context.Background(), sqlcdb.ListResourceAuditLogsParams{
|
||||
UserID: nullUUIDOrNil(userID),
|
||||
Resource: resource,
|
||||
ResourceID: nullUUIDOrNil(resourceID),
|
||||
})
|
||||
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,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch audit logs"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var logs []AuditLog
|
||||
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,
|
||||
})
|
||||
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)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"audit_logs": logs})
|
||||
@@ -317,8 +302,3 @@ 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}
|
||||
}
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
|
||||
"containr/internal/build"
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
"containr/internal/docker"
|
||||
"containr/internal/types"
|
||||
|
||||
@@ -670,22 +669,39 @@ func (h *BuildHandler) upsertBuild(status *BuildStatusResponse) error {
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
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,
|
||||
})
|
||||
_, 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,
|
||||
)
|
||||
if err != nil && h.isMissingBuildsTable(err) {
|
||||
return nil
|
||||
}
|
||||
@@ -697,37 +713,18 @@ func (h *BuildHandler) getBuildFromDB(buildID string) (BuildStatusResponse, bool
|
||||
return BuildStatusResponse{}, false, nil
|
||||
}
|
||||
|
||||
row, err := sqlcdb.New(h.db.DB).GetBuild(context.Background(), buildID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) || h.isMissingBuildsTable(err) {
|
||||
return BuildStatusResponse{}, false, nil
|
||||
}
|
||||
return BuildStatusResponse{}, false, err
|
||||
row := h.db.QueryRow(
|
||||
`SELECT id, project_id, service_id, status, progress, started_at, completed_at, image_name, image_tag, size, error, log, metadata
|
||||
FROM builds
|
||||
WHERE id = $1`,
|
||||
buildID,
|
||||
)
|
||||
|
||||
build, found, err := scanBuildRow(row.Scan)
|
||||
if err != nil && h.isMissingBuildsTable(err) {
|
||||
return BuildStatusResponse{}, false, nil
|
||||
}
|
||||
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
|
||||
return build, found, err
|
||||
}
|
||||
|
||||
func (h *BuildHandler) listBuildsFromDB(projectID, serviceID, status string, page, limit int) ([]BuildStatusResponse, int, error) {
|
||||
@@ -811,11 +808,19 @@ func (h *BuildHandler) cancelBuildInDB(buildID string) bool {
|
||||
return false
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
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,
|
||||
})
|
||||
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,
|
||||
)
|
||||
if err != nil {
|
||||
if h.isMissingBuildsTable(err) {
|
||||
return false
|
||||
@@ -823,7 +828,9 @@ func (h *BuildHandler) cancelBuildInDB(buildID string) bool {
|
||||
log.Printf("failed to cancel build %s in database: %v", buildID, err)
|
||||
return false
|
||||
}
|
||||
if affected == 0 {
|
||||
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil || affected == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -902,9 +909,12 @@ func scanBuildRow(scan func(dest ...interface{}) error) (BuildStatusResponse, bo
|
||||
return parsed, true, nil
|
||||
}
|
||||
|
||||
func nsNullIfEmpty(value string) sql.NullString {
|
||||
func nullIfEmptyString(value string) interface{} {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
return sql.NullString{String: trimmed, Valid: trimmed != ""}
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func jsonOrEmptyObject(raw []byte) string {
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
@@ -59,12 +57,15 @@ 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) {
|
||||
q := sqlcdb.New(db.DB)
|
||||
row, err := q.GetProjectReadAccess(context.Background(), projectID)
|
||||
var isApproved bool
|
||||
var ownerID string
|
||||
err := db.QueryRow(
|
||||
`SELECT is_approved, owner_id::text FROM projects WHERE id = $1`,
|
||||
projectID,
|
||||
).Scan(&isApproved, &ownerID)
|
||||
if err != nil {
|
||||
return false, false
|
||||
}
|
||||
isApproved, ownerID := row.IsApproved, row.OwnerID
|
||||
if isApproved || contextIsAdmin(c) {
|
||||
return true, true
|
||||
}
|
||||
@@ -75,11 +76,11 @@ func projectReadAccess(c *gin.Context, db *database.DB, projectID uuid.UUID) (bo
|
||||
if ownerID == userID {
|
||||
return true, true
|
||||
}
|
||||
userUUID, _ := uuid.Parse(userID)
|
||||
if member, err := q.ProjectMemberExists(context.Background(), sqlcdb.ProjectMemberExistsParams{
|
||||
ProjectID: projectID,
|
||||
UserID: userUUID,
|
||||
}); err == nil && member {
|
||||
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 {
|
||||
return true, true
|
||||
}
|
||||
return true, false
|
||||
@@ -90,8 +91,11 @@ 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) {
|
||||
q := sqlcdb.New(db.DB)
|
||||
ownerID, err := q.GetProjectOwnerText(context.Background(), projectID)
|
||||
var ownerID string
|
||||
err := db.QueryRow(
|
||||
`SELECT owner_id::text FROM projects WHERE id = $1`,
|
||||
projectID,
|
||||
).Scan(&ownerID)
|
||||
if err != nil {
|
||||
return false, false
|
||||
}
|
||||
@@ -105,11 +109,11 @@ func projectManageAccess(c *gin.Context, db *database.DB, projectID uuid.UUID) (
|
||||
if ownerID == userID {
|
||||
return true, true
|
||||
}
|
||||
userUUID, _ := uuid.Parse(userID)
|
||||
if member, err := q.ProjectMemberExists(context.Background(), sqlcdb.ProjectMemberExistsParams{
|
||||
ProjectID: projectID,
|
||||
UserID: userUUID,
|
||||
}); err == nil && member {
|
||||
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 {
|
||||
return true, true
|
||||
}
|
||||
return true, false
|
||||
@@ -117,7 +121,8 @@ 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) {
|
||||
projectID, err := sqlcdb.New(db.DB).GetServiceProjectID(context.Background(), serviceID)
|
||||
var projectID uuid.UUID
|
||||
err := db.QueryRow(`SELECT project_id FROM services WHERE id = $1`, serviceID).Scan(&projectID)
|
||||
if err != nil {
|
||||
return uuid.Nil, false
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@ func handleDeleteEnvironment(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
var projectID uuid.UUID
|
||||
if projectID, err = sqlcdb.New(db.DB).GetEnvironmentProjectID(context.Background(), envID); err != nil {
|
||||
if err := db.QueryRow(`SELECT project_id FROM environments WHERE id = $1`, envID).Scan(&projectID); err != nil {
|
||||
respondError(c, http.StatusNotFound, "NOT_FOUND", "Environment not found")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -17,11 +17,9 @@ 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"
|
||||
)
|
||||
|
||||
@@ -109,28 +107,23 @@ 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) {
|
||||
r, err := sqlcdb.New(db.DB).GetAPServiceBySlug(ctx, slug)
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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),
|
||||
}
|
||||
s.Enabled = enabled != 0
|
||||
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) {
|
||||
@@ -138,24 +131,26 @@ func authenticateGatewayKey(ctx context.Context, db *database.DB, presented stri
|
||||
if len(prefix) > 8 {
|
||||
prefix = prefix[:8]
|
||||
}
|
||||
rows, err := sqlcdb.New(db.DB).ListAPKeysByPrefix(ctx, prefix)
|
||||
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)
|
||||
if 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,
|
||||
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
|
||||
}
|
||||
k.Enabled = enabled != 0
|
||||
if bcrypt.CompareHashAndPassword([]byte(k.Hash), []byte(presented)) == nil {
|
||||
return &k, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
return nil, rows.Err()
|
||||
}
|
||||
|
||||
// keyAllowsService applies the allowed_service_ids JSON allowlist. An empty
|
||||
@@ -173,56 +168,48 @@ func keyAllowsService(k *gatewayKey, serviceID string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// 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,
|
||||
})
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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)
|
||||
_, _ = 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)
|
||||
labels, _ := json.Marshal(map[string]interface{}{
|
||||
"service_id": svc.ID,
|
||||
"key_id": k.ID,
|
||||
"status": status,
|
||||
})
|
||||
_ = q.InsertMetricPoint(ctx, sql.NullString{String: string(labels), Valid: true})
|
||||
_, _ = db.ExecContext(ctx, `
|
||||
INSERT INTO metrics_timeseries (metric, value, labels_json, occurred_at)
|
||||
VALUES ('gateway_request', 1, $1, NOW())
|
||||
`, string(labels))
|
||||
}
|
||||
|
||||
func recordGatewayIncident(ctx context.Context, db *database.DB, svcID string, keyID *string, code, msg, severity string, status int) {
|
||||
var kid uuid.NullUUID
|
||||
var kid interface{}
|
||||
if keyID != nil {
|
||||
if u, err := uuid.Parse(*keyID); err == nil {
|
||||
kid = uuid.NullUUID{UUID: u, Valid: true}
|
||||
}
|
||||
kid = *keyID
|
||||
}
|
||||
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},
|
||||
})
|
||||
_, _ = 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)
|
||||
}
|
||||
|
||||
// handleGatewayProxy is mounted at /g/:slug and /g/:slug/*path on the root
|
||||
@@ -280,13 +267,13 @@ func handleGatewayProxy(c *gin.Context) {
|
||||
|
||||
period := now.Format("2006-01")
|
||||
if key.MonthlyQuota.Valid && key.MonthlyQuota.Int64 > 0 {
|
||||
if used, err := monthlyKeyUsage(ctx, db, key.ID, period); err == nil && used >= key.MonthlyQuota.Int64 {
|
||||
if used, err := monthlyUsage(ctx, db, "api_key_id", key.ID, period); err == nil && int64(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 := monthlyServiceUsage(ctx, db, svc.ID, period); err == nil && used >= svc.MonthlyQuota.Int64 {
|
||||
if used, err := monthlyUsage(ctx, db, "service_id", svc.ID, period); err == nil && int64(used) >= svc.MonthlyQuota.Int64 {
|
||||
respondError(c, http.StatusTooManyRequests, "QUOTA_EXCEEDED", "Service monthly quota exceeded")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
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
|
||||
@@ -26,31 +22,26 @@ 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()
|
||||
|
||||
ownerUUID, _ := uuid.Parse(ownerID)
|
||||
var providerID uuid.UUID
|
||||
var providerID string
|
||||
if !strings.Contains(cloneURL, "://") {
|
||||
// Bare owner/repo — the connected repository row knows the host.
|
||||
row, err := q.GetGitRepoCloneByFullName(ctx, sqlcdb.GetGitRepoCloneByFullNameParams{
|
||||
FullName: cloneURL,
|
||||
UserID: ownerUUID,
|
||||
})
|
||||
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)
|
||||
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.
|
||||
if pid, err := q.GetGitRepoProviderByCloneURL(ctx, sqlcdb.GetGitRepoProviderByCloneURLParams{
|
||||
CloneUrl: cloneURL,
|
||||
UserID: ownerUUID,
|
||||
}); err == nil {
|
||||
providerID = pid
|
||||
}
|
||||
_ = db.QueryRow(
|
||||
`SELECT provider_id FROM git_repositories
|
||||
WHERE clone_url = $1 AND user_id = $2 LIMIT 1`,
|
||||
cloneURL, ownerID,
|
||||
).Scan(&providerID)
|
||||
}
|
||||
|
||||
if err := source.ValidateRemoteURL(cloneURL); err != nil {
|
||||
@@ -58,17 +49,17 @@ func resolveGitCloneSpec(db *database.DB, ownerID, gitRepo, branch, commit, buil
|
||||
}
|
||||
spec.CloneURL = cloneURL
|
||||
|
||||
if providerID == uuid.Nil {
|
||||
if providerID == "" {
|
||||
return spec, nil
|
||||
}
|
||||
prov, err := q.GetGitProviderForFetch(ctx, sqlcdb.GetGitProviderForFetchParams{
|
||||
ID: providerID,
|
||||
UserID: ownerUUID,
|
||||
})
|
||||
if err != 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 {
|
||||
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,7 +15,6 @@ import (
|
||||
"time"
|
||||
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
"containr/internal/deployment"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -89,8 +88,8 @@ func handleAdminGitHubAppManifest(c *gin.Context) {
|
||||
|
||||
callbackURL := base + "/git/github-app/callback"
|
||||
manifest, err := json.Marshal(map[string]interface{}{
|
||||
"name": name,
|
||||
"url": base,
|
||||
"name": name,
|
||||
"url": base,
|
||||
"hook_attributes": map[string]interface{}{
|
||||
"url": base + "/api/git/github-app/webhook",
|
||||
"active": true,
|
||||
@@ -207,8 +206,9 @@ 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 v, err := sqlcdb.New(db.DB).GetAppSetting(c.Request.Context(), settingGitHubAppID); err == nil && strings.TrimSpace(v) != "" {
|
||||
if err := db.QueryRow(`SELECT value FROM app_settings WHERE key = $1`, settingGitHubAppID).Scan(&stored); err == nil && strings.TrimSpace(stored) != "" {
|
||||
source = "settings"
|
||||
} else if strings.TrimSpace(os.Getenv("GITHUB_APP_ID")) != "" {
|
||||
source = "env"
|
||||
|
||||
@@ -193,8 +193,8 @@ func handleAcceptInvite(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
existing, err := q.CountUsersByEmail(c.Request.Context(), req.Email)
|
||||
if err != nil {
|
||||
var existing int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM users WHERE email = $1`, req.Email).Scan(&existing); err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "INTERNAL", "database error")
|
||||
return
|
||||
}
|
||||
@@ -214,23 +214,17 @@ func handleAcceptInvite(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
row, err := q.InsertUser(c.Request.Context(), sqlcdb.InsertUserParams{
|
||||
Email: req.Email,
|
||||
PasswordHash: string(hashedPassword),
|
||||
Name: strings.TrimSpace(req.Name),
|
||||
})
|
||||
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)
|
||||
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,7 +9,6 @@ import (
|
||||
"time"
|
||||
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
"containr/internal/docker"
|
||||
|
||||
containertypes "github.com/docker/docker/api/types/container"
|
||||
@@ -61,12 +60,17 @@ func handleGetServiceMetrics(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
svc, err := sqlcdb.New(db.DB).GetServiceProjectAndStatus(context.Background(), serviceID)
|
||||
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)
|
||||
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,9 +315,10 @@ 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{} {
|
||||
pid, _ := uuid.Parse(projectID)
|
||||
ownerID, err := sqlcdb.New(r.db.DB).GetProjectOwnerText(context.Background(), pid)
|
||||
if err != nil {
|
||||
var ownerID string
|
||||
if err := r.db.QueryRow(
|
||||
`SELECT owner_id FROM projects WHERE id = $1`, projectID,
|
||||
).Scan(&ownerID); err != nil {
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
auth := registryAuthFor(r.db, ownerID, imageRef)
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -30,13 +29,15 @@ func notifyAdmins(db *database.DB, kind, title, body, resourceType, resourceID s
|
||||
if db == nil {
|
||||
return
|
||||
}
|
||||
adminIDs, err := sqlcdb.New(db.DB).ListAdminUserIDs(context.Background())
|
||||
rows, err := db.Query(`SELECT id FROM users WHERE is_admin`)
|
||||
if err != nil {
|
||||
log.Printf("containr: notify admins lookup failed: %v", err)
|
||||
return
|
||||
}
|
||||
for _, id := range adminIDs {
|
||||
{
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id uuid.UUID
|
||||
if err := rows.Scan(&id); err == nil {
|
||||
insertUserNotification(db, id.String(), kind, title, body, resourceType, resourceID)
|
||||
}
|
||||
}
|
||||
@@ -72,32 +73,34 @@ type agentHealthRow struct {
|
||||
func reconcileAgentHealth(db *database.DB, staleAfter time.Duration) {
|
||||
cutoff := time.Now().Add(-staleAfter)
|
||||
var agents []agentHealthRow
|
||||
q := sqlcdb.New(db.DB)
|
||||
agentRows, err := q.ListAgentHealthRows(context.Background(), sql.NullTime{Time: cutoff, Valid: true})
|
||||
rows, err := db.Query(
|
||||
`SELECT id, COALESCE(name, hostname, id::text), COALESCE(status, ''),
|
||||
COALESCE(last_heartbeat < $1, TRUE), auto_prune
|
||||
FROM node_agents`, cutoff)
|
||||
if err != nil {
|
||||
log.Printf("containr: agent health sweep: %v", err)
|
||||
return
|
||||
}
|
||||
for _, r := range agentRows {
|
||||
agents = append(agents, agentHealthRow{
|
||||
id: r.ID, name: r.Name, status: r.Status, stale: r.Stale, autoPrune: r.AutoPrune,
|
||||
})
|
||||
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)
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
for _, a := range agents {
|
||||
switch {
|
||||
case a.stale && a.status != "offline":
|
||||
if err := q.SetAgentStatus(context.Background(), sqlcdb.SetAgentStatusParams{
|
||||
Status: sql.NullString{String: "offline", Valid: true}, ID: a.id,
|
||||
}); err != nil {
|
||||
if _, err := db.Exec(
|
||||
`UPDATE node_agents SET status = 'offline', updated_at = NOW() WHERE id = $1`, 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 := q.SetAgentStatus(context.Background(), sqlcdb.SetAgentStatusParams{
|
||||
Status: sql.NullString{String: "online", Valid: true}, ID: a.id,
|
||||
}); err != nil {
|
||||
if _, err := db.Exec(
|
||||
`UPDATE node_agents SET status = 'online', updated_at = NOW() WHERE id = $1`, a.id); err != nil {
|
||||
continue
|
||||
}
|
||||
notifyAdmins(db, "agent", "Node agent back online",
|
||||
@@ -183,17 +186,17 @@ func checkForUpgrade(db *database.DB, currentVersion string) {
|
||||
return
|
||||
}
|
||||
|
||||
lastNotified, _ := sqlcdb.New(db.DB).GetAppSetting(context.Background(), "upgrade_notified_tag")
|
||||
var lastNotified string
|
||||
_ = db.QueryRow(`SELECT value FROM app_settings WHERE key = 'upgrade_notified_tag'`).Scan(&lastNotified)
|
||||
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)
|
||||
_ = sqlcdb.New(db.DB).UpsertAppSettingValue(context.Background(), sqlcdb.UpsertAppSettingValueParams{
|
||||
Key: "upgrade_notified_tag",
|
||||
Value: 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)
|
||||
}
|
||||
|
||||
// semverNewer reports whether candidate is a higher semver than current.
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
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
|
||||
@@ -58,59 +55,91 @@ func handleGetOperations(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
isAdmin := contextIsAdmin(c)
|
||||
|
||||
userUUID, _ := uuid.Parse(userID)
|
||||
q := sqlcdb.New(db.DB)
|
||||
visibility := sqlcdb.ListActiveOperationDeploymentsParams{OwnerID: userUUID, Column2: isAdmin}
|
||||
// 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)`
|
||||
|
||||
active := []operationDeployment{}
|
||||
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,
|
||||
})
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
recentFailed := []operationDeployment{}
|
||||
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,
|
||||
})
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
cronRuns := []operationCronRun{}
|
||||
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),
|
||||
})
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
backups := []operationBackup{}
|
||||
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),
|
||||
})
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
var queue []deployqueue.ServiceQueueState
|
||||
if queueValue, exists := c.Get("deploy_queue"); exists && queueValue != nil {
|
||||
queue = queueValue.(*deployqueue.Queue).Snapshot()
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
"containr/internal/secrets"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -114,8 +112,7 @@ func handleUpdateProjectVariables(c *gin.Context) {
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
txq := sqlcdb.New(tx)
|
||||
if err = txq.DeleteProjectVariables(c.Request.Context(), projectID); err != nil {
|
||||
if _, err = tx.Exec(`DELETE FROM project_variables WHERE project_id = $1`, projectID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clear existing variables"})
|
||||
return
|
||||
}
|
||||
@@ -130,15 +127,11 @@ func handleUpdateProjectVariables(c *gin.Context) {
|
||||
if v.IsSecret {
|
||||
value = secrets.Encrypt(value)
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to insert variable: " + v.Key})
|
||||
return
|
||||
}
|
||||
@@ -175,10 +168,11 @@ 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) {
|
||||
value, err := sqlcdb.New(db.DB).GetProjectVariableValue(context.Background(), sqlcdb.GetProjectVariableValueParams{
|
||||
ProjectID: projectID,
|
||||
Key: key,
|
||||
})
|
||||
var value string
|
||||
err := db.QueryRow(
|
||||
`SELECT value FROM project_variables WHERE project_id = $1 AND key = $2`,
|
||||
projectID, key,
|
||||
).Scan(&value)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
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"
|
||||
@@ -179,17 +177,17 @@ func handleDeleteRegistry(c *gin.Context) {
|
||||
// bare image names (nginx:latest).
|
||||
func registryAuthFor(db *database.DB, ownerID, imageRef string) registry.AuthConfig {
|
||||
host := registryHost(imageRef)
|
||||
ownerUUID, _ := uuid.Parse(ownerID)
|
||||
row, err := sqlcdb.New(db.DB).GetRegistryAuth(context.Background(), sqlcdb.GetRegistryAuthParams{
|
||||
OwnerID: ownerUUID,
|
||||
Host: host,
|
||||
})
|
||||
var username, password string
|
||||
err := db.QueryRow(
|
||||
`SELECT username, password FROM registries WHERE owner_id = $1 AND host = $2`,
|
||||
ownerID, host,
|
||||
).Scan(&username, &password)
|
||||
if err != nil {
|
||||
return registry.AuthConfig{}
|
||||
}
|
||||
return registry.AuthConfig{
|
||||
Username: row.Username,
|
||||
Password: secrets.Decrypt(row.Password),
|
||||
Username: username,
|
||||
Password: secrets.Decrypt(password),
|
||||
ServerAddress: host,
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"containr/internal/build"
|
||||
"containr/internal/config"
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
"containr/internal/deployment"
|
||||
"containr/internal/deployqueue"
|
||||
"containr/internal/docker"
|
||||
@@ -22,7 +21,6 @@ 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) {
|
||||
@@ -85,18 +83,19 @@ 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) {
|
||||
sid, err := uuid.Parse(serviceID)
|
||||
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)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
svc, err := sqlcdb.New(db.DB).GetServiceNameProjectPort(ctx, sid)
|
||||
if err != nil {
|
||||
if err := docker.ConnectSelfToNetwork(ctx, deployment.ProjectNetworkName(projectID)); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
if err := docker.ConnectSelfToNetwork(ctx, deployment.ProjectNetworkName(svc.ProjectID)); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return svc.Name, int(svc.Port), nil
|
||||
return name, port, nil
|
||||
})
|
||||
}
|
||||
haAPIManager := NewHAManager(haManager)
|
||||
@@ -138,25 +137,31 @@ 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.
|
||||
bootQ := sqlcdb.New(db.DB)
|
||||
_ = bootQ.FailInterruptedDeployments(context.Background())
|
||||
_, _ = 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')`,
|
||||
)
|
||||
|
||||
// Boot sweep: encrypt any plaintext secret values left over from before
|
||||
// encryption-at-rest landed. Transparent and idempotent.
|
||||
if encRows, err := bootQ.ListSecretEnvVars(context.Background()); err == nil {
|
||||
toEncrypt := 0
|
||||
for _, r := range encRows {
|
||||
if secrets.IsEncrypted(r.Value) {
|
||||
continue
|
||||
}
|
||||
if err := bootQ.UpdateEnvVarValue(context.Background(), sqlcdb.UpdateEnvVarValueParams{
|
||||
Value: secrets.Encrypt(r.Value), ID: r.ID,
|
||||
}); err == nil {
|
||||
toEncrypt++
|
||||
if encRows, err := db.Query(`SELECT id, value FROM environment_variables WHERE is_secret = TRUE`); err == nil {
|
||||
type encRow struct {
|
||||
id string
|
||||
value string
|
||||
}
|
||||
var toEncrypt []encRow
|
||||
for encRows.Next() {
|
||||
var r encRow
|
||||
if err := encRows.Scan(&r.id, &r.value); err == nil && !secrets.IsEncrypted(r.value) {
|
||||
toEncrypt = append(toEncrypt, r)
|
||||
}
|
||||
}
|
||||
if toEncrypt > 0 {
|
||||
log.Printf("secrets: encrypted %d plaintext secret variable(s)", 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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ package api
|
||||
|
||||
import (
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
"containr/internal/deployment"
|
||||
"containr/internal/deployqueue"
|
||||
"context"
|
||||
@@ -71,18 +70,20 @@ 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) {
|
||||
row, err := sqlcdb.New(db.DB).GetServiceSleep(context.Background(), s.ID)
|
||||
if err == nil {
|
||||
s.SleepEnabled = row.SleepEnabled
|
||||
s.SleepIdleMinutes = int(row.SleepIdleMinutes)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
id, err := sqlcdb.New(db.DB).GetServiceNodeID(context.Background(), serviceID)
|
||||
if err == nil {
|
||||
var id sql.NullString
|
||||
if err := db.QueryRow(`SELECT node_id FROM services WHERE id = $1`, serviceID).Scan(&id); err == nil {
|
||||
return id.String
|
||||
}
|
||||
return ""
|
||||
@@ -90,14 +91,17 @@ 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 {
|
||||
spread, err := sqlcdb.New(db.DB).GetServiceSpread(context.Background(), serviceID)
|
||||
return err == nil && spread
|
||||
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
|
||||
}
|
||||
|
||||
// servicePlacementTags reads the placement_tags column with the lazy pattern.
|
||||
func servicePlacementTags(db *database.DB, serviceID uuid.UUID) []string {
|
||||
raw, err := sqlcdb.New(db.DB).GetServicePlacementTags(context.Background(), serviceID)
|
||||
if err != nil {
|
||||
var raw []byte
|
||||
if err := db.QueryRow(`SELECT placement_tags FROM services WHERE id = $1`, serviceID).Scan(&raw); err != nil {
|
||||
return nil
|
||||
}
|
||||
var tags []string
|
||||
@@ -175,8 +179,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 {
|
||||
raw, err := sqlcdb.New(db.DB).GetServiceTraefikLabels(context.Background(), serviceID)
|
||||
if err != nil {
|
||||
var raw []byte
|
||||
if err := db.QueryRow(`SELECT traefik_labels FROM services WHERE id = $1`, serviceID).Scan(&raw); err != nil {
|
||||
return nil
|
||||
}
|
||||
var labels map[string]string
|
||||
@@ -207,6 +211,7 @@ 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)
|
||||
@@ -214,8 +219,8 @@ func loadServiceNode(db *database.DB, s *Service) {
|
||||
if s.NodeID == "" {
|
||||
return
|
||||
}
|
||||
if name, err := sqlcdb.New(db.DB).GetNodeAgentName(context.Background(), s.NodeID); err == nil {
|
||||
s.NodeName = name
|
||||
if err := db.QueryRow(`SELECT name FROM node_agents WHERE id = $1`, s.NodeID).Scan(&name); err == nil {
|
||||
s.NodeName = name.String
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,8 +235,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 {
|
||||
raw, err := sqlcdb.New(db.DB).GetServiceVolumes(context.Background(), serviceID)
|
||||
if err != nil {
|
||||
var raw []byte
|
||||
if err := db.QueryRow(`SELECT volumes FROM services WHERE id = $1`, serviceID).Scan(&raw); err != nil {
|
||||
return nil
|
||||
}
|
||||
var stored []ServiceVolume
|
||||
@@ -698,11 +703,13 @@ func handleCreateService(c *gin.Context) {
|
||||
}
|
||||
|
||||
func getProjectEnvironmentID(db *database.DB, projectID uuid.UUID, environment string) (uuid.UUID, error) {
|
||||
row, err := sqlcdb.New(db.DB).GetProjectEnvironmentByName(context.Background(), sqlcdb.GetProjectEnvironmentByNameParams{
|
||||
ProjectID: projectID,
|
||||
Name: environment,
|
||||
})
|
||||
return row.ID, err
|
||||
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
|
||||
}
|
||||
|
||||
func inferServiceSourceType(service Service) string {
|
||||
|
||||
@@ -10,13 +10,11 @@ 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
|
||||
@@ -72,7 +70,8 @@ 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 {
|
||||
value, err := sqlcdb.New(db.DB).GetAppSetting(context.Background(), key)
|
||||
var value string
|
||||
err := db.QueryRow(`SELECT value FROM app_settings WHERE key = $1`, key).Scan(&value)
|
||||
if err == nil {
|
||||
return value
|
||||
}
|
||||
@@ -89,15 +88,18 @@ func settingValue(db *database.DB, key, envKey, fallback string) string {
|
||||
}
|
||||
|
||||
func setSetting(db *database.DB, key, value string, secret bool) error {
|
||||
return sqlcdb.New(db.DB).UpsertAppSetting(context.Background(), sqlcdb.UpsertAppSettingParams{
|
||||
Key: key,
|
||||
Value: value,
|
||||
IsSecret: secret,
|
||||
})
|
||||
_, 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
|
||||
}
|
||||
|
||||
func deleteSetting(db *database.DB, key string) error {
|
||||
return sqlcdb.New(db.DB).DeleteAppSetting(context.Background(), key)
|
||||
_, err := db.Exec(`DELETE FROM app_settings WHERE key = $1`, key)
|
||||
return err
|
||||
}
|
||||
|
||||
// signupEnabled controls whether public registration stays open after the
|
||||
@@ -108,12 +110,11 @@ func signupEnabled(db *database.DB) bool {
|
||||
}
|
||||
|
||||
func isAdminUser(db *database.DB, userID string) bool {
|
||||
parsed, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
var isAdmin bool
|
||||
if err := db.QueryRow(`SELECT is_admin FROM users WHERE id = $1`, userID).Scan(&isAdmin); err != nil {
|
||||
return false
|
||||
}
|
||||
isAdmin, err := sqlcdb.New(db.DB).GetUserIsAdmin(context.Background(), parsed)
|
||||
return err == nil && isAdmin
|
||||
return isAdmin
|
||||
}
|
||||
|
||||
func requireAdmin(c *gin.Context) (*database.DB, bool) {
|
||||
@@ -241,7 +242,8 @@ 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 {
|
||||
if value, err := sqlcdb.New(db.DB).GetAppSetting(context.Background(), settingCloudflareTunnelToken); err == nil && strings.TrimSpace(value) != "" {
|
||||
var value string
|
||||
if err := db.QueryRow(`SELECT value FROM app_settings WHERE key = $1`, settingCloudflareTunnelToken).Scan(&value); err == nil && strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value), "app"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,10 @@ 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"
|
||||
)
|
||||
@@ -27,7 +25,8 @@ func setupComplete(db *database.DB, hasUsers bool) bool {
|
||||
if !hasUsers {
|
||||
return false
|
||||
}
|
||||
if projects, err := sqlcdb.New(db.DB).CountProjects(context.Background()); err == nil && projects > 0 {
|
||||
var projects int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM projects`).Scan(&projects); err == nil && projects > 0 {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
||||
@@ -640,20 +640,19 @@ 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)
|
||||
}
|
||||
_ = q.DeleteEnvVarsForService(context.Background(), sid)
|
||||
_ = q.DeleteServiceByID(context.Background(), sid)
|
||||
_, _ = db.Exec(`DELETE FROM environment_variables WHERE service_id = $1`, sid)
|
||||
_, _ = db.Exec(`DELETE FROM services WHERE id = $1`, 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)
|
||||
}
|
||||
_ = q.DeleteDatabaseServiceByID(context.Background(), did)
|
||||
_, _ = db.Exec(`DELETE FROM database_services WHERE id = $1`, did)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -773,19 +772,14 @@ func deployTemplateGraph(
|
||||
if builder == "" {
|
||||
builder = "auto"
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
tx.Rollback()
|
||||
cleanup()
|
||||
return nil, &gin.H{"error": "Failed to configure service"}
|
||||
@@ -867,24 +861,15 @@ func queueTemplateDeployment(c *gin.Context, db *database.DB, engine *deployment
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
return uuid.Nil
|
||||
}
|
||||
_ = q.SetServiceStatus(context.Background(), sqlcdb.SetServiceStatusParams{
|
||||
Status: sql.NullString{String: "queued", Valid: true},
|
||||
UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true},
|
||||
ID: service.ID,
|
||||
})
|
||||
_, _ = db.Exec(`UPDATE services SET status = 'queued', updated_at = $1 WHERE id = $2`, time.Now(), service.ID)
|
||||
|
||||
getDeployQueue(c).Enqueue(service.ID, deployqueue.Job{
|
||||
DeploymentID: dep.ID,
|
||||
|
||||
@@ -411,11 +411,12 @@ func handleCreateFromTemplate(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err = sqlcdb.New(tx).UpdateServiceRuntimeMeta(ctx, sqlcdb.UpdateServiceRuntimeMetaParams{
|
||||
Port: int32(config.Port),
|
||||
HealthcheckPath: strings.TrimSpace(config.HealthCheck),
|
||||
ID: serviceID,
|
||||
}); err != nil {
|
||||
// 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 {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create service from template"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"containr/internal/database"
|
||||
"containr/internal/secrets"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
"containr/internal/secrets"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
@@ -150,8 +147,7 @@ func handleUpdateVariables(c *gin.Context) {
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
txq := sqlcdb.New(tx)
|
||||
err = txq.DeleteEnvVarsForService(c.Request.Context(), serviceID)
|
||||
_, err = tx.Exec("DELETE FROM environment_variables WHERE service_id = $1", serviceID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clear existing variables"})
|
||||
return
|
||||
@@ -168,15 +164,12 @@ func handleUpdateVariables(c *gin.Context) {
|
||||
if v.IsSecret {
|
||||
value = secrets.Encrypt(value)
|
||||
}
|
||||
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},
|
||||
})
|
||||
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,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to insert variable: " + v.Key})
|
||||
return
|
||||
|
||||
@@ -1,636 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: audit.sql
|
||||
|
||||
package sqlcdb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/sqlc-dev/pqtype"
|
||||
)
|
||||
|
||||
const insertAuditLog = `-- name: InsertAuditLog :exec
|
||||
INSERT INTO audit_logs (id, user_id, resource, resource_id, action, details, severity, category, label, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
`
|
||||
|
||||
type InsertAuditLogParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
UserID uuid.NullUUID `json:"user_id"`
|
||||
Resource string `json:"resource"`
|
||||
ResourceID uuid.NullUUID `json:"resource_id"`
|
||||
Action string `json:"action"`
|
||||
Details pqtype.NullRawMessage `json:"details"`
|
||||
Severity string `json:"severity"`
|
||||
Category string `json:"category"`
|
||||
Label string `json:"label"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) InsertAuditLog(ctx context.Context, arg InsertAuditLogParams) error {
|
||||
_, err := q.db.ExecContext(ctx, insertAuditLog,
|
||||
arg.ID,
|
||||
arg.UserID,
|
||||
arg.Resource,
|
||||
arg.ResourceID,
|
||||
arg.Action,
|
||||
arg.Details,
|
||||
arg.Severity,
|
||||
arg.Category,
|
||||
arg.Label,
|
||||
arg.CreatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const insertAuditLogWithRequest = `-- name: InsertAuditLogWithRequest :exec
|
||||
INSERT INTO audit_logs (id, user_id, resource, resource_id, action, details, ip_address, user_agent, severity, category, label, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7::inet, $8, $9, $10, $11, $12)
|
||||
`
|
||||
|
||||
type InsertAuditLogWithRequestParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
UserID uuid.NullUUID `json:"user_id"`
|
||||
Resource string `json:"resource"`
|
||||
ResourceID uuid.NullUUID `json:"resource_id"`
|
||||
Action string `json:"action"`
|
||||
Column6 json.RawMessage `json:"column_6"`
|
||||
Column7 pqtype.Inet `json:"column_7"`
|
||||
UserAgent sql.NullString `json:"user_agent"`
|
||||
Severity string `json:"severity"`
|
||||
Category string `json:"category"`
|
||||
Label string `json:"label"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) InsertAuditLogWithRequest(ctx context.Context, arg InsertAuditLogWithRequestParams) error {
|
||||
_, err := q.db.ExecContext(ctx, insertAuditLogWithRequest,
|
||||
arg.ID,
|
||||
arg.UserID,
|
||||
arg.Resource,
|
||||
arg.ResourceID,
|
||||
arg.Action,
|
||||
arg.Column6,
|
||||
arg.Column7,
|
||||
arg.UserAgent,
|
||||
arg.Severity,
|
||||
arg.Category,
|
||||
arg.Label,
|
||||
arg.CreatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const listAuditBackfillBatch = `-- name: ListAuditBackfillBatch :many
|
||||
SELECT id::text, resource, action FROM audit_logs
|
||||
WHERE label = '' AND id > $1::uuid ORDER BY id LIMIT 500
|
||||
`
|
||||
|
||||
type ListAuditBackfillBatchRow struct {
|
||||
ID string `json:"id"`
|
||||
Resource string `json:"resource"`
|
||||
Action string `json:"action"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListAuditBackfillBatch(ctx context.Context, dollar_1 uuid.UUID) ([]ListAuditBackfillBatchRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listAuditBackfillBatch, dollar_1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ListAuditBackfillBatchRow{}
|
||||
for rows.Next() {
|
||||
var i ListAuditBackfillBatchRow
|
||||
if err := rows.Scan(&i.ID, &i.Resource, &i.Action); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listResourceAuditLogs = `-- name: ListResourceAuditLogs :many
|
||||
SELECT id, COALESCE(user_id::text, '')::text AS user_id, resource,
|
||||
COALESCE(resource_id::text, '')::text AS resource_id, action,
|
||||
COALESCE(details::text, '{}')::text AS details,
|
||||
COALESCE(ip_address::text, '')::text AS ip_address,
|
||||
COALESCE(user_agent, '') AS user_agent, created_at
|
||||
FROM audit_logs
|
||||
WHERE user_id::text = $1 AND resource = $2 AND resource_id::text = $3
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 100
|
||||
`
|
||||
|
||||
type ListResourceAuditLogsParams struct {
|
||||
UserID uuid.NullUUID `json:"user_id"`
|
||||
Resource string `json:"resource"`
|
||||
ResourceID uuid.NullUUID `json:"resource_id"`
|
||||
}
|
||||
|
||||
type ListResourceAuditLogsRow struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Resource string `json:"resource"`
|
||||
ResourceID string `json:"resource_id"`
|
||||
Action string `json:"action"`
|
||||
Details string `json:"details"`
|
||||
IpAddress string `json:"ip_address"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListResourceAuditLogs(ctx context.Context, arg ListResourceAuditLogsParams) ([]ListResourceAuditLogsRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listResourceAuditLogs, arg.UserID, arg.Resource, arg.ResourceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ListResourceAuditLogsRow{}
|
||||
for rows.Next() {
|
||||
var i ListResourceAuditLogsRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.Resource,
|
||||
&i.ResourceID,
|
||||
&i.Action,
|
||||
&i.Details,
|
||||
&i.IpAddress,
|
||||
&i.UserAgent,
|
||||
&i.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const pruneAuditLogs = `-- name: PruneAuditLogs :execrows
|
||||
DELETE FROM audit_logs WHERE created_at < NOW() - ($1::int * INTERVAL '1 day')
|
||||
`
|
||||
|
||||
func (q *Queries) PruneAuditLogs(ctx context.Context, dollar_1 int32) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, pruneAuditLogs, dollar_1)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const setAuditLogMetadata = `-- name: SetAuditLogMetadata :exec
|
||||
UPDATE audit_logs SET severity = $1, category = $2, label = $3 WHERE id = $4::uuid
|
||||
`
|
||||
|
||||
type SetAuditLogMetadataParams struct {
|
||||
Severity string `json:"severity"`
|
||||
Category string `json:"category"`
|
||||
Label string `json:"label"`
|
||||
Column4 uuid.UUID `json:"column_4"`
|
||||
}
|
||||
|
||||
func (q *Queries) SetAuditLogMetadata(ctx context.Context, arg SetAuditLogMetadataParams) error {
|
||||
_, err := q.db.ExecContext(ctx, setAuditLogMetadata,
|
||||
arg.Severity,
|
||||
arg.Category,
|
||||
arg.Label,
|
||||
arg.Column4,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -34,24 +34,6 @@ func (q *Queries) CountUsersByEmail(ctx context.Context, email string) (int64, e
|
||||
return count, err
|
||||
}
|
||||
|
||||
const getAdminUserInfo = `-- name: GetAdminUserInfo :one
|
||||
SELECT email, COALESCE(name, '') AS name, is_admin
|
||||
FROM users WHERE id = $1
|
||||
`
|
||||
|
||||
type GetAdminUserInfoRow struct {
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetAdminUserInfo(ctx context.Context, id uuid.UUID) (GetAdminUserInfoRow, error) {
|
||||
row := q.db.QueryRowContext(ctx, getAdminUserInfo, id)
|
||||
var i GetAdminUserInfoRow
|
||||
err := row.Scan(&i.Email, &i.Name, &i.IsAdmin)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUserByEmailForAuth = `-- name: GetUserByEmailForAuth :one
|
||||
SELECT id, email, password_hash, name, COALESCE(avatar_url, '') AS avatar_url, is_admin, created_at
|
||||
FROM users WHERE email = $1
|
||||
@@ -186,69 +168,6 @@ func (q *Queries) InsertUserAdmin(ctx context.Context, arg InsertUserAdminParams
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listAdminUsers = `-- name: ListAdminUsers :many
|
||||
SELECT id, email, name, COALESCE(avatar_url, '') AS avatar_url, is_admin,
|
||||
COALESCE(to_char(created_at, 'YYYY-MM-DD"T"HH24:MI:SS"Z"'), '')::text AS created_at
|
||||
FROM users
|
||||
ORDER BY created_at ASC
|
||||
`
|
||||
|
||||
type ListAdminUsersRow struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
AvatarUrl string `json:"avatar_url"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListAdminUsers(ctx context.Context) ([]ListAdminUsersRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listAdminUsers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ListAdminUsersRow{}
|
||||
for rows.Next() {
|
||||
var i ListAdminUsersRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Email,
|
||||
&i.Name,
|
||||
&i.AvatarUrl,
|
||||
&i.IsAdmin,
|
||||
&i.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const setUserAdmin = `-- name: SetUserAdmin :execrows
|
||||
UPDATE users SET is_admin = $1, updated_at = NOW() WHERE id = $2
|
||||
`
|
||||
|
||||
type SetUserAdminParams struct {
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
}
|
||||
|
||||
func (q *Queries) SetUserAdmin(ctx context.Context, arg SetUserAdminParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, setUserAdmin, arg.IsAdmin, arg.ID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const updateUserProfile = `-- name: UpdateUserProfile :exec
|
||||
UPDATE users
|
||||
SET name = COALESCE($1::varchar, name), avatar_url = COALESCE($2::varchar, avatar_url)
|
||||
@@ -266,27 +185,6 @@ func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfilePa
|
||||
return err
|
||||
}
|
||||
|
||||
const upsertAdminUser = `-- name: UpsertAdminUser :one
|
||||
INSERT INTO users (email, password_hash, name, is_admin)
|
||||
VALUES ($1, $2, $3, true)
|
||||
ON CONFLICT (email) DO UPDATE
|
||||
SET is_admin = true, updated_at = NOW()
|
||||
RETURNING id
|
||||
`
|
||||
|
||||
type UpsertAdminUserParams struct {
|
||||
Email string `json:"email"`
|
||||
PasswordHash string `json:"password_hash"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpsertAdminUser(ctx context.Context, arg UpsertAdminUserParams) (uuid.UUID, error) {
|
||||
row := q.db.QueryRowContext(ctx, upsertAdminUser, arg.Email, arg.PasswordHash, arg.Name)
|
||||
var id uuid.UUID
|
||||
err := row.Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
const upsertLocalUser = `-- name: UpsertLocalUser :one
|
||||
INSERT INTO users (email, password_hash, name, avatar_url, is_admin)
|
||||
VALUES ($1, $2, $3, NULLIF($4::varchar, ''), $5)
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: builds.sql
|
||||
|
||||
package sqlcdb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
const cancelBuild = `-- name: CancelBuild :execrows
|
||||
UPDATE builds
|
||||
SET status = 'cancelled',
|
||||
progress = 100,
|
||||
completed_at = $1,
|
||||
log = COALESCE(log, '') || $2,
|
||||
updated_at = $1
|
||||
WHERE id = $3
|
||||
AND status NOT IN ('success', 'failed', 'cancelled')
|
||||
`
|
||||
|
||||
type CancelBuildParams struct {
|
||||
CompletedAt sql.NullTime `json:"completed_at"`
|
||||
Log string `json:"log"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
func (q *Queries) CancelBuild(ctx context.Context, arg CancelBuildParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, cancelBuild, arg.CompletedAt, arg.Log, arg.ID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const getBuild = `-- name: GetBuild :one
|
||||
SELECT id, project_id, service_id, status, progress, started_at, completed_at, image_name, image_tag, size, error, log, metadata
|
||||
FROM builds
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
type GetBuildRow struct {
|
||||
ID string `json:"id"`
|
||||
ProjectID sql.NullString `json:"project_id"`
|
||||
ServiceID sql.NullString `json:"service_id"`
|
||||
Status string `json:"status"`
|
||||
Progress int32 `json:"progress"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
CompletedAt sql.NullTime `json:"completed_at"`
|
||||
ImageName string `json:"image_name"`
|
||||
ImageTag string `json:"image_tag"`
|
||||
Size int64 `json:"size"`
|
||||
Error sql.NullString `json:"error"`
|
||||
Log string `json:"log"`
|
||||
Metadata json.RawMessage `json:"metadata"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetBuild(ctx context.Context, id string) (GetBuildRow, error) {
|
||||
row := q.db.QueryRowContext(ctx, getBuild, id)
|
||||
var i GetBuildRow
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.ServiceID,
|
||||
&i.Status,
|
||||
&i.Progress,
|
||||
&i.StartedAt,
|
||||
&i.CompletedAt,
|
||||
&i.ImageName,
|
||||
&i.ImageTag,
|
||||
&i.Size,
|
||||
&i.Error,
|
||||
&i.Log,
|
||||
&i.Metadata,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const upsertBuild = `-- name: UpsertBuild :exec
|
||||
INSERT INTO builds
|
||||
(id, project_id, service_id, status, progress, started_at, completed_at, image_name, image_tag, size, error, log, metadata, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb, $14, $14)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
project_id = EXCLUDED.project_id,
|
||||
service_id = EXCLUDED.service_id,
|
||||
status = EXCLUDED.status,
|
||||
progress = EXCLUDED.progress,
|
||||
started_at = EXCLUDED.started_at,
|
||||
completed_at = EXCLUDED.completed_at,
|
||||
image_name = EXCLUDED.image_name,
|
||||
image_tag = EXCLUDED.image_tag,
|
||||
size = EXCLUDED.size,
|
||||
error = EXCLUDED.error,
|
||||
log = EXCLUDED.log,
|
||||
metadata = EXCLUDED.metadata,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
`
|
||||
|
||||
type UpsertBuildParams struct {
|
||||
ID string `json:"id"`
|
||||
ProjectID sql.NullString `json:"project_id"`
|
||||
ServiceID sql.NullString `json:"service_id"`
|
||||
Status string `json:"status"`
|
||||
Progress int32 `json:"progress"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
CompletedAt sql.NullTime `json:"completed_at"`
|
||||
ImageName string `json:"image_name"`
|
||||
ImageTag string `json:"image_tag"`
|
||||
Size int64 `json:"size"`
|
||||
Error sql.NullString `json:"error"`
|
||||
Log string `json:"log"`
|
||||
Column13 json.RawMessage `json:"column_13"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpsertBuild(ctx context.Context, arg UpsertBuildParams) error {
|
||||
_, err := q.db.ExecContext(ctx, upsertBuild,
|
||||
arg.ID,
|
||||
arg.ProjectID,
|
||||
arg.ServiceID,
|
||||
arg.Status,
|
||||
arg.Progress,
|
||||
arg.StartedAt,
|
||||
arg.CompletedAt,
|
||||
arg.ImageName,
|
||||
arg.ImageTag,
|
||||
arg.Size,
|
||||
arg.Error,
|
||||
arg.Log,
|
||||
arg.Column13,
|
||||
arg.CreatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -157,28 +157,6 @@ func (q *Queries) GetGitProviderTimestamps(ctx context.Context, id uuid.UUID) (G
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getGitRepoCloneByFullName = `-- name: GetGitRepoCloneByFullName :one
|
||||
SELECT clone_url, provider_id FROM git_repositories
|
||||
WHERE full_name = $1 AND user_id = $2 LIMIT 1
|
||||
`
|
||||
|
||||
type GetGitRepoCloneByFullNameParams struct {
|
||||
FullName string `json:"full_name"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
}
|
||||
|
||||
type GetGitRepoCloneByFullNameRow struct {
|
||||
CloneUrl string `json:"clone_url"`
|
||||
ProviderID uuid.UUID `json:"provider_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetGitRepoCloneByFullName(ctx context.Context, arg GetGitRepoCloneByFullNameParams) (GetGitRepoCloneByFullNameRow, error) {
|
||||
row := q.db.QueryRowContext(ctx, getGitRepoCloneByFullName, arg.FullName, arg.UserID)
|
||||
var i GetGitRepoCloneByFullNameRow
|
||||
err := row.Scan(&i.CloneUrl, &i.ProviderID)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getGitRepoForPush = `-- name: GetGitRepoForPush :one
|
||||
SELECT clone_url, full_name, user_id FROM git_repositories WHERE id = $1
|
||||
`
|
||||
@@ -196,23 +174,6 @@ func (q *Queries) GetGitRepoForPush(ctx context.Context, id uuid.UUID) (GetGitRe
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getGitRepoProviderByCloneURL = `-- name: GetGitRepoProviderByCloneURL :one
|
||||
SELECT provider_id FROM git_repositories
|
||||
WHERE clone_url = $1 AND user_id = $2 LIMIT 1
|
||||
`
|
||||
|
||||
type GetGitRepoProviderByCloneURLParams struct {
|
||||
CloneUrl string `json:"clone_url"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetGitRepoProviderByCloneURL(ctx context.Context, arg GetGitRepoProviderByCloneURLParams) (uuid.UUID, error) {
|
||||
row := q.db.QueryRowContext(ctx, getGitRepoProviderByCloneURL, arg.CloneUrl, arg.UserID)
|
||||
var provider_id uuid.UUID
|
||||
err := row.Scan(&provider_id)
|
||||
return provider_id, err
|
||||
}
|
||||
|
||||
const getGitRepositoryByFullName = `-- name: GetGitRepositoryByFullName :one
|
||||
SELECT id, provider_id, name, full_name, COALESCE(description, '') AS description,
|
||||
clone_url, COALESCE(webhook_url, '') AS webhook_url,
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
// 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,66 +49,6 @@ 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"`
|
||||
@@ -138,24 +78,6 @@ 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"`
|
||||
@@ -340,18 +262,6 @@ type GitWebhook struct {
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
}
|
||||
|
||||
type IncidentEvent struct {
|
||||
ID int32 `json:"id"`
|
||||
ServiceID uuid.NullUUID `json:"service_id"`
|
||||
ApiKeyID uuid.NullUUID `json:"api_key_id"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Severity string `json:"severity"`
|
||||
HttpStatus sql.NullInt32 `json:"http_status"`
|
||||
Count int32 `json:"count"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
}
|
||||
|
||||
type InstanceMetric struct {
|
||||
ServiceID string `json:"service_id"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
@@ -377,14 +287,6 @@ 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"`
|
||||
@@ -506,16 +408,6 @@ 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"`
|
||||
@@ -643,15 +535,6 @@ 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"`
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
// 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,7 +16,6 @@ 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)
|
||||
@@ -26,8 +25,6 @@ type Querier interface {
|
||||
CompleteCommand(ctx context.Context, arg CompleteCommandParams) (AgentCommand, error)
|
||||
CompleteDeployment(ctx context.Context, arg CompleteDeploymentParams) error
|
||||
ComplianceFrameworkExists(ctx context.Context, id uuid.UUID) (bool, error)
|
||||
CountAPKeys(ctx context.Context) (int64, error)
|
||||
CountAPServices(ctx context.Context) (int64, error)
|
||||
CountAccessibleDeployments(ctx context.Context, arg CountAccessibleDeploymentsParams) (int64, error)
|
||||
CountActivePreviewsForBranch(ctx context.Context, arg CountActivePreviewsForBranchParams) (int64, error)
|
||||
CountDatabaseServicesByUserAndName(ctx context.Context, arg CountDatabaseServicesByUserAndNameParams) (int64, error)
|
||||
@@ -35,7 +32,6 @@ type Querier interface {
|
||||
CountDeploymentsForService(ctx context.Context, serviceID uuid.UUID) (int64, error)
|
||||
CountEnvironmentServices(ctx context.Context, environmentID uuid.UUID) (int64, error)
|
||||
CountGitRepositories(ctx context.Context, arg CountGitRepositoriesParams) (int64, error)
|
||||
CountProjects(ctx context.Context) (int64, error)
|
||||
CountProjectsByUser(ctx context.Context, arg CountProjectsByUserParams) (int64, error)
|
||||
CountServiceDomains(ctx context.Context, serviceID uuid.UUID) (int64, error)
|
||||
CountServicesByName(ctx context.Context, arg CountServicesByNameParams) (int64, error)
|
||||
@@ -67,39 +63,30 @@ 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)
|
||||
@@ -116,15 +103,12 @@ type Querier interface {
|
||||
GetDeploymentAccess(ctx context.Context, id uuid.UUID) (GetDeploymentAccessRow, error)
|
||||
GetDeploymentForRollback(ctx context.Context, id uuid.UUID) (GetDeploymentForRollbackRow, error)
|
||||
GetDeploymentWithProject(ctx context.Context, id uuid.UUID) (GetDeploymentWithProjectRow, error)
|
||||
GetEnvironmentProjectID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
|
||||
GetGitProviderForFetch(ctx context.Context, arg GetGitProviderForFetchParams) (GetGitProviderForFetchRow, error)
|
||||
GetGitProviderForRepos(ctx context.Context, arg GetGitProviderForReposParams) (GetGitProviderForReposRow, error)
|
||||
GetGitProviderName(ctx context.Context, arg GetGitProviderNameParams) (string, error)
|
||||
GetGitProviderNameByID(ctx context.Context, id uuid.UUID) (string, error)
|
||||
GetGitProviderTimestamps(ctx context.Context, id uuid.UUID) (GetGitProviderTimestampsRow, error)
|
||||
GetGitRepoCloneByFullName(ctx context.Context, arg GetGitRepoCloneByFullNameParams) (GetGitRepoCloneByFullNameRow, error)
|
||||
GetGitRepoForPush(ctx context.Context, id uuid.UUID) (GetGitRepoForPushRow, error)
|
||||
GetGitRepoProviderByCloneURL(ctx context.Context, arg GetGitRepoProviderByCloneURLParams) (uuid.UUID, error)
|
||||
GetGitRepositoryByFullName(ctx context.Context, arg GetGitRepositoryByFullNameParams) (GetGitRepositoryByFullNameRow, error)
|
||||
GetGitRepositoryProviderID(ctx context.Context, arg GetGitRepositoryProviderIDParams) (uuid.UUID, error)
|
||||
GetGitWebhookForPush(ctx context.Context, id uuid.UUID) (GetGitWebhookForPushRow, error)
|
||||
@@ -132,7 +116,6 @@ type Querier interface {
|
||||
GetLastDeployedImage(ctx context.Context, serviceID uuid.UUID) (string, error)
|
||||
GetLatestComplianceReport(ctx context.Context, projectID uuid.UUID) (GetLatestComplianceReportRow, error)
|
||||
GetLatestSecurityScan(ctx context.Context, projectID uuid.UUID) (GetLatestSecurityScanRow, error)
|
||||
GetNodeAgentName(ctx context.Context, id string) (string, error)
|
||||
GetOutboundWebhookByID(ctx context.Context, id uuid.UUID) (OutboundWebhook, error)
|
||||
GetOutboundWebhookByIDAndUser(ctx context.Context, arg GetOutboundWebhookByIDAndUserParams) (OutboundWebhook, error)
|
||||
GetPreviewEnvironment(ctx context.Context, arg GetPreviewEnvironmentParams) (GetPreviewEnvironmentRow, error)
|
||||
@@ -147,10 +130,7 @@ type Querier interface {
|
||||
GetProjectOwnerByID(ctx context.Context, projectID uuid.UUID) (uuid.UUID, error)
|
||||
GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
|
||||
GetProjectOwnerText(ctx context.Context, id uuid.UUID) (string, error)
|
||||
GetProjectReadAccess(ctx context.Context, id uuid.UUID) (GetProjectReadAccessRow, error)
|
||||
GetProjectRoleForUser(ctx context.Context, arg GetProjectRoleForUserParams) (string, error)
|
||||
GetProjectVariableValue(ctx context.Context, arg GetProjectVariableValueParams) (string, error)
|
||||
GetRegistryAuth(ctx context.Context, arg GetRegistryAuthParams) (GetRegistryAuthRow, error)
|
||||
GetSecurityScanProjectID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
|
||||
GetServiceAccess(ctx context.Context, id uuid.UUID) (GetServiceAccessRow, error)
|
||||
GetServiceDomainDefault(ctx context.Context, serviceID uuid.UUID) (string, error)
|
||||
@@ -159,67 +139,39 @@ type Querier interface {
|
||||
GetServiceEnvironment(ctx context.Context, id uuid.UUID) (string, error)
|
||||
GetServiceForDeploy(ctx context.Context, id uuid.UUID) (GetServiceForDeployRow, error)
|
||||
GetServiceForDeployWithOwner(ctx context.Context, id uuid.UUID) (GetServiceForDeployWithOwnerRow, error)
|
||||
GetServiceNameProjectPort(ctx context.Context, id uuid.UUID) (GetServiceNameProjectPortRow, error)
|
||||
GetServiceNodeID(ctx context.Context, id uuid.UUID) (sql.NullString, error)
|
||||
GetServicePlacementBrief(ctx context.Context, id uuid.UUID) (GetServicePlacementBriefRow, error)
|
||||
GetServicePlacementTags(ctx context.Context, id uuid.UUID) (json.RawMessage, error)
|
||||
GetServicePort(ctx context.Context, id uuid.UUID) (int32, error)
|
||||
GetServiceProjectAndStatus(ctx context.Context, id uuid.UUID) (GetServiceProjectAndStatusRow, error)
|
||||
GetServiceProjectID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
|
||||
GetServicePublishedPort(ctx context.Context, id uuid.UUID) (int32, error)
|
||||
GetServiceRuntimeWithOwner(ctx context.Context, id uuid.UUID) (GetServiceRuntimeWithOwnerRow, error)
|
||||
GetServiceSleep(ctx context.Context, id uuid.UUID) (GetServiceSleepRow, error)
|
||||
GetServiceSpread(ctx context.Context, id uuid.UUID) (bool, error)
|
||||
GetServiceStatusText(ctx context.Context, id uuid.UUID) (string, error)
|
||||
GetServiceTemplateByID(ctx context.Context, id string) (ServiceTemplate, error)
|
||||
GetServiceTraefikLabels(ctx context.Context, id uuid.UUID) (json.RawMessage, error)
|
||||
GetServiceTypeBrief(ctx context.Context, arg GetServiceTypeBriefParams) (GetServiceTypeBriefRow, error)
|
||||
GetServiceVariableValue(ctx context.Context, arg GetServiceVariableValueParams) (string, error)
|
||||
GetServiceVolumes(ctx context.Context, id uuid.UUID) (json.RawMessage, error)
|
||||
GetUserByEmailForAuth(ctx context.Context, email string) (GetUserByEmailForAuthRow, error)
|
||||
GetUserByID(ctx context.Context, id uuid.UUID) (GetUserByIDRow, error)
|
||||
GetUserInviteByTokenHash(ctx context.Context, tokenHash string) (UserInvite, error)
|
||||
GetUserIsAdmin(ctx context.Context, id uuid.UUID) (bool, error)
|
||||
GetUserTokenByHash(ctx context.Context, tokenHash string) (UserToken, error)
|
||||
GetVulnerabilityMetrics(ctx context.Context, projectID uuid.UUID) (GetVulnerabilityMetricsRow, error)
|
||||
GetVulnerabilityProjectID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
|
||||
InsertAPKey(ctx context.Context, arg InsertAPKeyParams) (uuid.UUID, error)
|
||||
InsertAPService(ctx context.Context, arg InsertAPServiceParams) (uuid.UUID, error)
|
||||
InsertAgentHeartbeat(ctx context.Context, arg InsertAgentHeartbeatParams) error
|
||||
InsertAuditLog(ctx context.Context, arg InsertAuditLogParams) error
|
||||
InsertAuditLogWithRequest(ctx context.Context, arg InsertAuditLogWithRequestParams) error
|
||||
InsertCronExecution(ctx context.Context, arg InsertCronExecutionParams) error
|
||||
InsertCronJob(ctx context.Context, arg InsertCronJobParams) error
|
||||
InsertDeployment(ctx context.Context, arg InsertDeploymentParams) error
|
||||
InsertEnvVar(ctx context.Context, arg InsertEnvVarParams) error
|
||||
InsertGatewayIncident(ctx context.Context, arg InsertGatewayIncidentParams) error
|
||||
InsertGitProvider(ctx context.Context, arg InsertGitProviderParams) error
|
||||
InsertGitRepository(ctx context.Context, arg InsertGitRepositoryParams) (InsertGitRepositoryRow, error)
|
||||
InsertMetricPoint(ctx context.Context, labelsJson sql.NullString) error
|
||||
InsertNotificationChannel(ctx context.Context, arg InsertNotificationChannelParams) (NotificationChannel, error)
|
||||
InsertPendingDeployment(ctx context.Context, arg InsertPendingDeploymentParams) error
|
||||
InsertProjectEnvironment(ctx context.Context, arg InsertProjectEnvironmentParams) error
|
||||
InsertProjectVariable(ctx context.Context, arg InsertProjectVariableParams) error
|
||||
InsertServiceIncident(ctx context.Context, arg InsertServiceIncidentParams) error
|
||||
InsertUser(ctx context.Context, arg InsertUserParams) (InsertUserRow, error)
|
||||
InsertUserAdmin(ctx context.Context, arg InsertUserAdminParams) (InsertUserAdminRow, error)
|
||||
ListAPKeys(ctx context.Context) ([]ListAPKeysRow, error)
|
||||
ListAPKeysByPrefix(ctx context.Context, keyPrefix string) ([]ListAPKeysByPrefixRow, error)
|
||||
ListAPServices(ctx context.Context) ([]ListAPServicesRow, error)
|
||||
ListActiveBanners(ctx context.Context) ([]Banner, error)
|
||||
ListActiveOperationDeployments(ctx context.Context, arg ListActiveOperationDeploymentsParams) ([]ListActiveOperationDeploymentsRow, error)
|
||||
ListAdminUserIDs(ctx context.Context) ([]uuid.UUID, error)
|
||||
ListAdminUsers(ctx context.Context) ([]ListAdminUsersRow, error)
|
||||
ListAgentAuthTokens(ctx context.Context) ([]AgentAuthToken, error)
|
||||
ListAgentHealthRows(ctx context.Context, lastHeartbeat sql.NullTime) ([]ListAgentHealthRowsRow, error)
|
||||
ListAgentHeartbeatsSince(ctx context.Context, arg ListAgentHeartbeatsSinceParams) ([]AgentHeartbeat, error)
|
||||
ListAgents(ctx context.Context) ([]NodeAgent, error)
|
||||
ListAllDatabaseServices(ctx context.Context) ([]DatabaseService, error)
|
||||
ListAllNotificationChannelsByUser(ctx context.Context, userID uuid.UUID) ([]NotificationChannel, error)
|
||||
ListAuditBackfillBatch(ctx context.Context, dollar_1 uuid.UUID) ([]ListAuditBackfillBatchRow, error)
|
||||
ListBackupTargetsByUser(ctx context.Context, userID string) ([]BackupTarget, error)
|
||||
ListBanners(ctx context.Context) ([]Banner, error)
|
||||
ListClientEventPaths(ctx context.Context) ([]ListClientEventPathsRow, error)
|
||||
ListCommandsForAgent(ctx context.Context, nodeAgentID string) ([]AgentCommand, error)
|
||||
ListComplianceFrameworks(ctx context.Context) ([]ListComplianceFrameworksRow, error)
|
||||
ListConnectedRepositories(ctx context.Context, arg ListConnectedRepositoriesParams) ([]ListConnectedRepositoriesRow, error)
|
||||
@@ -235,7 +187,6 @@ type Querier interface {
|
||||
ListExpiredPreviewsForUser(ctx context.Context, arg ListExpiredPreviewsForUserParams) ([]ListExpiredPreviewsForUserRow, error)
|
||||
ListGitProviders(ctx context.Context, userID uuid.UUID) ([]ListGitProvidersRow, error)
|
||||
ListGitRepositories(ctx context.Context, arg ListGitRepositoriesParams) ([]ListGitRepositoriesRow, error)
|
||||
ListIncidentStatusCodes(ctx context.Context) ([]ListIncidentStatusCodesRow, error)
|
||||
ListInstanceMetrics(ctx context.Context, arg ListInstanceMetricsParams) ([]ListInstanceMetricsRow, error)
|
||||
ListNodeAgentsForService(ctx context.Context, serviceID string) ([]string, error)
|
||||
ListNodeMetrics(ctx context.Context, arg ListNodeMetricsParams) ([]ListNodeMetricsRow, error)
|
||||
@@ -252,18 +203,12 @@ type Querier interface {
|
||||
// platform admin. Anonymous callers pass user_id = uuid.Nil, is_admin = false.
|
||||
ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error)
|
||||
ListRecentAccessibleDeployments(ctx context.Context, arg ListRecentAccessibleDeploymentsParams) ([]ListRecentAccessibleDeploymentsRow, error)
|
||||
ListRecentCronRuns(ctx context.Context, arg ListRecentCronRunsParams) ([]ListRecentCronRunsRow, error)
|
||||
ListRecentFailedDeployments(ctx context.Context, arg ListRecentFailedDeploymentsParams) ([]ListRecentFailedDeploymentsRow, error)
|
||||
ListRecentOperationBackups(ctx context.Context, arg ListRecentOperationBackupsParams) ([]ListRecentOperationBackupsRow, error)
|
||||
ListRequestsByDay(ctx context.Context) ([]ListRequestsByDayRow, error)
|
||||
ListResourceAuditLogs(ctx context.Context, arg ListResourceAuditLogsParams) ([]ListResourceAuditLogsRow, error)
|
||||
// Base domains of every online, schedulable agent carrying the required
|
||||
// placement tags — auto-domain candidates for spread services.
|
||||
ListSchedulableAgentDomains(ctx context.Context, dollar_1 json.RawMessage) ([]string, error)
|
||||
ListSchedulableAgents(ctx context.Context) ([]ListSchedulableAgentsRow, error)
|
||||
// Only agents carrying every required placement tag (jsonb array containment).
|
||||
ListSchedulableAgentsMatching(ctx context.Context, dollar_1 json.RawMessage) ([]ListSchedulableAgentsMatchingRow, error)
|
||||
ListSecretEnvVars(ctx context.Context) ([]ListSecretEnvVarsRow, error)
|
||||
// Distinct agents holding inventory rows for a service.
|
||||
ListServiceAgents(ctx context.Context, serviceID string) ([]string, error)
|
||||
ListServiceContainers(ctx context.Context, serviceID string) ([]ListServiceContainersRow, error)
|
||||
@@ -276,7 +221,6 @@ type Querier interface {
|
||||
ListServicesForPush(ctx context.Context, arg ListServicesForPushParams) ([]ListServicesForPushRow, error)
|
||||
ListSleepCandidates(ctx context.Context) ([]ListSleepCandidatesRow, error)
|
||||
ListSweepablePreviews(ctx context.Context) ([]ListSweepablePreviewsRow, error)
|
||||
ListTopAPServices(ctx context.Context) ([]ListTopAPServicesRow, error)
|
||||
ListUserInvites(ctx context.Context) ([]UserInvite, error)
|
||||
ListUserTokens(ctx context.Context, userID uuid.UUID) ([]UserToken, error)
|
||||
ListVulnerabilities(ctx context.Context, projectID uuid.UUID) ([]ListVulnerabilitiesRow, error)
|
||||
@@ -292,21 +236,14 @@ 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
|
||||
@@ -323,14 +260,8 @@ type Querier interface {
|
||||
SetServicePublishedPort(ctx context.Context, arg SetServicePublishedPortParams) error
|
||||
SetServiceReplicas(ctx context.Context, arg SetServiceReplicasParams) error
|
||||
SetServiceStatus(ctx context.Context, arg SetServiceStatusParams) error
|
||||
SetUserAdmin(ctx context.Context, arg SetUserAdminParams) (int64, error)
|
||||
SumRequestMetricSince(ctx context.Context, dollar_1 string) (int32, error)
|
||||
SumUsageByKey(ctx context.Context, arg SumUsageByKeyParams) (int64, error)
|
||||
SumUsageByService(ctx context.Context, arg SumUsageByServiceParams) (int64, error)
|
||||
SumUsageCounters(ctx context.Context) (interface{}, error)
|
||||
SyncDeploymentProgress(ctx context.Context, arg SyncDeploymentProgressParams) error
|
||||
SyncPreviewStatuses(ctx context.Context) error
|
||||
TouchAPKeyLastUsed(ctx context.Context, id uuid.UUID) error
|
||||
TouchAgentAuthToken(ctx context.Context, tokenHash string) error
|
||||
TouchServiceDomainChecked(ctx context.Context, id uuid.UUID) error
|
||||
TouchUserToken(ctx context.Context, id uuid.UUID) error
|
||||
@@ -351,21 +282,14 @@ type Querier interface {
|
||||
UpdateDatabaseServiceNameAndPlanByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameAndPlanByIDAndUserParams) error
|
||||
UpdateDatabaseServiceNameByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameByIDAndUserParams) error
|
||||
UpdateDatabaseServicePlanByIDAndUser(ctx context.Context, arg UpdateDatabaseServicePlanByIDAndUserParams) error
|
||||
UpdateEnvVarValue(ctx context.Context, arg UpdateEnvVarValueParams) error
|
||||
UpdateOutboundWebhook(ctx context.Context, arg UpdateOutboundWebhookParams) error
|
||||
UpdatePreviewEnvironment(ctx context.Context, arg UpdatePreviewEnvironmentParams) error
|
||||
UpdateProjectByID(ctx context.Context, arg UpdateProjectByIDParams) (int64, error)
|
||||
UpdateServiceDeployMeta(ctx context.Context, arg UpdateServiceDeployMetaParams) error
|
||||
UpdateServicePreviewBranch(ctx context.Context, arg UpdateServicePreviewBranchParams) error
|
||||
UpdateServiceRuntimeMeta(ctx context.Context, arg UpdateServiceRuntimeMetaParams) error
|
||||
UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) error
|
||||
UpdateUserTemplate(ctx context.Context, arg UpdateUserTemplateParams) (int64, error)
|
||||
UpdateVulnerabilityStatus(ctx context.Context, arg UpdateVulnerabilityStatusParams) error
|
||||
UpdateWebhookDeliveryResult(ctx context.Context, arg UpdateWebhookDeliveryResultParams) error
|
||||
UpsertAdminUser(ctx context.Context, arg UpsertAdminUserParams) (uuid.UUID, error)
|
||||
UpsertAppSetting(ctx context.Context, arg UpsertAppSettingParams) error
|
||||
UpsertAppSettingValue(ctx context.Context, arg UpsertAppSettingValueParams) error
|
||||
UpsertBuild(ctx context.Context, arg UpsertBuildParams) error
|
||||
UpsertEnvironmentVariable(ctx context.Context, arg UpsertEnvironmentVariableParams) error
|
||||
UpsertGitHubAppProvider(ctx context.Context, arg UpsertGitHubAppProviderParams) (UpsertGitHubAppProviderRow, error)
|
||||
UpsertGitRepository(ctx context.Context, arg UpsertGitRepositoryParams) error
|
||||
@@ -376,7 +300,6 @@ type Querier interface {
|
||||
UpsertServiceContainer(ctx context.Context, arg UpsertServiceContainerParams) error
|
||||
UpsertServiceMetrics(ctx context.Context, arg UpsertServiceMetricsParams) error
|
||||
UpsertServiceTemplate(ctx context.Context, arg UpsertServiceTemplateParams) error
|
||||
UpsertUsageCounter(ctx context.Context, arg UpsertUsageCounterParams) error
|
||||
}
|
||||
|
||||
var _ Querier = (*Queries)(nil)
|
||||
@@ -1,308 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
-- 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());
|
||||
@@ -1,28 +0,0 @@
|
||||
-- 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;
|
||||
@@ -35,23 +35,3 @@ FROM users WHERE id = $1;
|
||||
UPDATE users
|
||||
SET name = COALESCE($1::varchar, name), avatar_url = COALESCE($2::varchar, avatar_url)
|
||||
WHERE id = $3;
|
||||
|
||||
-- name: UpsertAdminUser :one
|
||||
INSERT INTO users (email, password_hash, name, is_admin)
|
||||
VALUES ($1, $2, $3, true)
|
||||
ON CONFLICT (email) DO UPDATE
|
||||
SET is_admin = true, updated_at = NOW()
|
||||
RETURNING id;
|
||||
|
||||
-- name: ListAdminUsers :many
|
||||
SELECT id, email, name, COALESCE(avatar_url, '') AS avatar_url, is_admin,
|
||||
COALESCE(to_char(created_at, 'YYYY-MM-DD"T"HH24:MI:SS"Z"'), '')::text AS created_at
|
||||
FROM users
|
||||
ORDER BY created_at ASC;
|
||||
|
||||
-- name: SetUserAdmin :execrows
|
||||
UPDATE users SET is_admin = $1, updated_at = NOW() WHERE id = $2;
|
||||
|
||||
-- name: GetAdminUserInfo :one
|
||||
SELECT email, COALESCE(name, '') AS name, is_admin
|
||||
FROM users WHERE id = $1;
|
||||
@@ -1,33 +0,0 @@
|
||||
-- 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');
|
||||
@@ -127,11 +127,3 @@ SELECT s.id, s.project_id, s.name,
|
||||
s.created_at, s.updated_at
|
||||
FROM services s
|
||||
WHERE (s.git_branch = $1 OR $1 = '') AND (s.git_repo = $2 OR s.git_repo = $3);
|
||||
|
||||
-- name: GetGitRepoCloneByFullName :one
|
||||
SELECT clone_url, provider_id FROM git_repositories
|
||||
WHERE full_name = $1 AND user_id = $2 LIMIT 1;
|
||||
|
||||
-- name: GetGitRepoProviderByCloneURL :one
|
||||
SELECT provider_id FROM git_repositories
|
||||
WHERE clone_url = $1 AND user_id = $2 LIMIT 1;
|
||||
@@ -1,29 +0,0 @@
|
||||
-- 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;
|
||||
@@ -1,43 +0,0 @@
|
||||
-- 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;
|
||||
@@ -1,62 +0,0 @@
|
||||
-- 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;
|
||||
@@ -1,21 +0,0 @@
|
||||
-- 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;
|
||||
@@ -144,17 +144,6 @@ 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,
|
||||
@@ -601,112 +590,3 @@ CREATE TABLE vulnerabilities (
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE app_settings (
|
||||
key VARCHAR(255) PRIMARY KEY,
|
||||
value TEXT NOT NULL DEFAULT '',
|
||||
is_secret BOOLEAN NOT NULL DEFAULT false,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE audit_logs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
timestamp TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
user_id UUID,
|
||||
action VARCHAR(100) NOT NULL,
|
||||
resource VARCHAR(100) NOT NULL,
|
||||
resource_id UUID,
|
||||
details JSONB DEFAULT '{}',
|
||||
ip_address INET,
|
||||
user_agent TEXT,
|
||||
success BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
severity TEXT NOT NULL DEFAULT 'info',
|
||||
category TEXT NOT NULL DEFAULT '',
|
||||
label TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE builds (
|
||||
id VARCHAR(255) PRIMARY KEY,
|
||||
project_id VARCHAR(255),
|
||||
service_id VARCHAR(255),
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'pending',
|
||||
progress INTEGER NOT NULL DEFAULT 0,
|
||||
started_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
completed_at TIMESTAMP WITH TIME ZONE,
|
||||
image_name VARCHAR(500) NOT NULL DEFAULT '',
|
||||
image_tag VARCHAR(200) NOT NULL DEFAULT '',
|
||||
size BIGINT NOT NULL DEFAULT 0,
|
||||
error TEXT,
|
||||
log TEXT NOT NULL DEFAULT '',
|
||||
metadata JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- APwhy API gateway (api_services/api_keys + usage/metrics/incident planes)
|
||||
CREATE TABLE api_services (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
upstream_url TEXT NOT NULL,
|
||||
route_prefix TEXT NOT NULL UNIQUE,
|
||||
health_path TEXT NOT NULL DEFAULT '/health',
|
||||
upstream_auth_header TEXT,
|
||||
upstream_auth_value TEXT,
|
||||
internal_token TEXT,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
rpm_limit INTEGER,
|
||||
monthly_quota INTEGER,
|
||||
request_timeout_ms INTEGER DEFAULT 8000,
|
||||
last_validation_at TIMESTAMP WITHOUT TIME ZONE,
|
||||
last_validation_status TEXT,
|
||||
last_validation_message TEXT,
|
||||
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE api_keys (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
key_hash TEXT NOT NULL,
|
||||
key_prefix TEXT NOT NULL,
|
||||
plan TEXT NOT NULL DEFAULT 'free',
|
||||
allowed_service_ids TEXT NOT NULL DEFAULT '[]',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
rpm_limit INTEGER DEFAULT 60,
|
||||
monthly_quota INTEGER DEFAULT 1000,
|
||||
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT now(),
|
||||
last_used_at TIMESTAMP WITHOUT TIME ZONE
|
||||
);
|
||||
|
||||
CREATE TABLE usage_counters (
|
||||
id INTEGER PRIMARY KEY,
|
||||
api_key_id UUID NOT NULL REFERENCES api_keys(id) ON DELETE CASCADE,
|
||||
service_id UUID NOT NULL REFERENCES api_services(id) ON DELETE CASCADE,
|
||||
period_month TEXT NOT NULL,
|
||||
request_count INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT now(),
|
||||
UNIQUE (api_key_id, service_id, period_month)
|
||||
);
|
||||
|
||||
CREATE TABLE metrics_timeseries (
|
||||
id INTEGER PRIMARY KEY,
|
||||
metric TEXT NOT NULL,
|
||||
value REAL NOT NULL,
|
||||
labels_json TEXT,
|
||||
occurred_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE incident_events (
|
||||
id INTEGER PRIMARY KEY,
|
||||
service_id UUID REFERENCES api_services(id) ON DELETE SET NULL,
|
||||
api_key_id UUID,
|
||||
code TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
severity TEXT NOT NULL DEFAULT 'medium',
|
||||
http_status INTEGER,
|
||||
count INTEGER NOT NULL DEFAULT 1,
|
||||
occurred_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -5,12 +5,6 @@
|
||||
<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="#56c9f8"/>
|
||||
<stop offset="1" stop-color="#0e86c9"/>
|
||||
<stop offset="0" stop-color="#cdf06f"/>
|
||||
<stop offset="1" stop-color="#7fb524"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="64" height="64" rx="15" fill="url(#bg)"/>
|
||||
<g fill="none" stroke="#061018" stroke-width="4.2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<g fill="none" stroke="#131507" 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,50 +1,39 @@
|
||||
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();
|
||||
@@ -103,7 +92,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="font-headline text-lg font-semibold text-[var(--text-primary)]">Admin access required</h1>
|
||||
<h1 className="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>
|
||||
@@ -126,7 +115,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="font-headline text-lg font-semibold text-[var(--text-primary)]">Page not found</h1>
|
||||
<h1 className="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>
|
||||
@@ -146,44 +135,43 @@ export default function App() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<Routes>
|
||||
<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>} />
|
||||
<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 />} />
|
||||
|
||||
{/* Public browsing — approved projects, templates, docs. */}
|
||||
{/* Project workspace renders outside the shell — fullscreen canvas, no nav chrome */}
|
||||
<Route path="/projects/:projectId" element={<Page><ProjectWorkspacePage /></Page>} />
|
||||
<Route path="/projects/:projectId/services/:serviceId" element={<Page><ServiceDetailPage /></Page>} />
|
||||
<Route path="/projects/:projectId" element={<ProjectWorkspacePage />} />
|
||||
<Route path="/projects/:projectId/services/:serviceId" element={<ServiceDetailPage />} />
|
||||
|
||||
<Route element={<PlatformShell />}>
|
||||
<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>} />
|
||||
<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 />} />
|
||||
|
||||
{/* Authenticated areas */}
|
||||
<Route element={<AuthRequired />}>
|
||||
<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 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>
|
||||
|
||||
{/* Platform owner only */}
|
||||
<Route element={<AdminRequired />}>
|
||||
<Route path="/admin" element={<Page><AdminPage /></Page>} />
|
||||
<Route path="/nodes/:id" element={<Page><NodeDetailPage /></Page>} />
|
||||
<Route path="/admin" element={<AdminPage />} />
|
||||
<Route path="/nodes/:id" element={<NodeDetailPage />} />
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
@@ -191,7 +179,7 @@ export default function App() {
|
||||
|
||||
{/* Full-bleed design demo — renders its own chrome, no app shell */}
|
||||
<Route element={<AuthRequired />}>
|
||||
<Route path="/metrics-demo" element={<Page><ServiceMetricsDashboard /></Page>} />
|
||||
<Route path="/metrics-demo" element={<ServiceMetricsDashboard />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -9,13 +9,11 @@ import {
|
||||
ChartBar,
|
||||
CheckCircle2,
|
||||
ChevronsLeft,
|
||||
ChevronsUpDown,
|
||||
Clock,
|
||||
Container,
|
||||
Database,
|
||||
FileText,
|
||||
FolderKanban,
|
||||
LayoutDashboard,
|
||||
LayoutTemplate,
|
||||
LogIn,
|
||||
LogOut,
|
||||
@@ -43,26 +41,22 @@ 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, CommandPalette, useToast } from '@/shared/components';
|
||||
import { BannerBar, BrandWordmark, 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: 'Platform',
|
||||
title: 'Build',
|
||||
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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -70,6 +64,7 @@ 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' },
|
||||
@@ -169,18 +164,6 @@ 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);
|
||||
@@ -251,19 +234,8 @@ export function PlatformShell() {
|
||||
}
|
||||
};
|
||||
|
||||
// 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 isActiveRoute = (itemHref: string) =>
|
||||
location.pathname === itemHref || location.pathname.startsWith(`${itemHref}/`);
|
||||
|
||||
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';
|
||||
@@ -274,20 +246,14 @@ export function PlatformShell() {
|
||||
.slice(0, 2)
|
||||
.toUpperCase() || 'C';
|
||||
|
||||
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 ?? [];
|
||||
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>
|
||||
);
|
||||
|
||||
if (!isDemoMode && signedIn && setupQuery.data?.needs_setup) {
|
||||
return <Navigate to="/setup" replace />;
|
||||
@@ -295,46 +261,40 @@ 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="shell-side hidden h-screen shrink-0 flex-col md:flex"
|
||||
style={{ width: sidebarExpanded ? '252px' : '64px' }}
|
||||
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)',
|
||||
}}
|
||||
>
|
||||
<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}
|
||||
<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" />
|
||||
</NavLink>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSidebarExpanded((v) => !v)}
|
||||
title={sidebarExpanded ? 'Collapse sidebar' : 'Expand sidebar'}
|
||||
className={`shell-collapse flex items-center justify-center ${
|
||||
sidebarExpanded ? 'ml-auto h-[22px] w-[22px]' : 'h-7 w-7'
|
||||
}`}
|
||||
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)]"
|
||||
>
|
||||
{sidebarExpanded ? <ChevronsLeft size={14} /> : <PanelLeft size={15} />}
|
||||
{sidebarExpanded ? <ChevronsLeft size={15} /> : <PanelLeft size={15} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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]' : ''}>
|
||||
<nav className="mt-3 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
|
||||
{visibleNavSections.map((section) => (
|
||||
<div key={section.title}>
|
||||
{sidebarExpanded ? (
|
||||
<p className="shell-nav-group">{section.title}</p>
|
||||
<p className="mb-1 px-2 text-[10px] font-semibold uppercase tracking-[0.14em] text-[var(--text-tertiary)]">
|
||||
{section.title}
|
||||
</p>
|
||||
) : null}
|
||||
<div className={`flex flex-col ${sidebarExpanded ? '' : 'items-center gap-1'}`}>
|
||||
<div className={`flex flex-col gap-0.5 ${sidebarExpanded ? '' : 'items-center'}`}>
|
||||
{section.items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = isActiveRoute(item.href);
|
||||
@@ -343,12 +303,27 @@ export function PlatformShell() {
|
||||
key={item.href}
|
||||
to={href(item.href)}
|
||||
title={sidebarExpanded ? item.hint : `${item.label} — ${item.hint}`}
|
||||
className={`shell-ni ${isActive ? 'on' : ''} ${
|
||||
sidebarExpanded ? '' : 'h-10 w-10 justify-center !px-0'
|
||||
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)]'
|
||||
}`}
|
||||
>
|
||||
<Icon size={16} className="shrink-0" />
|
||||
{sidebarExpanded ? <span className="truncate">{item.label}</span> : null}
|
||||
{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}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
@@ -357,26 +332,23 @@ export function PlatformShell() {
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className={`mt-auto ${sidebarExpanded ? 'px-3 pb-3.5' : 'flex justify-center px-2 pb-3'}`}>
|
||||
<div className={`flex flex-col gap-2 px-2 pt-2 ${sidebarExpanded ? '' : 'items-center'}`}>
|
||||
{signedIn ? (
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
title={sidebarExpanded ? undefined : userEmail}
|
||||
className={`shell-user ${
|
||||
sidebarExpanded ? 'w-full' : 'h-10 w-10 justify-center border-transparent bg-transparent'
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
<span className="shell-avatar">{initials}</span>
|
||||
{avatar}
|
||||
{sidebarExpanded ? (
|
||||
<>
|
||||
<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)]" />
|
||||
</>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[12.5px] font-medium text-[var(--text-primary)]">{userName}</span>
|
||||
<span className="block truncate text-[10.5px] text-[var(--text-tertiary)]">{userEmail}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
</DropdownMenu.Trigger>
|
||||
@@ -455,12 +427,12 @@ export function PlatformShell() {
|
||||
<NavLink
|
||||
to="/auth/sign-in"
|
||||
title="Sign in"
|
||||
className={`shell-ni ${
|
||||
sidebarExpanded ? 'w-full' : 'h-10 w-10 justify-center !px-0'
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
<LogIn size={16} className="shrink-0" />
|
||||
{sidebarExpanded ? <span className="text-[13px] font-medium">Sign in</span> : null}
|
||||
{sidebarExpanded ? <span className="text-[12.5px] font-medium">Sign in</span> : null}
|
||||
</NavLink>
|
||||
)}
|
||||
</div>
|
||||
@@ -468,46 +440,71 @@ export function PlatformShell() {
|
||||
|
||||
<div className="relative z-10 flex min-h-screen min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<BannerBar />
|
||||
{/* ── 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={() => setPaletteOpen(true)}
|
||||
className="shell-search"
|
||||
>
|
||||
<Search size={13} className="shrink-0" />
|
||||
Search anything
|
||||
<kbd>⌘ K</kbd>
|
||||
</button>
|
||||
<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>
|
||||
|
||||
<span className="v-env">{isDemoMode ? 'env:demo' : `env:${import.meta.env.MODE}`}</span>
|
||||
<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="flex-1" />
|
||||
<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>
|
||||
|
||||
{isAdmin && !isDemoMode ? (
|
||||
<div className="ml-auto flex items-center gap-4">
|
||||
<span className="v-env">{isDemoMode ? 'env:demo' : 'env:production'}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => pullMutation.mutate()}
|
||||
disabled={pullMutation.isPending || !upgradeQuery.data?.imageRef}
|
||||
className="s-btn-accent"
|
||||
title={upgradeQuery.data?.message || 'Pull latest configured image'}
|
||||
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'}
|
||||
>
|
||||
{pullMutation.isPending ? <RefreshCw size={13} className="animate-spin" /> : <UploadCloud size={13} />}
|
||||
Upgrade
|
||||
{theme === 'dark' ? <Sun size={15} /> : <Moon size={15} />}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{signedIn && !isDemoMode ? (
|
||||
<div className="relative">
|
||||
{isAdmin && !isDemoMode ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNotificationsOpen((open) => !open)}
|
||||
className="shell-tic relative"
|
||||
aria-label="Notifications"
|
||||
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)' }}
|
||||
title={upgradeQuery.data?.message || 'Pull latest configured image'}
|
||||
>
|
||||
<Bell size={16} />
|
||||
{unreadCount > 0 ? <span className="shell-tic-dot" /> : null}
|
||||
{pullMutation.isPending ? <RefreshCw size={14} className="animate-spin" /> : <UploadCloud size={14} />}
|
||||
Upgrade
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{signedIn && !isDemoMode ? (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNotificationsOpen((open) => !open)}
|
||||
className="relative flex h-8 w-8 items-center justify-center rounded-[var(--radius-md)] border text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
style={{ borderColor: 'var(--border-subtle)' }}
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<Bell size={15} />
|
||||
{unreadCount > 0 ? (
|
||||
<span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-[var(--accent-primary)] px-1 text-[10px] font-semibold text-[var(--accent-on)]">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
|
||||
{notificationsOpen ? (
|
||||
<div className="absolute right-0 top-10 z-50 w-80 rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)] p-3 shadow-2xl">
|
||||
@@ -569,21 +566,13 @@ export function PlatformShell() {
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</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('/')} className="flex items-center gap-3">
|
||||
<div className="shell-mark flex h-9 w-9 items-center justify-center">
|
||||
<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)]">
|
||||
<Container size={16} />
|
||||
</div>
|
||||
<span className="font-headline font-semibold text-[var(--text-primary)]">Containr</span>
|
||||
@@ -624,17 +613,6 @@ 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,7 +6,6 @@ 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,
|
||||
@@ -25,12 +24,8 @@ 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: 'events', type: 'search', searchFields: ['label', 'action', 'user_email'] },
|
||||
{ key: 'q', label: 'Search', type: 'search', searchFields: ['label', 'action', 'user_email'] },
|
||||
{
|
||||
key: 'severity', label: 'Severity', type: 'select',
|
||||
options: [
|
||||
@@ -63,33 +58,46 @@ export function ActivityPage() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<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 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>
|
||||
|
||||
<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">
|
||||
<div className="px-8 py-6 space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-1">
|
||||
<FilterBar {...filter} />
|
||||
</div>
|
||||
<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>
|
||||
{query.isFetching && <Loader2 size={14} className="animate-spin text-[var(--text-muted)] mt-3" />}
|
||||
</div>
|
||||
|
||||
<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 className="panel p-0 divide-y divide-[var(--border-subtle)]">
|
||||
{query.isLoading && <p className="px-5 py-8 text-xs text-[var(--text-muted)]">Loading…</p>}
|
||||
{!query.isLoading && 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>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage(page - 1)}
|
||||
className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--border-subtle)] text-[var(--text-muted)] disabled:opacity-40 hover:text-[var(--text-primary)]"
|
||||
>
|
||||
newer
|
||||
</button>
|
||||
<button
|
||||
disabled={!query.data?.has_more}
|
||||
onClick={() => setPage(page + 1)}
|
||||
className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--border-subtle)] text-[var(--text-muted)] disabled:opacity-40 hover:text-[var(--text-primary)]"
|
||||
>
|
||||
older
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -100,14 +108,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-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 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>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
<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>
|
||||
</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,7 +38,6 @@ 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',
|
||||
@@ -200,7 +199,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 font-headline text-lg font-semibold text-[var(--text-primary)]">Admin access required</h1>
|
||||
<h1 className="mt-4 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>
|
||||
@@ -217,74 +216,74 @@ export function AdminPage() {
|
||||
const invites = invitesQuery.data ?? [];
|
||||
|
||||
return (
|
||||
<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="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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-3.5 max-lg:grid-cols-3 max-sm:grid-cols-2">
|
||||
<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="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="s-stat-foot mt-3">
|
||||
<span>total</span>
|
||||
</div>
|
||||
<div
|
||||
key={key}
|
||||
className="rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)] px-4 py-3.5"
|
||||
>
|
||||
<p className="text-[20px] font-bold tabular-nums text-[var(--text-primary)]">{stats[key] ?? 0}</p>
|
||||
<p className="mt-0.5 text-[10.5px] uppercase tracking-wider text-[var(--text-tertiary)]">{label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<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}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{pending.length > 0 ? (
|
||||
<div>
|
||||
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)]">
|
||||
{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"
|
||||
>
|
||||
<span className="s-ibox !h-7 !w-7"><FolderKanban /></span>
|
||||
<FolderKanban size={15} className="shrink-0 text-[var(--text-tertiary)]" />
|
||||
<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>
|
||||
<GhostBtn
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
</GhostBtn>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="px-4 py-6 text-center text-[13px] text-[var(--text-tertiary)]">
|
||||
<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)]">
|
||||
Nothing waiting for approval.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<div>
|
||||
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)]">
|
||||
{allProjects.map((project) => (
|
||||
<div
|
||||
key={project.id}
|
||||
@@ -298,16 +297,22 @@ export function AdminPage() {
|
||||
{project.stats?.service_count ?? 0} services · {project.stats?.deployment_count ?? 0} deploys
|
||||
</p>
|
||||
</div>
|
||||
<SPill tone={project.isApproved ? 'ok' : 'warn'}>
|
||||
<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)]'
|
||||
}`}
|
||||
>
|
||||
{project.isApproved ? 'public' : 'pending'}
|
||||
</SPill>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditingProject(project);
|
||||
setEditForm({ name: project.name, description: project.description ?? '' });
|
||||
}}
|
||||
className="s-icon-btn"
|
||||
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)]"
|
||||
title="Edit project"
|
||||
>
|
||||
<Pencil size={12} />
|
||||
@@ -320,17 +325,19 @@ export function AdminPage() {
|
||||
}
|
||||
}}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="s-icon-btn !text-[var(--text-tertiary)] hover:!text-[var(--error)] disabled:opacity-40"
|
||||
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"
|
||||
title="Delete project"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
<QuietBtn
|
||||
<button
|
||||
type="button"
|
||||
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'}
|
||||
</QuietBtn>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{allProjects.length === 0 ? (
|
||||
@@ -339,13 +346,12 @@ export function AdminPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<div>
|
||||
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)]">
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-[var(--border-subtle)] px-4 py-3">
|
||||
<input
|
||||
value={bannerForm.title}
|
||||
@@ -369,12 +375,14 @@ export function AdminPage() {
|
||||
<option value="warning">warning</option>
|
||||
<option value="error">error</option>
|
||||
</select>
|
||||
<GhostBtn
|
||||
<button
|
||||
type="button"
|
||||
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'}
|
||||
</GhostBtn>
|
||||
</button>
|
||||
</div>
|
||||
{banners.map((banner) => (
|
||||
<div
|
||||
@@ -388,12 +396,14 @@ export function AdminPage() {
|
||||
{banner.body ? ` · ${banner.body}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<QuietBtn
|
||||
<button
|
||||
type="button"
|
||||
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'}
|
||||
</QuietBtn>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -402,7 +412,7 @@ export function AdminPage() {
|
||||
}
|
||||
}}
|
||||
disabled={bannerDeleteMutation.isPending}
|
||||
className="s-icon-btn !text-[var(--text-tertiary)] hover:!text-[var(--error)] disabled:opacity-40"
|
||||
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"
|
||||
title="Delete banner"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
@@ -415,13 +425,12 @@ export function AdminPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<div>
|
||||
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)]">
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-[var(--border-subtle)] px-4 py-3">
|
||||
<input
|
||||
value={inviteEmail}
|
||||
@@ -430,25 +439,35 @@ 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)]"
|
||||
/>
|
||||
<GhostBtn
|
||||
<button
|
||||
type="button"
|
||||
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'}
|
||||
</GhostBtn>
|
||||
</button>
|
||||
</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="v-mono min-w-0 flex-1 truncate text-[12px] text-[var(--accent-primary)]">{inviteLink}</code>
|
||||
<QuietBtn
|
||||
<code className="min-w-0 flex-1 truncate text-[12px] text-[var(--accent-primary)]">{inviteLink}</code>
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
</QuietBtn>
|
||||
<QuietBtn onClick={() => setInviteLink(null)}>Dismiss</QuietBtn>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setInviteLink(null)}
|
||||
className="text-[11px] text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{invites.map((invite) => {
|
||||
@@ -467,17 +486,26 @@ export function AdminPage() {
|
||||
expires {new Date(invite.expires_at).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<SPill tone={used ? 'off' : expired ? 'err' : 'ok'}>
|
||||
<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)]'
|
||||
}`}
|
||||
>
|
||||
{used ? 'used' : expired ? 'expired' : 'open'}
|
||||
</SPill>
|
||||
</span>
|
||||
{!used ? (
|
||||
<QuietBtn
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inviteRevokeMutation.mutate(invite.id)}
|
||||
disabled={inviteRevokeMutation.isPending}
|
||||
className="hover:!text-[var(--error)] hover:!border-[var(--error)]"
|
||||
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"
|
||||
>
|
||||
Revoke
|
||||
</QuietBtn>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
@@ -488,13 +516,12 @@ export function AdminPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<div>
|
||||
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)]">
|
||||
{users.map((user) => (
|
||||
<UserRow
|
||||
key={user.id}
|
||||
@@ -513,29 +540,30 @@ 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 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 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>
|
||||
<div className="space-y-3 px-5 py-4">
|
||||
<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)]">
|
||||
<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)]">
|
||||
{impersonation.token}
|
||||
</code>
|
||||
<QuietBtn
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
</QuietBtn>
|
||||
</button>
|
||||
</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>
|
||||
@@ -544,7 +572,13 @@ 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">
|
||||
<GhostBtn onClick={() => setImpersonation(null)}>Done</GhostBtn>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -552,13 +586,10 @@ 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 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 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>
|
||||
<div className="space-y-4 px-5 py-4">
|
||||
<div>
|
||||
@@ -580,13 +611,21 @@ 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">
|
||||
<QuietBtn onClick={() => setEditingProject(null)}>Cancel</QuietBtn>
|
||||
<GhostBtn
|
||||
<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"
|
||||
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'}
|
||||
</GhostBtn>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -621,22 +660,32 @@ function UserRow({
|
||||
<p className="truncate text-[11.5px] text-[var(--text-tertiary)]">{user.email}</p>
|
||||
</div>
|
||||
{user.is_admin ? (
|
||||
<SPill tone="info"><ShieldCheck size={10} /> admin</SPill>
|
||||
<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>
|
||||
) : null}
|
||||
{onImpersonate ? (
|
||||
<QuietBtn onClick={onImpersonate}>Impersonate</QuietBtn>
|
||||
<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>
|
||||
) : null}
|
||||
<QuietBtn
|
||||
<button
|
||||
type="button"
|
||||
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'
|
||||
)}
|
||||
</QuietBtn>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,9 +6,7 @@ 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;
|
||||
|
||||
@@ -74,101 +72,109 @@ 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="w-full px-4 pt-6 sm:px-8">
|
||||
<SPageHead
|
||||
title="Audit Logs"
|
||||
titleAccent="_"
|
||||
sub="Every authenticated action recorded by the platform"
|
||||
/>
|
||||
<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>
|
||||
|
||||
<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="w-full px-8 py-6">
|
||||
<section className="panel p-6">
|
||||
<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="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)]">
|
||||
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<p className="text-xs text-[var(--text-muted)]">
|
||||
Page {page}{logs.length > 0 ? ` — ${logs.length} entries` : ''}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</SCard>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -13,16 +13,15 @@ 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 '/';
|
||||
return '/projects';
|
||||
}
|
||||
|
||||
const value = raw.trim();
|
||||
if (!value.startsWith('/') || value.startsWith('//')) {
|
||||
return '/';
|
||||
return '/projects';
|
||||
}
|
||||
|
||||
return value;
|
||||
@@ -55,11 +54,9 @@ function AuthCard({
|
||||
}) {
|
||||
return (
|
||||
<div className="w-full max-w-[400px]">
|
||||
<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>
|
||||
<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>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
@@ -70,14 +67,19 @@ 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 v-mono text-[11px] uppercase tracking-[0.14em] text-[var(--text-muted)]">Self-hosted container platform</span>
|
||||
<span className="mt-1 text-xs text-[var(--text-muted)]">Self-hosted container platform</span>
|
||||
</div>
|
||||
{children}
|
||||
<p className="mt-8 text-center v-mono text-[10.5px] text-[var(--text-muted)]">
|
||||
<p className="mt-8 text-center text-[11px] text-[var(--text-muted)]">
|
||||
Sessions are cookie-based · first account becomes platform owner
|
||||
</p>
|
||||
</div>
|
||||
@@ -87,8 +89,8 @@ function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||
|
||||
function AuthErrorNotice({ message }: { message: string }) {
|
||||
return (
|
||||
<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" />
|
||||
<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" />
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
);
|
||||
@@ -96,7 +98,7 @@ function AuthErrorNotice({ message }: { message: string }) {
|
||||
|
||||
function AuthInfoNotice({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="mb-4 s-inset !border-[var(--success)]/30 px-3 py-2.5 text-[12.5px] text-[var(--success)]">
|
||||
<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)]">
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
@@ -204,10 +206,15 @@ export function SignInPage() {
|
||||
/>
|
||||
</label>
|
||||
|
||||
<GhostBtn type="submit" disabled={isSubmitting || sessionQuery.isPending} className="!h-10 w-full justify-center">
|
||||
<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)' }}
|
||||
>
|
||||
{isSubmitting ? <Loader2 size={15} className="animate-spin" /> : <Mail size={15} />}
|
||||
Continue
|
||||
</GhostBtn>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{providers.length > 0 ? (
|
||||
@@ -220,22 +227,24 @@ export function SignInPage() {
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{providers.includes('github') ? (
|
||||
<QuietBtn
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void signInWithGitHubProvider()}
|
||||
className="!h-10 justify-center text-sm"
|
||||
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)]"
|
||||
>
|
||||
<Github size={15} />
|
||||
GitHub
|
||||
</QuietBtn>
|
||||
</button>
|
||||
) : null}
|
||||
{providers.includes('google') ? (
|
||||
<QuietBtn
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void signInWithGoogleProvider()}
|
||||
className="!h-10 justify-center text-sm"
|
||||
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)]"
|
||||
>
|
||||
<GoogleMark />
|
||||
Google
|
||||
</QuietBtn>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
@@ -271,7 +280,7 @@ export function AcceptInvitePage() {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
if (sessionQuery.data) {
|
||||
return <Navigate to="/" replace />;
|
||||
return <Navigate to="/projects" replace />;
|
||||
}
|
||||
|
||||
const boundEmail = inviteQuery.data?.email ?? undefined;
|
||||
@@ -288,7 +297,7 @@ export function AcceptInvitePage() {
|
||||
// account, sign-in creates the browser session.
|
||||
await signInWithEmail(effectiveEmail.trim(), password);
|
||||
await queryClient.invalidateQueries({ queryKey: ['auth-session'] });
|
||||
navigate('/', { replace: true });
|
||||
navigate('/projects', { replace: true });
|
||||
} catch (exception) {
|
||||
setError(exception instanceof Error ? exception.message : 'Failed to accept invite');
|
||||
} finally {
|
||||
@@ -360,10 +369,15 @@ export function AcceptInvitePage() {
|
||||
/>
|
||||
</label>
|
||||
|
||||
<GhostBtn type="submit" disabled={isSubmitting} className="!h-10 w-full justify-center">
|
||||
<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)' }}
|
||||
>
|
||||
{isSubmitting ? <Loader2 size={15} className="animate-spin" /> : <User2 size={15} />}
|
||||
Accept invite
|
||||
</GhostBtn>
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
@@ -473,10 +487,15 @@ export function SignUpPage() {
|
||||
/>
|
||||
</label>
|
||||
|
||||
<GhostBtn type="submit" disabled={isSubmitting || sessionQuery.isPending} className="!h-10 w-full justify-center">
|
||||
<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)' }}
|
||||
>
|
||||
{isSubmitting ? <Loader2 size={15} className="animate-spin" /> : <User2 size={15} />}
|
||||
Create Account
|
||||
</GhostBtn>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-5 flex items-center justify-between text-xs text-[var(--text-secondary)]">
|
||||
|
||||
@@ -14,18 +14,6 @@ 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,
|
||||
@@ -36,6 +24,8 @@ import {
|
||||
Box,
|
||||
Sparkles,
|
||||
Filter,
|
||||
Circle,
|
||||
HardDrive,
|
||||
} from 'lucide-react';
|
||||
|
||||
const statusOptions: Array<{ value: '' | BuildStatus; label: string }> = [
|
||||
@@ -60,11 +50,26 @@ const BUILD_SCHEMA: FilterConfig<BuildEntity>[] = [
|
||||
},
|
||||
];
|
||||
|
||||
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 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 bytesToHumanReadable(bytes: number): string {
|
||||
@@ -144,113 +149,109 @@ 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">
|
||||
<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)]'}`} />
|
||||
{/* 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)]'}`} />
|
||||
<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>
|
||||
<IconBtn onClick={() => queryClient.invalidateQueries({ queryKey: ['builds-page'] })} title="Refresh builds">
|
||||
<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"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
</IconBtn>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Demo Mode Banner */}
|
||||
{isDemoMode && (
|
||||
<div className="w-full px-8 py-4">
|
||||
<div className="s-inset flex items-center gap-2 text-xs text-[var(--warning)]">
|
||||
<Sparkles size={13} />
|
||||
Demo mode active — using sample data
|
||||
<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>
|
||||
</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" />
|
||||
{/* 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>
|
||||
|
||||
<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]">
|
||||
{/* 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">
|
||||
<FilterBar {...filter} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10.5px] font-medium uppercase tracking-wider text-[var(--text-muted)] mb-1.5">
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
|
||||
Page Size
|
||||
</label>
|
||||
<select
|
||||
value={String(limit)}
|
||||
onChange={(e) => setLimit(Number(e.target.value))}
|
||||
className="s-chip !cursor-pointer appearance-none pr-6"
|
||||
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"
|
||||
>
|
||||
<option value="20">20</option>
|
||||
<option value="50">50</option>
|
||||
@@ -258,41 +259,127 @@ export function BuildsPage() {
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</SCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
}
|
||||
>
|
||||
{/* Build Table */}
|
||||
<div className="w-full px-8 py-6">
|
||||
<div className="panel overflow-hidden">
|
||||
{!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>
|
||||
) : !isDemoMode && buildsQuery.isError ? (
|
||||
<div className="p-10 text-center">
|
||||
) : 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>
|
||||
<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>
|
||||
) : builds.length === 0 ? (
|
||||
) : null}
|
||||
|
||||
{builds.length === 0 && !buildsQuery.isLoading ? (
|
||||
<div className="p-12 text-center">
|
||||
<div className="s-ibox mx-auto mb-3 !w-10 !h-10"><Box /></div>
|
||||
<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>
|
||||
<p className="text-sm text-[var(--text-muted)]">No builds match current filters</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-4">
|
||||
<STable
|
||||
cols={buildCols}
|
||||
rows={builds}
|
||||
rowKey={(b) => b.id}
|
||||
onRowClick={(b) => setSelectedBuild(b)}
|
||||
/>
|
||||
) : 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>
|
||||
</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>
|
||||
)}
|
||||
</SCard>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Toast */}
|
||||
@@ -306,21 +393,27 @@ 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 s-card">
|
||||
<div className="s-cardhead">
|
||||
<span className="s-ibox"><FileText /></span>
|
||||
<div>
|
||||
<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 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>
|
||||
<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>
|
||||
</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="v-mono text-xs text-[var(--text-secondary)] whitespace-pre-wrap break-all">
|
||||
<pre className="mono text-xs text-[var(--text-secondary)] whitespace-pre-wrap break-all">
|
||||
{isDemoMode
|
||||
? selectedBuild.log || '[demo] No logs available.'
|
||||
: logsQuery.isLoading
|
||||
@@ -330,7 +423,6 @@ export function BuildsPage() {
|
||||
: logsQuery.data || '(empty logs)'}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,662 +0,0 @@
|
||||
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,7 +15,6 @@ 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, ' ')
|
||||
@@ -120,7 +119,7 @@ export function DocsBrowser() {
|
||||
|
||||
<div className="min-w-0 flex-1 overflow-y-auto max-md:hidden">
|
||||
{selected ? (
|
||||
<article className="docs-article px-4 py-6 sm:px-8">
|
||||
<article className="docs-article px-8 py-6">
|
||||
<p className="mb-1 text-[11px] font-medium uppercase tracking-wider text-[var(--text-tertiary)]">
|
||||
{selected.section} / {selected.path}
|
||||
</p>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
@@ -9,18 +8,16 @@ import {
|
||||
GitBranch,
|
||||
LayoutTemplate,
|
||||
Rocket,
|
||||
Server,
|
||||
ShieldCheck,
|
||||
Terminal,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import { getHostMonitoring, listProjects, listRecentDeployments, listTemplates } from '@/lib/api-client';
|
||||
import { listProjects, 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, MiniBars, SPill, Ticks } from '@/shared/components';
|
||||
import { demoHostMonitoring, demoProjects, demoTemplates } from '@/lib/demo-data';
|
||||
import { BrandWordmark } from '@/shared/components';
|
||||
import { 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.' },
|
||||
@@ -31,14 +28,7 @@ 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 });
|
||||
@@ -47,35 +37,19 @@ 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)]">
|
||||
{/* ── Top bar ── */}
|
||||
<div className="ambient-glow" />
|
||||
|
||||
<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="font-headline text-[15px] font-semibold tracking-tight text-[var(--text-primary)]">
|
||||
<span className="text-[15px] font-extrabold tracking-tight text-[var(--text-primary)]">
|
||||
<BrandWordmark />
|
||||
</span>
|
||||
</Link>
|
||||
@@ -83,160 +57,112 @@ 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 || isDemoMode ? (
|
||||
<Link to={`/${demo}`} className="ml-1 s-btn-accent">
|
||||
{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)]"
|
||||
>
|
||||
Console <ArrowRight size={13} />
|
||||
</Link>
|
||||
) : (
|
||||
<Link to="/auth/sign-in" className="ml-1 s-btn-accent">
|
||||
Sign in <ArrowRight size={13} />
|
||||
<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>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<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="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 className="text-[var(--accent-primary)]">your own cloud</span>
|
||||
</h1>
|
||||
<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 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="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>
|
||||
|
||||
{/* 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>
|
||||
<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)]" />
|
||||
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]">
|
||||
Deploy containers like it's{' '}
|
||||
<span style={{ color: '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)]">
|
||||
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} />
|
||||
</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)]"
|
||||
>
|
||||
<BookOpen size={15} /> Read the docs
|
||||
</a>
|
||||
</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)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{/* ── 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)]">
|
||||
<section id="features" className="border-t 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 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="mt-1.5 max-w-lg text-[13px] text-[var(--text-secondary)]">
|
||||
One install, one dashboard, full control over your own infrastructure.
|
||||
</p>
|
||||
</div>
|
||||
<span className="v-mono text-[11px] text-[var(--text-muted)]">06 capabilities</span>
|
||||
</div>
|
||||
<div className="mt-9 grid grid-cols-3 gap-3.5 max-md:grid-cols-2 max-sm:grid-cols-1">
|
||||
<h2 className="text-center 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)]">
|
||||
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">
|
||||
{features.map((feature) => (
|
||||
<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
|
||||
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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Templates ── */}
|
||||
<section id="templates" className="border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/60">
|
||||
<section id="templates" className="border-t border-[var(--border-subtle)]">
|
||||
<div className="mx-auto max-w-6xl px-5 py-16">
|
||||
<div className="flex items-end justify-between gap-6">
|
||||
<div className="flex items-end justify-between">
|
||||
<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)]" />
|
||||
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)]">
|
||||
<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)]">
|
||||
Production-ready presets — deploy in one click from the console.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
to={`/templates${demo}`}
|
||||
className="s-chip !normal-case !tracking-normal"
|
||||
className="inline-flex items-center gap-1.5 text-[13px] font-medium text-[var(--accent-primary)] hover:underline"
|
||||
>
|
||||
Browse all <ArrowRight size={11} />
|
||||
Browse all <ArrowRight size={13} />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-8 grid grid-cols-4 gap-3 max-lg:grid-cols-3 max-sm:grid-cols-2">
|
||||
@@ -244,20 +170,22 @@ export function LandingPage() {
|
||||
<Link
|
||||
key={template.id}
|
||||
to={`/templates?template=${template.id}${isDemoMode ? '&demo=1' : ''}`}
|
||||
className="s-card group"
|
||||
className="group rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)] p-4 transition-colors hover:border-[var(--border-strong)]"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
{template.logo ? (
|
||||
<img src={template.logo} alt="" className="h-7 w-7 rounded-[6px]" />
|
||||
<img src={template.logo} alt="" className="h-7 w-7 rounded-md" />
|
||||
) : (
|
||||
<span className="s-ibox !h-7 !w-7"><LayoutTemplate size={13.5} /></span>
|
||||
<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="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="s-chip mt-2" style={{ cursor: 'default' }}>
|
||||
<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)]">
|
||||
{template.category || 'app'}
|
||||
</span>
|
||||
</Link>
|
||||
@@ -271,15 +199,10 @@ export function LandingPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Docs ── */}
|
||||
<section id="docs" className="border-b border-[var(--border-subtle)]">
|
||||
<section id="docs" className="border-t border-[var(--border-subtle)] bg-[var(--bg-base)]/60">
|
||||
<div className="mx-auto max-w-6xl px-5 py-16">
|
||||
<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)]">
|
||||
<h2 className="text-[26px] font-bold tracking-tight text-[var(--text-primary)]">Documentation</h2>
|
||||
<p className="mt-1 text-sm text-[var(--text-secondary)]">
|
||||
Guides and references — synced from GitHub, cached locally, always available.
|
||||
</p>
|
||||
<div className="mt-8">
|
||||
@@ -290,10 +213,10 @@ export function LandingPage() {
|
||||
</main>
|
||||
|
||||
<footer className="border-t border-[var(--border-subtle)]">
|
||||
<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)]">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between px-5 py-6 text-[12px] 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>
|
||||
@@ -305,3 +228,12 @@ 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,10 +19,8 @@ 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 = [
|
||||
@@ -131,7 +129,8 @@ export function NodeDetailPage() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<div className="w-full px-4 pt-6 sm:px-8">
|
||||
<div className="border-b border-[var(--border-subtle)]">
|
||||
<div className="w-full px-8 py-5">
|
||||
<div className="flex items-center gap-2 v-mono text-[11px] text-[var(--text-tertiary)] mb-2">
|
||||
<Link to="/usage" className="hover:text-[var(--text-primary)] flex items-center gap-1">
|
||||
<ArrowLeft size={12} /> usage
|
||||
@@ -141,7 +140,7 @@ export function NodeDetailPage() {
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 className="v-title font-headline">
|
||||
<h1 className="v-title">
|
||||
{rename === null ? agent.name : rename}
|
||||
{isAdmin && rename === null && (
|
||||
<button
|
||||
@@ -164,7 +163,7 @@ export function NodeDetailPage() {
|
||||
if (e.key === 'Escape') setRename(null);
|
||||
}}
|
||||
autoFocus
|
||||
className="rounded-md border border-[var(--border-subtle)] bg-[var(--bg-elevated)] px-2 py-1 text-sm text-[var(--text-primary)]"
|
||||
className="rounded-md border border-[var(--border-subtle)] bg-[var(--bg-tertiary)] 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>
|
||||
@@ -210,7 +209,7 @@ export function NodeDetailPage() {
|
||||
}}
|
||||
autoFocus
|
||||
placeholder="eu-west, gpu"
|
||||
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"
|
||||
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"
|
||||
/>
|
||||
<button
|
||||
onClick={() => updateMutation.mutate({ tags: tagDraft.split(',').map((t) => t.trim()).filter(Boolean) })}
|
||||
@@ -248,7 +247,7 @@ export function NodeDetailPage() {
|
||||
}}
|
||||
autoFocus
|
||||
placeholder="apps.node1.example.com — empty clears"
|
||||
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"
|
||||
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"
|
||||
/>
|
||||
<button
|
||||
onClick={() => updateMutation.mutate({ default_domain: domainDraft.trim().toLowerCase() })}
|
||||
@@ -259,9 +258,17 @@ export function NodeDetailPage() {
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<SPill tone={online ? 'ok' : 'err'}>{agent.status}</SPill>
|
||||
{!agent.schedulable && <SPill tone="warn">cordoned</SPill>}
|
||||
<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>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<>
|
||||
<label className="flex items-center gap-1.5 v-mono text-[11px] text-[var(--text-tertiary)] cursor-pointer" title="Daily automatic docker prune">
|
||||
@@ -273,79 +280,81 @@ export function NodeDetailPage() {
|
||||
/>
|
||||
auto-prune
|
||||
</label>
|
||||
<QuietBtn
|
||||
<button
|
||||
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'}
|
||||
</QuietBtn>
|
||||
<QuietBtn
|
||||
</button>
|
||||
<button
|
||||
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="!text-[var(--warning)] hover:!border-[var(--warning)]/60"
|
||||
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"
|
||||
>
|
||||
drain
|
||||
</QuietBtn>
|
||||
<GhostBtn
|
||||
</button>
|
||||
<button
|
||||
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'}
|
||||
</GhostBtn>
|
||||
<QuietBtn
|
||||
</button>
|
||||
<button
|
||||
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
|
||||
</QuietBtn>
|
||||
<QuietBtn
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
className="!text-[var(--error)] hover:!border-[var(--error)]/60"
|
||||
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"
|
||||
>
|
||||
remove
|
||||
</QuietBtn>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-6 sm:px-8 space-y-6">
|
||||
<div className="px-8 py-6 space-y-6">
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<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" />
|
||||
<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" />
|
||||
</div>
|
||||
|
||||
<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={`s-chip v-mono ${timeRange === r.value ? 'on' : ''}`}
|
||||
>
|
||||
{r.label}
|
||||
</button>
|
||||
))}
|
||||
</span>
|
||||
</span>
|
||||
<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">
|
||||
{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)]'}`}
|
||||
>
|
||||
{r.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
{metricsQuery.isLoading ? (
|
||||
<p className="text-xs text-[var(--text-muted)]">Loading metrics…</p>
|
||||
) : metrics.length < 2 ? (
|
||||
@@ -356,17 +365,12 @@ 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="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)]">
|
||||
<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)]">
|
||||
{containers.length === 0 && (
|
||||
<p className="px-5 py-6 text-xs text-[var(--text-muted)]">No containers reported on this node.</p>
|
||||
)}
|
||||
@@ -376,24 +380,15 @@ 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>
|
||||
{/* 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>
|
||||
<span className="v-mono text-[11px] text-[var(--text-secondary)]">{c.status ?? '—'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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)]">
|
||||
<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)]">
|
||||
{commands.length === 0 && (
|
||||
<p className="px-5 py-6 text-xs text-[var(--text-muted)]">No commands sent to this node yet.</p>
|
||||
)}
|
||||
@@ -471,19 +466,12 @@ function meshAddresses(agent: { metadata: Record<string, unknown> }): [string, s
|
||||
.filter((entry): entry is [string, string] => typeof entry[1] === 'string' && entry[1] !== '');
|
||||
}
|
||||
|
||||
function Stat({ label, icon, value, sub }: { label: string; icon: React.ReactNode; value: string; sub?: string }) {
|
||||
function Stat({ label, value, sub }: { label: string; value: string; sub?: string }) {
|
||||
return (
|
||||
<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 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>
|
||||
);
|
||||
}
|
||||
@@ -522,20 +510,24 @@ 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 backdrop-blur-sm" onClick={onCancel}>
|
||||
<div className="s-card w-full max-w-sm" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="s-t">{title}</h3>
|
||||
<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>
|
||||
<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">
|
||||
<QuietBtn onClick={onCancel}>cancel</QuietBtn>
|
||||
<GhostBtn
|
||||
<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
|
||||
onClick={onConfirm}
|
||||
disabled={pending}
|
||||
className={danger ? '!text-[var(--error)] !border-[var(--error)]/40 !bg-[var(--error)]/10' : undefined}
|
||||
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'}`}
|
||||
>
|
||||
{pending ? 'working…' : confirmLabel}
|
||||
</GhostBtn>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,10 +4,28 @@ 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;
|
||||
@@ -15,15 +33,13 @@ function Section({ title, icon: Icon, count, children }: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<div className="p-3">{children}</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -58,8 +74,13 @@ export function OperationsPage() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<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 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>
|
||||
|
||||
{opsQuery.isLoading ? (
|
||||
@@ -67,12 +88,24 @@ export function OperationsPage() {
|
||||
<Loader2 size={20} className="animate-spin text-[var(--text-muted)]" />
|
||||
</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 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>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||
@@ -92,7 +125,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>
|
||||
<SPill tone={statusTone(d.status)}>{d.status}</SPill>
|
||||
<span className={`text-xs mono ${statusClass(d.status)}`}>{d.status}</span>
|
||||
<span className="text-xs text-[var(--text-muted)]">{formatRelative(d.created_at)}</span>
|
||||
</button>
|
||||
))}
|
||||
@@ -119,7 +152,7 @@ export function OperationsPage() {
|
||||
<p className="text-[11px] text-[var(--error)]/80 truncate">{d.error}</p>
|
||||
)}
|
||||
</div>
|
||||
<SPill tone={statusTone(d.status)}>{d.status}</SPill>
|
||||
<span className={`text-xs mono ${statusClass(d.status)}`}>{d.status}</span>
|
||||
<span className="text-xs text-[var(--text-muted)]">{formatRelative(d.created_at)}</span>
|
||||
</button>
|
||||
))}
|
||||
@@ -134,9 +167,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">
|
||||
<SPill tone={statusTone(r.status)}>{r.status}</SPill>
|
||||
<span className={`mono ${statusClass(r.status)}`}>{r.status}</span>
|
||||
<span className="text-[var(--text-primary)]">{r.job_name}</span>
|
||||
<span className="v-mono text-[var(--text-muted)]">{r.schedule}</span>
|
||||
<span className="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>
|
||||
@@ -152,7 +185,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">
|
||||
<SPill tone={statusTone(b.status)}>{b.status}</SPill>
|
||||
<span className={`mono ${statusClass(b.status)}`}>{b.status}</span>
|
||||
<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>
|
||||
@@ -165,15 +198,14 @@ export function OperationsPage() {
|
||||
|
||||
{queue.length > 0 && (
|
||||
<Section title="Deploy queue depth" icon={Clock} count={queue.length}>
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1.5">
|
||||
{queue.map((q) => (
|
||||
<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} />
|
||||
<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'}
|
||||
</span>
|
||||
<span className="v-mono text-[var(--warning)]">{q.queued} queued</span>
|
||||
<span className="text-[var(--warning)]">{q.queued} queued</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -13,31 +13,10 @@ 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' },
|
||||
@@ -46,8 +25,6 @@ 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';
|
||||
@@ -55,6 +32,19 @@ 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();
|
||||
@@ -64,7 +54,6 @@ 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;
|
||||
@@ -95,61 +84,40 @@ function MiniCanvas({ project }: { project: ProjectEntity }) {
|
||||
function ProjectCard({ project, href }: { project: ProjectEntity; href: string }) {
|
||||
const navigate = useNavigate();
|
||||
const health = getHealthStatus(project.stats);
|
||||
const envLabel =
|
||||
health === 'healthy' ? 'production' : health === 'idle' ? 'idle' : health === 'critical' ? 'down' : 'degraded';
|
||||
const badge = healthBadge(health);
|
||||
const envOk = health === 'healthy';
|
||||
const envLabel = envOk ? 'production' : health === 'idle' ? 'idle' : health === 'critical' ? 'down' : 'degraded';
|
||||
|
||||
return (
|
||||
<article
|
||||
className="s-card group cursor-pointer transition-all duration-200 hover:-translate-y-0.5 hover:border-[var(--border-strong)]"
|
||||
className="group panel p-4 cursor-pointer transition-all duration-200 hover:-translate-y-0.5 hover:border-[var(--border-strong)]"
|
||||
onClick={() => navigate(href)}
|
||||
>
|
||||
<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 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>
|
||||
<div className="px-4">
|
||||
<MiniCanvas project={project} />
|
||||
</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:
|
||||
health === 'healthy'
|
||||
? 'var(--success)'
|
||||
: health === 'idle'
|
||||
? 'var(--text-tertiary)'
|
||||
: health === 'critical'
|
||||
? 'var(--error)'
|
||||
: 'var(--warning)',
|
||||
}}
|
||||
/>
|
||||
{envLabel} · {project.stats.running_services}/{project.stats.service_count} online
|
||||
<MiniCanvas project={project} />
|
||||
<div className="v-mono mt-3 flex items-center gap-2 text-[10.5px] text-[var(--text-tertiary)]">
|
||||
<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)' }}
|
||||
/>
|
||||
<span className="text-[var(--text-secondary)]">
|
||||
{envLabel}
|
||||
</span>
|
||||
<span>{formatRelative(project.updatedAt)}</span>
|
||||
<span>·</span>
|
||||
<span>
|
||||
{project.stats.running_services}/{project.stats.service_count} online
|
||||
</span>
|
||||
<span className="ml-auto">{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();
|
||||
@@ -213,7 +181,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;
|
||||
@@ -222,9 +190,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: DeployRow[] = isDemoMode
|
||||
const feed = isDemoMode
|
||||
? demoDeploys
|
||||
: deployments.slice(0, 6).map((d) => ({
|
||||
: deployments.slice(0, 5).map((d) => ({
|
||||
id: d.id,
|
||||
name: d.serviceName || d.imageName || d.serviceId || d.id,
|
||||
project: d.projectName,
|
||||
@@ -232,188 +200,120 @@ export function ProjectsPage() {
|
||||
when: formatRelative(d.startedAt ?? d.completedAt ?? d.createdAt),
|
||||
}));
|
||||
|
||||
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`] : [],
|
||||
]
|
||||
: [];
|
||||
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';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
<IconBtn title="Refresh" onClick={() => { projectsQuery.refetch(); deploysQuery.refetch(); hostQuery.refetch(); }}>
|
||||
<RefreshCw size={14} />
|
||||
</IconBtn>
|
||||
{signedIn && (
|
||||
<GhostBtn onClick={openCreate}>+ New Project</GhostBtn>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 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 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)' }}>
|
||||
<Search size={13} />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="search projects…"
|
||||
className="v-mono"
|
||||
style={{ fontSize: 11.5 }}
|
||||
/>
|
||||
</div>
|
||||
{signedIn && (
|
||||
<button onClick={openCreate} className="v-btn">
|
||||
+ new project
|
||||
</button>
|
||||
)}
|
||||
</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}
|
||||
/>
|
||||
{/* 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}%` }} />
|
||||
))}
|
||||
</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>
|
||||
</div>
|
||||
|
||||
{/* Projects grid */}
|
||||
<SSection right={`${filteredProjects.length} records`}>Projects</SSection>
|
||||
<p className="v-sect">PROJECTS <span className="v-sect-r">{filteredProjects.length} records</span></p>
|
||||
|
||||
{!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="s-card h-[210px] animate-pulse" />
|
||||
<div key={i} className="panel h-[210px] animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isDemoMode && projectsQuery.isError && (
|
||||
<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 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>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredProjects.length === 0 && !projectsQuery.isLoading && !projectsQuery.isError ? (
|
||||
<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 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>
|
||||
<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)]">
|
||||
<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)]">
|
||||
{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>
|
||||
<GhostBtn onClick={openCreate} className="mt-6">
|
||||
{signedIn ? '+ New Project' : 'Sign in to create'}
|
||||
</GhostBtn>
|
||||
<button onClick={openCreate} className="v-btn mt-6">
|
||||
{signedIn ? '+ create project' : 'sign in to create'}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -426,23 +326,24 @@ export function ProjectsPage() {
|
||||
)}
|
||||
|
||||
{/* Deploy feed */}
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -450,55 +351,58 @@ 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="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)]">
|
||||
<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)]">
|
||||
Projects organize your services and provide a visual canvas for topology management.
|
||||
</p>
|
||||
<div className="s-inset mt-3 text-[11.5px] text-[var(--warning)]">
|
||||
<p className="mt-2 rounded-[var(--radius-md)] bg-[var(--warning-soft)] px-3 py-2 text-xs text-[var(--warning)]">
|
||||
New projects stay private until a platform admin approves them for public listing.
|
||||
</div>
|
||||
</p>
|
||||
|
||||
<div className="mt-5 space-y-4">
|
||||
<div className="mt-6 space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-[11px] font-medium uppercase tracking-wider text-[var(--text-tertiary)]">
|
||||
<label className="mb-2 block text-xs 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-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"
|
||||
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)]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-[11px] font-medium uppercase tracking-wider text-[var(--text-tertiary)]">
|
||||
<label className="mb-2 block text-xs 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-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"
|
||||
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)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{createProjectMutation.isError && (
|
||||
<div className="s-inset mt-4 text-[12px] text-[var(--error)]">
|
||||
<div className="mt-4 rounded-[var(--radius-md)] bg-[var(--error-soft)] px-4 py-3 text-sm text-[var(--error)]">
|
||||
{(createProjectMutation.error as Error).message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex justify-end gap-2.5">
|
||||
<QuietBtn onClick={() => setCreateOpen(false)}>Cancel</QuietBtn>
|
||||
<GhostBtn
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<button onClick={() => setCreateOpen(false)} className="v-btn-ghost">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => createProjectMutation.mutate()}
|
||||
disabled={!form.name.trim() || createProjectMutation.isPending}
|
||||
className="v-btn"
|
||||
>
|
||||
{createProjectMutation.isPending ? 'Creating…' : 'Create Project'}
|
||||
</GhostBtn>
|
||||
{createProjectMutation.isPending ? 'Creating...' : 'Create Project'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
@@ -104,7 +104,6 @@ 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,
|
||||
@@ -149,10 +148,13 @@ import {
|
||||
Send,
|
||||
} from 'lucide-react';
|
||||
|
||||
function SecondaryPageHeader({ title, description, actions }: { title: string; description: string; actions?: ReactNode }) {
|
||||
function SecondaryPageHeader({ title, description }: { title: string; description: string }) {
|
||||
return (
|
||||
<div className="w-full px-4 pt-6 sm:px-8">
|
||||
<SPageHead title={title} titleAccent="_" sub={description} actions={actions} />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -178,17 +180,15 @@ function StatCard({
|
||||
};
|
||||
|
||||
return (
|
||||
<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 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>
|
||||
<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-4 py-6 sm:px-8">
|
||||
<div className="w-full px-8 py-6">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<div className={`w-2 h-2 rounded-full ${liveStatus === 'live' ? 'bg-[var(--success)] animate-pulse' : 'bg-[var(--text-muted)]'}`} />
|
||||
<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="s-card p-8 text-center">
|
||||
<div className="panel 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="s-card p-8 text-center">
|
||||
<div className="panel p-8 text-center">
|
||||
<p className="text-sm text-[var(--text-secondary)]">Sign in to view build activity and capacity.</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -490,12 +490,14 @@ export function UsagePage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="s-card p-5">
|
||||
<section className="panel p-6">
|
||||
<div className="mb-5 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="s-ibox !w-9 !h-9"><Server /></span>
|
||||
<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>
|
||||
<div>
|
||||
<h2 className="s-t">Host Monitoring</h2>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">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'}`
|
||||
@@ -576,11 +578,13 @@ export function UsagePage() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="s-card p-5">
|
||||
<section className="panel p-6">
|
||||
<div className="mb-5 flex items-center gap-3">
|
||||
<span className="s-ibox !w-9 !h-9"><Radio /></span>
|
||||
<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>
|
||||
<div>
|
||||
<h2 className="s-t">Node Agents</h2>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Node Agents</h2>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Remote VPS, VM, and LXC capacity for placement and autoscaling.</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -674,8 +678,8 @@ export function UsagePage() {
|
||||
<button
|
||||
onClick={() => createTokenMutation.mutate()}
|
||||
disabled={createTokenMutation.isPending}
|
||||
className="s-btn-accent h-8 disabled:opacity-50"
|
||||
|
||||
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)' }}
|
||||
>
|
||||
{createTokenMutation.isPending ? 'Issuing…' : 'Issue token'}
|
||||
</button>
|
||||
@@ -684,7 +688,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="v-mono text-xs text-[var(--text-primary)] break-all">{issuedToken.token}</span>
|
||||
<span className="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"
|
||||
@@ -702,11 +706,11 @@ export function UsagePage() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
<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>
|
||||
<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>
|
||||
<div className="mt-4 space-y-2 text-xs text-[var(--text-tertiary)]">
|
||||
<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 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 sends host resources, polls pending Docker commands, and reports command results.</p>
|
||||
</div>
|
||||
|
||||
@@ -840,7 +844,7 @@ export function PeoplePage() {
|
||||
title="People"
|
||||
description="Team management and access control"
|
||||
/>
|
||||
<div className="w-full px-4 py-6 sm:px-8">
|
||||
<div className="w-full px-8 py-6">
|
||||
{profileQuery.isLoading ? (
|
||||
<div className="py-16 text-center">
|
||||
<Loader2 size={24} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
|
||||
@@ -872,17 +876,19 @@ export function PeoplePage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="s-card p-5">
|
||||
<section className="panel p-6">
|
||||
<div className="mb-5 flex items-center gap-3">
|
||||
<span className="s-ibox !w-9 !h-9"><UserPlus /></span>
|
||||
<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>
|
||||
<div>
|
||||
<h2 className="s-t">Manual User Creation</h2>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">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-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_auto]"
|
||||
className="grid gap-3 lg:grid-cols-[1fr_1fr_1fr_auto]"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
createUserMutation.mutate();
|
||||
@@ -915,8 +921,8 @@ export function PeoplePage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createUserMutation.isPending}
|
||||
className="s-btn-accent h-10 justify-center disabled:opacity-60"
|
||||
|
||||
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)' }}
|
||||
>
|
||||
{createUserMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : <UserPlus size={14} />}
|
||||
Create
|
||||
@@ -1051,12 +1057,14 @@ function GitProvidersSection() {
|
||||
const selectedType = gitProviderTypes.find((t) => t.value === form.name)!;
|
||||
|
||||
return (
|
||||
<section className="s-card p-5">
|
||||
<section className="panel p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="s-ibox !w-9 !h-9"><GitBranch /></span>
|
||||
<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>
|
||||
<div>
|
||||
<h2 className="s-t">Git Providers</h2>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Git Providers</h2>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Connect accounts to deploy services from repositories</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1065,8 +1073,8 @@ function GitProvidersSection() {
|
||||
<button
|
||||
onClick={() => provisionMutation.mutate()}
|
||||
disabled={provisionMutation.isPending}
|
||||
className="s-btn-accent h-9 disabled:opacity-50"
|
||||
|
||||
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)' }}
|
||||
>
|
||||
<GitBranch size={14} />
|
||||
{provisionMutation.isPending ? 'Opening GitHub…' : 'Set up GitHub App'}
|
||||
@@ -1082,8 +1090,8 @@ function GitProvidersSection() {
|
||||
)}
|
||||
<button
|
||||
onClick={() => setFormOpen((open) => !open)}
|
||||
className="s-btn-accent h-9"
|
||||
|
||||
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)' }}
|
||||
>
|
||||
<Link2 size={14} />
|
||||
{formOpen ? 'Close' : 'Connect'}
|
||||
@@ -1132,10 +1140,12 @@ function GitProvidersSection() {
|
||||
{providers.map((provider) => (
|
||||
<div
|
||||
key={provider.id}
|
||||
className="s-inset flex items-center justify-between"
|
||||
className="flex items-center justify-between p-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="s-ibox"><GitBranch /></span>
|
||||
<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>
|
||||
<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>
|
||||
@@ -1191,7 +1201,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 v-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 mono focus:border-[var(--accent-primary)] transition-colors"
|
||||
placeholder="••••••••••••••••"
|
||||
/>
|
||||
<p className="text-xs text-[var(--text-muted)] mt-1.5">{selectedType.tokenHint}</p>
|
||||
@@ -1204,7 +1214,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 v-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 mono focus:border-[var(--accent-primary)] transition-colors"
|
||||
placeholder="https://gitea.example.com"
|
||||
/>
|
||||
</div>
|
||||
@@ -1218,8 +1228,8 @@ function GitProvidersSection() {
|
||||
<button
|
||||
onClick={() => createMutation.mutate()}
|
||||
disabled={!form.accessToken.trim() || createMutation.isPending}
|
||||
className="s-btn-accent h-9 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
|
||||
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)' }}
|
||||
>
|
||||
{createMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />}
|
||||
Connect Provider
|
||||
@@ -1264,7 +1274,7 @@ export function GitHubAppCallbackPage() {
|
||||
{!code ? (
|
||||
<>
|
||||
<AlertCircle size={28} className="mx-auto text-[var(--error)]" />
|
||||
<h1 className="mt-4 font-headline text-lg font-semibold text-[var(--text-primary)]">Missing code</h1>
|
||||
<h1 className="mt-4 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>
|
||||
@@ -1272,14 +1282,14 @@ export function GitHubAppCallbackPage() {
|
||||
) : convertMutation.isError ? (
|
||||
<>
|
||||
<AlertCircle size={28} className="mx-auto text-[var(--error)]" />
|
||||
<h1 className="mt-4 font-headline text-lg font-semibold text-[var(--text-primary)]">Setup failed</h1>
|
||||
<h1 className="mt-4 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="s-btn-accent mt-6"
|
||||
className="mt-6 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-sm font-semibold text-[var(--accent-on)]"
|
||||
>
|
||||
Back to settings
|
||||
</button>
|
||||
@@ -1287,7 +1297,7 @@ export function GitHubAppCallbackPage() {
|
||||
) : (
|
||||
<>
|
||||
<Loader2 size={28} className="mx-auto animate-spin text-[var(--accent-primary)]" />
|
||||
<h1 className="mt-4 font-headline text-lg font-semibold text-[var(--text-primary)]">Setting up GitHub App</h1>
|
||||
<h1 className="mt-4 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>
|
||||
</>
|
||||
)}
|
||||
@@ -1343,11 +1353,13 @@ function PlatformSettingsSection() {
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="s-card p-5">
|
||||
<section className="panel p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<span className="s-ibox !w-9 !h-9"><Cloud /></span>
|
||||
<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>
|
||||
<div>
|
||||
<h2 className="s-t">Platform</h2>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Platform</h2>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Owner only - values set here override environment variables</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1400,8 +1412,8 @@ function PlatformSettingsSection() {
|
||||
<button
|
||||
onClick={saveToken}
|
||||
disabled={saveMutation.isPending || !tokenDraft.trim()}
|
||||
className="s-btn-accent h-9 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
|
||||
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)' }}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
@@ -1463,8 +1475,8 @@ function PlatformSettingsSection() {
|
||||
saveMutation.mutate({ branding: b });
|
||||
}}
|
||||
disabled={saveMutation.isPending || Object.keys(brandDraft).length === 0}
|
||||
className="s-btn-accent h-9 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
|
||||
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)' }}
|
||||
>
|
||||
Save branding
|
||||
</button>
|
||||
@@ -1536,11 +1548,13 @@ function UserTokensSection({ isAdmin }: { isAdmin: boolean }) {
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="s-card p-5">
|
||||
<section className="panel p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<span className="s-ibox !w-9 !h-9"><Key /></span>
|
||||
<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>
|
||||
<div>
|
||||
<h2 className="s-t">Personal Access Tokens</h2>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">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>
|
||||
@@ -1574,8 +1588,8 @@ function UserTokensSection({ isAdmin }: { isAdmin: boolean }) {
|
||||
<button
|
||||
onClick={() => createMutation.mutate()}
|
||||
disabled={createMutation.isPending || !tokenName.trim()}
|
||||
className="s-btn-accent h-9 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
|
||||
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)' }}
|
||||
>
|
||||
{createMutation.isPending ? 'Creating…' : 'Create token'}
|
||||
</button>
|
||||
@@ -1584,7 +1598,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="v-mono text-xs text-[var(--text-primary)] break-all">{issuedToken.token}</span>
|
||||
<span className="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"
|
||||
@@ -1594,7 +1608,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="v-mono">containr auth login --token</span> or as a Bearer token.
|
||||
Shown once - store it now. Use with <span className="mono">containr auth login --token</span> or as a Bearer token.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -1616,7 +1630,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 v-mono text-[var(--text-muted)]">{token.key_prefix}…</span>
|
||||
<span className="ml-2 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'}
|
||||
@@ -1678,11 +1692,13 @@ function RegistriesSection() {
|
||||
const registries = registriesQuery.data ?? [];
|
||||
|
||||
return (
|
||||
<section className="s-card p-5">
|
||||
<section className="panel p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<span className="s-ibox !w-9 !h-9"><Archive /></span>
|
||||
<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>
|
||||
<div>
|
||||
<h2 className="s-t">Registry credentials</h2>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">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>
|
||||
@@ -1717,8 +1733,8 @@ function RegistriesSection() {
|
||||
<button
|
||||
onClick={() => createMutation.mutate()}
|
||||
disabled={createMutation.isPending || !name.trim() || !host.trim()}
|
||||
className="s-btn-accent h-9 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
|
||||
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)' }}
|
||||
>
|
||||
{createMutation.isPending ? 'Adding…' : 'Add registry'}
|
||||
</button>
|
||||
@@ -1744,7 +1760,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 v-mono text-[var(--text-muted)]">{r.host}</span>
|
||||
<span className="ml-2 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'}
|
||||
@@ -1812,11 +1828,13 @@ 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="s-card p-5">
|
||||
<section className="panel p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<span className="s-ibox !w-9 !h-9"><HardDrive /></span>
|
||||
<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>
|
||||
<div>
|
||||
<h2 className="s-t">Backup targets</h2>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Backup targets</h2>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">S3-compatible destinations for database archives — keys stored encrypted</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1835,8 +1853,8 @@ function BackupTargetsSection() {
|
||||
<button
|
||||
onClick={() => createMutation.mutate()}
|
||||
disabled={createMutation.isPending || !form.name.trim() || !form.endpoint.trim() || !form.bucket.trim()}
|
||||
className="s-btn-accent h-9 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
|
||||
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)' }}
|
||||
>
|
||||
{createMutation.isPending ? 'Checking…' : 'Add target'}
|
||||
</button>
|
||||
@@ -1862,7 +1880,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 v-mono text-[var(--text-muted)]">{t.endpoint}/{t.bucket}{t.prefix ? `/${t.prefix}` : ''}</span>
|
||||
<span className="ml-2 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>
|
||||
@@ -1943,11 +1961,13 @@ function NotificationChannelsSection() {
|
||||
const channels = channelsQuery.data ?? [];
|
||||
|
||||
return (
|
||||
<section className="s-card p-5">
|
||||
<section className="panel p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<span className="s-ibox !w-9 !h-9"><Bell /></span>
|
||||
<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>
|
||||
<div>
|
||||
<h2 className="s-t">Push notifications</h2>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Push notifications</h2>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Relay notifications to a self-hosted ntfy or Gotify server</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1978,8 +1998,8 @@ function NotificationChannelsSection() {
|
||||
<button
|
||||
onClick={() => createMutation.mutate()}
|
||||
disabled={createMutation.isPending || !endpoint.trim()}
|
||||
className="s-btn-accent h-9 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
|
||||
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)' }}
|
||||
>
|
||||
{createMutation.isPending ? 'Adding…' : 'Add channel'}
|
||||
</button>
|
||||
@@ -2007,7 +2027,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 v-mono text-[var(--text-primary)] break-all">{ch.endpoint}</span>
|
||||
<span className="ml-2 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] ? (
|
||||
@@ -2125,14 +2145,16 @@ export function SettingsPage() {
|
||||
title="Settings"
|
||||
description="Manage account profile and local configuration"
|
||||
/>
|
||||
<div className="w-full px-4 py-6 sm:px-8">
|
||||
<div className="w-full px-8 py-6">
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||
{/* Profile Section */}
|
||||
<section className="s-card p-5">
|
||||
<section className="panel p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<span className="s-ibox !w-9 !h-9"><User /></span>
|
||||
<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>
|
||||
<div>
|
||||
<h2 className="s-t">Profile</h2>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Profile</h2>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Backed by GET/PUT /user/profile</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2174,8 +2196,8 @@ export function SettingsPage() {
|
||||
<button
|
||||
onClick={() => updateProfileMutation.mutate()}
|
||||
disabled={!hasProfileChanges || updateProfileMutation.isPending}
|
||||
className="s-btn-accent h-9 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
|
||||
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)' }}
|
||||
>
|
||||
{updateProfileMutation.isPending ? (
|
||||
<>
|
||||
@@ -2226,11 +2248,13 @@ export function SettingsPage() {
|
||||
<GitProvidersSection />
|
||||
|
||||
{/* Audit Logs Section */}
|
||||
<section className="s-card p-5">
|
||||
<section className="panel p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<span className="s-ibox !w-9 !h-9"><ScrollText /></span>
|
||||
<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>
|
||||
<div>
|
||||
<h2 className="s-t">Audit Logs</h2>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Audit Logs</h2>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Every authenticated action recorded by the platform</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2247,11 +2271,13 @@ export function SettingsPage() {
|
||||
</section>
|
||||
|
||||
{/* Runtime & Local State Section */}
|
||||
<section className="s-card p-5">
|
||||
<section className="panel p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<span className="s-ibox !w-9 !h-9"><Settings /></span>
|
||||
<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>
|
||||
<div>
|
||||
<h2 className="s-t">Runtime & Local State</h2>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Runtime & Local State</h2>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">API endpoints, cookie-session mode, and storage diagnostics</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2262,7 +2288,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="v-mono text-xs text-[var(--text-primary)] break-all">{getApiBaseUrl()}</p>
|
||||
<p className="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)]">
|
||||
@@ -2270,7 +2296,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="v-mono text-xs text-[var(--text-primary)] break-all">{getAuthBaseUrl()}</p>
|
||||
<p className="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)]">
|
||||
@@ -2324,7 +2350,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="v-mono text-xs text-[var(--text-tertiary)] truncate">
|
||||
<li key={key} className="mono text-xs text-[var(--text-tertiary)] truncate">
|
||||
{key}
|
||||
</li>
|
||||
))}
|
||||
@@ -2350,7 +2376,7 @@ export function DocsPage() {
|
||||
title="Docs"
|
||||
description="Guides and references — synced from GitHub, cached offline"
|
||||
/>
|
||||
<div className="w-full px-4 py-6 sm:px-8">
|
||||
<div className="w-full px-8 py-6">
|
||||
<DocsBrowser />
|
||||
</div>
|
||||
</div>
|
||||
@@ -2487,7 +2513,7 @@ export function DatabasesPage() {
|
||||
title="Databases"
|
||||
description="Managed database services — connection info, runtime actions and backups"
|
||||
/>
|
||||
<div className="w-full px-4 py-6 sm:px-8">
|
||||
<div className="w-full px-8 py-6">
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<p className="text-xs text-[var(--text-tertiary)]">
|
||||
{databases.length} {databases.length === 1 ? 'database' : 'databases'}
|
||||
@@ -2496,7 +2522,7 @@ export function DatabasesPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
className="s-btn-accent h-9 text-[12.5px]"
|
||||
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)]"
|
||||
>
|
||||
<Database size={13} /> New database
|
||||
</button>
|
||||
@@ -2514,7 +2540,7 @@ export function DatabasesPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
className="s-btn-accent mt-4 text-[12.5px]"
|
||||
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)]"
|
||||
>
|
||||
<Database size={13} /> Create your first database
|
||||
</button>
|
||||
@@ -2548,7 +2574,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)] v-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)] 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>
|
||||
)}
|
||||
@@ -2568,7 +2594,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="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)]">
|
||||
<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)]">
|
||||
{db.connection_url || '—'}
|
||||
</code>
|
||||
{db.connection_url && (
|
||||
@@ -2591,7 +2617,7 @@ export function DatabasesPage() {
|
||||
<button
|
||||
onClick={() => testByIdMutation.mutate(db.id ?? '')}
|
||||
disabled={testByIdMutation.isPending}
|
||||
className="s-btn-quiet"
|
||||
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"
|
||||
>
|
||||
<RefreshCw size={11} /> Test connection
|
||||
</button>
|
||||
@@ -2608,21 +2634,21 @@ export function DatabasesPage() {
|
||||
<button
|
||||
onClick={() => actionMutation.mutate({ id: db.id ?? '', action: 'start' })}
|
||||
disabled={running || actionMutation.isPending}
|
||||
className="s-btn-quiet"
|
||||
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"
|
||||
>
|
||||
<Play size={11} /> Start
|
||||
</button>
|
||||
<button
|
||||
onClick={() => actionMutation.mutate({ id: db.id ?? '', action: 'stop' })}
|
||||
disabled={!running || actionMutation.isPending}
|
||||
className="s-btn-quiet"
|
||||
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"
|
||||
>
|
||||
<Square size={11} /> Stop
|
||||
</button>
|
||||
<button
|
||||
onClick={() => actionMutation.mutate({ id: db.id ?? '', action: 'restart' })}
|
||||
disabled={!running || actionMutation.isPending}
|
||||
className="s-btn-quiet"
|
||||
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"
|
||||
>
|
||||
<RefreshCw size={11} /> Restart
|
||||
</button>
|
||||
@@ -2639,7 +2665,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="s-btn-quiet"
|
||||
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"
|
||||
>
|
||||
<Link2 size={11} /> Bind
|
||||
</button>
|
||||
@@ -2671,7 +2697,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="v-mono text-sm text-[var(--text-primary)]">{value}</p>
|
||||
<p className="mono text-sm text-[var(--text-primary)]">{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -2717,8 +2743,8 @@ export function DatabasesPage() {
|
||||
<button
|
||||
onClick={() => backupMutation.mutate(db.id ?? '')}
|
||||
disabled={!running || backupMutation.isPending}
|
||||
className="s-btn-accent disabled:opacity-40"
|
||||
|
||||
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)' }}
|
||||
>
|
||||
<Archive size={11} />
|
||||
{backupMutation.isPending ? 'Starting…' : 'New backup'}
|
||||
@@ -2738,7 +2764,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="v-mono text-[var(--text-muted)]">{b.size}</span>
|
||||
<span className="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' && (
|
||||
@@ -2968,7 +2994,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="s-btn-accent disabled:opacity-50"
|
||||
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"
|
||||
>
|
||||
{(createMode === 'managed' ? createMutation.isPending : registerExternalMutation.isPending) ? <Loader2 size={13} className="animate-spin" /> : null}
|
||||
{createMode === 'managed' ? 'Create database' : 'Register database'}
|
||||
@@ -3015,10 +3041,10 @@ function BindDatabasePanel({
|
||||
const services = servicesQuery.data ?? [];
|
||||
|
||||
return (
|
||||
<div className="s-card space-y-3">
|
||||
<div className="rounded-[var(--radius-md)] border border-[var(--border-default)] bg-[var(--surface-card)] p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--text-muted)]">
|
||||
Injects this database's connection URL as an environment variable on the selected service.
|
||||
@@ -3056,13 +3082,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 v-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 mono text-[var(--text-primary)] w-40"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
onClick={() => bindMutation.mutate()}
|
||||
disabled={!serviceId || !varKey.trim() || bindMutation.isPending}
|
||||
className="s-btn-accent disabled:opacity-40"
|
||||
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"
|
||||
>
|
||||
{bindMutation.isPending ? <Loader2 size={11} className="animate-spin" /> : <Link2 size={11} />} Bind
|
||||
</button>
|
||||
@@ -3103,7 +3129,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 v-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 mono text-[var(--text-primary)]"
|
||||
/>
|
||||
<button
|
||||
onClick={() => mutation.mutate(value.trim())}
|
||||
@@ -3225,7 +3251,7 @@ export function HighAvailabilityPage() {
|
||||
title="High availability"
|
||||
description="Failover manager, policies, and active alerts"
|
||||
/>
|
||||
<div className="p-4 space-y-8 sm:p-8">
|
||||
<div className="p-8 space-y-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>
|
||||
@@ -3271,17 +3297,17 @@ export function HighAvailabilityPage() {
|
||||
</div>
|
||||
|
||||
{isAdmin ? (
|
||||
<div className="s-card p-5">
|
||||
<div className="panel p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Zap size={18} className="text-[var(--accent-primary)]" />
|
||||
<h2 className="s-t">Manager controls</h2>
|
||||
<h2 className="text-base font-semibold text-[var(--text-primary)]">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="s-btn-accent disabled:opacity-50"
|
||||
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{status?.enabled ? 'Disable HA manager' : 'Enable HA manager'}
|
||||
</button>
|
||||
@@ -3312,10 +3338,10 @@ export function HighAvailabilityPage() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="s-card p-5">
|
||||
<div className="panel p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<AlertCircle size={18} className="text-[var(--accent-primary)]" />
|
||||
<h2 className="s-t">Active alerts</h2>
|
||||
<h2 className="text-base font-semibold text-[var(--text-primary)]">Active alerts</h2>
|
||||
</div>
|
||||
{!isDemoMode && alertsQuery.isLoading ? (
|
||||
<p className="text-sm text-[var(--text-secondary)]">Loading…</p>
|
||||
@@ -3339,7 +3365,7 @@ export function HighAvailabilityPage() {
|
||||
type="button"
|
||||
disabled={resolveMutation.isPending}
|
||||
onClick={() => alert.id && resolveMutation.mutate(alert.id)}
|
||||
className="s-btn-quiet"
|
||||
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"
|
||||
>
|
||||
Resolve
|
||||
</button>
|
||||
@@ -3350,17 +3376,17 @@ export function HighAvailabilityPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="s-card p-5">
|
||||
<div className="panel p-6">
|
||||
<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="s-t">Failover policies</h2>
|
||||
<h2 className="text-base font-semibold text-[var(--text-primary)]">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="s-btn-accent "
|
||||
className="px-3 py-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-xs font-medium"
|
||||
>
|
||||
New policy
|
||||
</button>
|
||||
@@ -3454,14 +3480,14 @@ export function HighAvailabilityPage() {
|
||||
type="button"
|
||||
disabled={savePolicyMutation.isPending || !policyForm.serviceId}
|
||||
onClick={() => savePolicyMutation.mutate()}
|
||||
className="s-btn-accent disabled:opacity-50"
|
||||
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{savePolicyMutation.isPending ? 'Saving…' : 'Save policy'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPolicyForm(null)}
|
||||
className="s-btn-quiet"
|
||||
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@@ -3500,7 +3526,7 @@ export function HighAvailabilityPage() {
|
||||
enabled: policy.enabled ?? true,
|
||||
editing: true,
|
||||
})}
|
||||
className="s-btn-quiet"
|
||||
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs text-[var(--text-primary)]"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
@@ -3527,10 +3553,10 @@ export function HighAvailabilityPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="s-card p-5">
|
||||
<div className="panel p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<HeartPulse size={18} className="text-[var(--accent-primary)]" />
|
||||
<h2 className="s-t">Health check results</h2>
|
||||
<h2 className="text-base font-semibold text-[var(--text-primary)]">Health check results</h2>
|
||||
</div>
|
||||
{!isDemoMode && healthQuery.isLoading ? (
|
||||
<p className="text-sm text-[var(--text-secondary)]">Loading…</p>
|
||||
@@ -3589,43 +3615,39 @@ 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', effectiveProjectId],
|
||||
queryFn: () => listServicesByProject(effectiveProjectId),
|
||||
enabled: !isDemoMode && Boolean(effectiveProjectId),
|
||||
queryKey: ['services', projectId],
|
||||
queryFn: () => listServicesByProject(projectId),
|
||||
enabled: !isDemoMode && Boolean(projectId),
|
||||
});
|
||||
const services = isDemoMode ? getDemoServicesByProject(effectiveProjectId) : servicesQuery.data ?? [];
|
||||
const services = isDemoMode ? getDemoServicesByProject(projectId) : servicesQuery.data ?? [];
|
||||
const metricsQuery = useQuery({
|
||||
queryKey: ['security-metrics', effectiveProjectId],
|
||||
queryFn: () => getSecurityMetrics(effectiveProjectId),
|
||||
enabled: !isDemoMode && Boolean(effectiveProjectId),
|
||||
queryKey: ['security-metrics', projectId],
|
||||
queryFn: () => getSecurityMetrics(projectId),
|
||||
enabled: !isDemoMode && Boolean(projectId),
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
const historyQuery = useQuery({
|
||||
queryKey: ['security-history', effectiveProjectId],
|
||||
queryFn: () => getSecurityHistory(effectiveProjectId),
|
||||
enabled: !isDemoMode && Boolean(effectiveProjectId),
|
||||
queryKey: ['security-history', projectId],
|
||||
queryFn: () => getSecurityHistory(projectId),
|
||||
enabled: !isDemoMode && Boolean(projectId),
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
const vulnsQuery = useQuery({
|
||||
queryKey: ['security-vulns', effectiveProjectId],
|
||||
queryFn: () => listVulnerabilities(effectiveProjectId),
|
||||
enabled: !isDemoMode && Boolean(effectiveProjectId),
|
||||
queryKey: ['security-vulns', projectId],
|
||||
queryFn: () => listVulnerabilities(projectId),
|
||||
enabled: !isDemoMode && Boolean(projectId),
|
||||
});
|
||||
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['security-metrics', effectiveProjectId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['security-history', effectiveProjectId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['security-vulns', effectiveProjectId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['security-metrics', projectId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['security-history', projectId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['security-vulns', projectId] });
|
||||
};
|
||||
|
||||
const scanMutation = useMutation({
|
||||
mutationFn: () => startSecurityScan({
|
||||
project_id: effectiveProjectId,
|
||||
project_id: projectId,
|
||||
service_id: scanServiceId || undefined,
|
||||
scan_type: scanType as 'dependency' | 'configuration' | 'comprehensive',
|
||||
}),
|
||||
@@ -3639,10 +3661,10 @@ export function SecurityPage() {
|
||||
|
||||
const metrics = isDemoMode ? demoSecurityMetrics : metricsQuery.data;
|
||||
const scanHistory = isDemoMode
|
||||
? demoSecurityScans.filter((s) => s.project_id === effectiveProjectId)
|
||||
? demoSecurityScans.filter((s) => s.project_id === projectId)
|
||||
: historyQuery.data ?? [];
|
||||
const vulns = (isDemoMode
|
||||
? demoVulnerabilities.filter((v) => v.project_id === effectiveProjectId)
|
||||
? demoVulnerabilities.filter((v) => v.project_id === projectId)
|
||||
: vulnsQuery.data ?? []
|
||||
).filter((v) => v.status !== 'resolved');
|
||||
const pageError =
|
||||
@@ -3655,7 +3677,7 @@ export function SecurityPage() {
|
||||
title="Security"
|
||||
description="Scan findings, security posture, and compliance status"
|
||||
/>
|
||||
<div className="p-4 space-y-8 sm:p-8">
|
||||
<div className="p-8 space-y-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>
|
||||
@@ -3673,7 +3695,7 @@ export function SecurityPage() {
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Project</label>
|
||||
<select
|
||||
value={effectiveProjectId}
|
||||
value={projectId}
|
||||
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"
|
||||
>
|
||||
@@ -3683,7 +3705,7 @@ export function SecurityPage() {
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{effectiveProjectId && (
|
||||
{projectId && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Scan type</label>
|
||||
@@ -3715,7 +3737,7 @@ export function SecurityPage() {
|
||||
type="button"
|
||||
disabled={scanMutation.isPending}
|
||||
onClick={() => scanMutation.mutate()}
|
||||
className="s-btn-accent disabled:opacity-50"
|
||||
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{scanMutation.isPending ? 'Starting…' : 'Run scan'}
|
||||
</button>
|
||||
@@ -3726,7 +3748,7 @@ export function SecurityPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{effectiveProjectId && metrics && (
|
||||
{projectId && metrics && (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
title="Security score"
|
||||
@@ -3757,11 +3779,11 @@ export function SecurityPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{effectiveProjectId && (
|
||||
<div className="s-card p-5">
|
||||
{projectId && (
|
||||
<div className="panel p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Shield size={18} className="text-[var(--accent-primary)]" />
|
||||
<h2 className="s-t">Findings</h2>
|
||||
<h2 className="text-base font-semibold text-[var(--text-primary)]">Findings</h2>
|
||||
</div>
|
||||
{!isDemoMode && vulnsQuery.isLoading ? (
|
||||
<p className="text-sm text-[var(--text-secondary)]">Loading…</p>
|
||||
@@ -3800,7 +3822,7 @@ export function SecurityPage() {
|
||||
type="button"
|
||||
disabled={vulnMutation.isPending}
|
||||
onClick={() => vulnMutation.mutate({ id: v.id!, status: 'ignored' })}
|
||||
className="s-btn-quiet"
|
||||
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs text-[var(--text-secondary)] disabled:opacity-50"
|
||||
>
|
||||
Ignore
|
||||
</button>
|
||||
@@ -3813,11 +3835,11 @@ export function SecurityPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{effectiveProjectId && (
|
||||
<div className="s-card p-5">
|
||||
{projectId && (
|
||||
<div className="panel p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Clock size={18} className="text-[var(--accent-primary)]" />
|
||||
<h2 className="s-t">Scan history</h2>
|
||||
<h2 className="text-base font-semibold text-[var(--text-primary)]">Scan history</h2>
|
||||
</div>
|
||||
{!isDemoMode && historyQuery.isLoading ? (
|
||||
<p className="text-sm text-[var(--text-secondary)]">Loading…</p>
|
||||
|
||||
@@ -34,7 +34,8 @@ export function ComponentShowcase() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen p-8" style={{ background: 'var(--bg-void)' }}>
|
||||
<div className="min-h-screen p-8" style={{ background: '#16171c' }}>
|
||||
<div className="ambient-glow" />
|
||||
|
||||
<div className="w-full relative z-10">
|
||||
{/* Header */}
|
||||
@@ -90,25 +91,25 @@ export function ComponentShowcase() {
|
||||
Line Charts
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="s-card p-6">
|
||||
<h3 className="s-t mb-4">
|
||||
<div className="panel p-6">
|
||||
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4">
|
||||
Basic Line Chart (CPU Style)
|
||||
</h3>
|
||||
<LineChart
|
||||
data={sampleLineData}
|
||||
color="#38bdf8"
|
||||
color="#b4e34a"
|
||||
height={120}
|
||||
showDots={true}
|
||||
smooth={true}
|
||||
/>
|
||||
</div>
|
||||
<div className="s-card p-6">
|
||||
<h3 className="s-t mb-4">
|
||||
<div className="panel p-6">
|
||||
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4">
|
||||
Line Chart (No Dots)
|
||||
</h3>
|
||||
<LineChart
|
||||
data={sampleLineData}
|
||||
color="var(--accent-primary)"
|
||||
color="#7ab8ff"
|
||||
height={120}
|
||||
showDots={false}
|
||||
smooth={true}
|
||||
@@ -122,8 +123,8 @@ export function ComponentShowcase() {
|
||||
Area Charts
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="s-card p-6">
|
||||
<h3 className="s-t mb-4">
|
||||
<div className="panel p-6">
|
||||
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4">
|
||||
Area Chart (Active Users Style)
|
||||
</h3>
|
||||
<LineAreaChart
|
||||
@@ -133,8 +134,8 @@ export function ComponentShowcase() {
|
||||
height={150}
|
||||
/>
|
||||
</div>
|
||||
<div className="s-card p-6">
|
||||
<h3 className="s-t mb-4">
|
||||
<div className="panel p-6">
|
||||
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4">
|
||||
Area Chart (Success Color)
|
||||
</h3>
|
||||
<LineAreaChart
|
||||
@@ -152,20 +153,20 @@ export function ComponentShowcase() {
|
||||
Multi-Line & Bar Charts
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="s-card p-6">
|
||||
<h3 className="s-t mb-4">
|
||||
<div className="panel p-6">
|
||||
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4">
|
||||
Multi-Line Chart (Performance Style)
|
||||
</h3>
|
||||
<MultiLineChart
|
||||
datasets={[
|
||||
{ data: sampleLineData.map(v => v + 10), color: 'var(--accent-primary)', fillOpacity: 0.15 },
|
||||
{ data: sampleLineData, color: 'var(--warning)', fillOpacity: 0.15 }
|
||||
{ data: sampleLineData.map(v => v + 10), color: '#7ab8ff', fillOpacity: 0.15 },
|
||||
{ data: sampleLineData, color: '#f2c94c', fillOpacity: 0.15 }
|
||||
]}
|
||||
height={120}
|
||||
/>
|
||||
</div>
|
||||
<div className="s-card p-6">
|
||||
<h3 className="s-t mb-4">
|
||||
<div className="panel p-6">
|
||||
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4">
|
||||
Bar Chart (Timeline Style)
|
||||
</h3>
|
||||
<BarChart
|
||||
@@ -183,23 +184,23 @@ export function ComponentShowcase() {
|
||||
Donut Charts
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="s-card p-6 flex flex-col items-center">
|
||||
<h3 className="s-t mb-4">
|
||||
<div className="panel p-6 flex flex-col items-center">
|
||||
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4">
|
||||
RAM Usage (65%)
|
||||
</h3>
|
||||
<DonutChart percentage={65} color="var(--warning)" size={160} />
|
||||
<DonutChart percentage={65} color="#f2c94c" size={160} />
|
||||
</div>
|
||||
<div className="s-card p-6 flex flex-col items-center">
|
||||
<h3 className="s-t mb-4">
|
||||
<div className="panel p-6 flex flex-col items-center">
|
||||
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4">
|
||||
Disk Usage (82%)
|
||||
</h3>
|
||||
<DonutChart percentage={82} color="#38bdf8" size={160} />
|
||||
<DonutChart percentage={82} color="#b4e34a" size={160} />
|
||||
</div>
|
||||
<div className="s-card p-6 flex flex-col items-center">
|
||||
<h3 className="s-t mb-4">
|
||||
<div className="panel p-6 flex flex-col items-center">
|
||||
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4">
|
||||
Network (45%)
|
||||
</h3>
|
||||
<DonutChart percentage={45} color="var(--accent-primary)" size={160} />
|
||||
<DonutChart percentage={45} color="#7ab8ff" size={160} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -221,7 +222,7 @@ export function ComponentShowcase() {
|
||||
status="good"
|
||||
statusText="Good"
|
||||
icon={<Cpu size={16} />}
|
||||
chart={<LineChart data={sampleLineData} color="#38bdf8" height={76} />}
|
||||
chart={<LineChart data={sampleLineData} color="#b4e34a" height={76} />}
|
||||
onClick={() => handleCardClick('CPU Usage')}
|
||||
animationDelay={0.04}
|
||||
/>
|
||||
@@ -232,7 +233,7 @@ export function ComponentShowcase() {
|
||||
status="average"
|
||||
statusText="Average"
|
||||
icon={<MemoryStick size={16} />}
|
||||
chart={<LineChart data={sampleLineData.map(v => v + 20)} color="var(--warning)" height={76} />}
|
||||
chart={<LineChart data={sampleLineData.map(v => v + 20)} color="#f2c94c" height={76} />}
|
||||
onClick={() => handleCardClick('Memory')}
|
||||
animationDelay={0.09}
|
||||
/>
|
||||
@@ -275,7 +276,7 @@ export function ComponentShowcase() {
|
||||
value="12.5K"
|
||||
subtitle="Queries per minute"
|
||||
icon={<Database size={16} />}
|
||||
chart={<LineAreaChart data={sampleAreaData.map(v => v + 10)} color="var(--accent-primary)" height={130} />}
|
||||
chart={<LineAreaChart data={sampleAreaData.map(v => v + 10)} color="#7ab8ff" height={130} />}
|
||||
details={
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
@@ -312,8 +313,8 @@ export function ComponentShowcase() {
|
||||
upSpeed={10.4}
|
||||
downSpeed={5.2}
|
||||
datasets={[
|
||||
{ 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 }
|
||||
{ data: sampleLineData.map(v => v + 20), color: '#7ab8ff', fillOpacity: 0.15 },
|
||||
{ data: sampleLineData.map(v => v + 10), color: '#f2c94c', fillOpacity: 0.15 }
|
||||
]}
|
||||
onClick={() => handleCardClick('Performance')}
|
||||
animationDelay={0.09}
|
||||
@@ -330,7 +331,7 @@ export function ComponentShowcase() {
|
||||
<h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4">
|
||||
Badges & Indicators
|
||||
</h2>
|
||||
<div className="s-card p-6 space-y-4">
|
||||
<div className="panel p-6 space-y-4">
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<span className="badge-active">
|
||||
<span className="live-dot" />
|
||||
@@ -357,7 +358,7 @@ export function ComponentShowcase() {
|
||||
<h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4">
|
||||
Buttons
|
||||
</h2>
|
||||
<div className="s-card p-6 space-y-4">
|
||||
<div className="panel 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">
|
||||
@@ -386,7 +387,7 @@ export function ComponentShowcase() {
|
||||
<h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4">
|
||||
Pills & Tabs
|
||||
</h2>
|
||||
<div className="s-card p-6 space-y-6">
|
||||
<div className="panel p-6 space-y-6">
|
||||
<div>
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-3">Pill Group</p>
|
||||
<div className="pill-group">
|
||||
@@ -419,9 +420,9 @@ export function ComponentShowcase() {
|
||||
<h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4">
|
||||
Search & Input
|
||||
</h2>
|
||||
<div className="s-card p-6 space-y-4">
|
||||
<div className="panel p-6 space-y-4">
|
||||
<div className="search-box">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2" strokeLinecap="round">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#6b6e7d" strokeWidth="2" strokeLinecap="round">
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</svg>
|
||||
@@ -434,7 +435,7 @@ export function ComponentShowcase() {
|
||||
<h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4">
|
||||
Toast Notifications
|
||||
</h2>
|
||||
<div className="s-card p-6">
|
||||
<div className="panel 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: 'var(--accent-primary)', fillOpacity: 0.15 },
|
||||
{ data: perfData2, color: 'var(--warning)', fillOpacity: 0.15 }
|
||||
{ data: perfData1, color: '#7ab8ff', fillOpacity: 0.15 },
|
||||
{ data: perfData2, color: '#f2c94c', fillOpacity: 0.15 }
|
||||
],
|
||||
upSpeed: seededRandom(`${serviceId}:upspeed`, 8, 13).toFixed(1),
|
||||
downSpeed: seededRandom(`${serviceId}:downspeed`, 4, 7).toFixed(1),
|
||||
@@ -107,8 +107,9 @@ export function ServiceMetricsDashboard() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen" style={{ background: 'var(--bg-void)' }}>
|
||||
<div className="min-h-screen" style={{ background: '#16171c' }}>
|
||||
{/* Ambient glow */}
|
||||
<div className="ambient-glow" />
|
||||
|
||||
<div className="flex min-h-screen relative">
|
||||
{/* Sidebar - self.html exact match */}
|
||||
@@ -116,8 +117,8 @@ export function ServiceMetricsDashboard() {
|
||||
className="hidden md:flex shrink-0 flex-col items-center border-r"
|
||||
style={{
|
||||
width: '64px',
|
||||
background: 'var(--bg-surface)',
|
||||
borderRight: '1px solid var(--border-subtle)',
|
||||
background: '#111217',
|
||||
borderRight: '1px solid rgba(255,255,255,0.07)',
|
||||
padding: '16px 0',
|
||||
gap: '5px'
|
||||
}}
|
||||
@@ -168,10 +169,10 @@ export function ServiceMetricsDashboard() {
|
||||
style={{
|
||||
width: '34px',
|
||||
height: '34px',
|
||||
background: 'var(--accent-primary-soft)',
|
||||
background: '#22233a',
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
color: 'var(--text-tertiary)',
|
||||
color: '#9295a4',
|
||||
marginTop: '4px',
|
||||
letterSpacing: '-0.3px'
|
||||
}}
|
||||
@@ -187,8 +188,8 @@ export function ServiceMetricsDashboard() {
|
||||
className="shrink-0 flex items-center"
|
||||
style={{
|
||||
height: '52px',
|
||||
background: 'var(--bg-surface)',
|
||||
borderBottom: '1px solid var(--border-subtle)',
|
||||
background: '#111217',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.07)',
|
||||
padding: '0 22px',
|
||||
gap: '14px'
|
||||
}}
|
||||
@@ -200,22 +201,22 @@ export function ServiceMetricsDashboard() {
|
||||
|
||||
<div className="ml-auto flex items-center" style={{ gap: '8px' }}>
|
||||
<button
|
||||
className="rounded-[9px] border bg-transparent text-[var(--text-tertiary)] font-medium cursor-pointer"
|
||||
className="rounded-[9px] border bg-transparent text-[#9295a4] font-medium cursor-pointer"
|
||||
style={{
|
||||
height: '32px',
|
||||
padding: '0 14px',
|
||||
border: '1px solid var(--border-default)',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
fontSize: '13px'
|
||||
}}
|
||||
>
|
||||
Support
|
||||
</button>
|
||||
<button
|
||||
className="rounded-[9px] border-none flex items-center text-[var(--text-primary)] font-medium cursor-pointer"
|
||||
className="rounded-[9px] border-none flex items-center text-[#e8e9f0] font-medium cursor-pointer"
|
||||
style={{
|
||||
height: '32px',
|
||||
padding: '0 14px',
|
||||
background: 'var(--bg-overlay)',
|
||||
background: 'rgba(255,255,255,0.08)',
|
||||
fontSize: '13px',
|
||||
gap: '6px'
|
||||
}}
|
||||
@@ -228,10 +229,10 @@ export function ServiceMetricsDashboard() {
|
||||
style={{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
border: '1px solid var(--border-default)'
|
||||
border: '1px solid rgba(255,255,255,0.1)'
|
||||
}}
|
||||
>
|
||||
<Bell size={15} />
|
||||
<Bell size={15} color="#9295a4" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -244,22 +245,22 @@ export function ServiceMetricsDashboard() {
|
||||
style={{
|
||||
gap: '6px',
|
||||
padding: '14px 0 10px',
|
||||
color: 'var(--text-tertiary)',
|
||||
color: '#6b6e7d',
|
||||
fontSize: '13px'
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#6b6e7d" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="15 18 9 12 15 6"/>
|
||||
</svg>
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
style={{ color: 'var(--text-tertiary)' }}
|
||||
className="hover:text-[var(--text-tertiary)] transition-colors"
|
||||
style={{ color: '#6b6e7d' }}
|
||||
className="hover:text-[#9295a4] transition-colors"
|
||||
>
|
||||
Servers
|
||||
</button>
|
||||
<span style={{ opacity: 0.4 }}>/</span>
|
||||
<span style={{ color: 'var(--text-tertiary)' }}>[NuFest] - App Project</span>
|
||||
<span style={{ color: '#9295a4' }}>[NuFest] - App Project</span>
|
||||
</div>
|
||||
|
||||
{/* Project Header */}
|
||||
@@ -279,7 +280,7 @@ export function ServiceMetricsDashboard() {
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center" style={{ gap: '10px' }}>
|
||||
<span style={{ fontSize: '20px', fontWeight: 800, letterSpacing: '-0.5px', color: 'var(--text-primary)' }}>
|
||||
<span style={{ fontSize: '20px', fontWeight: 800, letterSpacing: '-0.5px', color: '#e8e9f0' }}>
|
||||
[NuFest] - App Project
|
||||
</span>
|
||||
<span className={`badge-${isSystemRunning ? 'active' : 'stopped'}`}>
|
||||
@@ -292,8 +293,8 @@ export function ServiceMetricsDashboard() {
|
||||
href="https://nufest-dth.app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center hover:text-[var(--text-tertiary)] transition-colors"
|
||||
style={{ color: 'var(--text-tertiary)', fontSize: '12.5px', textDecoration: 'none', gap: '4px' }}
|
||||
className="flex items-center hover:text-[#9295a4] transition-colors"
|
||||
style={{ color: '#6b6e7d', 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">
|
||||
@@ -303,8 +304,8 @@ export function ServiceMetricsDashboard() {
|
||||
</svg>
|
||||
</a>
|
||||
<button
|
||||
className="flex items-center hover:text-[var(--text-tertiary)] transition-colors"
|
||||
style={{ color: 'var(--text-tertiary)', fontSize: '12.5px', gap: '4px' }}
|
||||
className="flex items-center hover:text-[#9295a4] transition-colors"
|
||||
style={{ color: '#6b6e7d', 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">
|
||||
@@ -343,7 +344,7 @@ export function ServiceMetricsDashboard() {
|
||||
<div
|
||||
className="flex"
|
||||
style={{
|
||||
borderBottom: '1px solid var(--border-subtle)',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.07)',
|
||||
marginBottom: '18px'
|
||||
}}
|
||||
>
|
||||
@@ -364,16 +365,16 @@ export function ServiceMetricsDashboard() {
|
||||
|
||||
{/* Metrics Header */}
|
||||
<div className="flex items-center justify-between" style={{ marginBottom: '16px' }}>
|
||||
<span style={{ fontSize: '15px', fontWeight: 700, color: 'var(--text-primary)' }}>Metrics</span>
|
||||
<span style={{ fontSize: '15px', fontWeight: 700, color: '#e8e9f0' }}>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 var(--border-default)',
|
||||
background: 'var(--bg-elevated)',
|
||||
color: 'var(--text-tertiary)',
|
||||
border: '1px solid rgba(255,255,255,0.09)',
|
||||
background: 'rgba(255,255,255,0.04)',
|
||||
color: '#9295a4',
|
||||
fontSize: '12.5px',
|
||||
gap: '6px'
|
||||
}}
|
||||
@@ -407,7 +408,7 @@ export function ServiceMetricsDashboard() {
|
||||
status="good"
|
||||
statusText="Good"
|
||||
icon={<Cpu size={16} />}
|
||||
chart={<LineChart data={metrics.cpuData} color="#38bdf8" height={76} />}
|
||||
chart={<LineChart data={metrics.cpuData} color="#b4e34a" height={76} />}
|
||||
animationDelay={0.04}
|
||||
/>
|
||||
|
||||
@@ -417,23 +418,23 @@ export function ServiceMetricsDashboard() {
|
||||
<div className="card-icon">
|
||||
<MemoryStick size={16} />
|
||||
</div>
|
||||
<span style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)' }}>RAM Usage</span>
|
||||
<span style={{ fontSize: 14, fontWeight: 600, color: '#e8e9f0' }}>RAM Usage</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 38, fontWeight: 900, letterSpacing: '-1.5px', lineHeight: 1, color: 'var(--text-primary)' }}>
|
||||
<div style={{ fontSize: 38, fontWeight: 900, letterSpacing: '-1.5px', lineHeight: 1, color: '#e8e9f0' }}>
|
||||
{metrics.ram}%
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-tertiary)', marginTop: 4 }}>
|
||||
<span style={{ color: 'var(--warning)', fontWeight: 700 }}>Average</span> Daily usage
|
||||
<div style={{ fontSize: 12, color: '#6b6e7d', marginTop: 4 }}>
|
||||
<span style={{ color: '#f2c94c', 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="var(--warning)" />
|
||||
<DonutChart percentage={metrics.ram} color="#f2c94c" />
|
||||
<div style={{ position: 'absolute', bottom: 14, textAlign: 'center' }}>
|
||||
<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 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>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingTop: 4 }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-tertiary)', fontWeight: 500, cursor: 'pointer' }}>Details</span>
|
||||
<span style={{ fontSize: 13, color: '#6b6e7d', 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,7 +9,6 @@ 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.
|
||||
@@ -40,7 +39,7 @@ export function SetupWizardPage() {
|
||||
mutationFn: completeSetup,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['setup-status'] });
|
||||
navigate('/', { replace: true });
|
||||
navigate('/projects', { replace: true });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -54,7 +53,7 @@ export function SetupWizardPage() {
|
||||
|
||||
// Wizard is optional once the instance is already configured.
|
||||
if (status && !status.needs_setup && !override) {
|
||||
return <Navigate to="/" replace />;
|
||||
return <Navigate to="/projects" replace />;
|
||||
}
|
||||
|
||||
const steps: { key: Step; label: string }[] = [
|
||||
@@ -93,12 +92,9 @@ export function SetupWizardPage() {
|
||||
</div>
|
||||
|
||||
{step === 'account' ? (
|
||||
<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)]">
|
||||
<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)]">
|
||||
First boot detected. Create the admin account — registration closes to the public
|
||||
right after, so this account owns the instance.
|
||||
</p>
|
||||
@@ -109,7 +105,8 @@ export function SetupWizardPage() {
|
||||
</p>
|
||||
<Link
|
||||
to="/auth/sign-in?redirect=/setup"
|
||||
className="mt-4 s-btn-accent inline-flex items-center gap-2"
|
||||
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)' }}
|
||||
>
|
||||
Sign in <ChevronRight size={14} />
|
||||
</Link>
|
||||
@@ -117,7 +114,8 @@ export function SetupWizardPage() {
|
||||
) : !status?.has_users ? (
|
||||
<Link
|
||||
to="/auth/sign-up?redirect=/setup"
|
||||
className="mt-4 s-btn-accent inline-flex items-center gap-2"
|
||||
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)' }}
|
||||
>
|
||||
Create admin account <ChevronRight size={14} />
|
||||
</Link>
|
||||
@@ -129,9 +127,9 @@ export function SetupWizardPage() {
|
||||
{step === 'tunnel' ? <TunnelStep onNext={() => setOverride('done')} /> : null}
|
||||
|
||||
{step === 'done' ? (
|
||||
<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>
|
||||
<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>
|
||||
<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.
|
||||
@@ -143,10 +141,12 @@ export function SetupWizardPage() {
|
||||
: 'Failed to save setup state'}
|
||||
</p>
|
||||
) : null}
|
||||
<GhostBtn
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => completeMutation.mutate()}
|
||||
disabled={completeMutation.isPending}
|
||||
className="mt-5 !h-10 !px-6"
|
||||
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)' }}
|
||||
>
|
||||
{completeMutation.isPending ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
@@ -154,7 +154,7 @@ export function SetupWizardPage() {
|
||||
<ShieldCheck size={15} />
|
||||
)}
|
||||
Enter Containr
|
||||
</GhostBtn>
|
||||
</button>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -185,12 +185,12 @@ function GitHubStep({ onNext }: { onNext: () => void }) {
|
||||
});
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-[var(--text-secondary)]">
|
||||
<p className="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,15 +201,24 @@ function GitHubStep({ onNext }: { onNext: () => void }) {
|
||||
: 'Could not start GitHub App provisioning'}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-5 flex items-center gap-2">
|
||||
<GhostBtn
|
||||
<div className="mt-5 flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
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={13} className="animate-spin" /> : null}
|
||||
{provisionMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : null}
|
||||
Set up GitHub App
|
||||
</GhostBtn>
|
||||
<QuietBtn onClick={onNext}>Skip for now</QuietBtn>
|
||||
</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>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
@@ -228,12 +237,9 @@ function TunnelStep({ onNext }: { onNext: () => void }) {
|
||||
});
|
||||
|
||||
return (
|
||||
<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)]">
|
||||
<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)]">
|
||||
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>
|
||||
@@ -242,22 +248,31 @@ 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)] v-mono 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)] 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-2">
|
||||
<GhostBtn
|
||||
<div className="mt-5 flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
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={13} className="animate-spin" /> : null}
|
||||
{saveMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : null}
|
||||
{token.trim() ? 'Save tunnel token' : 'Continue'}
|
||||
</GhostBtn>
|
||||
<QuietBtn onClick={onNext}>Skip</QuietBtn>
|
||||
</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>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -17,7 +17,6 @@ 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,
|
||||
@@ -58,7 +57,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="v-mono max-w-[120px] truncate text-[10.5px] text-[var(--text-secondary)]">
|
||||
<span className="mono max-w-[120px] truncate text-[10.5px] text-[var(--text-secondary)]">
|
||||
{data.svc.runtime || data.svc.repo || '—'}
|
||||
</span>
|
||||
</div>
|
||||
@@ -121,8 +120,8 @@ function BuilderInner({
|
||||
id: `${dep}->${svc.key}`,
|
||||
source: dep,
|
||||
target: svc.key,
|
||||
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 },
|
||||
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 },
|
||||
className: 'edge-premium',
|
||||
})),
|
||||
),
|
||||
@@ -240,7 +239,7 @@ function BuilderInner({
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => addService(t)}
|
||||
className="s-chip !py-1.5"
|
||||
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)]"
|
||||
>
|
||||
<Icon size={12} className={meta.cls} />
|
||||
{meta.label}
|
||||
@@ -264,7 +263,7 @@ function BuilderInner({
|
||||
deleteKeyCode={['Backspace', 'Delete']}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={22} size={1.2} color="var(--tint-07)" />
|
||||
<Background variant={BackgroundVariant.Dots} gap={22} size={1.2} color="rgba(255,255,255,0.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
|
||||
@@ -296,16 +295,21 @@ function BuilderInner({
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex gap-2">
|
||||
<QuietBtn onClick={onCancel} className="flex-1 justify-center">
|
||||
<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)]"
|
||||
>
|
||||
Back to JSON
|
||||
</QuietBtn>
|
||||
<GhostBtn
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={errors.length > 0 || services.size === 0}
|
||||
onClick={() => onApply(configFromCanvas(builderNodes, config))}
|
||||
className="flex-1 justify-center"
|
||||
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"
|
||||
>
|
||||
Apply to JSON
|
||||
</GhostBtn>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -405,7 +409,7 @@ function Inspector({
|
||||
|
||||
<label className={`${LABEL_CLS} mt-3`}>Environment (KEY=value, one per line)</label>
|
||||
<textarea
|
||||
className={`${FIELD_CLS} v-mono min-h-[70px] resize-y`}
|
||||
className={`${FIELD_CLS} mono min-h-[70px] resize-y`}
|
||||
defaultValue={envText}
|
||||
spellCheck={false}
|
||||
onBlur={(e) => {
|
||||
@@ -421,7 +425,7 @@ function Inspector({
|
||||
|
||||
<label className={`${LABEL_CLS} mt-3`}>Volumes (source:target[:ro], one per line)</label>
|
||||
<textarea
|
||||
className={`${FIELD_CLS} v-mono min-h-[50px] resize-y`}
|
||||
className={`${FIELD_CLS} mono min-h-[50px] resize-y`}
|
||||
defaultValue={volText}
|
||||
spellCheck={false}
|
||||
onBlur={(e) => {
|
||||
|
||||
@@ -18,7 +18,6 @@ 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,
|
||||
@@ -490,51 +489,67 @@ export function TemplatesPage() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<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 ? (
|
||||
<>
|
||||
<QuietBtn
|
||||
onClick={() => {
|
||||
setComposeYaml('');
|
||||
setComposeWarnings([]);
|
||||
setComposeError(null);
|
||||
setComposeOpen(true);
|
||||
}}
|
||||
>
|
||||
<FileCode size={13} /> Import Compose
|
||||
</QuietBtn>
|
||||
<GhostBtn onClick={openCreateEditor}>
|
||||
<Plus size={13} /> New template
|
||||
</GhostBtn>
|
||||
</>
|
||||
) : undefined}
|
||||
/>
|
||||
{/* 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 ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Demo Mode Banner */}
|
||||
{isDemoMode && (
|
||||
<div className="w-full px-8 py-4">
|
||||
<div className="s-inset flex items-center gap-2 text-xs text-[var(--warning)]">
|
||||
<Sparkles size={13} />
|
||||
Demo mode active — using sample data
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="w-full px-4 py-6 sm:px-8">
|
||||
<div className="w-full px-8 py-6">
|
||||
<div className="grid grid-cols-1 xl:grid-cols-[380px_1fr] gap-6">
|
||||
{/* Template List */}
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
|
||||
@@ -599,21 +614,21 @@ export function TemplatesPage() {
|
||||
{filteredTemplates.map((template) => {
|
||||
const selected = template.id === selectedTemplateId;
|
||||
const Icon = categoryIcon(template.category);
|
||||
const categoryColor = template.category === 'database' ? 'var(--info)' : template.category === 'frontend' ? 'var(--warning)' : template.category === 'web' ? 'var(--accent-primary)' : 'var(--text-tertiary)';
|
||||
const categoryColor = template.category === 'database' ? '#7ab8ff' : template.category === 'frontend' ? '#f2c94c' : template.category === 'web' ? 'var(--accent-primary)' : '#9295a4';
|
||||
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)]/60 bg-[var(--accent-primary-soft)]'
|
||||
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] shadow-lg shadow-[var(--accent-primary-glow)]'
|
||||
: '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-1 ring-[var(--accent-primary)]/40' : ''
|
||||
selected ? 'ring-2 ring-white/20' : ''
|
||||
}`}
|
||||
style={{
|
||||
background: selected ? categoryColor : `${categoryColor}20`,
|
||||
@@ -676,10 +691,12 @@ export function TemplatesPage() {
|
||||
</section>
|
||||
|
||||
{/* Template Detail */}
|
||||
<section className="s-card p-5">
|
||||
<section className="panel p-6">
|
||||
{!selectedTemplateId ? (
|
||||
<div className="py-16 text-center">
|
||||
<div className="s-ibox mx-auto mb-4 !w-14 !h-14"><Layers size={22} /></div>
|
||||
<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>
|
||||
<p className="text-sm text-[var(--text-muted)]">Select a template to view details and deploy</p>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -705,15 +722,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="s-ibox !w-14 !h-14 !rounded-xl text-[var(--accent-primary)]">
|
||||
<div className="w-14 h-14 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
|
||||
{(() => {
|
||||
const Icon = categoryIcon(selectedDetail.template.category);
|
||||
return <Icon size={24} />;
|
||||
return <Icon size={24} className="text-[var(--accent-primary)]" />;
|
||||
})()}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="font-headline text-lg font-semibold text-[var(--text-primary)]">{selectedDetail.template.name}</h2>
|
||||
<h2 className="text-xl 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)]" />
|
||||
@@ -721,7 +738,7 @@ export function TemplatesPage() {
|
||||
</span>
|
||||
)}
|
||||
{!selectedDetail.template.isOfficial && selectedDetail.template.isPublic && (
|
||||
<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]">
|
||||
<span className="px-2 py-0.5 rounded-full bg-[var(--info-soft)] text-[var(--info)] text-xs font-medium">
|
||||
Public
|
||||
</span>
|
||||
)}
|
||||
@@ -730,10 +747,10 @@ export function TemplatesPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="s-chip v-mono !text-[10.5px]">
|
||||
<span className="px-3 py-1 rounded-full border border-[var(--border-subtle)] text-xs text-[var(--text-tertiary)]">
|
||||
{categoryLabel(selectedDetail.template.category)}
|
||||
</span>
|
||||
<span className="s-chip v-mono !text-[10.5px]">
|
||||
<span className="px-3 py-1 rounded-full border border-[var(--border-subtle)] text-xs text-[var(--text-tertiary)]">
|
||||
{selectedDetail.config.runtime || 'n/a'}
|
||||
</span>
|
||||
{canManageTemplate(selectedDetail.template) ? (
|
||||
@@ -760,25 +777,25 @@ export function TemplatesPage() {
|
||||
|
||||
{/* Config Grid */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<div className="s-inset">
|
||||
<div className="panel-soft p-4">
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Type</p>
|
||||
<p className="v-mono text-sm text-[var(--text-primary)] mt-2">{selectedDetail.config.type || '—'}</p>
|
||||
<p className="mono text-sm text-[var(--text-primary)] mt-2">{selectedDetail.config.type || '—'}</p>
|
||||
</div>
|
||||
<div className="s-inset">
|
||||
<div className="panel-soft p-4">
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Port</p>
|
||||
<p className="v-mono text-sm text-[var(--text-primary)] mt-2">{selectedDetail.config.port || '—'}</p>
|
||||
<p className="mono text-sm text-[var(--text-primary)] mt-2">{selectedDetail.config.port || '—'}</p>
|
||||
</div>
|
||||
<div className="s-inset">
|
||||
<div className="panel-soft p-4">
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Build</p>
|
||||
<p className="v-mono text-xs text-[var(--text-primary)] mt-2 truncate">{selectedDetail.config.buildCommand || '—'}</p>
|
||||
<p className="mono text-xs text-[var(--text-primary)] mt-2 truncate">{selectedDetail.config.buildCommand || '—'}</p>
|
||||
</div>
|
||||
<div className="s-inset">
|
||||
<div className="panel-soft p-4">
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Start</p>
|
||||
<p className="v-mono text-xs text-[var(--text-primary)] mt-2 truncate">{selectedDetail.config.startCommand || '—'}</p>
|
||||
<p className="mono text-xs text-[var(--text-primary)] mt-2 truncate">{selectedDetail.config.startCommand || '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="s-inset mb-6">
|
||||
<div className="panel-soft p-4 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.
|
||||
@@ -791,7 +808,7 @@ export function TemplatesPage() {
|
||||
</div>
|
||||
|
||||
{isGraphTemplate ? (
|
||||
<div className="s-inset mb-6">
|
||||
<div className="panel-soft p-4 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)]">
|
||||
@@ -807,14 +824,14 @@ export function TemplatesPage() {
|
||||
) : (
|
||||
<Box size={13} className="text-[var(--text-tertiary)] shrink-0" />
|
||||
)}
|
||||
<span className="v-mono text-[var(--text-primary)] truncate">{member.name || member.key}</span>
|
||||
<span className="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="v-mono text-[11px] text-[var(--text-tertiary)] truncate">
|
||||
<span className="mono text-[11px] text-[var(--text-tertiary)] truncate">
|
||||
{member.type === 'database' ? `managed ${member.runtime}` : member.runtime || member.repo || '—'}
|
||||
</span>
|
||||
</div>
|
||||
@@ -828,28 +845,26 @@ export function TemplatesPage() {
|
||||
|
||||
{/* Deploy Section */}
|
||||
<div className="border-t border-[var(--border-subtle)] pt-6">
|
||||
<div className="s-cardhead !mb-4">
|
||||
<span className="s-ibox"><Play /></span>
|
||||
<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>
|
||||
<span className="s-t">Deploy from Template</span>
|
||||
<p className="v-mono text-[10.5px] text-[var(--text-tertiary)] mt-0.5">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">Deploy from Template</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{isGraphTemplate ? 'Deploy all members into a project' : 'Configure and create a new service'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{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 ? (
|
||||
{!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="s-btn-accent mt-3 text-[12.5px]"
|
||||
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)]"
|
||||
>
|
||||
Sign in <ArrowRight size={13} />
|
||||
</Link>
|
||||
@@ -955,7 +970,7 @@ export function TemplatesPage() {
|
||||
<span>
|
||||
{lastDeployment.created
|
||||
? `Stack deployed — ${lastDeployment.created.length} resources`
|
||||
: <>Service <span className="v-mono font-medium">{lastDeployment.serviceName}</span> created</>}
|
||||
: <>Service <span className="mono font-medium">{lastDeployment.serviceName}</span> created</>}
|
||||
</span>
|
||||
<Link
|
||||
to={lastDeployment.created
|
||||
@@ -971,7 +986,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="v-mono">{m.name}</span>
|
||||
<span className="mono">{m.name}</span>
|
||||
<span className="opacity-70">({m.kind})</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -984,8 +999,8 @@ export function TemplatesPage() {
|
||||
<button
|
||||
onClick={() => deployMutation.mutate()}
|
||||
disabled={isDeployDisabled}
|
||||
className="s-btn-accent h-11 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
|
||||
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)' }}
|
||||
>
|
||||
{deployMutation.isPending ? (
|
||||
<>
|
||||
@@ -1118,7 +1133,7 @@ export function TemplatesPage() {
|
||||
value={editorJson}
|
||||
onChange={(e) => setEditorJson(e.target.value)}
|
||||
spellCheck={false}
|
||||
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"
|
||||
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"
|
||||
/>
|
||||
)}
|
||||
{composeWarnings.length > 0 ? (
|
||||
@@ -1156,7 +1171,7 @@ export function TemplatesPage() {
|
||||
type="button"
|
||||
onClick={submitEditor}
|
||||
disabled={saveTemplateMutation.isPending}
|
||||
className="s-btn-accent disabled:opacity-50"
|
||||
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"
|
||||
>
|
||||
{saveTemplateMutation.isPending ? <Loader2 size={13} className="animate-spin" /> : null}
|
||||
{editingTemplate ? 'Save changes' : 'Create template'}
|
||||
@@ -1208,7 +1223,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="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"
|
||||
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"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex min-h-[320px] flex-1 flex-col gap-3 bg-[var(--bg-void)] p-4">
|
||||
@@ -1219,7 +1234,7 @@ export function TemplatesPage() {
|
||||
onChange={(e) => setGitRepo(e.target.value)}
|
||||
spellCheck={false}
|
||||
placeholder="owner/repo or https://github.com/owner/repo.git"
|
||||
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)]"
|
||||
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)]"
|
||||
/>
|
||||
</label>
|
||||
<label className="text-xs text-[var(--text-tertiary)]">
|
||||
@@ -1229,7 +1244,7 @@ export function TemplatesPage() {
|
||||
onChange={(e) => setGitPath(e.target.value)}
|
||||
spellCheck={false}
|
||||
placeholder="deploy/compose.yml"
|
||||
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)]"
|
||||
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)]"
|
||||
/>
|
||||
</label>
|
||||
<label className="text-xs text-[var(--text-tertiary)]">
|
||||
@@ -1239,7 +1254,7 @@ export function TemplatesPage() {
|
||||
onChange={(e) => setGitRef(e.target.value)}
|
||||
spellCheck={false}
|
||||
placeholder="main"
|
||||
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)]"
|
||||
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)]"
|
||||
/>
|
||||
</label>
|
||||
<p className="text-[11px] text-[var(--text-tertiary)]">
|
||||
@@ -1264,7 +1279,7 @@ export function TemplatesPage() {
|
||||
type="button"
|
||||
onClick={() => composeImportMutation.mutate()}
|
||||
disabled={(composeMode === 'paste' ? !composeYaml.trim() : !gitRepo.trim()) || composeImportMutation.isPending}
|
||||
className="s-btn-accent disabled:opacity-50"
|
||||
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"
|
||||
>
|
||||
{composeImportMutation.isPending ? <Loader2 size={13} className="animate-spin" /> : <FileCode size={13} />}
|
||||
Convert to template
|
||||
|
||||
@@ -8,12 +8,11 @@ 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,
|
||||
@@ -31,6 +30,17 @@ 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,
|
||||
@@ -53,50 +63,59 @@ function WebhookRow({
|
||||
});
|
||||
|
||||
return (
|
||||
<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="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="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 && <SPill tone="off">disabled</SPill>}
|
||||
{!webhook.enabled && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--surface-muted)] text-[var(--text-muted)]">
|
||||
disabled
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="v-mono text-[10.5px] text-[var(--text-muted)] truncate">{webhook.url}</p>
|
||||
<p className="text-xs text-[var(--text-muted)] mono truncate">{webhook.url}</p>
|
||||
</div>
|
||||
<div className="hidden md:flex gap-1.5">
|
||||
<div className="hidden md:flex gap-1">
|
||||
{webhook.events.map((ev) => (
|
||||
<span key={ev} className="s-chip">{ev}</span>
|
||||
<span
|
||||
key={ev}
|
||||
className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--accent-primary-soft)] text-[var(--accent-primary)] mono"
|
||||
>
|
||||
{ev}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div 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="s-icon-btn disabled:opacity-40"
|
||||
className="p-2 rounded-[var(--radius-md)] text-[var(--text-tertiary)] hover:text-[var(--accent-primary)] disabled:opacity-40 transition-colors"
|
||||
title="Send test ping"
|
||||
>
|
||||
{testing ? <Loader2 size={13} className="animate-spin" /> : <Send size={13} />}
|
||||
{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'}
|
||||
</button>
|
||||
<QuietBtn onClick={onToggle}>{webhook.enabled ? 'Disable' : 'Enable'}</QuietBtn>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="s-icon-btn !text-[var(--text-tertiary)] hover:!text-[var(--error)]"
|
||||
className="p-2 rounded-[var(--radius-md)] text-[var(--text-tertiary)] hover:text-[var(--error)] transition-colors"
|
||||
title="Delete webhook"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{expanded && (
|
||||
<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">
|
||||
<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">
|
||||
Recent deliveries
|
||||
</p>
|
||||
{deliveriesQuery.isLoading ? (
|
||||
@@ -104,10 +123,10 @@ function WebhookRow({
|
||||
) : (deliveriesQuery.data ?? []).length === 0 ? (
|
||||
<p className="text-xs text-[var(--text-muted)]">No deliveries yet.</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<div className="space-y-1">
|
||||
{(deliveriesQuery.data ?? []).slice(0, 20).map((d) => (
|
||||
<div key={d.id} className="flex items-center gap-3 text-[11px] v-mono">
|
||||
<SPill tone={statusTone(d.status)}>{d.status}</SPill>
|
||||
<div key={d.id} className="flex items-center gap-3 text-xs mono">
|
||||
<span className={statusClass(d.status)}>{d.status}</span>
|
||||
<span className="text-[var(--text-secondary)]">{d.event}</span>
|
||||
{d.response_status != null && (
|
||||
<span className="text-[var(--text-muted)]">HTTP {d.response_status}</span>
|
||||
@@ -188,41 +207,46 @@ export function WebhooksPage() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<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 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>
|
||||
|
||||
<div className="w-full px-4 sm:px-8 space-y-4 max-w-4xl">
|
||||
<div className="w-full px-8 py-6 space-y-4 max-w-4xl">
|
||||
{secret && (
|
||||
<div className="s-inset p-4 flex items-center gap-3">
|
||||
<span className="s-ibox shrink-0"><Webhook /></span>
|
||||
<div className="panel-soft p-4 flex items-center gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">Signing secret — shown once</p>
|
||||
<p className="v-mono text-[11px] text-[var(--text-muted)]">
|
||||
Verify deliveries with <span className="v-mono">X-Containr-Signature</span>
|
||||
<p className="text-xs text-[var(--text-muted)]">
|
||||
Verify deliveries with <span className="mono">X-Containr-Signature</span>
|
||||
</p>
|
||||
<code className="block mt-2 v-mono text-xs text-[var(--accent-primary)] break-all">{secret}</code>
|
||||
<code className="block mt-2 text-xs mono text-[var(--accent-primary)] break-all">{secret}</code>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard?.writeText(secret);
|
||||
toast.showToast('Secret copied', 'success');
|
||||
}}
|
||||
className="s-icon-btn"
|
||||
className="p-2 text-[var(--text-tertiary)] hover:text-[var(--text-primary)]"
|
||||
>
|
||||
<Copy size={13} />
|
||||
<Copy size={15} />
|
||||
</button>
|
||||
<button onClick={() => setSecret(null)} className="s-icon-btn">
|
||||
<X size={13} />
|
||||
<button onClick={() => setSecret(null)} className="p-2 text-[var(--text-tertiary)]">
|
||||
<X size={15} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -230,12 +254,12 @@ export function WebhooksPage() {
|
||||
{webhooksQuery.isLoading ? (
|
||||
<Loader2 size={18} className="animate-spin text-[var(--text-muted)]" />
|
||||
) : (webhooksQuery.data ?? []).length === 0 ? (
|
||||
<div className="s-card py-10 text-center">
|
||||
<div className="s-ibox mx-auto mb-3"><Webhook /></div>
|
||||
<div className="panel p-8 text-center">
|
||||
<Webhook size={28} className="mx-auto text-[var(--text-muted)] mb-3" />
|
||||
<p className="text-sm text-[var(--text-secondary)]">No webhooks yet.</p>
|
||||
<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 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>
|
||||
</div>
|
||||
) : (
|
||||
@@ -259,11 +283,8 @@ 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 s-card">
|
||||
<div className="s-cardhead mb-4">
|
||||
<span className="s-ibox"><Webhook /></span>
|
||||
<h3 className="s-t">New Webhook</h3>
|
||||
</div>
|
||||
<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="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
|
||||
@@ -283,7 +304,7 @@ export function WebhooksPage() {
|
||||
<input
|
||||
value={form.url}
|
||||
onChange={(e) => setForm((f) => ({ ...f, url: e.target.value }))}
|
||||
className={`${inputClass} w-full v-mono`}
|
||||
className={`${inputClass} w-full mono`}
|
||||
placeholder="https://example.com/hooks/containr"
|
||||
/>
|
||||
</div>
|
||||
@@ -296,21 +317,30 @@ export function WebhooksPage() {
|
||||
<button
|
||||
key={ev}
|
||||
onClick={() => toggleEvent(ev)}
|
||||
className={`s-chip v-mono ${form.events.includes(ev) ? 'on' : ''}`}
|
||||
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)]'
|
||||
}`}
|
||||
>
|
||||
{ev}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<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 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>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<QuietBtn onClick={() => setCreating(false)}>Cancel</QuietBtn>
|
||||
<GhostBtn
|
||||
<button
|
||||
onClick={() => setCreating(false)}
|
||||
className="h-10 px-4 rounded-[var(--radius-md)] text-sm text-[var(--text-secondary)]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => createMutation.mutate()}
|
||||
disabled={
|
||||
createMutation.isPending ||
|
||||
@@ -318,10 +348,12 @@ 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={13} className="animate-spin" />}
|
||||
{createMutation.isPending && <Loader2 size={14} className="animate-spin" />}
|
||||
Create
|
||||
</GhostBtn>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -167,12 +167,12 @@ function toFlowEdges(
|
||||
},
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
color: 'color-mix(in srgb, var(--accent-primary) 55%, transparent)',
|
||||
color: 'rgba(180, 227, 74, 0.55)',
|
||||
width: 13,
|
||||
height: 13,
|
||||
},
|
||||
style: {
|
||||
stroke: 'color-mix(in srgb, var(--accent-primary) 42%, transparent)',
|
||||
stroke: 'rgba(180, 227, 74, 0.42)',
|
||||
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 v-mono w-12 text-[11px]"
|
||||
className="canvas-pill-btn 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 'var(--tint-08)';
|
||||
return 'rgba(255,255,255,0.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="var(--tint-05)" />
|
||||
<Background variant={BackgroundVariant.Dots} gap={26} size={1.2} color="rgba(255,255,255,0.055)" />
|
||||
<MiniMap
|
||||
position="bottom-right"
|
||||
pannable
|
||||
zoomable
|
||||
nodeColor={minimapNodeColor}
|
||||
bgColor="var(--surface-glass)"
|
||||
maskColor="var(--surface-glass)"
|
||||
bgColor="rgba(14, 15, 18, 0.92)"
|
||||
maskColor="rgba(12, 13, 15, 0.72)"
|
||||
className="canvas-minimap hidden md:block"
|
||||
/>
|
||||
</ReactFlow>
|
||||
@@ -937,19 +937,21 @@ 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 !rounded-[9px] p-1">
|
||||
<span className="s-seg">
|
||||
{['all', ...environments].map((env) => (
|
||||
<button
|
||||
key={env}
|
||||
type="button"
|
||||
onClick={() => setEnvFilter(env)}
|
||||
className={`!w-auto !h-auto px-2.5 py-1 capitalize v-mono text-[10.5px] ${envFilter === env ? 'on' : ''}`}
|
||||
>
|
||||
{env}
|
||||
</button>
|
||||
))}
|
||||
</span>
|
||||
<div className="canvas-pill flex items-center gap-0.5 p-1">
|
||||
{['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)]'
|
||||
}`}
|
||||
>
|
||||
{env}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!readOnly && (
|
||||
@@ -1016,12 +1018,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 s-card !py-8 !px-10">
|
||||
<div className="s-ibox mx-auto mb-4 !h-11 !w-11">
|
||||
<Rocket />
|
||||
<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>
|
||||
<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">
|
||||
<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">
|
||||
Add a service from a git repo or Docker image - it runs on a private network with the rest of this
|
||||
project.
|
||||
</p>
|
||||
@@ -1029,9 +1031,10 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAddService}
|
||||
className="s-btn-accent inline-flex items-center gap-2"
|
||||
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)' }}
|
||||
>
|
||||
<Plus size={13} />
|
||||
<Plus size={15} />
|
||||
Add Service
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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';
|
||||
@@ -64,11 +63,28 @@ 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="v-mono text-[10px] uppercase tracking-[0.14em] text-[var(--text-muted)]">{title}</p>
|
||||
<p className="text-[10px] uppercase tracking-wider text-[var(--text-muted)]">{title}</p>
|
||||
{action}
|
||||
</div>
|
||||
{children}
|
||||
@@ -147,49 +163,48 @@ 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="s-cardhead border-b border-[var(--border-subtle)]">
|
||||
<span
|
||||
className="s-ibox flex-shrink-0"
|
||||
style={{ background: `${accent}14`, color: accent, borderColor: `${accent}33` }}
|
||||
<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 }}
|
||||
>
|
||||
<ServiceIcon service={service} size={15} />
|
||||
</span>
|
||||
<ServiceIcon service={service} size={16} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<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>
|
||||
<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>}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="s-icon-btn"
|
||||
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)]"
|
||||
title="Close (Esc)"
|
||||
>
|
||||
<X size={12} />
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-4">
|
||||
{(publicUrl || service.port) && (
|
||||
<Section title="Address">
|
||||
<div className="s-inset flex items-center gap-1.5 px-2.5 py-2 group">
|
||||
<div className="flex items-center gap-1.5 group">
|
||||
<Globe size={11} className="text-[var(--text-tertiary)] flex-shrink-0" />
|
||||
{publicUrl ? (
|
||||
<a
|
||||
href={publicUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="v-mono text-[11px] text-[var(--accent-primary)] hover:underline break-all"
|
||||
className="mono text-[11px] text-[var(--accent-primary)] hover:underline break-all"
|
||||
>
|
||||
{publicUrl.replace(/^https?:\/\//, '')}
|
||||
</a>
|
||||
) : (
|
||||
<span className="v-mono text-[11px] text-[var(--text-secondary)]">
|
||||
<span className="mono text-[11px] text-[var(--text-secondary)]">
|
||||
{service.name}:{service.port}
|
||||
</span>
|
||||
)}
|
||||
@@ -203,7 +218,7 @@ export function ServiceInspector({
|
||||
</button>
|
||||
</div>
|
||||
{publicUrl && service.port ? (
|
||||
<p className="v-mono text-[10.5px] text-[var(--text-tertiary)] mt-1 pl-1">
|
||||
<p className="mono text-[10.5px] text-[var(--text-tertiary)] mt-0.5 pl-4">
|
||||
{service.name}:{service.port}
|
||||
</p>
|
||||
) : null}
|
||||
@@ -251,13 +266,13 @@ export function ServiceInspector({
|
||||
{variables.slice(0, 6).map((variable) => (
|
||||
<div
|
||||
key={variable.key}
|
||||
className="s-inset !rounded-[6px] flex items-center gap-2 px-2 py-1.5"
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded-[var(--radius-sm)] bg-[var(--surface-muted)]/50"
|
||||
>
|
||||
<KeyRound size={9} className="text-[var(--text-tertiary)] flex-shrink-0" />
|
||||
<span className="v-mono text-[10px] text-[var(--text-primary)] truncate font-medium">
|
||||
<span className="mono text-[10px] text-[var(--text-primary)] truncate font-medium">
|
||||
{variable.key}
|
||||
</span>
|
||||
<span className="v-mono text-[10px] text-[var(--text-tertiary)] truncate ml-auto max-w-[130px]" title={variable.isSecret ? 'Secret' : variable.value}>
|
||||
<span className="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>
|
||||
@@ -284,7 +299,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="v-mono text-[10.5px] text-[var(--text-secondary)] truncate" title={value}>
|
||||
<dd className="mono text-[10.5px] text-[var(--text-secondary)] truncate" title={value}>
|
||||
{value}
|
||||
</dd>
|
||||
</div>
|
||||
@@ -292,7 +307,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="v-mono text-[10.5px] text-[var(--text-secondary)]">{service.replicas}</dd>
|
||||
<dd className="mono text-[10.5px] text-[var(--text-secondary)]">{service.replicas}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
@@ -303,7 +318,7 @@ export function ServiceInspector({
|
||||
{connections.length === 0 ? (
|
||||
<p className="text-[11px] text-[var(--text-muted)]">
|
||||
No inferred connections. Reference another service via{' '}
|
||||
<span className="v-mono">{'{{variable}}'}</span> placeholders in env vars.
|
||||
<span className="mono">{'{{variable}}'}</span> placeholders in env vars.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
@@ -312,13 +327,13 @@ export function ServiceInspector({
|
||||
key={conn.id}
|
||||
type="button"
|
||||
onClick={() => onSelectPeer(conn.peerId)}
|
||||
className="s-inset !rounded-[6px] w-full text-left px-2.5 py-2 hover:border-[var(--border-default)] transition-colors"
|
||||
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"
|
||||
>
|
||||
<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="v-mono text-[9.5px] text-[var(--text-tertiary)] truncate mt-0.5 pl-4">
|
||||
<p className="mono text-[9.5px] text-[var(--text-tertiary)] truncate mt-0.5 pl-4">
|
||||
{conn.reasons.join(' · ')}
|
||||
</p>
|
||||
</button>
|
||||
@@ -335,58 +350,65 @@ export function ServiceInspector({
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[var(--border-subtle)] px-4 py-3 space-y-2">
|
||||
<GhostBtn
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenService(service.id)}
|
||||
className="w-full justify-center !h-9"
|
||||
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)' }}
|
||||
>
|
||||
<ArrowUpRight size={14} />
|
||||
Open service
|
||||
</GhostBtn>
|
||||
</button>
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
<QuietBtn
|
||||
<button
|
||||
type="button"
|
||||
disabled={actions.pending || readOnly}
|
||||
onClick={actions.deploy}
|
||||
className="justify-center"
|
||||
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"
|
||||
>
|
||||
{actions.deployPending ? <Loader2 size={11} className="animate-spin" /> : <Rocket size={11} />}
|
||||
Deploy
|
||||
</QuietBtn>
|
||||
</button>
|
||||
{service.status !== 'running' ? (
|
||||
<QuietBtn
|
||||
<button
|
||||
type="button"
|
||||
disabled={actions.pending || readOnly}
|
||||
onClick={actions.start}
|
||||
className="justify-center"
|
||||
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"
|
||||
>
|
||||
<Play size={11} />
|
||||
Start
|
||||
</QuietBtn>
|
||||
</button>
|
||||
) : (
|
||||
<QuietBtn
|
||||
<button
|
||||
type="button"
|
||||
disabled={actions.pending || readOnly}
|
||||
onClick={actions.restart}
|
||||
className="justify-center"
|
||||
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"
|
||||
>
|
||||
{actions.restartPending ? <Loader2 size={11} className="animate-spin" /> : <Play size={11} />}
|
||||
Restart
|
||||
</QuietBtn>
|
||||
</button>
|
||||
)}
|
||||
<QuietBtn
|
||||
<button
|
||||
type="button"
|
||||
disabled={actions.pending || readOnly || service.status !== 'running'}
|
||||
onClick={actions.stop}
|
||||
className="justify-center"
|
||||
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"
|
||||
>
|
||||
<Square size={11} />
|
||||
Stop
|
||||
</QuietBtn>
|
||||
</button>
|
||||
</div>
|
||||
<QuietBtn
|
||||
<button
|
||||
type="button"
|
||||
disabled={actions.pending || readOnly}
|
||||
onClick={actions.remove}
|
||||
className="w-full justify-center !text-[var(--error)] hover:!border-[var(--error)]/50"
|
||||
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"
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
Delete service
|
||||
</QuietBtn>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
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;
|
||||
@@ -66,10 +65,10 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`w-full rounded-[10px] border transition-[border-color,box-shadow] duration-150 group relative overflow-hidden ${
|
||||
className={`w-full rounded-[var(--radius-lg)] border transition-[border-color,box-shadow,transform] duration-150 group relative overflow-hidden ${
|
||||
selected
|
||||
? '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)]'
|
||||
? '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'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
@@ -87,39 +86,39 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
|
||||
style={{ background: stateColor }}
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
<div className="flex items-center gap-2.5 px-3.5 pt-3 pb-2.5">
|
||||
<div className="relative px-3.5 pt-3.5 pb-3">
|
||||
<div className="flex items-center gap-3 mb-2.5">
|
||||
<div
|
||||
className="w-8 h-8 rounded-[7px] border border-[var(--border-subtle)] flex items-center justify-center flex-shrink-0 transition-all duration-200"
|
||||
className="w-9 h-9 rounded-[var(--radius-md)] flex items-center justify-center flex-shrink-0 transition-all duration-200"
|
||||
style={{
|
||||
background: selected && !brand ? typeColor : `${typeColor}14`,
|
||||
background: selected && !brand ? typeColor : `${typeColor}1c`,
|
||||
color: selected && !brand ? 'var(--bg-void)' : typeColor,
|
||||
}}
|
||||
>
|
||||
{isTransient ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<ServiceIcon service={service} size={15} />
|
||||
<ServiceIcon service={service} size={17} />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="font-semibold text-[12.5px] leading-tight text-[var(--text-primary)] truncate tracking-tight">
|
||||
<h4 className="font-semibold text-[13px] leading-tight text-[var(--text-primary)] truncate tracking-tight">
|
||||
{service.name}
|
||||
</h4>
|
||||
<p className="v-mono text-[10px] text-[var(--text-tertiary)] truncate mt-0.5">
|
||||
<p className="text-[10.5px] text-[var(--text-tertiary)] truncate mt-0.5">
|
||||
{service.type}
|
||||
{service.environment && service.environment !== 'production' ? ` · ${service.environment}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
{replicas > 1 && (
|
||||
<span className="s-chip">
|
||||
<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)]">
|
||||
<Layers size={9} />
|
||||
{replicas}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mx-3 mb-2.5 s-inset !rounded-[6px] flex items-center gap-2 px-2.5 py-1.5 group/addr">
|
||||
<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">
|
||||
<ExternalLink size={10} className="text-[var(--text-tertiary)] flex-shrink-0" />
|
||||
{publicUrl ? (
|
||||
<a
|
||||
@@ -127,12 +126,12 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="v-mono text-[10.5px] text-[var(--accent-primary)] truncate hover:underline"
|
||||
className="text-[10.5px] text-[var(--accent-primary)] truncate mono hover:underline"
|
||||
>
|
||||
{publicUrl.replace(/^https?:\/\//, '')}
|
||||
</a>
|
||||
) : (
|
||||
<span className="v-mono text-[10.5px] text-[var(--text-secondary)] truncate" title="Internal network address">
|
||||
<span className="text-[10.5px] text-[var(--text-secondary)] truncate mono" title="Internal network address">
|
||||
{internalAddr}
|
||||
</span>
|
||||
)}
|
||||
@@ -149,10 +148,15 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
|
||||
</button>
|
||||
</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>
|
||||
<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>
|
||||
{service.image && (
|
||||
<span className="v-mono text-[9.5px] text-[var(--text-muted)] truncate max-w-[110px]" title={service.image}>
|
||||
<span className="text-[9.5px] text-[var(--text-muted)] truncate mono max-w-[110px]" title={service.image}>
|
||||
{service.image}
|
||||
</span>
|
||||
)}
|
||||
@@ -187,25 +191,25 @@ export function GroupNode({ id, data }: NodeProps<GroupNodeType>) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-full w-full rounded-[12px] border border-dashed transition-all duration-200 relative"
|
||||
className="h-full w-full rounded-[var(--radius-xl)] border-2 border-dashed transition-all duration-200 relative"
|
||||
style={{
|
||||
borderColor: isHovered ? 'var(--border-strong)' : 'var(--border-default)',
|
||||
background: isHovered ? 'var(--accent-primary-soft)' : 'var(--tint-03)',
|
||||
background: isHovered ? 'var(--accent-primary-soft)' : 'rgba(255,255,255,0.02)',
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<div className="relative p-4">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span
|
||||
className="s-ibox !h-7 !w-7 transition-colors duration-200"
|
||||
<div
|
||||
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors duration-200"
|
||||
style={{
|
||||
background: isHovered ? 'var(--accent-primary-soft)' : undefined,
|
||||
color: isHovered ? 'var(--accent-primary)' : undefined,
|
||||
background: isHovered ? 'var(--accent-primary-soft)' : 'var(--surface-card)',
|
||||
color: isHovered ? 'var(--accent-primary)' : 'var(--text-tertiary)',
|
||||
}}
|
||||
>
|
||||
<Box />
|
||||
</span>
|
||||
<Box size={14} />
|
||||
</div>
|
||||
{editing ? (
|
||||
<input
|
||||
autoFocus
|
||||
@@ -223,7 +227,7 @@ export function GroupNode({ id, data }: NodeProps<GroupNodeType>) {
|
||||
/>
|
||||
) : (
|
||||
<h3
|
||||
className="font-headline text-sm font-semibold text-[var(--text-primary)] tracking-tight"
|
||||
className="text-sm font-semibold text-[var(--text-primary)] tracking-tight"
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDraft(data.title);
|
||||
@@ -237,7 +241,7 @@ export function GroupNode({ id, data }: NodeProps<GroupNodeType>) {
|
||||
</div>
|
||||
|
||||
<p
|
||||
className="v-mono text-[10px] uppercase tracking-[0.12em] text-[var(--text-tertiary)] mt-2 transition-opacity duration-200"
|
||||
className="text-[10px] 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 'var(--info)';
|
||||
return '#7ab8ff';
|
||||
case 'database':
|
||||
return '#38bdf8';
|
||||
return '#b4e34a';
|
||||
case 'worker':
|
||||
return 'var(--warning)';
|
||||
return '#f2c94c';
|
||||
case 'cron':
|
||||
return 'var(--accent-secondary)';
|
||||
return '#f2994a';
|
||||
default:
|
||||
return 'var(--text-tertiary)';
|
||||
return '#9295a4';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,198 +1,306 @@
|
||||
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';
|
||||
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;
|
||||
}
|
||||
|
||||
interface MetricsDashboardProps {
|
||||
services: { id: string; name: string; status: string }[];
|
||||
isDemoMode?: boolean;
|
||||
projectId?: string;
|
||||
isRunning?: boolean;
|
||||
onStop?: () => void;
|
||||
onRestart?: () => void;
|
||||
}
|
||||
|
||||
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 getRandom(min: number, max: number) {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
|
||||
function Tile({ icon: Icon, label, children }: {
|
||||
icon: React.ElementType;
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
function generateInitialData(): MetricData {
|
||||
return {
|
||||
cpu: Array.from({ length: 24 }, () => getRandom(10, 45)),
|
||||
ram: getRandom(50, 85),
|
||||
ramUsed: '5.4 GB',
|
||||
cache: 352,
|
||||
cacheBreakdown: { cache: 212, nonCache: 85.5, total: 1750 },
|
||||
users: Array.from({ length: 15 }, () => getRandom(40, 110)),
|
||||
performance: Array.from({ length: 12 }, () => getRandom(70, 95)),
|
||||
performanceAlt: Array.from({ length: 12 }, () => getRandom(60, 85)),
|
||||
upSpeed: 10.4,
|
||||
downSpeed: 5.2,
|
||||
};
|
||||
}
|
||||
|
||||
export function MetricsDashboard({ services, isDemoMode = false }: MetricsDashboardProps) {
|
||||
const liveServices = services.filter((s) => s.status === 'running');
|
||||
export function MetricsDashboard({
|
||||
isRunning = true,
|
||||
}: MetricsDashboardProps) {
|
||||
const [data, setData] = useState<MetricData>(generateInitialData);
|
||||
const [timeRange, setTimeRange] = useState<'day' | 'month' | 'year'>('day');
|
||||
|
||||
const metricsQueries = useQueries({
|
||||
queries: liveServices.map((service) => ({
|
||||
queryKey: ['service-metrics', service.id],
|
||||
queryFn: () => getServiceMetrics(service.id),
|
||||
enabled: !isDemoMode,
|
||||
refetchInterval: 5000,
|
||||
retry: false,
|
||||
})),
|
||||
});
|
||||
|
||||
const samples = metricsQueries
|
||||
.map((q) => q.data)
|
||||
.filter((m): m is ServiceMetrics => Boolean(m) && m!.status === 'ok');
|
||||
|
||||
const totalCpu = samples.reduce((sum, m) => sum + m.cpu_percent, 0);
|
||||
const avgCpu = samples.length > 0 ? totalCpu / samples.length : 0;
|
||||
const memUsed = samples.reduce((sum, m) => sum + m.memory_usage_bytes, 0);
|
||||
const memLimit = samples.reduce((sum, m) => sum + m.memory_limit_bytes, 0);
|
||||
const memPct = memLimit > 0 ? (memUsed / memLimit) * 100 : 0;
|
||||
const netRx = samples.reduce((sum, m) => sum + m.network_rx_bytes, 0);
|
||||
const netTx = samples.reduce((sum, m) => sum + m.network_tx_bytes, 0);
|
||||
const instanceCount = samples.reduce((sum, m) => sum + m.instances.length, 0);
|
||||
|
||||
// Rolling history for the sparklines — accumulated from real samples.
|
||||
const [cpuHistory, setCpuHistory] = useState<number[]>([]);
|
||||
const [netHistory, setNetHistory] = useState<{ rx: number[]; tx: number[] }>({ rx: [], tx: [] });
|
||||
const lastNetRef = useRef<{ rx: number; tx: number; at: number } | null>(null);
|
||||
|
||||
const samplesKey = samples.map((s) => `${s.service_id}:${s.collected_at}`).join('|');
|
||||
const avgCpuRef = avgCpu;
|
||||
const netRxRef = netRx;
|
||||
const netTxRef = netTx;
|
||||
useEffect(() => {
|
||||
if (!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]);
|
||||
if (!isRunning) return;
|
||||
|
||||
const cpuHealth = healthLabel(avgCpu);
|
||||
const memHealth = healthLabel(memPct);
|
||||
const reporting = samples.length;
|
||||
const noData = !isDemoMode && liveServices.length > 0 && reporting === 0;
|
||||
const interval = setInterval(() => {
|
||||
setData((prev) => {
|
||||
const newCpu = [...prev.cpu.slice(1), getRandom(10, 45)];
|
||||
const newUsers = [...prev.users.slice(1), getRandom(60, 110)];
|
||||
const newPerf = [...prev.performance.slice(1), getRandom(82, 99)];
|
||||
const newPerfAlt = [...prev.performanceAlt.slice(1), getRandom(60, 85)];
|
||||
|
||||
return {
|
||||
...prev,
|
||||
cpu: newCpu,
|
||||
ram: getRandom(50, 85),
|
||||
ramUsed: `${(8 * (prev.ram / 100)).toFixed(1)} GB`,
|
||||
users: newUsers,
|
||||
performance: newPerf,
|
||||
performanceAlt: newPerfAlt,
|
||||
upSpeed: parseFloat((Math.random() * 5 + 8).toFixed(1)),
|
||||
downSpeed: parseFloat((Math.random() * 3 + 4).toFixed(1)),
|
||||
};
|
||||
});
|
||||
}, 2000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isRunning]);
|
||||
|
||||
const currentCpu = data.cpu[data.cpu.length - 1];
|
||||
const currentUsers = data.users[data.users.length - 1];
|
||||
const currentPerf = data.performance[data.performance.length - 1];
|
||||
|
||||
return (
|
||||
<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>
|
||||
{/* 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>
|
||||
|
||||
<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>
|
||||
{/* 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 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 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: 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 style={{ height: 76, margin: '12px 0 6px' }}>
|
||||
<LineMetricChart data={data.cpu} color="#b4e34a" height={76} />
|
||||
</div>
|
||||
</Tile>
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
{/* 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 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 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 className="mt-2 flex justify-center">
|
||||
<DonutChart percentage={memPct} size={100} thickness={11} />
|
||||
<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 className="mt-1 text-center v-mono text-[10.5px] text-[var(--text-tertiary)]">
|
||||
{formatBytes(memUsed)} / {memLimit > 0 ? formatBytes(memLimit) : '∞'}
|
||||
<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>
|
||||
</Tile>
|
||||
</div>
|
||||
|
||||
{/* 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)}
|
||||
{/* 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 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 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>
|
||||
<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}
|
||||
{/* 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>
|
||||
<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'}
|
||||
|
||||
{/* 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>
|
||||
<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 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>
|
||||
|
||||
{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>
|
||||
)}
|
||||
{/* Row 2: Active Users, Performance - self.html exact: 13px gap */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '13px' }}>
|
||||
{/* Active Users - horizontal layout */}
|
||||
<div className="card" style={{ flexDirection: 'row', padding: 0, overflow: 'hidden' }}>
|
||||
<div style={{ flex: 1, padding: '20px 18px 18px 20px', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '12px' }}>
|
||||
<div className="card-icon"><svg viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/></svg></div>
|
||||
<span style={{ fontSize: '14px', fontWeight: 600, color: '#e8e9f0' }}>Active User</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '36px', fontWeight: 900, letterSpacing: '-1.5px', lineHeight: 1, color: '#e8e9f0' }}>{currentUsers} K</div>
|
||||
<div style={{ fontSize: '12px', color: '#6b6e7d', marginTop: '4px' }}>User active right now</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '5px', marginTop: '12px', flexWrap: 'wrap' }}>
|
||||
{['🇨🇳', '🇮🇩', '🇲🇲', '🇲🇾', '🇯🇵', '🇮🇳', '🇰🇷', '🇵🇭'].map((flag, i) => (
|
||||
<span key={i} className="flag">{flag}</span>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: 'auto', paddingTop: '14px' }}>
|
||||
<span style={{ fontSize: '13px', color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}>Details</span>
|
||||
<div className="arrow-btn">
|
||||
<svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ width: '50%', padding: '16px 14px 46px 0', display: 'flex', alignItems: 'flex-end' }}>
|
||||
<LineMetricChart data={data.users} color="var(--accent-primary)" fillOpacity={0.15} showArea height={130} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Performance Card */}
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '12px' }}>
|
||||
<div className="card-icon"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></div>
|
||||
<span style={{ fontSize: '14px', fontWeight: 600, color: '#e8e9f0' }}>Performance</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '16px', flex: 1 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: '36px', fontWeight: 900, letterSpacing: '-1.5px', lineHeight: 1, color: '#e8e9f0' }}>{currentPerf}%</div>
|
||||
<div style={{ fontSize: '12px', color: '#6b6e7d', marginTop: '4px' }}>
|
||||
<span style={{ color: currentPerf > 85 ? '#5ee6a0' : '#f2c94c', fontWeight: 700 }}>
|
||||
{currentPerf > 85 ? 'Good' : 'Average'}
|
||||
</span>{' '}
|
||||
Last scan
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: '8px' }}>
|
||||
<div style={{ width: 134, height: 58 }}>
|
||||
<DualLineChart data1={data.performance} data2={data.performanceAlt} height={58} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px', alignItems: 'flex-end' }}>
|
||||
<div className="speed-row" style={{ color: '#7ab8ff' }}>
|
||||
<svg viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 19 19 12"/></svg>
|
||||
<span>{data.upSpeed}</span> Mbps
|
||||
</div>
|
||||
<div className="speed-row" style={{ color: 'var(--accent-primary)' }}>
|
||||
<svg viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 5 5 12"/></svg>
|
||||
<span>{data.downSpeed}</span> Mbps
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingTop: '14px' }}>
|
||||
<span style={{ fontSize: '13px', color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}>Check Speed</span>
|
||||
<div className="arrow-btn">
|
||||
<svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,6 @@ 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 };
|
||||
|
||||
@@ -43,23 +42,26 @@ 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="s-t">Shared Variables</h3>
|
||||
<h3 className="text-sm font-medium text-[var(--text-secondary)]">Shared Variables</h3>
|
||||
<p className="mt-1 text-xs text-[var(--text-muted)]">
|
||||
Reference from any service as{' '}
|
||||
<code className="v-mono text-[var(--accent-primary)]">{'${{shared.KEY}}'}</code>. Applied on next
|
||||
<code className="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">
|
||||
<QuietBtn onClick={() => setDrafts([...rows, { key: '', value: '', isSecret: false }])}>
|
||||
<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"
|
||||
>
|
||||
<Plus size={12} />
|
||||
Add
|
||||
</QuietBtn>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => saveMutation.mutate(rows)}
|
||||
disabled={!dirty || saveMutation.isPending}
|
||||
className="s-btn-accent disabled:opacity-50"
|
||||
|
||||
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)' }}
|
||||
>
|
||||
{saveMutation.isPending ? <Loader2 size={12} className="animate-spin" /> : <Save size={12} />}
|
||||
Save
|
||||
@@ -76,12 +78,12 @@ export function SharedVariablesSection({ projectId }: { projectId: string }) {
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{rows.map((row, i) => (
|
||||
<div key={i} className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto_auto] gap-2 items-center">
|
||||
<div key={i} className="grid grid-cols-[1fr_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)] 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"
|
||||
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"
|
||||
/>
|
||||
<input
|
||||
value={row.value}
|
||||
@@ -89,13 +91,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)] 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"
|
||||
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"
|
||||
/>
|
||||
<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-lg border flex items-center justify-center transition-colors ${
|
||||
className={`w-9 h-9 rounded-[var(--radius-md)] 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)]'
|
||||
@@ -106,7 +108,7 @@ export function SharedVariablesSection({ projectId }: { projectId: string }) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDrafts(rows.filter((_, idx) => idx !== i))}
|
||||
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"
|
||||
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"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
|
||||
@@ -50,9 +50,7 @@ import {
|
||||
GitBranch,
|
||||
Container,
|
||||
FileUp,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import { GhostBtn, QuietBtn } from '@/shared/components/sentry';
|
||||
|
||||
type WorkspaceView = 'canvas' | 'observability' | 'logs' | 'activity' | 'settings';
|
||||
|
||||
@@ -148,10 +146,12 @@ 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 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 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>
|
||||
<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="s-inset space-y-3">
|
||||
<div className="space-y-3 p-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]/50">
|
||||
<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 v-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 mono text-sm"
|
||||
placeholder="ghcr.io/org/app:latest"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="s-inset space-y-3">
|
||||
<div className="space-y-3 p-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]/50">
|
||||
<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 v-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 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="v-mono text-xs truncate">{repo.full_name}</span>
|
||||
<span className="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 v-mono"
|
||||
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm 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 v-mono"
|
||||
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm 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 v-mono"
|
||||
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm 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 v-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 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="s-btn-quiet"
|
||||
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"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@@ -454,8 +454,8 @@ function ServiceCreateDialog(props: {
|
||||
database: isDatabase ? { engine: dbEngine, plan: dbPlan } : undefined,
|
||||
})
|
||||
}
|
||||
className="s-btn-accent disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
|
||||
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)' }}
|
||||
>
|
||||
{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="s-card p-8 text-center max-w-md">
|
||||
<div className="panel 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="font-headline text-lg font-medium text-[var(--text-primary)]">Project not found</p>
|
||||
<p className="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="s-btn-quiet"
|
||||
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"
|
||||
>
|
||||
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 font-headline text-sm shrink-0 min-w-0 truncate">{project.name}<span className="v-cursor">_</span></h1>
|
||||
<h1 className="v-title 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 mx-2 overflow-x-auto min-w-0 self-stretch [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
<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">
|
||||
{viewItems.filter((item) => signedIn || !item.authOnly).map((item) => {
|
||||
const active = activeView === item.key;
|
||||
const Icon = item.icon;
|
||||
@@ -742,7 +742,11 @@ export function ProjectWorkspacePage() {
|
||||
next.set('view', item.key);
|
||||
return next;
|
||||
})}
|
||||
className={`tab ${active ? 'active' : ''} !py-0 h-full !text-[11.5px]`}
|
||||
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)]'
|
||||
}`}
|
||||
>
|
||||
<Icon size={13} />
|
||||
<span className="hidden md:inline">{item.label}</span>
|
||||
@@ -761,21 +765,29 @@ export function ProjectWorkspacePage() {
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setCommandPaletteOpen(true)}
|
||||
className="s-chip hidden lg:flex"
|
||||
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"
|
||||
>
|
||||
<Search size={12} />
|
||||
<kbd className="px-1 py-px rounded border border-[var(--border-subtle)] bg-[var(--bg-surface)] text-[10px] font-mono">⌘K</kbd>
|
||||
<kbd className="px-1 py-0.5 rounded bg-[var(--surface-card)] text-[10px] font-mono">⌘K</kbd>
|
||||
</button>
|
||||
{!isDemoMode && signedIn && (
|
||||
<>
|
||||
<QuietBtn onClick={() => setImportOpen(true)} title="Deploy a docker-compose file into this project">
|
||||
<FileUp size={13} />
|
||||
<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} />
|
||||
<span className="hidden sm:inline">Compose</span>
|
||||
</QuietBtn>
|
||||
<GhostBtn onClick={openAddService}>
|
||||
<Plus size={13} />
|
||||
</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} />
|
||||
<span className="hidden sm:inline">Add Service</span>
|
||||
</GhostBtn>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -806,48 +818,67 @@ export function ProjectWorkspacePage() {
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
{isDemoMode && (
|
||||
<div className="w-full px-8 pt-6">
|
||||
<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 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>
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full px-4 py-6 sm:px-8">
|
||||
<div className="w-full px-8 py-6">
|
||||
<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-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>
|
||||
<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="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>
|
||||
))}
|
||||
<p className="mt-2 text-3xl font-semibold text-[var(--success)]">{runningServices}</p>
|
||||
</div>
|
||||
<div className="panel-soft p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-[var(--error)]" />
|
||||
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Failed</p>
|
||||
</div>
|
||||
<p className="mt-2 text-3xl font-semibold text-[var(--error)]">
|
||||
{services.filter((s) => s.status === 'failed').length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="panel-soft p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-[var(--warning)] animate-pulse" />
|
||||
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Building</p>
|
||||
</div>
|
||||
<p className="mt-2 text-3xl font-semibold text-[var(--warning)]">
|
||||
{services.filter((s) => s.status === 'building').length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metrics Dashboard */}
|
||||
<MetricsDashboard services={visibleServices} isDemoMode={isDemoMode} />
|
||||
<MetricsDashboard
|
||||
isRunning={runningServices > 0}
|
||||
onStop={() => toast.showToast('Services stopped', 'warning')}
|
||||
onRestart={() => toast.showToast('Services restarted', 'success')}
|
||||
/>
|
||||
|
||||
{/* Service List */}
|
||||
<div className="s-card">
|
||||
<div className="s-cardhead">
|
||||
<span className="s-ibox"><Layers /></span>
|
||||
<span className="s-t">Services</span>
|
||||
<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>
|
||||
{!isDemoMode && environments.length > 0 && (
|
||||
<div className="s-trail flex-wrap">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<button
|
||||
onClick={() => setEnvFilter(null)}
|
||||
className={`s-chip !py-1 !px-2.5 ${envFilter === null ? '!text-[var(--accent-primary)] !border-[var(--accent-primary)]/40' : ''}`}
|
||||
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)]'
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
@@ -855,7 +886,11 @@ export function ProjectWorkspacePage() {
|
||||
<button
|
||||
key={env.id}
|
||||
onClick={() => setEnvFilter(envFilter === env.name ? null : env.name)}
|
||||
className={`s-chip !py-1 !px-2.5 ${envFilter === env.name ? '!text-[var(--accent-primary)] !border-[var(--accent-primary)]/40' : ''}`}
|
||||
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)]'
|
||||
}`}
|
||||
>
|
||||
{env.name} ({env.service_count})
|
||||
</button>
|
||||
@@ -864,7 +899,6 @@ 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">
|
||||
@@ -884,7 +918,9 @@ 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">
|
||||
<span className="s-ibox"><Box /></span>
|
||||
<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>
|
||||
<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>
|
||||
@@ -895,31 +931,33 @@ export function ProjectWorkspacePage() {
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeView === 'logs' && (
|
||||
<div className="s-card">
|
||||
<div className="s-cardhead">
|
||||
<span className="s-ibox"><FileText /></span>
|
||||
<div>
|
||||
<div className="s-t">Logs</div>
|
||||
<div className="mt-0.5 text-[11px] text-[var(--text-tertiary)]">Aggregated service output</div>
|
||||
<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>
|
||||
<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>
|
||||
{!isDemoMode && (
|
||||
<div className="s-trail">
|
||||
<QuietBtn onClick={() => workspaceLogsQuery.refetch()}>
|
||||
<RefreshCw size={13} />
|
||||
Refresh
|
||||
</QuietBtn>
|
||||
</div>
|
||||
<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"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-4 pb-4">
|
||||
|
||||
<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">
|
||||
<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">
|
||||
{services.length === 0 ? (
|
||||
<p className="text-[var(--text-muted)]">No services available.</p>
|
||||
) : isDemoMode ? (
|
||||
@@ -960,20 +998,20 @@ export function ProjectWorkspacePage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeView === 'activity' && (
|
||||
<div className="s-card">
|
||||
<div className="s-cardhead">
|
||||
<span className="s-ibox"><Clock /></span>
|
||||
<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>
|
||||
<div className="s-t">Activity</div>
|
||||
<div className="mt-0.5 text-[11px] text-[var(--text-tertiary)]">Project timeline — deploys, config, membership</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>
|
||||
</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>
|
||||
@@ -996,40 +1034,40 @@ export function ProjectWorkspacePage() {
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeView === 'settings' && (
|
||||
<div className="s-card">
|
||||
<div className="s-cardhead">
|
||||
<span className="s-ibox"><Settings /></span>
|
||||
<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>
|
||||
<div className="s-t">Settings</div>
|
||||
<div className="mt-0.5 text-[11px] text-[var(--text-tertiary)]">Project configuration</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>
|
||||
</div>
|
||||
<div className="px-4 pb-4">
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="s-inset">
|
||||
<div className="panel-soft p-4">
|
||||
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Project ID</p>
|
||||
<p className="v-mono mt-2 text-sm text-[var(--text-primary)] break-all">{project.id}</p>
|
||||
<p className="mono mt-2 text-sm text-[var(--text-primary)] break-all">{project.id}</p>
|
||||
</div>
|
||||
<div className="s-inset">
|
||||
<div className="panel-soft p-4">
|
||||
<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="s-inset md:col-span-2">
|
||||
<div className="panel-soft p-4 md:col-span-2">
|
||||
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Canvas Storage Key</p>
|
||||
<p className="v-mono mt-2 text-xs text-[var(--text-secondary)] break-all">{canvasStorageKey(project.id)}</p>
|
||||
<p className="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="s-t mb-3">Local Data</h3>
|
||||
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-3">Local Data</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
clearCanvasMetadata(project.id);
|
||||
@@ -1041,7 +1079,6 @@ export function ProjectWorkspacePage() {
|
||||
Reset Canvas Layout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
@@ -1068,10 +1105,12 @@ 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 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 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>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
Paste a docker-compose.yml. Services deploy in dependency order; postgres, mysql, redis, mongo,
|
||||
@@ -1081,7 +1120,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 v-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 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">
|
||||
@@ -1094,8 +1133,8 @@ export function ProjectWorkspacePage() {
|
||||
<button
|
||||
onClick={() => composeImportMutation.mutate(composeYaml)}
|
||||
disabled={composeImportMutation.isPending || composeYaml.trim().length === 0}
|
||||
className="s-btn-accent h-10 disabled:opacity-50"
|
||||
|
||||
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)' }}
|
||||
>
|
||||
{composeImportMutation.isPending && <Loader2 size={14} className="animate-spin" />}
|
||||
Deploy stack
|
||||
@@ -1124,9 +1163,6 @@ export function ProjectWorkspacePage() {
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
navTargets={viewItems
|
||||
.filter((item) => signedIn || !item.authOnly)
|
||||
.map((item) => ({ label: `Go to ${item.label}`, target: item.key }))}
|
||||
/>
|
||||
|
||||
{/* Error Banner */}
|
||||
|
||||
+194
-715
File diff suppressed because it is too large.
Load diff
@@ -492,12 +492,11 @@ export function getApiBaseUrl(): string {
|
||||
|
||||
// pingServer hits the public /health route — any HTTP response proves
|
||||
// reachability, and no auth means no 401 noise in the connectivity probe.
|
||||
export async function pingServer(): Promise<boolean> {
|
||||
export async function pingServer(): Promise<void> {
|
||||
const res = await fetch(`${API_ROOT}/health`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Server returned ${res.status}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export type SetupStatus = {
|
||||
@@ -1239,17 +1238,9 @@ export type AgentContainer = {
|
||||
created_at?: string;
|
||||
};
|
||||
|
||||
type RawAgentContainer = Omit<AgentContainer, 'status'> & {
|
||||
status?: string | { state?: string; health?: string };
|
||||
};
|
||||
|
||||
export async function listAgentContainers(id: string): Promise<AgentContainer[]> {
|
||||
const payload = await requestJson<{ containers?: RawAgentContainer[] }>(`/agents/${id}/containers`);
|
||||
// The wire sends status as a Docker state object — flatten to its state string.
|
||||
return (payload.containers ?? []).map((c) => ({
|
||||
...c,
|
||||
status: typeof c.status === 'string' ? c.status : c.status?.state || '—',
|
||||
}));
|
||||
const payload = await requestJson<{ containers?: AgentContainer[] }>(`/agents/${id}/containers`);
|
||||
return payload.containers ?? [];
|
||||
}
|
||||
|
||||
export type AgentCommand = {
|
||||
|
||||
@@ -13,11 +13,6 @@ export function formatRelative(iso?: string): string {
|
||||
const hour = 60 * minute;
|
||||
const day = 24 * hour;
|
||||
|
||||
// Future-dated or year-plus-old timestamps are effectively meaningless —
|
||||
// surface them as absent rather than "739898d ago".
|
||||
if (diffMs < 0 || diffMs > 365 * day) {
|
||||
return 'n/a';
|
||||
}
|
||||
if (diffMs < minute) {
|
||||
return 'just now';
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ const queryClient = new QueryClient({
|
||||
// High-churn or bulky keys never hit localStorage — a stale log tail is
|
||||
// worse than none.
|
||||
const NEVER_PERSIST = new Set([
|
||||
'auth-session',
|
||||
'shell-notifications',
|
||||
'notifications',
|
||||
'service-logs',
|
||||
|
||||
@@ -77,12 +77,12 @@ export function CardHeader({ title, description, icon, action, className = '', .
|
||||
<div className={`flex items-start justify-between gap-4 ${className}`} {...props}>
|
||||
<div className="flex items-start gap-3">
|
||||
{icon && (
|
||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 ring-1 ring-[var(--tint-05)]" style={{ background: 'var(--tint-07)' }}>
|
||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 ring-1 ring-white/5" style={{ background: 'rgba(255,255,255,0.07)' }}>
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="font-headline text-base font-semibold text-[var(--text-primary)] tracking-tight">{title}</h3>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] tracking-tight">{title}</h3>
|
||||
{description && (
|
||||
<p className="text-sm text-[var(--text-secondary)] mt-0.5">{description}</p>
|
||||
)}
|
||||
|
||||
@@ -68,7 +68,7 @@ function gradientId(prefix: string, color: string, index = 0) {
|
||||
|
||||
export function LineChart({
|
||||
data,
|
||||
color = '#38bdf8',
|
||||
color = '#b4e34a',
|
||||
fillColor = 'transparent',
|
||||
height = 76,
|
||||
showDots = true,
|
||||
@@ -181,7 +181,7 @@ export function MultiLineChart({
|
||||
|
||||
export function LineMetricChart({
|
||||
data,
|
||||
color = '#38bdf8',
|
||||
color = '#b4e34a',
|
||||
fillOpacity = 0.15,
|
||||
height = 76,
|
||||
showArea = false,
|
||||
@@ -204,8 +204,8 @@ export function DualLineChart({
|
||||
data1,
|
||||
data2,
|
||||
height = 58,
|
||||
color1 = 'var(--accent-primary)',
|
||||
color2 = 'var(--warning)',
|
||||
color1 = '#7ab8ff',
|
||||
color2 = '#f2c94c',
|
||||
fillOpacity1 = 0.15,
|
||||
fillOpacity2 = 0.15,
|
||||
}: DualLineChartProps) {
|
||||
|
||||
@@ -1,25 +1,24 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Command } from 'cmdk';
|
||||
import {
|
||||
ArrowRight,
|
||||
Box,
|
||||
CornerDownRight,
|
||||
Database,
|
||||
Github,
|
||||
Image,
|
||||
Clock,
|
||||
Search,
|
||||
Settings,
|
||||
FileText,
|
||||
Activity,
|
||||
LayoutGrid,
|
||||
} from 'lucide-react';
|
||||
|
||||
export interface PaletteTarget {
|
||||
label: string;
|
||||
target: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface CommandItem {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
icon: typeof Box;
|
||||
shortcut?: string;
|
||||
action: () => void;
|
||||
category?: string;
|
||||
}
|
||||
@@ -28,39 +27,14 @@ interface CommandPaletteProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onAddService?: (type: string) => void;
|
||||
onNavigate?: (target: string) => void;
|
||||
/** Navigate-group items; falls back to the full app route map. */
|
||||
navTargets?: PaletteTarget[];
|
||||
/** Extra searchable "Open …" rows, e.g. projects. */
|
||||
openTargets?: PaletteTarget[];
|
||||
onNavigate?: (path: string) => void;
|
||||
}
|
||||
|
||||
const APP_NAV: PaletteTarget[] = [
|
||||
{ label: 'Dashboard', target: '/', description: 'Index — stats, nodes, deploys' },
|
||||
{ label: 'Projects', target: '/projects', description: 'All projects' },
|
||||
{ label: 'Templates', target: '/templates', description: 'Template catalog' },
|
||||
{ label: 'Builds', target: '/builds', description: 'Build pipeline' },
|
||||
{ label: 'Databases', target: '/databases', description: 'Managed databases' },
|
||||
{ label: 'Operations', target: '/operations', description: 'Backups, cron, maintenance' },
|
||||
{ label: 'Activity', target: '/activity', description: 'Platform event feed' },
|
||||
{ label: 'High Availability', target: '/ha', description: 'Failover & alerts' },
|
||||
{ label: 'Security', target: '/security', description: 'Access, sessions, secrets' },
|
||||
{ label: 'Usage', target: '/usage', description: 'Resource consumption' },
|
||||
{ label: 'People', target: '/people', description: 'Team & access control' },
|
||||
{ label: 'Audit Logs', target: '/settings/audit-logs', description: 'Recorded actions' },
|
||||
{ label: 'Webhooks', target: '/settings/webhooks', description: 'Signed event delivery' },
|
||||
{ label: 'Settings', target: '/settings', description: 'Platform preferences' },
|
||||
{ label: 'Docs', target: '/docs', description: 'Guides & reference' },
|
||||
{ label: 'Landing', target: '/landing', description: 'Public page' },
|
||||
];
|
||||
|
||||
export function CommandPalette({
|
||||
open,
|
||||
onClose,
|
||||
onAddService,
|
||||
onNavigate,
|
||||
navTargets,
|
||||
openTargets,
|
||||
}: CommandPaletteProps) {
|
||||
if (!open) return null;
|
||||
|
||||
@@ -69,8 +43,6 @@ export function CommandPalette({
|
||||
onClose={onClose}
|
||||
onAddService={onAddService}
|
||||
onNavigate={onNavigate}
|
||||
navTargets={navTargets}
|
||||
openTargets={openTargets}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -79,8 +51,6 @@ function CommandPaletteContent({
|
||||
onClose,
|
||||
onAddService,
|
||||
onNavigate,
|
||||
navTargets,
|
||||
openTargets,
|
||||
}: Omit<CommandPaletteProps, 'open'>) {
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
@@ -96,69 +66,119 @@ function CommandPaletteContent({
|
||||
return () => document.removeEventListener('keydown', down);
|
||||
}, [onClose]);
|
||||
|
||||
const commands: CommandItem[] = [];
|
||||
|
||||
// Create items only exist where onAddService is wired — elsewhere the
|
||||
// entries would silently no-op.
|
||||
if (onAddService) {
|
||||
commands.push(
|
||||
{
|
||||
id: 'add-web',
|
||||
label: 'Add Web Service',
|
||||
description: 'Deploy a web application',
|
||||
icon: Box,
|
||||
category: 'Create',
|
||||
action: () => { onAddService('web'); onClose(); },
|
||||
const commands: CommandItem[] = [
|
||||
{
|
||||
id: 'add-web',
|
||||
label: 'Add Web Service',
|
||||
description: 'Deploy a web application',
|
||||
icon: Box,
|
||||
shortcut: 'W',
|
||||
category: 'Create',
|
||||
action: () => {
|
||||
onAddService?.('web');
|
||||
onClose();
|
||||
},
|
||||
{
|
||||
id: 'add-worker',
|
||||
label: 'Add Worker Service',
|
||||
description: 'Deploy a background worker',
|
||||
icon: Clock,
|
||||
category: 'Create',
|
||||
action: () => { onAddService('worker'); onClose(); },
|
||||
},
|
||||
{
|
||||
id: 'add-worker',
|
||||
label: 'Add Worker Service',
|
||||
description: 'Deploy a background worker',
|
||||
icon: Clock,
|
||||
shortcut: 'K',
|
||||
category: 'Create',
|
||||
action: () => {
|
||||
onAddService?.('worker');
|
||||
onClose();
|
||||
},
|
||||
{
|
||||
id: 'add-database',
|
||||
label: 'Add Database',
|
||||
description: 'Provision a PostgreSQL database',
|
||||
icon: Database,
|
||||
category: 'Create',
|
||||
action: () => { onAddService('database'); onClose(); },
|
||||
},
|
||||
{
|
||||
id: 'add-database',
|
||||
label: 'Add Database',
|
||||
description: 'Provision a PostgreSQL database',
|
||||
icon: Database,
|
||||
shortcut: 'D',
|
||||
category: 'Create',
|
||||
action: () => {
|
||||
onAddService?.('database');
|
||||
onClose();
|
||||
},
|
||||
{
|
||||
id: 'add-cron',
|
||||
label: 'Add Cron Job',
|
||||
description: 'Schedule a recurring task',
|
||||
icon: Clock,
|
||||
category: 'Create',
|
||||
action: () => { onAddService('cron'); onClose(); },
|
||||
},
|
||||
{
|
||||
id: 'add-cron',
|
||||
label: 'Add Cron Job',
|
||||
description: 'Schedule a recurring task',
|
||||
icon: Clock,
|
||||
category: 'Create',
|
||||
action: () => {
|
||||
onAddService?.('cron');
|
||||
onClose();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (onNavigate) {
|
||||
(navTargets ?? APP_NAV).forEach((t, i) =>
|
||||
commands.push({
|
||||
id: `nav-${i}`,
|
||||
label: t.label,
|
||||
description: t.description,
|
||||
icon: CornerDownRight,
|
||||
category: 'Navigate',
|
||||
action: () => { onNavigate(t.target); onClose(); },
|
||||
}),
|
||||
);
|
||||
(openTargets ?? []).forEach((t, i) =>
|
||||
commands.push({
|
||||
id: `open-${i}`,
|
||||
label: t.label,
|
||||
description: t.description,
|
||||
icon: ArrowRight,
|
||||
category: 'Open',
|
||||
action: () => { onNavigate(t.target); onClose(); },
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'connect-github',
|
||||
label: 'Connect GitHub Repo',
|
||||
description: 'Link a repository for auto-deploy',
|
||||
icon: Github,
|
||||
category: 'Connect',
|
||||
action: () => {
|
||||
onClose();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'deploy-image',
|
||||
label: 'Deploy Docker Image',
|
||||
description: 'Deploy from a container registry',
|
||||
icon: Image,
|
||||
category: 'Connect',
|
||||
action: () => {
|
||||
onClose();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'goto-canvas',
|
||||
label: 'Go to Canvas',
|
||||
icon: LayoutGrid,
|
||||
shortcut: 'C',
|
||||
category: 'Navigate',
|
||||
action: () => {
|
||||
onNavigate?.('canvas');
|
||||
onClose();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'goto-logs',
|
||||
label: 'Go to Logs',
|
||||
icon: FileText,
|
||||
shortcut: 'L',
|
||||
category: 'Navigate',
|
||||
action: () => {
|
||||
onNavigate?.('logs');
|
||||
onClose();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'goto-metrics',
|
||||
label: 'Go to Metrics',
|
||||
icon: Activity,
|
||||
shortcut: 'M',
|
||||
category: 'Navigate',
|
||||
action: () => {
|
||||
onNavigate?.('observability');
|
||||
onClose();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'goto-settings',
|
||||
label: 'Go to Settings',
|
||||
icon: Settings,
|
||||
shortcut: 'S',
|
||||
category: 'Navigate',
|
||||
action: () => {
|
||||
onNavigate?.('settings');
|
||||
onClose();
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const filteredCommands = commands.filter(
|
||||
(cmd) =>
|
||||
@@ -177,10 +197,6 @@ function CommandPaletteContent({
|
||||
{} as Record<string, CommandItem[]>
|
||||
);
|
||||
|
||||
const placeholder = onNavigate
|
||||
? 'Search pages, projects, actions…'
|
||||
: 'What would you like to create?';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50">
|
||||
<div
|
||||
@@ -189,7 +205,7 @@ function CommandPaletteContent({
|
||||
/>
|
||||
<div className="absolute left-1/2 top-[20%] -translate-x-1/2 w-full max-w-xl">
|
||||
<Command
|
||||
className="s-card !p-0 overflow-hidden shadow-2xl shadow-black/50"
|
||||
className="panel overflow-hidden"
|
||||
loop
|
||||
>
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-[var(--border-subtle)]">
|
||||
@@ -197,7 +213,7 @@ function CommandPaletteContent({
|
||||
<Command.Input
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
placeholder={placeholder}
|
||||
placeholder="What would you like to create?"
|
||||
className="flex-1 bg-transparent text-[var(--text-primary)] placeholder:text-[var(--text-muted)] text-sm outline-none"
|
||||
/>
|
||||
<kbd className="px-2 py-0.5 rounded bg-[var(--surface-muted)] text-[10px] font-mono text-[var(--text-muted)]">
|
||||
@@ -212,7 +228,7 @@ function CommandPaletteContent({
|
||||
|
||||
{Object.entries(groupedCommands).map(([category, items]) => (
|
||||
<Command.Group key={category} heading={category} className="mb-2">
|
||||
<div className="px-2 py-1.5 v-mono text-[10px] uppercase tracking-[0.14em] text-[var(--text-muted)]">
|
||||
<div className="px-2 py-1.5 text-[10px] font-semibold uppercase tracking-widest text-[var(--text-muted)]">
|
||||
{category}
|
||||
</div>
|
||||
{items.map((item) => {
|
||||
@@ -222,15 +238,20 @@ function CommandPaletteContent({
|
||||
key={item.id}
|
||||
value={item.label}
|
||||
onSelect={item.action}
|
||||
className="flex items-center gap-3 px-3 py-2 rounded-[var(--radius-md)] cursor-pointer text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] aria-selected:bg-[var(--accent-primary-soft)] aria-selected:text-[var(--accent-primary)] transition-colors"
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-[var(--radius-md)] cursor-pointer text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] aria-selected:bg-[var(--accent-primary-soft)] aria-selected:text-[var(--accent-primary)] transition-colors"
|
||||
>
|
||||
<span className="s-ibox !h-7 !w-7"><Icon size={13.5} /></span>
|
||||
<Icon size={18} className="text-[var(--text-muted)]" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium">{item.label}</div>
|
||||
{item.description && (
|
||||
<div className="text-xs text-[var(--text-muted)]">{item.description}</div>
|
||||
)}
|
||||
</div>
|
||||
{item.shortcut && (
|
||||
<kbd className="px-2 py-0.5 rounded bg-[var(--surface-muted)] text-[10px] font-mono text-[var(--text-muted)]">
|
||||
{item.shortcut}
|
||||
</kbd>
|
||||
)}
|
||||
</Command.Item>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -8,15 +8,15 @@ import { Sparkles } from 'lucide-react';
|
||||
export function DemoRestricted({ feature }: { feature: string }) {
|
||||
return (
|
||||
<div className="flex min-h-[60vh] items-center justify-center p-8">
|
||||
<div className="s-card max-w-md text-center">
|
||||
<div className="s-ibox mx-auto mb-4 !h-10 !w-10">
|
||||
<Sparkles size={18} />
|
||||
<div className="panel max-w-md p-8 text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-[var(--accent-primary-soft)]">
|
||||
<Sparkles size={22} className="text-[var(--accent-primary)]" />
|
||||
</div>
|
||||
<p className="font-headline text-[17px] font-semibold text-[var(--text-primary)]">Not part of the demo</p>
|
||||
<p className="text-lg font-semibold text-[var(--text-primary)]">Not part of the demo</p>
|
||||
<p className="mt-2 text-sm text-[var(--text-secondary)]">
|
||||
{feature} needs a live Containr session. The demo covers sample projects, services, and platform views only.
|
||||
</p>
|
||||
<Link to="/projects?demo=1" className="s-btn-accent mt-5 inline-flex">
|
||||
<Link to="/projects?demo=1" className="v-btn mt-5 inline-flex">
|
||||
Back to demo projects
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -17,8 +17,8 @@ export function DonutChart({
|
||||
percentage,
|
||||
size = 160,
|
||||
thickness = 16,
|
||||
color = '#38bdf8',
|
||||
trackColor = 'var(--tint-07)',
|
||||
color = '#b4e34a',
|
||||
trackColor = 'rgba(255, 255, 255, 0.07)',
|
||||
segments = 28,
|
||||
gap = 0.048,
|
||||
animated = true,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { type ReactNode } from 'react';
|
||||
import { ChevronRight, MoreHorizontal } from 'lucide-react';
|
||||
|
||||
interface EnhancedMetricCardProps {
|
||||
title: string;
|
||||
@@ -16,42 +15,6 @@ interface EnhancedMetricCardProps {
|
||||
horizontal?: boolean;
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
good: 'var(--success)',
|
||||
average: 'var(--warning)',
|
||||
warning: 'var(--warning)',
|
||||
};
|
||||
|
||||
function CardHead({ icon, title }: { icon: ReactNode; title: string }) {
|
||||
return (
|
||||
<div className="s-cardhead">
|
||||
<span className="s-ibox">{icon}</span>
|
||||
<span className="text-[11.5px] font-medium text-[var(--text-secondary)] whitespace-nowrap">{title}</span>
|
||||
<span className="ml-auto text-[var(--text-tertiary)]">
|
||||
<MoreHorizontal size={14} />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CardFoot({ label, onClick }: { label: string; onClick?: () => void }) {
|
||||
return (
|
||||
<div className="s-stat-foot mt-auto">
|
||||
<span
|
||||
onClick={onClick}
|
||||
className="cursor-pointer hover:text-[var(--text-secondary)] transition-colors"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{onClick && (
|
||||
<button className="s-icon-btn !w-[26px] !h-[26px]" onClick={onClick} title={label}>
|
||||
<ChevronRight size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EnhancedMetricCard({
|
||||
title,
|
||||
value,
|
||||
@@ -66,23 +29,75 @@ export function EnhancedMetricCard({
|
||||
animationDelay = 0,
|
||||
horizontal = false,
|
||||
}: EnhancedMetricCardProps) {
|
||||
const statusColors: Record<string, string> = {
|
||||
good: '#5ee6a0',
|
||||
average: '#f2c94c',
|
||||
warning: '#f2c94c',
|
||||
};
|
||||
|
||||
// Horizontal layout for cards like Active User
|
||||
if (horizontal) {
|
||||
return (
|
||||
<div className={`s-card flex flex-row overflow-hidden ${className}`} style={{ animationDelay: `${animationDelay}s` }}>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<CardHead icon={icon} title={title} />
|
||||
<div className="px-4">
|
||||
<div className="font-headline text-[32px] font-bold leading-none tracking-[-0.025em] text-[var(--text-primary)]">
|
||||
{value}
|
||||
<div
|
||||
className={`card ${className}`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
padding: 0,
|
||||
overflow: 'hidden',
|
||||
animationDelay: `${animationDelay}s`,
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
flex: 1,
|
||||
padding: '20px 18px 18px 20px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
|
||||
<div className="card-icon">{icon}</div>
|
||||
<span style={{ fontSize: 14, fontWeight: 600, color: '#e8e9f0' }}>{title}</span>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 36,
|
||||
fontWeight: 900,
|
||||
letterSpacing: '-1.5px',
|
||||
lineHeight: 1,
|
||||
color: '#e8e9f0',
|
||||
}}>
|
||||
{value}
|
||||
</div>
|
||||
{subtitle && (
|
||||
<div style={{ fontSize: 12, color: '#6b6e7d', marginTop: 4 }}>{subtitle}</div>
|
||||
)}
|
||||
{details && <div style={{ marginTop: 12 }}>{details}</div>}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginTop: 'auto',
|
||||
paddingTop: 14,
|
||||
}}>
|
||||
<span
|
||||
style={{ fontSize: 13, color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}
|
||||
onClick={onClick}
|
||||
>
|
||||
Details
|
||||
</span>
|
||||
<div className="arrow-btn" onClick={onClick}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
</div>
|
||||
{subtitle && <div className="mt-1 text-[11.5px] text-[var(--text-tertiary)]">{subtitle}</div>}
|
||||
{details && <div className="mt-3">{details}</div>}
|
||||
</div>
|
||||
<div className="mt-4" />
|
||||
<CardFoot label="Details" onClick={onClick} />
|
||||
</div>
|
||||
{chart && (
|
||||
<div className="flex w-1/2 items-end py-3 pr-3.5">
|
||||
<div style={{
|
||||
width: '50%',
|
||||
padding: '16px 14px 46px 0',
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
}}>
|
||||
{chart}
|
||||
</div>
|
||||
)}
|
||||
@@ -90,25 +105,64 @@ export function EnhancedMetricCard({
|
||||
);
|
||||
}
|
||||
|
||||
// Standard vertical card layout - matching self.html exactly
|
||||
return (
|
||||
<div className={`s-card flex flex-col ${className}`} style={{ animationDelay: `${animationDelay}s` }}>
|
||||
<CardHead icon={icon} title={title} />
|
||||
<div className="px-4 pb-1">
|
||||
<div className="font-headline text-[32px] font-bold leading-none tracking-[-0.025em] text-[var(--text-primary)]">
|
||||
{value}
|
||||
<div
|
||||
className={`card ${className}`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: 20,
|
||||
animationDelay: `${animationDelay}s`,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
|
||||
<div className="card-icon">{icon}</div>
|
||||
<span style={{ fontSize: 14, fontWeight: 600, color: '#e8e9f0' }}>{title}</span>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
fontSize: 38,
|
||||
fontWeight: 900,
|
||||
letterSpacing: '-1.5px',
|
||||
lineHeight: 1,
|
||||
color: '#e8e9f0',
|
||||
}}>
|
||||
{value}
|
||||
</div>
|
||||
|
||||
{(subtitle || statusText) && (
|
||||
<div style={{ fontSize: 12, color: '#6b6e7d', marginTop: 4 }}>
|
||||
{statusText && status && (
|
||||
<span style={{ color: statusColors[status], fontWeight: 700 }}>{statusText}</span>
|
||||
)}{' '}
|
||||
{subtitle}
|
||||
</div>
|
||||
{(subtitle || statusText) && (
|
||||
<div className="mt-1.5 text-[11.5px] text-[var(--text-tertiary)]">
|
||||
{statusText && status && (
|
||||
<span className="font-semibold" style={{ color: statusColors[status] }}>{statusText}</span>
|
||||
)}{' '}
|
||||
{subtitle}
|
||||
</div>
|
||||
)}
|
||||
{chart && <div className="mt-3 mb-1">{chart}</div>}
|
||||
{details && <div className="mt-3">{details}</div>}
|
||||
)}
|
||||
|
||||
{chart && <div style={{ marginTop: 12, marginBottom: 6 }}>{chart}</div>}
|
||||
|
||||
{details && <div style={{ marginTop: 16 }}>{details}</div>}
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingTop: 4,
|
||||
marginTop: 'auto'
|
||||
}}>
|
||||
<span
|
||||
onClick={onClick}
|
||||
style={{ fontSize: 13, color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}
|
||||
>
|
||||
Details
|
||||
</span>
|
||||
<div className="arrow-btn" onClick={onClick}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<CardFoot label="Details" onClick={onClick} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -132,56 +186,118 @@ export function CacheMetricCard({
|
||||
const nonCachePercent = Math.round((nonCacheMB / totalMB) * 100);
|
||||
|
||||
return (
|
||||
<div className="s-card flex flex-col" style={{ animationDelay: `${animationDelay}s` }}>
|
||||
<CardHead
|
||||
icon={
|
||||
<svg viewBox="0 0 24 24" width="13.5" height="13.5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
<div
|
||||
className="card"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: 20,
|
||||
animationDelay: `${animationDelay}s`,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
|
||||
<div className="card-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="#9295a4" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="3 6 5 6 21 6"/>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||||
</svg>
|
||||
}
|
||||
title="Cache"
|
||||
/>
|
||||
<div className="px-4">
|
||||
<div className="font-headline text-[32px] font-bold leading-none tracking-[-0.025em] text-[var(--text-primary)]">
|
||||
{totalMB} <span className="text-[12px] font-medium text-[var(--text-tertiary)]">MB</span>
|
||||
</div>
|
||||
<div className="mt-1.5 text-[11.5px] text-[var(--text-tertiary)]">
|
||||
<span className="font-semibold text-[var(--warning)]">{Math.round(totalMB * 0.625)}MB average</span> cached images and files
|
||||
</div>
|
||||
<span style={{ fontSize: 14, fontWeight: 600, color: '#e8e9f0' }}>Cache</span>
|
||||
</div>
|
||||
|
||||
{/* Segmented bar */}
|
||||
<div className="my-4 flex h-8 items-center gap-1">
|
||||
<div className="h-full rounded-l-md" style={{ width: `${cachePercent}%`, background: 'var(--error)' }} />
|
||||
<div className="h-full" style={{ width: `${nonCachePercent}%`, background: 'var(--accent-tertiary)' }} />
|
||||
<div className="h-full flex-1 rounded-r-md bg-[var(--tint-07)]" />
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 38,
|
||||
fontWeight: 900,
|
||||
letterSpacing: '-1.5px',
|
||||
lineHeight: 1,
|
||||
color: '#e8e9f0',
|
||||
}}>
|
||||
{totalMB} MB
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="grid grid-cols-[1fr_auto_1fr_auto_1fr] items-stretch">
|
||||
{[
|
||||
{ dot: 'var(--error)', label: 'Cache', value: `${cacheMB} MB`, pct: `${cachePercent}%` },
|
||||
{ dot: 'var(--accent-tertiary)', label: 'Non-Cache', value: `${nonCacheMB} MB`, pct: `${nonCachePercent}%` },
|
||||
{ dot: null, label: 'Total', value: `${totalMB * 5} GB`, pct: '' },
|
||||
].map((s, i) => (
|
||||
<div key={s.label} className="contents">
|
||||
{i > 0 && <div className="mx-4 w-px bg-[var(--border-subtle)]" />}
|
||||
<div>
|
||||
<div className="mb-1 flex items-center gap-1.5 text-[10.5px] text-[var(--text-tertiary)]">
|
||||
{s.dot && <i className="h-1.5 w-1.5 rounded-full" style={{ background: s.dot }} />}
|
||||
{s.label}
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-[14px] font-bold text-[var(--text-primary)]">{s.value}</span>
|
||||
{s.pct && <span className="text-[10.5px] text-[var(--text-tertiary)]">{s.pct}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ fontSize: 12, color: '#6b6e7d', marginTop: 4 }}>
|
||||
<span style={{ color: '#f2c94c', fontWeight: 700 }}>{Math.round(totalMB * 0.625)}MB Average</span> cached images and files
|
||||
</div>
|
||||
|
||||
{/* Segmented Bar */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, margin: '16px 0 15px', height: 32 }}>
|
||||
<div
|
||||
className="cache-seg"
|
||||
style={{
|
||||
width: `${cachePercent}%`,
|
||||
background: '#ff6b5b',
|
||||
borderRadius: '10px 4px 4px 10px'
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="cache-seg"
|
||||
style={{
|
||||
width: `${nonCachePercent}%`,
|
||||
background: '#8c6ef0',
|
||||
borderRadius: '5px'
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="cache-seg"
|
||||
style={{
|
||||
flex: 1,
|
||||
background: 'rgba(255,255,255,0.07)',
|
||||
borderRadius: '4px 10px 10px 4px'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Stats Row */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr auto 1fr auto 1fr',
|
||||
gap: 0,
|
||||
alignItems: 'stretch'
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 11, color: '#6b6e7d', marginBottom: 5 }}>
|
||||
<div className="stat-dot" style={{ background: '#ff6b5b' }} /> Cache
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 4 }}>
|
||||
<span style={{ fontSize: 15, fontWeight: 800 }}>{cacheMB} MB</span>
|
||||
<span style={{ fontSize: 11, color: '#6b6e7d' }}>{cachePercent}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ width: 1, background: 'rgba(255,255,255,0.08)', margin: '0 16px' }} />
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 11, color: '#6b6e7d', marginBottom: 5 }}>
|
||||
<div className="stat-dot" style={{ background: '#8c6ef0' }} /> Non-Cache
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 4 }}>
|
||||
<span style={{ fontSize: 15, fontWeight: 800 }}>{nonCacheMB} MB</span>
|
||||
<span style={{ fontSize: 11, color: '#6b6e7d' }}>{nonCachePercent}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ width: 1, background: 'rgba(255,255,255,0.08)', margin: '0 16px' }} />
|
||||
<div>
|
||||
<div style={{ fontSize: 11, color: '#6b6e7d', marginBottom: 5 }}>Total</div>
|
||||
<div style={{ fontSize: 15, fontWeight: 800 }}>{totalMB * 5} GB</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingTop: 16
|
||||
}}>
|
||||
<span
|
||||
onClick={onClick}
|
||||
style={{ fontSize: 13, color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}
|
||||
>
|
||||
Details
|
||||
</span>
|
||||
<div className="arrow-btn" onClick={onClick}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4" />
|
||||
<CardFoot label="Details" onClick={onClick} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -203,47 +319,86 @@ export function PerformanceMetricCard({
|
||||
animationDelay = 0,
|
||||
}: PerformanceMetricCardProps) {
|
||||
const status = percentage >= 85 ? 'Good' : percentage >= 70 ? 'Average' : 'Warning';
|
||||
const statusColor = percentage >= 85 ? 'var(--success)' : percentage >= 70 ? 'var(--warning)' : 'var(--error)';
|
||||
const statusColor = percentage >= 85 ? '#5ee6a0' : percentage >= 70 ? '#f2c94c' : '#ff6f5c';
|
||||
|
||||
return (
|
||||
<div className="s-card flex flex-col" style={{ animationDelay: `${animationDelay}s` }}>
|
||||
<CardHead
|
||||
icon={
|
||||
<svg viewBox="0 0 24 24" width="13.5" height="13.5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="2" y1="12" x2="22" y2="12" />
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
|
||||
<div
|
||||
className="card"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: 20,
|
||||
animationDelay: `${animationDelay}s`,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
|
||||
<div className="card-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="#9295a4" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="2" y1="12" x2="22" y2="12"/>
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
|
||||
</svg>
|
||||
}
|
||||
title="Performance"
|
||||
/>
|
||||
<div className="flex flex-1 items-start gap-4 px-4">
|
||||
<div className="flex-1">
|
||||
<div className="font-headline text-[32px] font-bold leading-none tracking-[-0.025em] text-[var(--text-primary)]">
|
||||
</div>
|
||||
<span style={{ fontSize: 14, fontWeight: 600, color: '#e8e9f0' }}>Performance</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 16, flex: 1 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{
|
||||
fontSize: 36,
|
||||
fontWeight: 900,
|
||||
letterSpacing: '-1.5px',
|
||||
lineHeight: 1,
|
||||
color: '#e8e9f0',
|
||||
}}>
|
||||
{percentage}%
|
||||
</div>
|
||||
<div className="mt-1.5 text-[11.5px] text-[var(--text-tertiary)]">
|
||||
<span className="font-semibold" style={{ color: statusColor }}>{status}</span>
|
||||
{' '}Last scan {new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||
<div style={{ fontSize: 12, color: '#6b6e7d', marginTop: 4 }}>
|
||||
<span style={{ color: statusColor, fontWeight: 700 }}>{status}</span> Last scan on {new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<div className="h-[58px] w-[134px] rounded-lg bg-[var(--tint-03)]" />
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
{[{ v: upSpeed, up: false }, { v: downSpeed, up: true }].map((s, i) => (
|
||||
<div key={i} className="flex items-center gap-1 text-[11px] font-medium text-[var(--accent-primary)]">
|
||||
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
|
||||
<line x1="12" y1={s.up ? 5 : 19} x2="12" y2={s.up ? 19 : 5} />
|
||||
{s.up ? <polyline points="19 12 12 5 5 12" /> : <polyline points="5 12 12 19 19 12" />}
|
||||
</svg>
|
||||
<span>{s.v}</span> Mbps
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 8 }}>
|
||||
{/* Chart placeholder - will be replaced with actual chart */}
|
||||
<div style={{ width: 134, height: 58, background: 'rgba(255,255,255,0.03)', borderRadius: 8 }} />
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, alignItems: 'flex-end' }}>
|
||||
<div className="speed-row" style={{ color: '#7ab8ff' }}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
|
||||
<line x1="12" y1="19" x2="12" y2="5"/>
|
||||
<polyline points="5 12 12 19 19 12"/>
|
||||
</svg>
|
||||
<span>{upSpeed}</span> Mbps
|
||||
</div>
|
||||
<div className="speed-row" style={{ color: 'var(--accent-primary)' }}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/>
|
||||
<polyline points="19 12 12 5 5 12"/>
|
||||
</svg>
|
||||
<span>{downSpeed}</span> Mbps
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4" />
|
||||
<CardFoot label="Check Speed" onClick={onClick} />
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingTop: 14
|
||||
}}>
|
||||
<span
|
||||
onClick={onClick}
|
||||
style={{ fontSize: 13, color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}
|
||||
>
|
||||
Check Speed
|
||||
</span>
|
||||
<div className="arrow-btn" onClick={onClick}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -61,9 +61,9 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--bg-void)] flex items-center justify-center p-4">
|
||||
<div className="s-card max-w-md w-full space-y-4">
|
||||
<div className="max-w-md w-full bg-[var(--bg-primary)] border border-[var(--border-subtle)] rounded-lg p-6 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<h2 className="font-headline text-xl font-semibold text-[var(--text-primary)]">
|
||||
<h2 className="text-xl font-semibold text-[var(--text-primary)]">
|
||||
Something went wrong
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
@@ -72,18 +72,24 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
</div>
|
||||
|
||||
{import.meta.env.DEV && this.state.error && (
|
||||
<div className="s-inset">
|
||||
<p className="v-mono text-[11px] text-[var(--error)] break-all">
|
||||
<div className="bg-[var(--surface-muted)] border border-[var(--border-subtle)] rounded p-3">
|
||||
<p className="text-xs font-mono text-[var(--error)] break-all">
|
||||
{this.state.error.toString()}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button onClick={this.handleReset} className="s-btn-accent flex-1 justify-center">
|
||||
<button
|
||||
onClick={this.handleReset}
|
||||
className="flex-1 px-4 py-2 bg-[var(--accent-primary-soft)] text-[var(--text-primary)] rounded hover:opacity-90 transition-opacity"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
<button onClick={() => window.location.reload()} className="s-btn-quiet flex-1 justify-center">
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="flex-1 px-4 py-2 bg-[var(--surface-muted)] text-[var(--text-primary)] border border-[var(--border-subtle)] rounded hover:bg-[var(--surface-soft)] transition-colors"
|
||||
>
|
||||
Reload Page
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { type ReactNode, useEffect, useRef, useState } from 'react';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
|
||||
interface MetricCardProps {
|
||||
title: string;
|
||||
@@ -16,12 +15,6 @@ interface MetricCardProps {
|
||||
horizontal?: boolean;
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
good: 'var(--success)',
|
||||
average: 'var(--warning)',
|
||||
warning: 'var(--warning)',
|
||||
};
|
||||
|
||||
export function MetricCard({
|
||||
title,
|
||||
value,
|
||||
@@ -56,64 +49,140 @@ export function MetricCard({
|
||||
return () => observer.disconnect();
|
||||
}, [animationDelay]);
|
||||
|
||||
const reveal = {
|
||||
opacity: isVisible ? 1 : 0,
|
||||
transform: isVisible ? 'translateY(0)' : 'translateY(12px)',
|
||||
transition: 'all 0.5s ease',
|
||||
} as const;
|
||||
|
||||
const footer = (
|
||||
<div className="s-stat-foot">
|
||||
<span
|
||||
onClick={onClick}
|
||||
style={{ cursor: onClick ? 'pointer' : 'default' }}
|
||||
>
|
||||
{statusText && status ? (
|
||||
<b style={{ color: statusColors[status], fontWeight: 600 }}>{statusText}</b>
|
||||
) : null}{' '}
|
||||
{subtitle ?? 'Details'}
|
||||
</span>
|
||||
{onClick ? (
|
||||
<button className="s-icon-btn !h-6 !w-6" onClick={onClick} title="Details">
|
||||
<ChevronRight size={11} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
const statusColors: Record<string, string> = {
|
||||
good: '#5ee6a0',
|
||||
average: '#f2c94c',
|
||||
warning: '#f2c94c',
|
||||
};
|
||||
|
||||
// Horizontal layout for cards like Active User
|
||||
if (horizontal) {
|
||||
return (
|
||||
<div ref={cardRef} className={`s-stat ${className}`} style={{ ...reveal, display: 'flex' }}>
|
||||
<div className="flex-1 px-4 pt-4 pb-2 flex flex-col">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="s-ibox">{icon}</span>
|
||||
<span className="text-[11.5px] font-medium text-[var(--text-secondary)]">{title}</span>
|
||||
<div
|
||||
ref={cardRef}
|
||||
className={`panel ${className}`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
padding: 0,
|
||||
overflow: 'hidden',
|
||||
opacity: isVisible ? 1 : 0,
|
||||
transform: isVisible ? 'translateY(0)' : 'translateY(12px)',
|
||||
transition: 'all 0.5s ease',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
flex: 1,
|
||||
padding: '20px 18px 18px 20px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
|
||||
<div className="card-icon">{icon}</div>
|
||||
<span style={{ fontSize: 14, fontWeight: 600, color: '#e8e9f0' }}>{title}</span>
|
||||
</div>
|
||||
<div className="mt-4 font-headline text-[30px] font-bold leading-none tracking-[-0.025em] text-[var(--text-primary)]">
|
||||
<div style={{
|
||||
fontSize: 36,
|
||||
fontWeight: 900,
|
||||
letterSpacing: '-1.5px',
|
||||
lineHeight: 1,
|
||||
color: '#e8e9f0',
|
||||
}}>
|
||||
{value}
|
||||
</div>
|
||||
{details ? <div className="mt-3">{details}</div> : null}
|
||||
<div className="mt-auto">{footer}</div>
|
||||
{subtitle && (
|
||||
<div style={{ fontSize: 12, color: '#6b6e7d', marginTop: 4 }}>{subtitle}</div>
|
||||
)}
|
||||
{details && <div style={{ marginTop: 12 }}>{details}</div>}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginTop: 'auto',
|
||||
paddingTop: 14,
|
||||
}}>
|
||||
<span
|
||||
style={{ fontSize: 13, color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}
|
||||
onClick={onClick}
|
||||
>
|
||||
Details
|
||||
</span>
|
||||
<div className="arrow-btn" onClick={onClick}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{chart ? (
|
||||
<div className="w-1/2 flex items-end pr-3 pb-10 pt-3">{chart}</div>
|
||||
) : null}
|
||||
{chart && (
|
||||
<div style={{
|
||||
width: '50%',
|
||||
padding: '16px 14px 46px 0',
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
}}>
|
||||
{chart}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Standard vertical card layout - matching self.html exactly
|
||||
return (
|
||||
<div ref={cardRef} className={`s-stat ${className}`} style={reveal}>
|
||||
<div className="flex items-center gap-2.5 px-4 pt-4">
|
||||
<span className="s-ibox">{icon}</span>
|
||||
<span className="text-[11.5px] font-medium text-[var(--text-secondary)]">{title}</span>
|
||||
<div
|
||||
ref={cardRef}
|
||||
className={`panel ${className}`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: 20,
|
||||
opacity: isVisible ? 1 : 0,
|
||||
transform: isVisible ? 'translateY(0)' : 'translateY(12px)',
|
||||
transition: 'all 0.5s ease',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
|
||||
<div className="card-icon">{icon}</div>
|
||||
<span style={{ fontSize: 14, fontWeight: 600, color: '#e8e9f0' }}>{title}</span>
|
||||
</div>
|
||||
<div className="px-4 mt-4 font-headline text-[32px] font-bold leading-none tracking-[-0.025em] text-[var(--text-primary)]">
|
||||
|
||||
<div style={{
|
||||
fontSize: 38,
|
||||
fontWeight: 900,
|
||||
letterSpacing: '-1.5px',
|
||||
lineHeight: 1,
|
||||
color: '#e8e9f0',
|
||||
}}>
|
||||
{value}
|
||||
</div>
|
||||
{chart ? <div className="px-4 mt-3">{chart}</div> : null}
|
||||
{details ? <div className="px-4 mt-3">{details}</div> : null}
|
||||
<div className="mt-4">{footer}</div>
|
||||
|
||||
{(subtitle || statusText) && (
|
||||
<div style={{ fontSize: 12, color: '#6b6e7d', marginTop: 4 }}>
|
||||
{statusText && status && (
|
||||
<span style={{ color: statusColors[status], fontWeight: 700 }}>{statusText}</span>
|
||||
)}{' '}
|
||||
{subtitle}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{chart && <div style={{ marginTop: 12, marginBottom: 6 }}>{chart}</div>}
|
||||
|
||||
{details && <div style={{ marginTop: 16 }}>{details}</div>}
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingTop: 4, marginTop: 'auto' }}>
|
||||
<span
|
||||
onClick={onClick}
|
||||
style={{ fontSize: 13, color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}
|
||||
>
|
||||
Details
|
||||
</span>
|
||||
<div className="arrow-btn" onClick={onClick}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Check, X, Loader2, Box, HelpCircle, type LucideIcon } from 'lucide-react';
|
||||
import type { PillTone } from './sentry-utils';
|
||||
|
||||
type ServiceStatus = 'running' | 'building' | 'failed' | 'stopped' | 'unknown';
|
||||
|
||||
@@ -13,29 +12,43 @@ interface StatusBadgeProps {
|
||||
|
||||
const statusConfig: Record<
|
||||
ServiceStatus,
|
||||
{ tone: PillTone; Icon: LucideIcon; label: string; animate?: boolean }
|
||||
{ color: string; bg: string; Icon: LucideIcon; label: string; animate?: boolean; glow?: string }
|
||||
> = {
|
||||
running: { tone: 'ok', Icon: Check, label: 'Active' },
|
||||
building: { tone: 'warn', Icon: Loader2, label: 'Building', animate: true },
|
||||
failed: { tone: 'err', Icon: X, label: 'Failed' },
|
||||
stopped: { tone: 'off', Icon: Box, label: 'Stopped' },
|
||||
unknown: { tone: 'off', Icon: HelpCircle, label: 'Unknown' },
|
||||
};
|
||||
|
||||
const TONE_CLS: Record<PillTone, string> = {
|
||||
ok: 'text-[var(--success)] bg-[var(--success-soft)] border-[color-mix(in_srgb,var(--success)_16%,transparent)]',
|
||||
warn: 'text-[var(--warning)] bg-[var(--warning-soft)] border-[color-mix(in_srgb,var(--warning)_16%,transparent)]',
|
||||
err: 'text-[var(--error)] bg-[var(--error-soft)] border-[color-mix(in_srgb,var(--error)_16%,transparent)]',
|
||||
info: 'text-[var(--info)] bg-[var(--info-soft)] border-[color-mix(in_srgb,var(--info)_16%,transparent)]',
|
||||
off: 'text-[var(--text-tertiary)] bg-[var(--tint-06)] border-[var(--border-subtle)]',
|
||||
};
|
||||
|
||||
const TONE_DOT: Record<PillTone, string> = {
|
||||
ok: 'var(--success)',
|
||||
warn: 'var(--warning)',
|
||||
err: 'var(--error)',
|
||||
info: 'var(--info)',
|
||||
off: 'var(--text-tertiary)',
|
||||
running: {
|
||||
color: 'var(--success)',
|
||||
bg: 'var(--success-soft)',
|
||||
Icon: Check,
|
||||
label: 'Active',
|
||||
animate: true,
|
||||
glow: 'rgba(61, 214, 140, 0.4)',
|
||||
},
|
||||
building: {
|
||||
color: 'var(--warning)',
|
||||
bg: 'var(--warning-soft)',
|
||||
Icon: Loader2,
|
||||
label: 'Building',
|
||||
animate: true,
|
||||
glow: 'rgba(255, 112, 67, 0.4)',
|
||||
},
|
||||
failed: {
|
||||
color: 'var(--error)',
|
||||
bg: 'var(--error-soft)',
|
||||
Icon: X,
|
||||
label: 'Failed',
|
||||
glow: 'rgba(255, 107, 91, 0.4)',
|
||||
},
|
||||
stopped: {
|
||||
color: 'var(--text-muted)',
|
||||
bg: 'var(--surface-muted)',
|
||||
Icon: Box,
|
||||
label: 'Stopped',
|
||||
},
|
||||
unknown: {
|
||||
color: 'var(--text-muted)',
|
||||
bg: 'var(--surface-muted)',
|
||||
Icon: HelpCircle,
|
||||
label: 'Unknown',
|
||||
},
|
||||
};
|
||||
|
||||
export function StatusBadge({
|
||||
@@ -49,26 +62,36 @@ export function StatusBadge({
|
||||
const { Icon } = config;
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'px-2 py-[2px] text-[10px] gap-1.5',
|
||||
md: 'px-2 py-[3.5px] text-[10.5px] gap-1.5',
|
||||
lg: 'px-2.5 py-1 text-[11.5px] gap-1.5',
|
||||
sm: 'px-2 py-0.5 text-[10px] gap-1.5',
|
||||
md: 'px-2.5 py-1 text-xs gap-1.5',
|
||||
lg: 'px-3 py-1.5 text-sm gap-2',
|
||||
};
|
||||
|
||||
const iconSizes = { sm: 10, md: 11, lg: 12 };
|
||||
const iconSizes = {
|
||||
sm: 10,
|
||||
md: 12,
|
||||
lg: 14,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`inline-flex items-center rounded-[5px] border font-medium transition-colors ${sizeClasses[size]} ${TONE_CLS[config.tone]} ${className}`}
|
||||
className={`inline-flex items-center rounded-full font-bold uppercase tracking-wider ring-1 ring-inset transition-all duration-200 ${sizeClasses[size]} ${className}`}
|
||||
style={{
|
||||
background: config.bg,
|
||||
color: config.color,
|
||||
borderColor: `${config.color}20`,
|
||||
boxShadow: status === 'running' && pulse ? `0 0 12px ${config.glow}` : 'none',
|
||||
}}
|
||||
>
|
||||
{status === 'running' && pulse && (
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full live-pulse"
|
||||
style={{ background: TONE_DOT[config.tone] }}
|
||||
style={{ background: config.color, boxShadow: `0 0 6px ${config.color}` }}
|
||||
/>
|
||||
)}
|
||||
<Icon
|
||||
size={iconSizes[size]}
|
||||
className={config.animate ? 'animate-spin' : ''}
|
||||
className={config.animate && status === 'building' ? 'animate-spin' : ''}
|
||||
/>
|
||||
{showLabel && <span>{config.label}</span>}
|
||||
</div>
|
||||
@@ -83,19 +106,27 @@ interface LiveIndicatorProps {
|
||||
|
||||
export function LiveIndicator({ isLive, label, size = 'md' }: LiveIndicatorProps) {
|
||||
const sizeClasses = {
|
||||
sm: 'px-2 py-[2px] text-[10px] gap-1.5',
|
||||
md: 'px-2 py-[3.5px] text-[10.5px] gap-1.5',
|
||||
lg: 'px-2.5 py-1 text-[11.5px] gap-1.5',
|
||||
sm: 'px-2 py-0.5 text-[10px] gap-1.5',
|
||||
md: 'px-2.5 py-1 text-xs gap-2',
|
||||
lg: 'px-3 py-1.5 text-sm gap-2',
|
||||
};
|
||||
const tone: PillTone = isLive ? 'ok' : 'off';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`inline-flex items-center rounded-[5px] border font-medium transition-colors ${sizeClasses[size]} ${TONE_CLS[tone]}`}
|
||||
className={`inline-flex items-center rounded-full font-bold uppercase tracking-wider ring-1 ring-inset transition-all duration-300 ${sizeClasses[size]}`}
|
||||
style={{
|
||||
background: isLive ? 'var(--success-soft)' : 'var(--surface-muted)',
|
||||
color: isLive ? 'var(--success)' : 'var(--text-muted)',
|
||||
borderColor: isLive ? 'rgba(61, 214, 140, 0.2)' : 'rgba(255, 255, 255, 0.05)',
|
||||
boxShadow: isLive ? '0 0 12px rgba(61, 214, 140, 0.3)' : 'none',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full ${isLive ? 'live-pulse' : ''}`}
|
||||
style={{ background: TONE_DOT[tone] }}
|
||||
style={{
|
||||
background: isLive ? 'var(--success)' : 'var(--text-muted)',
|
||||
boxShadow: isLive ? '0 0 6px var(--success)' : 'none',
|
||||
}}
|
||||
/>
|
||||
{label || (isLive ? 'Active' : 'Stopped')}
|
||||
</div>
|
||||
|
||||
@@ -12,4 +12,3 @@ export { useToast } from '../hooks/use-toast';
|
||||
export type { ToastType, ToastContextValue } from '../hooks/use-toast';
|
||||
export * from './BrandWordmark';
|
||||
export * from './FilterBar';
|
||||
export * from './sentry';
|
||||
@@ -1,18 +0,0 @@
|
||||
/* Non-component helpers for the Sentry kit — kept out of sentry.tsx so the
|
||||
component file stays fast-refresh clean. */
|
||||
|
||||
export type PillTone = 'ok' | 'warn' | 'err' | 'off' | 'info';
|
||||
|
||||
export function statusTone(status: string): PillTone {
|
||||
const s = status.toLowerCase();
|
||||
if (['running', 'healthy', 'online', 'active', 'deployed', 'succeeded', 'success', 'live'].includes(s)) return 'ok';
|
||||
if (['degraded', 'warning', 'building', 'deploying', 'pending', 'scheduled', 'queued', 'sleeping'].includes(s)) return 'warn';
|
||||
if (['failed', 'critical', 'error', 'offline', 'stopped'].includes(s)) return s === 'stopped' || s === 'offline' ? 'off' : 'err';
|
||||
return 'info';
|
||||
}
|
||||
|
||||
/* CSS var color helper for charts. */
|
||||
export function cssVar(name: string, fallback = ''): string {
|
||||
if (typeof window === 'undefined') return fallback;
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
|
||||
}
|
||||
@@ -1,372 +0,0 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { ChevronDown, MoreHorizontal } from 'lucide-react';
|
||||
import type { PillTone } from './sentry-utils';
|
||||
|
||||
export type { PillTone };
|
||||
|
||||
/* ============================================================
|
||||
Sentry kit — React bindings for the 23-sentry.html language.
|
||||
Every page composes these; no page invents its own chrome.
|
||||
============================================================ */
|
||||
|
||||
export function Ibox({ children }: { children: ReactNode }) {
|
||||
return <span className="s-ibox">{children}</span>;
|
||||
}
|
||||
|
||||
/* ---------- buttons ---------- */
|
||||
|
||||
type BtnProps = {
|
||||
children: ReactNode;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
title?: string;
|
||||
className?: string;
|
||||
type?: 'button' | 'submit';
|
||||
};
|
||||
|
||||
export function GhostBtn({ children, onClick, disabled, title, className = '', type = 'button' }: BtnProps) {
|
||||
return (
|
||||
<button type={type} className={`s-btn-accent ${className}`} onClick={onClick} disabled={disabled} title={title}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function QuietBtn({ children, onClick, disabled, title, className = '', type = 'button' }: BtnProps) {
|
||||
return (
|
||||
<button type={type} className={`s-btn-quiet ${className}`} onClick={onClick} disabled={disabled} title={title}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconBtn({ children, onClick, disabled, title, className = '', type = 'button' }: BtnProps) {
|
||||
return (
|
||||
<button type={type} className={`s-icon-btn ${className}`} onClick={onClick} disabled={disabled} title={title}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* Filter/sort chip with trailing chevron — the `.sel` in sentry. */
|
||||
export function SelectChip({ children, onClick, title, icon }: BtnProps & { icon?: ReactNode }) {
|
||||
return (
|
||||
<button className="s-chip" onClick={onClick} title={title}>
|
||||
{icon}
|
||||
{children}
|
||||
<ChevronDown size={10} style={{ opacity: 0.55 }} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* Segmented icon buttons — chart-type switchers. */
|
||||
export function Seg({ options, value, onChange }: {
|
||||
options: { key: string; icon: ReactNode; title?: string }[];
|
||||
value: string;
|
||||
onChange: (key: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<span className="s-seg">
|
||||
{options.map((o) => (
|
||||
<button
|
||||
key={o.key}
|
||||
className={o.key === value ? 'on' : ''}
|
||||
onClick={() => onChange(o.key)}
|
||||
title={o.title}
|
||||
>
|
||||
{o.icon}
|
||||
</button>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- stat card ---------- */
|
||||
|
||||
export function SStat({ icon, label, value, unit, foot, delta, extra, onMenu }: {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
unit?: string;
|
||||
foot: ReactNode;
|
||||
delta?: { dir: 'up' | 'down'; text: string };
|
||||
extra?: ReactNode;
|
||||
onMenu?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="s-stat">
|
||||
<div className="flex items-center gap-2.5 px-4 pt-4">
|
||||
<Ibox>{icon}</Ibox>
|
||||
<span className="text-[11.5px] font-medium text-[var(--text-secondary)] whitespace-nowrap">{label}</span>
|
||||
<button
|
||||
className="ml-auto text-[var(--text-tertiary)] hover:text-[var(--text-primary)] leading-none"
|
||||
onClick={onMenu}
|
||||
title="More"
|
||||
>
|
||||
<MoreHorizontal size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1.5 px-4 mt-4">
|
||||
<span className="font-headline text-[34px] font-bold leading-none tracking-[-0.025em] text-[var(--text-primary)]">
|
||||
{value}
|
||||
</span>
|
||||
{unit && <span className="text-[12px] font-medium text-[var(--text-tertiary)]">{unit}</span>}
|
||||
</div>
|
||||
{extra && <div className="px-4 mt-3">{extra}</div>}
|
||||
<div className="s-stat-foot mt-3.5">
|
||||
<span>{foot}</span>
|
||||
{delta && <SDelta dir={delta.dir}>{delta.text}</SDelta>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SDelta({ dir, children }: { dir: 'up' | 'down'; children: ReactNode }) {
|
||||
return (
|
||||
<span className={`s-delta ${dir}`}>
|
||||
{dir === 'up' ? '▲' : '▼'}{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- card ---------- */
|
||||
|
||||
export function SCard({ icon, title, accent, trail, children, pad = true, className = '' }: {
|
||||
icon?: ReactNode;
|
||||
title?: ReactNode;
|
||||
accent?: ReactNode;
|
||||
trail?: ReactNode;
|
||||
children: ReactNode;
|
||||
pad?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={`s-card ${className}`}>
|
||||
{(title !== undefined || icon) && (
|
||||
<div className="s-cardhead">
|
||||
{icon && <Ibox>{icon}</Ibox>}
|
||||
<span className="s-t">{title}{accent && <> <span className="s-ta">{accent}</span></>}</span>
|
||||
{trail && <span className="s-trail">{trail}</span>}
|
||||
</div>
|
||||
)}
|
||||
<div className={pad ? 'px-4 pb-4' : ''}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- ticks / bars ---------- */
|
||||
|
||||
export function Ticks({ pct, count = 56, warn = false }: { pct: number; count?: number; warn?: boolean }) {
|
||||
const on = Math.round((Math.max(0, Math.min(100, pct)) / 100) * count);
|
||||
return (
|
||||
<span className="s-ticks" role="img" aria-label={`${pct}%`}>
|
||||
{Array.from({ length: count }, (_, i) => (
|
||||
<i key={i} className={i < on ? (warn ? 'warn' : 'on') : ''} />
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* Ascending mini bar glyph used inside table metric cells. */
|
||||
export function MiniBars({ tone = 'var(--accent-primary)' }: { tone?: string }) {
|
||||
return (
|
||||
<span className="inline-flex items-end gap-[1.5px] h-3" aria-hidden>
|
||||
{[9, 5, 12, 7].map((h, i) => (
|
||||
<i key={i} className="w-[2.5px] rounded-[0.5px]" style={{ height: h, background: tone }} />
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- status pill ---------- */
|
||||
|
||||
const PILL_CLS: Record<PillTone, string> = {
|
||||
ok: 'text-[var(--success)] bg-[var(--success-soft)] border-[color-mix(in_srgb,var(--success)_16%,transparent)]',
|
||||
warn: 'text-[var(--warning)] bg-[var(--warning-soft)] border-[color-mix(in_srgb,var(--warning)_16%,transparent)]',
|
||||
err: 'text-[var(--error)] bg-[var(--error-soft)] border-[color-mix(in_srgb,var(--error)_16%,transparent)]',
|
||||
info: 'text-[var(--info)] bg-[var(--info-soft)] border-[color-mix(in_srgb,var(--info)_16%,transparent)]',
|
||||
off: 'text-[var(--text-tertiary)] bg-[var(--tint-06)] border-[var(--border-subtle)]',
|
||||
};
|
||||
|
||||
export function SPill({ tone, children }: { tone: PillTone; children: ReactNode }) {
|
||||
return (
|
||||
<span className={`inline-block text-[10.5px] font-medium rounded-[5px] px-2 py-[3.5px] border ${PILL_CLS[tone]}`}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- inset callout ---------- */
|
||||
|
||||
export function SAnomaly({ icon, title, items }: { icon?: ReactNode; title: ReactNode; items: ReactNode[] }) {
|
||||
return (
|
||||
<div className="s-inset">
|
||||
<div className="flex items-center gap-2 pb-2 mb-2 border-b border-[var(--border-subtle)] text-[11.5px] text-[var(--text-secondary)]">
|
||||
{icon}
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
{items.map((it, i) => (
|
||||
<div key={i} className="s-anomaly-item">{it}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- data table (inset header bar, sortable) ---------- */
|
||||
|
||||
export type SCol<T> = {
|
||||
key: string;
|
||||
label: ReactNode;
|
||||
width?: string;
|
||||
sortable?: boolean;
|
||||
sortValue?: (row: T) => string | number;
|
||||
render: (row: T) => ReactNode;
|
||||
};
|
||||
|
||||
const CARET = (
|
||||
<svg viewBox="0 0 7 10" width="7" height="10" className="shrink-0">
|
||||
<path d="M3.5 .8 5.8 3.6H1.2zM3.5 9.2 1.2 6.4h4.6z" fill="currentColor" opacity="0.45" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export function STable<T extends { id?: string | number }>({ cols, rows, rowKey, selectable, onRowClick }: {
|
||||
cols: SCol<T>[];
|
||||
rows: T[];
|
||||
rowKey?: (row: T, i: number) => string | number;
|
||||
selectable?: boolean;
|
||||
onRowClick?: (row: T) => void;
|
||||
}) {
|
||||
const [sortKey, setSortKey] = useState<string | null>(null);
|
||||
const [sortDir, setSortDir] = useState<1 | -1>(1);
|
||||
const [checked, setChecked] = useState<Set<number>>(new Set());
|
||||
|
||||
const allCols: SCol<T>[] = selectable
|
||||
? [{ key: '$sel', label: '', width: '30px', render: () => null }, ...cols]
|
||||
: cols;
|
||||
const template = allCols.map((c) => c.width ?? '1fr').join(' ');
|
||||
|
||||
const sorted = [...rows];
|
||||
const sc = cols.find((c) => c.key === sortKey);
|
||||
if (sc?.sortValue) {
|
||||
sorted.sort((a, b) => {
|
||||
const x = sc.sortValue!(a); const y = sc.sortValue!(b);
|
||||
return (x > y ? 1 : x < y ? -1 : 0) * sortDir;
|
||||
});
|
||||
}
|
||||
|
||||
const toggleAll = () =>
|
||||
setChecked(checked.size === rows.length ? new Set() : new Set(rows.map((_, i) => i)));
|
||||
|
||||
return (
|
||||
<div className="s-dt pb-2.5">
|
||||
<div className="s-dt-head" style={{ gridTemplateColumns: template }}>
|
||||
{allCols.map((c) =>
|
||||
c.key === '$sel' ? (
|
||||
<span key="$sel" className="s-dt-h">
|
||||
<span className={`s-ck ${checked.size === rows.length && rows.length > 0 ? 'on' : ''}`} onClick={toggleAll} />
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
key={c.key}
|
||||
className={`s-dt-h ${c.sortable !== false ? 'sortable' : ''} ${sortKey === c.key ? 'on' : ''}`}
|
||||
onClick={() => {
|
||||
if (c.sortable === false) return;
|
||||
setSortDir(sortKey === c.key ? (sortDir === 1 ? -1 : 1) : 1);
|
||||
setSortKey(c.key);
|
||||
}}
|
||||
>
|
||||
{c.label}{c.sortable !== false && CARET}
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
{sorted.map((row, i) => {
|
||||
const key = rowKey ? rowKey(row, i) : row.id ?? i;
|
||||
const ri = rows.indexOf(row);
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={`s-dt-row ${onRowClick ? 'clickable' : ''}`}
|
||||
style={{ gridTemplateColumns: template }}
|
||||
onClick={() => onRowClick?.(row)}
|
||||
>
|
||||
{allCols.map((c) =>
|
||||
c.key === '$sel' ? (
|
||||
<span key="$sel">
|
||||
<span
|
||||
className={`s-ck ${checked.has(ri) ? 'on' : ''}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setChecked((prev) => {
|
||||
const n = new Set(prev);
|
||||
if (n.has(ri)) n.delete(ri); else n.add(ri);
|
||||
return n;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
) : (
|
||||
<span key={c.key} className="min-w-0">{c.render(row)}</span>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- page header (Welcome back … + actions) ---------- */
|
||||
|
||||
export function SPageHead({ title, titleAccent, sub, subAccent, actions }: {
|
||||
title: ReactNode;
|
||||
titleAccent?: ReactNode;
|
||||
sub?: ReactNode;
|
||||
subAccent?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-4 mb-5 flex-wrap">
|
||||
<div>
|
||||
<h1 className="font-headline text-[24px] font-bold tracking-[-0.02em] text-[var(--text-primary)]">
|
||||
{title}{titleAccent && <> <em className="not-italic text-[var(--accent-primary)]">{titleAccent}</em></>}
|
||||
</h1>
|
||||
{sub && (
|
||||
<div className="text-[12px] text-[var(--text-tertiary)] mt-[3px]">
|
||||
{sub}{subAccent && <> <b className="text-[var(--accent-primary)] font-semibold">{subAccent}</b></>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{actions && <div className="ml-auto flex items-center gap-2.5">{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* Section label — small caps with accent square. */
|
||||
export function SSection({ children, right }: { children: ReactNode; right?: ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between mb-3 mt-1">
|
||||
<div className="flex items-center gap-2 text-[10.5px] font-medium uppercase tracking-[0.1em] text-[var(--text-tertiary)]">
|
||||
<span className="w-[5px] h-[5px] rounded-[1px] bg-[var(--accent-primary)]" />
|
||||
{children}
|
||||
</div>
|
||||
{right && <span className="v-mono text-[10.5px] text-[var(--text-muted)]">{right}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* Legend row for charts. */
|
||||
export function SLegend({ items }: { items: { color: string; label: string }[] }) {
|
||||
return (
|
||||
<div className="flex gap-5 px-4 pb-2.5 pt-0.5 text-[11.5px] font-medium text-[var(--text-secondary)]">
|
||||
{items.map((it) => (
|
||||
<span key={it.label}>
|
||||
<i className="inline-block w-[7px] h-[7px] rounded-[1.5px] mr-2" style={{ background: it.color }} />
|
||||
{it.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -59,12 +59,8 @@ export default defineConfig({
|
||||
},
|
||||
proxy: {
|
||||
// Same-origin API path for dev - matches the nginx proxy in the shipped image
|
||||
'/health': {
|
||||
target: process.env.VITE_API_PROXY ?? 'http://localhost:8082',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/api': {
|
||||
target: process.env.VITE_API_PROXY ?? 'http://localhost:8082',
|
||||
target: 'http://localhost:8082',
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user