mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
Merge pull request #7 from Dvorinka/feat/dragonfly-selfhosted-auth
Dragonfly cache, remote-install fix, self-hosted email-only auth
This commit is contained in:
48 files changed
+3684
-407
No files matched your search
+28
-6
@@ -1,4 +1,5 @@
|
|||||||
# Local development env file. Use .env.prod for production.
|
# Local development env file. install.sh writes ENVIRONMENT=production for
|
||||||
|
# self-hosted installs - keep development here only for the checkout workflow.
|
||||||
|
|
||||||
# Domain Configuration
|
# Domain Configuration
|
||||||
DOMAIN=localhost
|
DOMAIN=localhost
|
||||||
@@ -28,9 +29,12 @@ AUTO_MIGRATE=true
|
|||||||
MIGRATION_LOCK_TIMEOUT=2m
|
MIGRATION_LOCK_TIMEOUT=2m
|
||||||
SEED_DATA_ON_START=false
|
SEED_DATA_ON_START=false
|
||||||
|
|
||||||
# Redis Configuration
|
# Dragonfly Configuration (Redis-compatible cache - REDIS_* names kept for compatibility)
|
||||||
REDIS_PASSWORD=your_secure_redis_password
|
REDIS_PASSWORD=your_secure_redis_password
|
||||||
REDIS_URL=redis://:your_secure_redis_password@localhost:6379/0
|
REDIS_URL=redis://:your_secure_redis_password@localhost:6379/0
|
||||||
|
# DRAGONFLY_VERSION=latest
|
||||||
|
# DRAGONFLY_THREADS=1
|
||||||
|
# DRAGONFLY_MAXMEMORY=256mb
|
||||||
|
|
||||||
# Application Configuration
|
# Application Configuration
|
||||||
# In production this must be a strong value with at least 32 characters.
|
# In production this must be a strong value with at least 32 characters.
|
||||||
@@ -40,8 +44,11 @@ CONTAINR_AGENT_AUTH_TOKEN=replace_with_strong_agent_secret
|
|||||||
# Optional rotation list (comma-separated). When set, this takes precedence.
|
# Optional rotation list (comma-separated). When set, this takes precedence.
|
||||||
# CONTAINR_AGENT_AUTH_TOKENS=current_secret,next_secret
|
# CONTAINR_AGENT_AUTH_TOKENS=current_secret,next_secret
|
||||||
CORS_ORIGINS=http://localhost:3000,http://localhost:8080
|
CORS_ORIGINS=http://localhost:3000,http://localhost:8080
|
||||||
VITE_API_URL=http://localhost:8082
|
# Optional build-time API URL overrides. Leave empty for the default
|
||||||
VITE_AUTH_URL=http://localhost:8082/api/auth
|
# same-origin setup - the frontend nginx proxies /api to the backend, which
|
||||||
|
# works from any host or IP without rebuilding the image.
|
||||||
|
# VITE_API_URL=https://api.example.com
|
||||||
|
# VITE_AUTH_URL=https://api.example.com/api/auth
|
||||||
AUTH_PORT=3001
|
AUTH_PORT=3001
|
||||||
# In production this must be true.
|
# In production this must be true.
|
||||||
COOKIE_SECURE=false
|
COOKIE_SECURE=false
|
||||||
@@ -54,6 +61,8 @@ BETTER_AUTH_AUTO_MIGRATE=true
|
|||||||
BETTER_AUTH_INTERNAL_TOKEN=PLACEHOLDER_INTERNAL_AUTH_TOKEN
|
BETTER_AUTH_INTERNAL_TOKEN=PLACEHOLDER_INTERNAL_AUTH_TOKEN
|
||||||
BETTER_AUTH_INTERNAL_URL=http://127.0.0.1:3001/internal/session
|
BETTER_AUTH_INTERNAL_URL=http://127.0.0.1:3001/internal/session
|
||||||
BETTER_AUTH_PROXY_URL=http://127.0.0.1:3001
|
BETTER_AUTH_PROXY_URL=http://127.0.0.1:3001
|
||||||
|
# Extra trusted origins (comma-separated). Same-origin traffic is trusted
|
||||||
|
# automatically, so this only needs entries for cross-origin callers.
|
||||||
BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3000,http://localhost:8082
|
BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3000,http://localhost:8082
|
||||||
# Optional explicit auth DB settings (recommended when password contains URL special chars)
|
# Optional explicit auth DB settings (recommended when password contains URL special chars)
|
||||||
DB_HOST=postgres
|
DB_HOST=postgres
|
||||||
@@ -64,7 +73,9 @@ DB_PASSWORD=your_secure_postgres_password
|
|||||||
FRONTEND_URL=http://localhost:3000
|
FRONTEND_URL=http://localhost:3000
|
||||||
BACKEND_URL=http://localhost:8082
|
BACKEND_URL=http://localhost:8082
|
||||||
|
|
||||||
# OAuth (user auth)
|
# OAuth (user auth) - GitHub/Google sign-in is reserved for the managed
|
||||||
|
# offering. Self-hosted installs use email/password only; PLACEHOLDER_*
|
||||||
|
# values are treated as unset and the buttons stay hidden.
|
||||||
GITHUB_CLIENT_ID=PLACEHOLDER_GITHUB_OAUTH_CLIENT_ID
|
GITHUB_CLIENT_ID=PLACEHOLDER_GITHUB_OAUTH_CLIENT_ID
|
||||||
GITHUB_CLIENT_SECRET=PLACEHOLDER_GITHUB_OAUTH_CLIENT_SECRET
|
GITHUB_CLIENT_SECRET=PLACEHOLDER_GITHUB_OAUTH_CLIENT_SECRET
|
||||||
GITLAB_CLIENT_ID=PLACEHOLDER_GITLAB_CLIENT_ID
|
GITLAB_CLIENT_ID=PLACEHOLDER_GITLAB_CLIENT_ID
|
||||||
@@ -109,10 +120,21 @@ TRAEFIK_AUTH=admin:$$apr1$$b8mh8c8v$$KkR8hQZQZQZQZQZQZQZQZ/
|
|||||||
# Development convenience: true in local dev, false in production
|
# Development convenience: true in local dev, false in production
|
||||||
TRAEFIK_API_INSECURE=true
|
TRAEFIK_API_INSECURE=true
|
||||||
|
|
||||||
# Optional: Cloudflare Tunnel (alternative to domain)
|
# Optional: Cloudflare Tunnel (alternative to domain). Can also be set in-app
|
||||||
|
# under Settings > Platform (in-app value overrides this).
|
||||||
# Get token from: https://dash.cloudflare.com/argotunnel
|
# Get token from: https://dash.cloudflare.com/argotunnel
|
||||||
CLOUDFLARED_TOKEN=your_cloudflare_tunnel_token_here
|
CLOUDFLARED_TOKEN=your_cloudflare_tunnel_token_here
|
||||||
|
|
||||||
|
# Optional: reopen public registration after the owner account exists.
|
||||||
|
# Manageable in-app under Settings > Platform (in-app value overrides this).
|
||||||
|
# SIGNUP_ENABLED=false
|
||||||
|
|
||||||
|
# Docker socket gid AS SEEN INSIDE containers - lets the backend manage
|
||||||
|
# containers (deployments, cloudflared sidecar). install.sh detects it with a
|
||||||
|
# throwaway container. Modern Docker maps the socket to root: use 0.
|
||||||
|
# Older daemons expose the host docker gid: stat -c %g /var/run/docker.sock
|
||||||
|
DOCKER_GID=0
|
||||||
|
|
||||||
# Optional: Custom Docker Registry
|
# Optional: Custom Docker Registry
|
||||||
# DOCKER_REGISTRY=your-registry.com
|
# DOCKER_REGISTRY=your-registry.com
|
||||||
# DOCKER_USERNAME=your_username
|
# DOCKER_USERNAME=your_username
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ jobs:
|
|||||||
BETTER_AUTH_SECRET=$(openssl rand -hex 32)
|
BETTER_AUTH_SECRET=$(openssl rand -hex 32)
|
||||||
BETTER_AUTH_INTERNAL_TOKEN=$(openssl rand -hex 32)
|
BETTER_AUTH_INTERNAL_TOKEN=$(openssl rand -hex 32)
|
||||||
export BETTER_AUTH_SECRET BETTER_AUTH_INTERNAL_TOKEN
|
export BETTER_AUTH_SECRET BETTER_AUTH_INTERNAL_TOKEN
|
||||||
docker compose -f infra/docker-compose.yml up -d --build postgres redis backend frontend traefik
|
docker compose -f infra/docker-compose.yml up -d --build postgres dragonfly backend frontend traefik
|
||||||
|
|
||||||
- name: Wait for backend health
|
- name: Wait for backend health
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -121,3 +121,5 @@ bin
|
|||||||
|
|
||||||
# Local reference checkouts (not project code)
|
# Local reference checkouts (not project code)
|
||||||
ref/
|
ref/
|
||||||
|
.playwright-mcp/
|
||||||
|
canvas-*.png
|
||||||
@@ -40,13 +40,15 @@ click away.
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Project canvas** — visual service topology, groups, drag/drop, auto-inferred connections
|
- **Project canvas** — visual service topology, groups, drag/drop, auto-inferred connections, right-click lifecycle actions
|
||||||
- **Deployments & builds** — Docker deploys with history, logs, rollback, live build status
|
- **Private networking** — every project gets an isolated Docker network; services reach each other by name (`web:3000`) and share variables via `${{service.KEY}}` references
|
||||||
|
- **Deployments & builds** — Docker deploys with history, logs, image rollback, live build status
|
||||||
|
- **Runtime** — replicas, published ports, domains, health checks, restart policies; start/stop/restart/redeploy from canvas or service page
|
||||||
- **Git integration** — GitHub, GitLab, Bitbucket, Gitea; webhooks, GitHub App
|
- **Git integration** — GitHub, GitLab, Bitbucket, Gitea; webhooks, GitHub App
|
||||||
- **Managed databases** — one-click provisioning with backup/restore
|
- **Managed databases** — one-click provisioning with backup/restore
|
||||||
- **Metrics** — per-service Docker stats plus host CPU/memory/disk telemetry
|
- **Metrics** — per-service Docker stats plus host CPU/memory/disk telemetry
|
||||||
- **Templates, cron, previews** — service catalog, scheduled jobs, preview deploys
|
- **Templates, cron, previews** — service catalog, scheduled jobs, preview deploys
|
||||||
- **Auth & security** — Better Auth sessions (email + OAuth), audit logs, vuln scans
|
- **Auth & security** — Better Auth sessions (email/password self-hosted), audit logs, vuln scans
|
||||||
- **Networking** — Traefik reverse proxy, optional Cloudflare Tunnel
|
- **Networking** — Traefik reverse proxy, optional Cloudflare Tunnel
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
@@ -60,6 +62,9 @@ curl -fsSL https://raw.githubusercontent.com/Dvorinka/Containr/main/install.sh |
|
|||||||
Installs into `./containr`, generates secrets, pulls the published `:latest`
|
Installs into `./containr`, generates secrets, pulls the published `:latest`
|
||||||
images, and starts the stack — UI at `http://localhost:3000`, API at
|
images, and starts the stack — UI at `http://localhost:3000`, API at
|
||||||
`http://localhost:8082`. Non-interactive; re-runs never overwrite `.env`.
|
`http://localhost:8082`. Non-interactive; re-runs never overwrite `.env`.
|
||||||
|
On first visit you create the owner account (email/password); registration
|
||||||
|
then closes automatically. The owner can reopen it and configure a Cloudflare
|
||||||
|
Tunnel token under **Settings → Platform** — in-app values override env vars.
|
||||||
|
|
||||||
Override with env vars:
|
Override with env vars:
|
||||||
|
|
||||||
@@ -80,7 +85,7 @@ see [docs/guides/](docs/guides/) and `infra/docker-compose.yml`.
|
|||||||
|
|
||||||
```
|
```
|
||||||
app/frontend/ React 19 + Vite + TypeScript + Tailwind + React Flow
|
app/frontend/ React 19 + Vite + TypeScript + Tailwind + React Flow
|
||||||
app/backend/ Go API (Gin) · PostgreSQL · Redis · Docker SDK · Better Auth sidecar
|
app/backend/ Go API (Gin) · PostgreSQL · Dragonfly · Docker SDK · Better Auth sidecar
|
||||||
infra/ Self-hosted compose: Traefik + Cloudflare Tunnel
|
infra/ Self-hosted compose: Traefik + Cloudflare Tunnel
|
||||||
docs/ Guides, OpenAPI spec, design documents
|
docs/ Guides, OpenAPI spec, design documents
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
### Prerequisites
|
### Prerequisites
|
||||||
- Go 1.21+
|
- Go 1.21+
|
||||||
- PostgreSQL 12+
|
- PostgreSQL 12+
|
||||||
- Redis (optional)
|
- Dragonfly (Redis-compatible cache)
|
||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
||||||
|
|||||||
@@ -46,10 +46,15 @@ const database = new PostgresDialect({
|
|||||||
pool,
|
pool,
|
||||||
});
|
});
|
||||||
|
|
||||||
const githubClientId = env.GITHUB_CLIENT_ID?.trim() || '';
|
const realCredential = (value) => {
|
||||||
const githubClientSecret = env.GITHUB_CLIENT_SECRET?.trim() || '';
|
const trimmed = (value || '').trim();
|
||||||
const googleClientId = env.GOOGLE_CLIENT_ID?.trim() || '';
|
return trimmed && !trimmed.startsWith('PLACEHOLDER_') ? trimmed : '';
|
||||||
const googleClientSecret = env.GOOGLE_CLIENT_SECRET?.trim() || '';
|
};
|
||||||
|
|
||||||
|
const githubClientId = realCredential(env.GITHUB_CLIENT_ID);
|
||||||
|
const githubClientSecret = realCredential(env.GITHUB_CLIENT_SECRET);
|
||||||
|
const googleClientId = realCredential(env.GOOGLE_CLIENT_ID);
|
||||||
|
const googleClientSecret = realCredential(env.GOOGLE_CLIENT_SECRET);
|
||||||
|
|
||||||
const socialProviders = {};
|
const socialProviders = {};
|
||||||
if (githubClientId && githubClientSecret) {
|
if (githubClientId && githubClientSecret) {
|
||||||
@@ -67,7 +72,7 @@ if (googleClientId && googleClientSecret) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const trustedOrigins = [
|
const staticTrustedOrigins = [
|
||||||
...splitCsv(env.BETTER_AUTH_TRUSTED_ORIGINS),
|
...splitCsv(env.BETTER_AUTH_TRUSTED_ORIGINS),
|
||||||
env.FRONTEND_URL,
|
env.FRONTEND_URL,
|
||||||
env.BACKEND_URL,
|
env.BACKEND_URL,
|
||||||
@@ -86,12 +91,33 @@ const trustedOrigins = [
|
|||||||
.map((origin) => (origin || '').trim())
|
.map((origin) => (origin || '').trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
|
// Self-hosted installs are reached over arbitrary hosts/IPs, so the static
|
||||||
|
// list above cannot cover them. Trust an Origin only when it matches the
|
||||||
|
// request's own Host header - that is genuine same-origin traffic (browsers
|
||||||
|
// cannot forge either header cross-origin). Do NOT consult x-forwarded-host
|
||||||
|
// here: clients can set it freely.
|
||||||
|
const resolveTrustedOrigins = (request) => {
|
||||||
|
const origins = new Set(staticTrustedOrigins);
|
||||||
|
const host = request?.headers?.get('host');
|
||||||
|
const origin = request?.headers?.get('origin');
|
||||||
|
if (host && origin) {
|
||||||
|
try {
|
||||||
|
if (new URL(origin).host === host) {
|
||||||
|
origins.add(origin);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// malformed Origin header - leave untrusted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...origins];
|
||||||
|
};
|
||||||
|
|
||||||
export const auth = betterAuth({
|
export const auth = betterAuth({
|
||||||
appName: env.BETTER_AUTH_APP_NAME || 'Containr',
|
appName: env.BETTER_AUTH_APP_NAME || 'Containr',
|
||||||
baseURL: env.BETTER_AUTH_URL || defaultBackendURL,
|
baseURL: env.BETTER_AUTH_URL || defaultBackendURL,
|
||||||
basePath: '/api/auth',
|
basePath: '/api/auth',
|
||||||
secret: env.BETTER_AUTH_SECRET || 'PLACEHOLDER_BETTER_AUTH_SECRET_CHANGE_ME_32CHARS_MIN',
|
secret: env.BETTER_AUTH_SECRET || 'PLACEHOLDER_BETTER_AUTH_SECRET_CHANGE_ME_32CHARS_MIN',
|
||||||
trustedOrigins,
|
trustedOrigins: resolveTrustedOrigins,
|
||||||
database,
|
database,
|
||||||
user: {
|
user: {
|
||||||
modelName: 'auth_users',
|
modelName: 'auth_users',
|
||||||
|
|||||||
@@ -89,6 +89,9 @@ func main() {
|
|||||||
// Setup Gin router
|
// Setup Gin router
|
||||||
if cfg.IsProduction() {
|
if cfg.IsProduction() {
|
||||||
gin.SetMode(gin.ReleaseMode)
|
gin.SetMode(gin.ReleaseMode)
|
||||||
|
if !cfg.CookieSecure {
|
||||||
|
log.Println("Warning: COOKIE_SECURE=false in production - session cookies are sent over plain HTTP. Enable TLS and set COOKIE_SECURE=true.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
router := gin.New()
|
router := gin.New()
|
||||||
|
|||||||
@@ -14,15 +14,15 @@ services:
|
|||||||
retries: 10
|
retries: 10
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
redis:
|
dragonfly:
|
||||||
image: redis:7-alpine
|
image: docker.dragonflydb.io/dragonflydb/dragonfly:latest
|
||||||
env_file:
|
env_file:
|
||||||
- ../../.env.prod
|
- ../../.env.prod
|
||||||
command: ["sh", "-c", "redis-server --requirepass \"$$REDIS_PASSWORD\""]
|
command: ["sh", "-c", "exec dragonfly --requirepass \"$${REDIS_PASSWORD}\" --proactor_threads=1 --maxmemory=256mb --dir=/data"]
|
||||||
ports:
|
ports:
|
||||||
- "6379:6379"
|
- "6379:6379"
|
||||||
volumes:
|
volumes:
|
||||||
- backend_redis_data:/data
|
- backend_dragonfly_data:/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "redis-cli -a \"$${REDIS_PASSWORD}\" ping | grep PONG"]
|
test: ["CMD-SHELL", "redis-cli -a \"$${REDIS_PASSWORD}\" ping | grep PONG"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -38,10 +38,14 @@ services:
|
|||||||
- ../../.env.prod
|
- ../../.env.prod
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
group_add:
|
||||||
|
- "${DOCKER_GID:-0}"
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
dragonfly:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -52,4 +56,4 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
backend_postgres_data:
|
backend_postgres_data:
|
||||||
backend_redis_data:
|
backend_dragonfly_data:
|
||||||
@@ -46,6 +46,7 @@ type User struct {
|
|||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
AvatarURL string `json:"avatar_url,omitempty"`
|
AvatarURL string `json:"avatar_url,omitempty"`
|
||||||
|
IsAdmin bool `json:"is_admin"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,10 +64,10 @@ func handleLogin(c *gin.Context) {
|
|||||||
var user User
|
var user User
|
||||||
var hashedPassword string
|
var hashedPassword string
|
||||||
err := db.QueryRow(`
|
err := db.QueryRow(`
|
||||||
SELECT id, email, password_hash, name, COALESCE(avatar_url, ''), created_at
|
SELECT id, email, password_hash, name, COALESCE(avatar_url, ''), is_admin, created_at
|
||||||
FROM users
|
FROM users
|
||||||
WHERE email = $1
|
WHERE email = $1
|
||||||
`, req.Email).Scan(&user.ID, &user.Email, &hashedPassword, &user.Name, &user.AvatarURL, &user.CreatedAt)
|
`, req.Email).Scan(&user.ID, &user.Email, &hashedPassword, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt)
|
||||||
|
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
authUser, ok := verifyBetterAuthCredentials(c, req.Email, req.Password)
|
authUser, ok := verifyBetterAuthCredentials(c, req.Email, req.Password)
|
||||||
@@ -120,13 +121,32 @@ func handleAuthBootstrap(c *gin.Context) {
|
|||||||
mode = "register"
|
mode = "register"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
providers := []string{}
|
||||||
|
for _, provider := range []string{"GITHUB", "GOOGLE"} {
|
||||||
|
if oauthProviderConfigured(provider) {
|
||||||
|
providers = append(providers, strings.ToLower(provider))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"has_users": count > 0,
|
"has_users": count > 0,
|
||||||
"user_count": count,
|
"user_count": count,
|
||||||
"mode": mode,
|
"mode": mode,
|
||||||
|
"providers": providers,
|
||||||
|
"signup_enabled": signupEnabled(db),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// oauthProviderConfigured reports whether an OAuth provider has real
|
||||||
|
// credentials. Placeholder values from .env.example do not count, so
|
||||||
|
// self-hosted installs default to email/password only.
|
||||||
|
func oauthProviderConfigured(provider string) bool {
|
||||||
|
id := strings.TrimSpace(os.Getenv(provider + "_CLIENT_ID"))
|
||||||
|
secret := strings.TrimSpace(os.Getenv(provider + "_CLIENT_SECRET"))
|
||||||
|
return id != "" && secret != "" &&
|
||||||
|
!strings.HasPrefix(id, "PLACEHOLDER_") && !strings.HasPrefix(secret, "PLACEHOLDER_")
|
||||||
|
}
|
||||||
|
|
||||||
type betterAuthLoginUser struct {
|
type betterAuthLoginUser struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
@@ -236,17 +256,23 @@ func createLocalUserFromBetterAuth(db *database.DB, authUser betterAuthLoginUser
|
|||||||
return User{}, err
|
return User{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A mirrored first account owns the platform, same as handleRegister.
|
||||||
|
var total int
|
||||||
|
if err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&total); err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
|
||||||
var user User
|
var user User
|
||||||
err = db.QueryRow(`
|
err = db.QueryRow(`
|
||||||
INSERT INTO users (email, password_hash, name, avatar_url)
|
INSERT INTO users (email, password_hash, name, avatar_url, is_admin)
|
||||||
VALUES ($1, $2, $3, NULLIF($4, ''))
|
VALUES ($1, $2, $3, NULLIF($4, ''), $5)
|
||||||
ON CONFLICT (email) DO UPDATE
|
ON CONFLICT (email) DO UPDATE
|
||||||
SET name = EXCLUDED.name,
|
SET name = EXCLUDED.name,
|
||||||
avatar_url = COALESCE(EXCLUDED.avatar_url, users.avatar_url),
|
avatar_url = COALESCE(EXCLUDED.avatar_url, users.avatar_url),
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
RETURNING id, email, name, COALESCE(avatar_url, ''), created_at
|
RETURNING id, email, name, COALESCE(avatar_url, ''), is_admin, created_at
|
||||||
`, email, string(hashedPassword), name, strings.TrimSpace(authUser.Image)).
|
`, email, string(hashedPassword), name, strings.TrimSpace(authUser.Image), total == 0).
|
||||||
Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.CreatedAt)
|
Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return User{}, err
|
return User{}, err
|
||||||
}
|
}
|
||||||
@@ -264,24 +290,26 @@ func handleRegister(c *gin.Context) {
|
|||||||
db := c.MustGet("db").(*database.DB)
|
db := c.MustGet("db").(*database.DB)
|
||||||
jwtSecret := c.MustGet("jwt_secret").(string)
|
jwtSecret := c.MustGet("jwt_secret").(string)
|
||||||
|
|
||||||
count, err := countLocalUsers(db)
|
total, err := countLocalUsers(db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if count > 0 {
|
// Closed once the first account exists unless the owner reopens registration.
|
||||||
|
if total > 0 && !signupEnabled(db) {
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Public registration is disabled after bootstrap"})
|
c.JSON(http.StatusForbidden, gin.H{"error": "Public registration is disabled after bootstrap"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = db.QueryRow("SELECT COUNT(*) FROM users WHERE email = $1", strings.ToLower(strings.TrimSpace(req.Email))).Scan(&count)
|
var existing int
|
||||||
|
err = db.QueryRow("SELECT COUNT(*) FROM users WHERE email = $1", strings.ToLower(strings.TrimSpace(req.Email))).Scan(&existing)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if count > 0 {
|
if existing > 0 {
|
||||||
c.JSON(http.StatusConflict, gin.H{"error": "User already exists"})
|
c.JSON(http.StatusConflict, gin.H{"error": "User already exists"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -293,13 +321,13 @@ func handleRegister(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create user
|
// First account owns the platform.
|
||||||
var user User
|
var user User
|
||||||
err = db.QueryRow(`
|
err = db.QueryRow(`
|
||||||
INSERT INTO users (email, password_hash, name)
|
INSERT INTO users (email, password_hash, name, is_admin)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3, $4)
|
||||||
RETURNING id, email, name, COALESCE(avatar_url, ''), created_at
|
RETURNING id, email, name, COALESCE(avatar_url, ''), is_admin, created_at
|
||||||
`, req.Email, string(hashedPassword), req.Name).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.CreatedAt)
|
`, req.Email, string(hashedPassword), req.Name, total == 0).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
|
||||||
@@ -320,13 +348,16 @@ func handleRegister(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func handleCreateUser(c *gin.Context) {
|
func handleCreateUser(c *gin.Context) {
|
||||||
|
db, ok := requireAdmin(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var req ManualUserCreateRequest
|
var req ManualUserCreateRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
db := c.MustGet("db").(*database.DB)
|
|
||||||
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
||||||
req.Name = strings.TrimSpace(req.Name)
|
req.Name = strings.TrimSpace(req.Name)
|
||||||
if req.Email == "" || req.Name == "" {
|
if req.Email == "" || req.Name == "" {
|
||||||
@@ -363,8 +394,8 @@ func handleCreateUser(c *gin.Context) {
|
|||||||
err = db.QueryRow(`
|
err = db.QueryRow(`
|
||||||
INSERT INTO users (email, password_hash, name)
|
INSERT INTO users (email, password_hash, name)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3)
|
||||||
RETURNING id, email, name, COALESCE(avatar_url, ''), created_at
|
RETURNING id, email, name, COALESCE(avatar_url, ''), is_admin, created_at
|
||||||
`, req.Email, string(hashedPassword), req.Name).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.CreatedAt)
|
`, req.Email, string(hashedPassword), req.Name).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusConflict, gin.H{"error": "User already exists"})
|
c.JSON(http.StatusConflict, gin.H{"error": "User already exists"})
|
||||||
return
|
return
|
||||||
@@ -459,10 +490,10 @@ func handleGetProfile(c *gin.Context) {
|
|||||||
|
|
||||||
var user User
|
var user User
|
||||||
err := db.QueryRow(`
|
err := db.QueryRow(`
|
||||||
SELECT id, email, name, COALESCE(avatar_url, ''), created_at
|
SELECT id, email, name, COALESCE(avatar_url, ''), is_admin, created_at
|
||||||
FROM users
|
FROM users
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`, userID).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.CreatedAt)
|
`, userID).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt)
|
||||||
|
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
||||||
|
|||||||
@@ -67,7 +67,9 @@ func shouldBlockPublicBetterAuthSignup(c *gin.Context, cfg *config.Config, db *d
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
return count > 0
|
// Registration is closed once the first account exists unless the owner
|
||||||
|
// reopens it in Settings (signup_enabled).
|
||||||
|
return count > 0 && !signupEnabled(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseAuthProxyTarget(rawTarget string) (*url.URL, error) {
|
func parseAuthProxyTarget(rawTarget string) (*url.URL, error) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -120,6 +121,72 @@ func handleGetDeployments(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"deployments": deployments})
|
c.JSON(http.StatusOK, gin.H{"deployments": deployments})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RecentDeployment is a deployment row joined with its service and project names.
|
||||||
|
type RecentDeployment struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ServiceID uuid.UUID `json:"service_id"`
|
||||||
|
ServiceName string `json:"service_name"`
|
||||||
|
ProjectName string `json:"project_name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ImageName string `json:"image_name"`
|
||||||
|
StartedAt *time.Time `json:"started_at"`
|
||||||
|
CompletedAt *time.Time `json:"completed_at"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetRecentDeployments lists the latest deployments across all of the
|
||||||
|
// caller's services — feeds the dashboard deploy feed.
|
||||||
|
func handleGetRecentDeployments(c *gin.Context) {
|
||||||
|
db, exists := c.Get("db")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, exists := c.Get("user_id")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
limit := 10
|
||||||
|
if v := c.Query("limit"); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 50 {
|
||||||
|
limit = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := db.(*database.DB).Query(
|
||||||
|
`SELECT d.id, d.service_id, s.name, p.name, d.status,
|
||||||
|
COALESCE(d.image_name, ''), d.started_at, d.completed_at, d.created_at
|
||||||
|
FROM deployments d
|
||||||
|
JOIN services s ON s.id = d.service_id
|
||||||
|
JOIN projects p ON p.id = s.project_id
|
||||||
|
WHERE p.owner_id = $1
|
||||||
|
ORDER BY d.created_at DESC
|
||||||
|
LIMIT $2`,
|
||||||
|
userID.(string), limit,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve deployments"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
deployments := []RecentDeployment{}
|
||||||
|
for rows.Next() {
|
||||||
|
var d RecentDeployment
|
||||||
|
if err := rows.Scan(&d.ID, &d.ServiceID, &d.ServiceName, &d.ProjectName,
|
||||||
|
&d.Status, &d.ImageName, &d.StartedAt, &d.CompletedAt, &d.CreatedAt); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan deployment"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deployments = append(deployments, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"deployments": deployments})
|
||||||
|
}
|
||||||
|
|
||||||
func handleCreateDeployment(c *gin.Context) {
|
func handleCreateDeployment(c *gin.Context) {
|
||||||
db, exists := c.Get("db")
|
db, exists := c.Get("db")
|
||||||
if !exists {
|
if !exists {
|
||||||
@@ -155,6 +222,9 @@ func handleCreateDeployment(c *gin.Context) {
|
|||||||
err = db.(*database.DB).QueryRow(
|
err = db.(*database.DB).QueryRow(
|
||||||
`SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command,
|
`SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command,
|
||||||
s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory,
|
s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory,
|
||||||
|
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
||||||
|
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
||||||
|
COALESCE(s.restart_policy, 'unless-stopped'),
|
||||||
s.created_at, s.updated_at, p.owner_id
|
s.created_at, s.updated_at, p.owner_id
|
||||||
FROM services s
|
FROM services s
|
||||||
JOIN projects p ON s.project_id = p.id
|
JOIN projects p ON s.project_id = p.id
|
||||||
@@ -164,7 +234,8 @@ func handleCreateDeployment(c *gin.Context) {
|
|||||||
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
|
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
|
||||||
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
||||||
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
||||||
&service.CreatedAt, &service.UpdatedAt, &projectOwner,
|
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
||||||
|
&service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt, &projectOwner,
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -256,6 +327,22 @@ func runDeploymentAndSync(
|
|||||||
service Service,
|
service Service,
|
||||||
req CreateDeploymentRequest,
|
req CreateDeploymentRequest,
|
||||||
userID string,
|
userID string,
|
||||||
|
) {
|
||||||
|
runDeploymentAndSyncWithImage(parentCtx, db, engine, dbDeployment, service, req, userID, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// runDeploymentAndSyncWithImage deploys imageOverride as a prebuilt image
|
||||||
|
// (rollback / redeploy of an existing tag) when set; otherwise builds from
|
||||||
|
// source or pulls the service image.
|
||||||
|
func runDeploymentAndSyncWithImage(
|
||||||
|
parentCtx context.Context,
|
||||||
|
db *database.DB,
|
||||||
|
engine *deployment.DeploymentEngine,
|
||||||
|
dbDeployment *DeploymentModel,
|
||||||
|
service Service,
|
||||||
|
req CreateDeploymentRequest,
|
||||||
|
userID string,
|
||||||
|
imageOverride string,
|
||||||
) {
|
) {
|
||||||
ctx, cancel := context.WithTimeout(parentCtx, 30*time.Minute)
|
ctx, cancel := context.WithTimeout(parentCtx, 30*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -265,21 +352,44 @@ func runDeploymentAndSync(
|
|||||||
sourcePath = "."
|
sourcePath = "."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve the runtime env: stored service variables (with ${{svc.KEY}}
|
||||||
|
// references expanded) plus any per-deployment overrides.
|
||||||
|
env, envErr := resolveServiceEnv(db, service)
|
||||||
|
if envErr != nil {
|
||||||
|
env = map[string]string{}
|
||||||
|
}
|
||||||
|
for k, v := range req.EnvVars {
|
||||||
|
env[k] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
var command []string
|
||||||
|
if cmd := strings.TrimSpace(service.Command); cmd != "" {
|
||||||
|
command = strings.Fields(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
replicas := service.Replicas
|
||||||
|
if replicas < 1 {
|
||||||
|
replicas = 1
|
||||||
|
}
|
||||||
|
|
||||||
deployReq := &deployment.DeploymentRequest{
|
deployReq := &deployment.DeploymentRequest{
|
||||||
ProjectID: service.ProjectID.String(),
|
ProjectID: service.ProjectID.String(),
|
||||||
ServiceID: service.ID.String(),
|
ServiceID: service.ID.String(),
|
||||||
Environment: service.Environment,
|
Environment: service.Environment,
|
||||||
Config: deployment.ServiceConfig{
|
Config: deployment.ServiceConfig{
|
||||||
Name: service.Name,
|
Name: service.Name,
|
||||||
Image: service.Image,
|
Image: service.Image,
|
||||||
Environment: req.EnvVars,
|
Command: command,
|
||||||
Replicas: 1,
|
Environment: env,
|
||||||
},
|
Replicas: replicas,
|
||||||
BuildConfig: &deployment.BuildConfig{
|
PublicPort: int32(service.Port),
|
||||||
BuildType: "nixpacks",
|
Domain: service.Domain,
|
||||||
SourcePath: sourcePath,
|
HealthPath: service.HealthCheckPath,
|
||||||
Branch: req.Branch,
|
RestartPolicy: service.RestartPolicy,
|
||||||
Commit: req.CommitHash,
|
Resources: deployment.ResourceLimits{
|
||||||
|
MemoryBytes: parseMemoryLimit(service.Memory),
|
||||||
|
CPUQuota: parseCPULimit(service.CPU),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Trigger: deployment.TriggerConfig{
|
Trigger: deployment.TriggerConfig{
|
||||||
Type: req.Trigger,
|
Type: req.Trigger,
|
||||||
@@ -289,6 +399,26 @@ func runDeploymentAndSync(
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if imageOverride != "" {
|
||||||
|
deployReq.BuildConfig = &deployment.BuildConfig{
|
||||||
|
BuildType: "prebuilt",
|
||||||
|
PrebuiltImage: imageOverride,
|
||||||
|
}
|
||||||
|
} else if service.GitRepo == "" {
|
||||||
|
// Image-sourced service: pull the image instead of building.
|
||||||
|
deployReq.BuildConfig = &deployment.BuildConfig{
|
||||||
|
BuildType: "prebuilt",
|
||||||
|
PrebuiltImage: service.Image,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
deployReq.BuildConfig = &deployment.BuildConfig{
|
||||||
|
BuildType: "nixpacks",
|
||||||
|
SourcePath: sourcePath,
|
||||||
|
Branch: req.Branch,
|
||||||
|
Commit: req.CommitHash,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
engineDeployment, err := engine.Deploy(ctx, deployReq)
|
engineDeployment, err := engine.Deploy(ctx, deployReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
failedAt := time.Now()
|
failedAt := time.Now()
|
||||||
@@ -550,17 +680,58 @@ func handleRollbackDeployment(c *gin.Context) {
|
|||||||
time.Now(), serviceID,
|
time.Now(), serviceID,
|
||||||
)
|
)
|
||||||
|
|
||||||
go func() {
|
// Real rollback: redeploy the target deployment's image.
|
||||||
time.Sleep(2 * time.Second)
|
engineValue, _ := c.Get("deployment_engine")
|
||||||
db.(*database.DB).Exec(
|
engine, _ := engineValue.(*deployment.DeploymentEngine)
|
||||||
`UPDATE deployments SET status = 'deployed', completed_at = $1, updated_at = $1 WHERE id = $2`,
|
targetImage := targetDeployment.ImageName
|
||||||
time.Now(), rollbackID,
|
if targetDeployment.ImageTag != "" {
|
||||||
|
targetImage += ":" + targetDeployment.ImageTag
|
||||||
|
}
|
||||||
|
|
||||||
|
if engine == nil || targetImage == "" {
|
||||||
|
completedAt := time.Now()
|
||||||
|
reason := "Rollback image unavailable"
|
||||||
|
if engine == nil {
|
||||||
|
reason = "Deployment engine unavailable. Docker may not be configured on this server."
|
||||||
|
}
|
||||||
|
_, _ = db.(*database.DB).Exec(
|
||||||
|
`UPDATE deployments SET status = 'failed', error = $1, completed_at = $2, updated_at = $2 WHERE id = $3`,
|
||||||
|
reason, completedAt, rollbackID,
|
||||||
)
|
)
|
||||||
db.(*database.DB).Exec(
|
_, _ = db.(*database.DB).Exec(
|
||||||
`UPDATE services SET status = 'running', updated_at = $1 WHERE id = $2`,
|
`UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`,
|
||||||
time.Now(), serviceID,
|
completedAt, serviceID,
|
||||||
)
|
)
|
||||||
}()
|
} else {
|
||||||
|
var service Service
|
||||||
|
_ = db.(*database.DB).QueryRow(
|
||||||
|
`SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command,
|
||||||
|
s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory,
|
||||||
|
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
||||||
|
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
||||||
|
COALESCE(s.restart_policy, 'unless-stopped'),
|
||||||
|
s.created_at, s.updated_at
|
||||||
|
FROM services s WHERE s.id = $1`, serviceID,
|
||||||
|
).Scan(
|
||||||
|
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
|
||||||
|
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
||||||
|
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
||||||
|
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
||||||
|
&service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
rollbackReq := CreateDeploymentRequest{
|
||||||
|
CommitHash: func() string {
|
||||||
|
if targetDeployment.CommitHash != nil {
|
||||||
|
return *targetDeployment.CommitHash
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}(),
|
||||||
|
Trigger: "rollback",
|
||||||
|
}
|
||||||
|
// Reuse the stored image directly — no rebuild.
|
||||||
|
go runDeploymentAndSyncWithImage(context.Background(), db.(*database.DB), engine, &rollback, service, rollbackReq, userID.(string), targetImage)
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, gin.H{
|
c.JSON(http.StatusCreated, gin.H{
|
||||||
"deployment": DeploymentResponse{
|
"deployment": DeploymentResponse{
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/docker/docker/api/types/filters"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
@@ -73,7 +75,21 @@ func handleGetLogs(c *gin.Context) {
|
|||||||
respondEmptyServiceLogs(c, "Container log streaming is unavailable because Docker is not configured.")
|
respondEmptyServiceLogs(c, "Container log streaming is unavailable because Docker is not configured.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
containerName := fmt.Sprintf("containr-%s", serviceID)
|
// Replicas are named containr-<serviceID>-<n>; resolve them by label and
|
||||||
|
// merge logs across all of them.
|
||||||
|
ctx := context.Background()
|
||||||
|
containers, err := client.ListContainersFiltered(ctx, filters.NewArgs(
|
||||||
|
filters.Arg("label", "containr.service="+serviceID.String()),
|
||||||
|
), false)
|
||||||
|
if err != nil {
|
||||||
|
respondDependencyUnavailable(c, "docker", "Failed to fetch container logs. The container may be unavailable.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(containers) == 0 {
|
||||||
|
respondEmptyServiceLogs(c, "No container logs are available for this service yet.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sort.Slice(containers, func(i, j int) bool { return containers[i].Names[0] < containers[j].Names[0] })
|
||||||
|
|
||||||
logOpts := docker.LogOptions{
|
logOpts := docker.LogOptions{
|
||||||
Stdout: true,
|
Stdout: true,
|
||||||
@@ -83,17 +99,25 @@ func handleGetLogs(c *gin.Context) {
|
|||||||
Timestamps: true,
|
Timestamps: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := context.Background()
|
var readers []io.ReadCloser
|
||||||
logsReader, err := client.GetContainerLogs(ctx, containerName, logOpts)
|
var streams []io.Reader
|
||||||
if err != nil {
|
for _, ctr := range containers {
|
||||||
if isDockerNotFoundError(err) {
|
r, rerr := client.GetContainerLogs(ctx, ctr.ID, logOpts)
|
||||||
respondEmptyServiceLogs(c, "No container logs are available for this service yet.")
|
if rerr == nil {
|
||||||
return
|
readers = append(readers, r)
|
||||||
|
streams = append(streams, r)
|
||||||
}
|
}
|
||||||
respondDependencyUnavailable(c, "docker", "Failed to fetch container logs. The container may be unavailable.")
|
}
|
||||||
|
if len(readers) == 0 {
|
||||||
|
respondEmptyServiceLogs(c, "No container logs are available for this service yet.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer logsReader.Close()
|
logsReader := io.MultiReader(streams...)
|
||||||
|
defer func() {
|
||||||
|
for _, r := range readers {
|
||||||
|
_ = r.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
if follow {
|
if follow {
|
||||||
c.Header("Content-Type", "text/event-stream")
|
c.Header("Content-Type", "text/event-stream")
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"containr/internal/database"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -86,3 +88,30 @@ func TestRequireAuthenticatedUserIDRejectsMissingUserContext(t *testing.T) {
|
|||||||
t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rec.Code)
|
t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rec.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRecentDeploymentsRequireAuthentication(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
router := gin.New()
|
||||||
|
router.GET("/api/v1/deployments", handleGetRecentDeployments)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/deployments", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
// No db in context → dependency failure surfaces before auth.
|
||||||
|
if rec.Code != http.StatusInternalServerError {
|
||||||
|
t.Fatalf("expected status %d, got %d", http.StatusInternalServerError, rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// With db present but no user context → 401.
|
||||||
|
router2 := gin.New()
|
||||||
|
router2.GET("/api/v1/deployments", func(c *gin.Context) {
|
||||||
|
c.Set("db", &database.DB{})
|
||||||
|
handleGetRecentDeployments(c)
|
||||||
|
})
|
||||||
|
rec2 := httptest.NewRecorder()
|
||||||
|
router2.ServeHTTP(rec2, httptest.NewRequest(http.MethodGet, "/api/v1/deployments", nil))
|
||||||
|
if rec2.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rec2.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ package api
|
|||||||
import (
|
import (
|
||||||
"containr/internal/database"
|
"containr/internal/database"
|
||||||
"containr/internal/database/sqlcdb"
|
"containr/internal/database/sqlcdb"
|
||||||
|
"containr/internal/deployment"
|
||||||
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -310,6 +312,16 @@ func handleDeleteProject(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove live containers and the project network before dropping rows.
|
||||||
|
// Best-effort: Docker may be unavailable; DB cleanup proceeds regardless.
|
||||||
|
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
|
||||||
|
engine := engineValue.(*deployment.DeploymentEngine)
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second)
|
||||||
|
_ = engine.RemoveProjectContainers(ctx, projectID.String())
|
||||||
|
_ = engine.RemoveProjectNetwork(ctx, projectID.String())
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
|
||||||
deletedRows, err := queries.DeleteProjectByID(c.Request.Context(), projectID)
|
deletedRows, err := queries.DeleteProjectByID(c.Request.Context(), projectID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete project"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete project"})
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
dockerClient = client
|
dockerClient = client
|
||||||
buildManager = build.NewBuildManager("/tmp/containr-builds", dockerClient)
|
buildManager = build.NewBuildManager("/tmp/containr-builds", dockerClient)
|
||||||
deploymentEngine = deployment.NewDeploymentEngine(buildManager, dockerClient)
|
deploymentEngine = deployment.NewDeploymentEngine(buildManager, dockerClient)
|
||||||
|
// Restore managed sidecars (e.g. cloudflared) from saved settings.
|
||||||
|
syncManagedContainers(dockerClient, db)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize build handler
|
// Initialize build handler
|
||||||
@@ -184,6 +186,10 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
protected.PUT("/user/profile", handleUpdateProfile)
|
protected.PUT("/user/profile", handleUpdateProfile)
|
||||||
protected.POST("/users", handleCreateUser)
|
protected.POST("/users", handleCreateUser)
|
||||||
|
|
||||||
|
// Platform settings (admin-gated inside handlers)
|
||||||
|
protected.GET("/settings", handleGetSettings)
|
||||||
|
protected.PUT("/settings", handleUpdateSettings)
|
||||||
|
|
||||||
// Project routes
|
// Project routes
|
||||||
protected.GET("/projects", handleGetProjects)
|
protected.GET("/projects", handleGetProjects)
|
||||||
protected.POST("/projects", handleCreateProject)
|
protected.POST("/projects", handleCreateProject)
|
||||||
@@ -203,9 +209,17 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
protected.DELETE("/services/:id", handleDeleteService)
|
protected.DELETE("/services/:id", handleDeleteService)
|
||||||
protected.GET("/services/:id/metrics", handleGetServiceMetrics)
|
protected.GET("/services/:id/metrics", handleGetServiceMetrics)
|
||||||
|
|
||||||
|
// Runtime lifecycle (Railway-style)
|
||||||
|
protected.GET("/services/:id/runtime", handleGetServiceRuntime)
|
||||||
|
protected.POST("/services/:id/start", handleServiceStart)
|
||||||
|
protected.POST("/services/:id/stop", handleServiceStop)
|
||||||
|
protected.POST("/services/:id/restart", handleServiceRestart)
|
||||||
|
protected.POST("/services/:id/redeploy", handleServiceRedeploy)
|
||||||
|
|
||||||
// Deployment routes
|
// Deployment routes
|
||||||
protected.GET("/services/:id/deployments", handleGetDeployments)
|
protected.GET("/services/:id/deployments", handleGetDeployments)
|
||||||
protected.POST("/services/:id/deployments", handleCreateDeployment)
|
protected.POST("/services/:id/deployments", handleCreateDeployment)
|
||||||
|
protected.GET("/deployments", handleGetRecentDeployments)
|
||||||
protected.GET("/deployments/:id", handleGetDeployment)
|
protected.GET("/deployments/:id", handleGetDeployment)
|
||||||
protected.POST("/deployments/:id/rollback", handleRollbackDeployment)
|
protected.POST("/deployments/:id/rollback", handleRollbackDeployment)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,440 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"containr/internal/database"
|
||||||
|
"containr/internal/deployment"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// serviceRuntimeSpec builds the container spec for a service: stored env vars
|
||||||
|
// merged with ${{Service.KEY}} references resolved across the project.
|
||||||
|
func serviceRuntimeSpec(db *database.DB, service Service) (deployment.RuntimeSpec, error) {
|
||||||
|
env, err := resolveServiceEnv(db, service)
|
||||||
|
if err != nil {
|
||||||
|
return deployment.RuntimeSpec{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
spec := deployment.RuntimeSpec{
|
||||||
|
ProjectID: service.ProjectID.String(),
|
||||||
|
ServiceID: service.ID.String(),
|
||||||
|
Name: service.Name,
|
||||||
|
Image: service.Image,
|
||||||
|
Env: env,
|
||||||
|
Replicas: service.Replicas,
|
||||||
|
Port: int32(service.Port),
|
||||||
|
Domain: service.Domain,
|
||||||
|
HealthPath: service.HealthCheckPath,
|
||||||
|
RestartPolicy: service.RestartPolicy,
|
||||||
|
}
|
||||||
|
if cmd := strings.TrimSpace(service.Command); cmd != "" {
|
||||||
|
spec.Command = strings.Fields(cmd)
|
||||||
|
}
|
||||||
|
if spec.Replicas < 1 {
|
||||||
|
spec.Replicas = 1
|
||||||
|
}
|
||||||
|
if spec.RestartPolicy == "" {
|
||||||
|
spec.RestartPolicy = "unless-stopped"
|
||||||
|
}
|
||||||
|
spec.NanoCPUs = parseCPULimit(service.CPU)
|
||||||
|
spec.MemoryBytes = parseMemoryLimit(service.Memory)
|
||||||
|
if spec.Port > 0 {
|
||||||
|
env["PORT"] = strconv.Itoa(service.Port)
|
||||||
|
}
|
||||||
|
return spec, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var refVarPattern = regexp.MustCompile(`\$\{\{\s*([A-Za-z0-9_-]+)\.([A-Za-z_][A-Za-z0-9_]*)\s*\}\}`)
|
||||||
|
|
||||||
|
// resolveServiceEnv loads the service's variables (real values, including
|
||||||
|
// secrets) and expands ${{service.KEY}} references. KEY may be a variable on
|
||||||
|
// the referenced service, or the builtins HOST (service name alias) and PORT.
|
||||||
|
func resolveServiceEnv(db *database.DB, service Service) (map[string]string, error) {
|
||||||
|
rows, err := db.Query(
|
||||||
|
`SELECT key, value FROM environment_variables WHERE service_id = $1`,
|
||||||
|
service.ID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
env := map[string]string{}
|
||||||
|
for rows.Next() {
|
||||||
|
var k, v string
|
||||||
|
if err := rows.Scan(&k, &v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
env[k] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
var refServices []struct{ ID, Name string }
|
||||||
|
svcRows, err := db.Query(
|
||||||
|
`SELECT id, name FROM services WHERE project_id = $1`,
|
||||||
|
service.ProjectID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer svcRows.Close()
|
||||||
|
for svcRows.Next() {
|
||||||
|
var s struct{ ID, Name string }
|
||||||
|
if err := svcRows.Scan(&s.ID, &s.Name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
refServices = append(refServices, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
byName := map[string]string{}
|
||||||
|
for _, s := range refServices {
|
||||||
|
byName[strings.ToLower(s.Name)] = s.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve := func(serviceName, key string) (string, bool) {
|
||||||
|
id, ok := byName[strings.ToLower(serviceName)]
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
switch strings.ToUpper(key) {
|
||||||
|
case "HOST":
|
||||||
|
return serviceName, true
|
||||||
|
case "PORT":
|
||||||
|
var port int
|
||||||
|
if err := db.QueryRow(`SELECT COALESCE(port, 0) FROM services WHERE id = $1`, id).Scan(&port); err == nil && port > 0 {
|
||||||
|
return strconv.Itoa(port), true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
var value string
|
||||||
|
err := db.QueryRow(
|
||||||
|
`SELECT value FROM environment_variables WHERE service_id = $1 AND key = $2`,
|
||||||
|
id, key,
|
||||||
|
).Scan(&value)
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return value, true
|
||||||
|
}
|
||||||
|
|
||||||
|
for k, v := range env {
|
||||||
|
env[k] = refVarPattern.ReplaceAllStringFunc(v, func(match string) string {
|
||||||
|
parts := refVarPattern.FindStringSubmatch(match)
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
if resolved, ok := resolve(parts[1], parts[2]); ok {
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
return match
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return env, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseCPULimit(cpu string) int64 {
|
||||||
|
v, err := strconv.ParseFloat(strings.TrimSpace(cpu), 64)
|
||||||
|
if err != nil || v <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return int64(v * 1e9)
|
||||||
|
}
|
||||||
|
|
||||||
|
var memUnits = map[string]int64{"k": 1e3, "m": 1e6, "g": 1e9, "ki": 1 << 10, "mi": 1 << 20, "gi": 1 << 30}
|
||||||
|
|
||||||
|
func parseMemoryLimit(mem string) int64 {
|
||||||
|
s := strings.ToLower(strings.TrimSpace(mem))
|
||||||
|
for suffix, mult := range memUnits {
|
||||||
|
if strings.HasSuffix(s, suffix) {
|
||||||
|
if v, err := strconv.ParseFloat(strings.TrimSuffix(s, suffix), 64); err == nil {
|
||||||
|
return int64(v * float64(mult))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v, err := strconv.ParseInt(s, 10, 64); err == nil {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func getRuntimeEngine(c *gin.Context) (*deployment.DeploymentEngine, *database.DB, bool) {
|
||||||
|
dbValue, exists := c.Get("db")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
||||||
|
return nil, nil, false
|
||||||
|
}
|
||||||
|
engineValue, exists := c.Get("deployment_engine")
|
||||||
|
if !exists || engineValue == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Deployment engine unavailable. Docker may not be configured on this server."})
|
||||||
|
return nil, nil, false
|
||||||
|
}
|
||||||
|
return engineValue.(*deployment.DeploymentEngine), dbValue.(*database.DB), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadOwnedService(c *gin.Context, db *database.DB) (Service, bool) {
|
||||||
|
serviceID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID"})
|
||||||
|
return Service{}, false
|
||||||
|
}
|
||||||
|
userID, exists := c.Get("user_id")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||||
|
return Service{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
var service Service
|
||||||
|
var owner string
|
||||||
|
err = db.QueryRow(
|
||||||
|
`SELECT s.id, s.project_id, s.name,
|
||||||
|
COALESCE(s.type, s.service_type, ''), COALESCE(s.status, ''),
|
||||||
|
COALESCE(s.image, s.image_name, ''), COALESCE(s.command, s.start_command, ''),
|
||||||
|
COALESCE(s.environment, ''), COALESCE(s.git_repo, s.source_url, ''),
|
||||||
|
COALESCE(s.git_branch, ''), COALESCE(s.build_path, ''),
|
||||||
|
COALESCE(s.cpu, ''), COALESCE(s.memory, ''),
|
||||||
|
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
||||||
|
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
||||||
|
COALESCE(s.restart_policy, 'unless-stopped'),
|
||||||
|
s.created_at, s.updated_at, p.owner_id
|
||||||
|
FROM services s JOIN projects p ON s.project_id = p.id
|
||||||
|
WHERE s.id = $1`,
|
||||||
|
serviceID,
|
||||||
|
).Scan(
|
||||||
|
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
|
||||||
|
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
||||||
|
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
||||||
|
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
||||||
|
&service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt, &owner,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
||||||
|
return Service{}, false
|
||||||
|
}
|
||||||
|
if owner != userID.(string) && !isAdminUser(db, userID.(string)) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||||
|
return Service{}, false
|
||||||
|
}
|
||||||
|
return service, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleServiceRuntime returns live container state for a service.
|
||||||
|
func handleGetServiceRuntime(c *gin.Context) {
|
||||||
|
engine, db, ok := getRuntimeEngine(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
service, ok := loadOwnedService(c, db)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state, err := engine.RuntimeState(c.Request.Context(), service.ID.String(), service.Replicas)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"runtime": state})
|
||||||
|
}
|
||||||
|
|
||||||
|
func reconcileNow(c *gin.Context, engine *deployment.DeploymentEngine, db *database.DB, service Service) bool {
|
||||||
|
spec, err := serviceRuntimeSpec(db, service)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
state, err := engine.ReconcileService(ctx, spec)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
status := "stopped"
|
||||||
|
if state != nil {
|
||||||
|
status = state.Status
|
||||||
|
}
|
||||||
|
_, _ = db.Exec(`UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`, status, time.Now(), service.ID)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"runtime": state})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleServiceStart starts all stopped replicas.
|
||||||
|
func handleServiceStart(c *gin.Context) {
|
||||||
|
engine, db, ok := getRuntimeEngine(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
service, ok := loadOwnedService(c, db)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := engine.StartService(c.Request.Context(), service.ID.String()); err != nil {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = db.Exec(`UPDATE services SET status = 'running', updated_at = $1 WHERE id = $2`, time.Now(), service.ID)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "running"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleServiceStop stops all replicas without removing them.
|
||||||
|
func handleServiceStop(c *gin.Context) {
|
||||||
|
engine, db, ok := getRuntimeEngine(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
service, ok := loadOwnedService(c, db)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := engine.StopService(c.Request.Context(), service.ID.String()); err != nil {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = db.Exec(`UPDATE services SET status = 'stopped', updated_at = $1 WHERE id = $2`, time.Now(), service.ID)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "stopped"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleServiceRestart restarts all replicas.
|
||||||
|
func handleServiceRestart(c *gin.Context) {
|
||||||
|
engine, db, ok := getRuntimeEngine(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
service, ok := loadOwnedService(c, db)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
if err := engine.StopService(ctx, service.ID.String()); err != nil {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := engine.StartService(ctx, service.ID.String()); err != nil {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = db.Exec(`UPDATE services SET status = 'running', updated_at = $1 WHERE id = $2`, time.Now(), service.ID)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "running"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleServiceRedeploy re-applies the current spec: pulls the image for
|
||||||
|
// image-sourced services and reconciles containers (env, ports, replicas).
|
||||||
|
func handleServiceRedeploy(c *gin.Context) {
|
||||||
|
engine, db, ok := getRuntimeEngine(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
service, ok := loadOwnedService(c, db)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
image := service.Image
|
||||||
|
if image == "" {
|
||||||
|
// Git-sourced service: reuse the last successful deployment image.
|
||||||
|
err := db.QueryRow(
|
||||||
|
`SELECT image_name || ':' || image_tag FROM deployments
|
||||||
|
WHERE service_id = $1 AND status = 'deployed' AND image_name <> ''
|
||||||
|
ORDER BY created_at DESC LIMIT 1`,
|
||||||
|
service.ID,
|
||||||
|
).Scan(&image)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "No deployable image yet — run a deployment first"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
service.Image = image
|
||||||
|
reconcileNow(c, engine, db, service)
|
||||||
|
}
|
||||||
|
|
||||||
|
// liveServiceStatus reconciles the stored status with real container state.
|
||||||
|
// No-op when Docker is unavailable — the stored status is returned as-is.
|
||||||
|
func liveServiceStatus(c *gin.Context, db *database.DB, service *Service) {
|
||||||
|
engineValue, exists := c.Get("deployment_engine")
|
||||||
|
if !exists || engineValue == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
engine := engineValue.(*deployment.DeploymentEngine)
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
state, err := engine.RuntimeState(ctx, service.ID.String(), service.Replicas)
|
||||||
|
if err != nil || state == nil || len(state.Containers) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if state.Status != service.Status {
|
||||||
|
service.Status = state.Status
|
||||||
|
_, _ = db.Exec(`UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`, state.Status, time.Now(), service.ID)
|
||||||
|
}
|
||||||
|
if service.Domain != "" {
|
||||||
|
service.PublicURL = "https://" + service.Domain
|
||||||
|
} else if len(state.URLs) > 0 {
|
||||||
|
service.PublicURL = state.URLs[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runtimeScaleService adjusts replica count both in bookkeeping and reality.
|
||||||
|
func runtimeScaleService(c *gin.Context, serviceID string, replicas int) error {
|
||||||
|
dbValue, _ := c.Get("db")
|
||||||
|
engineValue, _ := c.Get("deployment_engine")
|
||||||
|
db, _ := dbValue.(*database.DB)
|
||||||
|
engine, _ := engineValue.(*deployment.DeploymentEngine)
|
||||||
|
if db == nil || engine == nil {
|
||||||
|
return fmt.Errorf("runtime unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
var service Service
|
||||||
|
var owner string
|
||||||
|
err := db.QueryRow(
|
||||||
|
`SELECT s.id, s.project_id, s.name,
|
||||||
|
COALESCE(s.type, s.service_type, ''), COALESCE(s.status, ''),
|
||||||
|
COALESCE(s.image, s.image_name, ''), COALESCE(s.command, s.start_command, ''),
|
||||||
|
COALESCE(s.environment, ''), COALESCE(s.git_repo, s.source_url, ''),
|
||||||
|
COALESCE(s.git_branch, ''), COALESCE(s.build_path, ''),
|
||||||
|
COALESCE(s.cpu, ''), COALESCE(s.memory, ''),
|
||||||
|
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
||||||
|
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
||||||
|
COALESCE(s.restart_policy, 'unless-stopped'),
|
||||||
|
s.created_at, s.updated_at, p.owner_id
|
||||||
|
FROM services s JOIN projects p ON s.project_id = p.id
|
||||||
|
WHERE s.id = $1`, serviceID,
|
||||||
|
).Scan(
|
||||||
|
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
|
||||||
|
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
||||||
|
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
||||||
|
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
||||||
|
&service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt, &owner,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if service.Image == "" {
|
||||||
|
if err := db.QueryRow(
|
||||||
|
`SELECT image_name || ':' || image_tag FROM deployments
|
||||||
|
WHERE service_id = $1 AND status = 'deployed' AND image_name <> ''
|
||||||
|
ORDER BY created_at DESC LIMIT 1`, service.ID,
|
||||||
|
).Scan(&service.Image); err != nil {
|
||||||
|
return fmt.Errorf("no deployed image to scale")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
spec, err := serviceRuntimeSpec(db, service)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
spec.Replicas = replicas
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
if _, err := engine.ReconcileService(ctx, spec); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, _ = db.Exec(`UPDATE services SET replicas = $1, updated_at = $2 WHERE id = $3`, replicas, time.Now(), service.ID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -204,7 +204,16 @@ func (h *ScalingHandler) ManualScale(c *gin.Context) {
|
|||||||
|
|
||||||
event, err := h.autoScaler.ManualScale(c.Request.Context(), serviceID, request.Replicas, strings.TrimSpace(request.Reason))
|
event, err := h.autoScaler.ManualScale(c.Request.Context(), serviceID, request.Replicas, strings.TrimSpace(request.Reason))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
// Service unknown to the autoscaler: the runtime is authoritative —
|
||||||
|
// reconcile containers directly and persist the replica count.
|
||||||
|
if runtimeErr := runtimeScaleService(c, serviceID, request.Replicas); runtimeErr != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "runtime_error": runtimeErr.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"message": "Service scaled successfully",
|
||||||
|
"state": gin.H{"service_id": serviceID, "desired_replicas": request.Replicas},
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,11 +223,19 @@ func (h *ScalingHandler) ManualScale(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
// Apply the replica change to live containers (best effort — the
|
||||||
|
// bookkeeping update above already succeeded).
|
||||||
|
runtimeErr := runtimeScaleService(c, serviceID, request.Replicas)
|
||||||
|
|
||||||
|
resp := gin.H{
|
||||||
"message": "Service scaled successfully",
|
"message": "Service scaled successfully",
|
||||||
"event": event,
|
"event": event,
|
||||||
"state": state,
|
"state": state,
|
||||||
})
|
}
|
||||||
|
if runtimeErr != nil {
|
||||||
|
resp["runtime_error"] = runtimeErr.Error()
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetScalingStatus returns the overall status of the auto-scaler
|
// GetScalingStatus returns the overall status of the auto-scaler
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"containr/internal/database"
|
"containr/internal/database"
|
||||||
|
"containr/internal/deployment"
|
||||||
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -24,37 +26,54 @@ type Service struct {
|
|||||||
BuildPath string `json:"build_path" db:"build_path"`
|
BuildPath string `json:"build_path" db:"build_path"`
|
||||||
CPU string `json:"cpu" db:"cpu"`
|
CPU string `json:"cpu" db:"cpu"`
|
||||||
Memory string `json:"memory" db:"memory"`
|
Memory string `json:"memory" db:"memory"`
|
||||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
// Runtime spec
|
||||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
Replicas int `json:"replicas" db:"replicas"`
|
||||||
|
Port int `json:"port" db:"port"` // container port to expose
|
||||||
|
Domain string `json:"domain" db:"domain"` // public hostname via Traefik
|
||||||
|
HealthCheckPath string `json:"healthcheck_path" db:"healthcheck_path"` // probed on Port
|
||||||
|
RestartPolicy string `json:"restart_policy" db:"restart_policy"`
|
||||||
|
PublicURL string `json:"public_url,omitempty" db:"-"` // computed at read time
|
||||||
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateServiceRequest represents a request to create a service
|
// CreateServiceRequest represents a request to create a service
|
||||||
type CreateServiceRequest struct {
|
type CreateServiceRequest struct {
|
||||||
ProjectID uuid.UUID `json:"project_id"`
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
Name string `json:"name" binding:"required,min=1,max=255"`
|
Name string `json:"name" binding:"required,min=1,max=255"`
|
||||||
Type string `json:"type" binding:"required,oneof=web worker database cron"`
|
Type string `json:"type" binding:"required,oneof=web worker database cron"`
|
||||||
Image string `json:"image"`
|
Image string `json:"image"`
|
||||||
Command string `json:"command"`
|
Command string `json:"command"`
|
||||||
Environment string `json:"environment" binding:"required,oneof=production preview development"`
|
Environment string `json:"environment" binding:"required,oneof=production preview development"`
|
||||||
GitRepo string `json:"git_repo"`
|
GitRepo string `json:"git_repo"`
|
||||||
GitBranch string `json:"git_branch"`
|
GitBranch string `json:"git_branch"`
|
||||||
BuildPath string `json:"build_path"`
|
BuildPath string `json:"build_path"`
|
||||||
CPU string `json:"cpu"`
|
CPU string `json:"cpu"`
|
||||||
Memory string `json:"memory"`
|
Memory string `json:"memory"`
|
||||||
|
Replicas int `json:"replicas"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
HealthCheckPath string `json:"healthcheck_path"`
|
||||||
|
RestartPolicy string `json:"restart_policy"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateServiceRequest represents a request to update a service
|
// UpdateServiceRequest represents a request to update a service
|
||||||
type UpdateServiceRequest struct {
|
type UpdateServiceRequest struct {
|
||||||
Name string `json:"name" binding:"omitempty,min=1,max=255"`
|
Name string `json:"name" binding:"omitempty,min=1,max=255"`
|
||||||
Type string `json:"type" binding:"omitempty,oneof=web worker database cron"`
|
Type string `json:"type" binding:"omitempty,oneof=web worker database cron"`
|
||||||
Image string `json:"image"`
|
Image string `json:"image"`
|
||||||
Command string `json:"command"`
|
Command string `json:"command"`
|
||||||
Environment string `json:"environment" binding:"omitempty,oneof=production preview development"`
|
Environment string `json:"environment" binding:"omitempty,oneof=production preview development"`
|
||||||
GitRepo string `json:"git_repo"`
|
GitRepo string `json:"git_repo"`
|
||||||
GitBranch string `json:"git_branch"`
|
GitBranch string `json:"git_branch"`
|
||||||
BuildPath string `json:"build_path"`
|
BuildPath string `json:"build_path"`
|
||||||
CPU string `json:"cpu"`
|
CPU string `json:"cpu"`
|
||||||
Memory string `json:"memory"`
|
Memory string `json:"memory"`
|
||||||
|
Replicas *int `json:"replicas"`
|
||||||
|
Port *int `json:"port"`
|
||||||
|
Domain *string `json:"domain"`
|
||||||
|
HealthCheckPath *string `json:"healthcheck_path"`
|
||||||
|
RestartPolicy string `json:"restart_policy"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleGetServices retrieves all services for a project
|
// handleGetServices retrieves all services for a project
|
||||||
@@ -110,6 +129,9 @@ func handleGetServices(c *gin.Context) {
|
|||||||
COALESCE(build_path, ''),
|
COALESCE(build_path, ''),
|
||||||
COALESCE(cpu, ''),
|
COALESCE(cpu, ''),
|
||||||
COALESCE(memory, ''),
|
COALESCE(memory, ''),
|
||||||
|
COALESCE(replicas, 1), COALESCE(port, 0),
|
||||||
|
COALESCE(domain, ''), COALESCE(healthcheck_path, ''),
|
||||||
|
COALESCE(restart_policy, 'unless-stopped'),
|
||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
FROM services
|
FROM services
|
||||||
WHERE project_id = $1
|
WHERE project_id = $1
|
||||||
@@ -129,7 +151,8 @@ func handleGetServices(c *gin.Context) {
|
|||||||
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
|
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
|
||||||
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
||||||
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
||||||
&service.CreatedAt, &service.UpdatedAt,
|
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
||||||
|
&service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan service"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan service"})
|
||||||
@@ -138,6 +161,11 @@ func handleGetServices(c *gin.Context) {
|
|||||||
services = append(services, service)
|
services = append(services, service)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reconcile stored status with live container state (no-op without Docker).
|
||||||
|
for i := range services {
|
||||||
|
liveServiceStatus(c, db.(*database.DB), &services[i])
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"services": services})
|
c.JSON(http.StatusOK, gin.H{"services": services})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,21 +241,26 @@ func handleCreateService(c *gin.Context) {
|
|||||||
|
|
||||||
// Create new service
|
// Create new service
|
||||||
service := Service{
|
service := Service{
|
||||||
ID: uuid.New(),
|
ID: uuid.New(),
|
||||||
ProjectID: req.ProjectID,
|
ProjectID: req.ProjectID,
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
Type: req.Type,
|
Type: req.Type,
|
||||||
Status: "stopped", // Initial status
|
Status: "stopped", // Initial status
|
||||||
Image: req.Image,
|
Image: req.Image,
|
||||||
Command: req.Command,
|
Command: req.Command,
|
||||||
Environment: req.Environment,
|
Environment: req.Environment,
|
||||||
GitRepo: req.GitRepo,
|
GitRepo: req.GitRepo,
|
||||||
GitBranch: req.GitBranch,
|
GitBranch: req.GitBranch,
|
||||||
BuildPath: req.BuildPath,
|
BuildPath: req.BuildPath,
|
||||||
CPU: req.CPU,
|
CPU: req.CPU,
|
||||||
Memory: req.Memory,
|
Memory: req.Memory,
|
||||||
CreatedAt: time.Now(),
|
Replicas: req.Replicas,
|
||||||
UpdatedAt: time.Now(),
|
Port: req.Port,
|
||||||
|
Domain: req.Domain,
|
||||||
|
HealthCheckPath: req.HealthCheckPath,
|
||||||
|
RestartPolicy: req.RestartPolicy,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set default values if not provided
|
// Set default values if not provided
|
||||||
@@ -237,6 +270,12 @@ func handleCreateService(c *gin.Context) {
|
|||||||
if service.Memory == "" {
|
if service.Memory == "" {
|
||||||
service.Memory = "512Mi"
|
service.Memory = "512Mi"
|
||||||
}
|
}
|
||||||
|
if service.Replicas < 1 {
|
||||||
|
service.Replicas = 1
|
||||||
|
}
|
||||||
|
if service.RestartPolicy == "" {
|
||||||
|
service.RestartPolicy = "unless-stopped"
|
||||||
|
}
|
||||||
|
|
||||||
environmentID, err := getProjectEnvironmentID(db.(*database.DB), service.ProjectID, service.Environment)
|
environmentID, err := getProjectEnvironmentID(db.(*database.DB), service.ProjectID, service.Environment)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -251,12 +290,14 @@ func handleCreateService(c *gin.Context) {
|
|||||||
`INSERT INTO services
|
`INSERT INTO services
|
||||||
(id, project_id, name, environment_id, service_type, source_type, source_url, image_name,
|
(id, project_id, name, environment_id, service_type, source_type, source_url, image_name,
|
||||||
build_command, start_command, type, status, image, command, environment,
|
build_command, start_command, type, status, image, command, environment,
|
||||||
git_repo, git_branch, build_path, cpu, memory, created_at, updated_at)
|
git_repo, git_branch, build_path, cpu, memory, replicas, port, domain,
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)`,
|
healthcheck_path, restart_policy, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27)`,
|
||||||
service.ID, service.ProjectID, service.Name, environmentID, service.Type,
|
service.ID, service.ProjectID, service.Name, environmentID, service.Type,
|
||||||
sourceType, firstNonEmpty(service.GitRepo, service.Image), service.Image,
|
sourceType, firstNonEmpty(service.GitRepo, service.Image), service.Image,
|
||||||
"", service.Command, service.Type, service.Status, service.Image, service.Command,
|
"", service.Command, service.Type, service.Status, service.Image, service.Command,
|
||||||
service.Environment, service.GitRepo, service.GitBranch, service.BuildPath, service.CPU, service.Memory,
|
service.Environment, service.GitRepo, service.GitBranch, service.BuildPath, service.CPU, service.Memory,
|
||||||
|
service.Replicas, service.Port, service.Domain, service.HealthCheckPath, service.RestartPolicy,
|
||||||
service.CreatedAt, service.UpdatedAt,
|
service.CreatedAt, service.UpdatedAt,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -324,6 +365,9 @@ func handleGetService(c *gin.Context) {
|
|||||||
COALESCE(s.build_path, ''),
|
COALESCE(s.build_path, ''),
|
||||||
COALESCE(s.cpu, ''),
|
COALESCE(s.cpu, ''),
|
||||||
COALESCE(s.memory, ''),
|
COALESCE(s.memory, ''),
|
||||||
|
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
||||||
|
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
||||||
|
COALESCE(s.restart_policy, 'unless-stopped'),
|
||||||
s.created_at, s.updated_at
|
s.created_at, s.updated_at
|
||||||
FROM services s
|
FROM services s
|
||||||
JOIN projects p ON s.project_id = p.id
|
JOIN projects p ON s.project_id = p.id
|
||||||
@@ -333,7 +377,8 @@ func handleGetService(c *gin.Context) {
|
|||||||
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
|
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
|
||||||
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
||||||
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
||||||
&service.CreatedAt, &service.UpdatedAt,
|
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
||||||
|
&service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt,
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -341,6 +386,7 @@ func handleGetService(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
liveServiceStatus(c, db.(*database.DB), &service)
|
||||||
c.JSON(http.StatusOK, gin.H{"service": service})
|
c.JSON(http.StatusOK, gin.H{"service": service})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,6 +432,9 @@ func handleUpdateService(c *gin.Context) {
|
|||||||
COALESCE(s.build_path, ''),
|
COALESCE(s.build_path, ''),
|
||||||
COALESCE(s.cpu, ''),
|
COALESCE(s.cpu, ''),
|
||||||
COALESCE(s.memory, ''),
|
COALESCE(s.memory, ''),
|
||||||
|
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
||||||
|
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
||||||
|
COALESCE(s.restart_policy, 'unless-stopped'),
|
||||||
s.created_at, s.updated_at
|
s.created_at, s.updated_at
|
||||||
FROM services s
|
FROM services s
|
||||||
JOIN projects p ON s.project_id = p.id
|
JOIN projects p ON s.project_id = p.id
|
||||||
@@ -396,6 +445,8 @@ func handleUpdateService(c *gin.Context) {
|
|||||||
&existingService.Status, &existingService.Image, &existingService.Command,
|
&existingService.Status, &existingService.Image, &existingService.Command,
|
||||||
&existingService.Environment, &existingService.GitRepo, &existingService.GitBranch,
|
&existingService.Environment, &existingService.GitRepo, &existingService.GitBranch,
|
||||||
&existingService.BuildPath, &existingService.CPU, &existingService.Memory,
|
&existingService.BuildPath, &existingService.CPU, &existingService.Memory,
|
||||||
|
&existingService.Replicas, &existingService.Port, &existingService.Domain,
|
||||||
|
&existingService.HealthCheckPath, &existingService.RestartPolicy,
|
||||||
&existingService.CreatedAt, &existingService.UpdatedAt,
|
&existingService.CreatedAt, &existingService.UpdatedAt,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -435,6 +486,21 @@ func handleUpdateService(c *gin.Context) {
|
|||||||
if req.Memory != "" {
|
if req.Memory != "" {
|
||||||
existingService.Memory = req.Memory
|
existingService.Memory = req.Memory
|
||||||
}
|
}
|
||||||
|
if req.Replicas != nil {
|
||||||
|
existingService.Replicas = *req.Replicas
|
||||||
|
}
|
||||||
|
if req.Port != nil {
|
||||||
|
existingService.Port = *req.Port
|
||||||
|
}
|
||||||
|
if req.Domain != nil {
|
||||||
|
existingService.Domain = *req.Domain
|
||||||
|
}
|
||||||
|
if req.HealthCheckPath != nil {
|
||||||
|
existingService.HealthCheckPath = *req.HealthCheckPath
|
||||||
|
}
|
||||||
|
if req.RestartPolicy != "" {
|
||||||
|
existingService.RestartPolicy = req.RestartPolicy
|
||||||
|
}
|
||||||
|
|
||||||
existingService.UpdatedAt = time.Now()
|
existingService.UpdatedAt = time.Now()
|
||||||
|
|
||||||
@@ -442,11 +508,15 @@ func handleUpdateService(c *gin.Context) {
|
|||||||
_, err = db.(*database.DB).Exec(
|
_, err = db.(*database.DB).Exec(
|
||||||
`UPDATE services
|
`UPDATE services
|
||||||
SET name = $1, type = $2, image = $3, command = $4, environment = $5,
|
SET name = $1, type = $2, image = $3, command = $4, environment = $5,
|
||||||
git_repo = $6, git_branch = $7, build_path = $8, cpu = $9, memory = $10, updated_at = $11
|
git_repo = $6, git_branch = $7, build_path = $8, cpu = $9, memory = $10,
|
||||||
WHERE id = $12`,
|
replicas = $11, port = $12, domain = $13, healthcheck_path = $14,
|
||||||
|
restart_policy = $15, updated_at = $16
|
||||||
|
WHERE id = $17`,
|
||||||
existingService.Name, existingService.Type, existingService.Image, existingService.Command,
|
existingService.Name, existingService.Type, existingService.Image, existingService.Command,
|
||||||
existingService.Environment, existingService.GitRepo, existingService.GitBranch,
|
existingService.Environment, existingService.GitRepo, existingService.GitBranch,
|
||||||
existingService.BuildPath, existingService.CPU, existingService.Memory,
|
existingService.BuildPath, existingService.CPU, existingService.Memory,
|
||||||
|
existingService.Replicas, existingService.Port, existingService.Domain,
|
||||||
|
existingService.HealthCheckPath, existingService.RestartPolicy,
|
||||||
existingService.UpdatedAt, existingService.ID,
|
existingService.UpdatedAt, existingService.ID,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -455,6 +525,24 @@ func handleUpdateService(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A replica change applies immediately when the service has live
|
||||||
|
// containers; other spec fields take effect on the next deploy.
|
||||||
|
if req.Replicas != nil && existingService.Status == "running" {
|
||||||
|
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
|
||||||
|
engine := engineValue.(*deployment.DeploymentEngine)
|
||||||
|
serviceCopy := existingService
|
||||||
|
go func() {
|
||||||
|
spec, err := serviceRuntimeSpec(db.(*database.DB), serviceCopy)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
_, _ = engine.ReconcileService(ctx, spec)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"service": existingService})
|
c.JSON(http.StatusOK, gin.H{"service": existingService})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -496,11 +584,18 @@ func handleDeleteService(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if user owns the project
|
// Check if user owns the project
|
||||||
if projectOwnerID != userID.(string) {
|
if projectOwnerID != userID.(string) && !isAdminUser(db.(*database.DB), userID.(string)) {
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove runtime containers before dropping the row.
|
||||||
|
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
|
||||||
|
if engine, ok := engineValue.(*deployment.DeploymentEngine); ok {
|
||||||
|
_ = engine.RemoveServiceContainers(c.Request.Context(), serviceID.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Delete service (cascade will handle related records)
|
// Delete service (cascade will handle related records)
|
||||||
_, err = db.(*database.DB).Exec(
|
_, err = db.(*database.DB).Exec(
|
||||||
"DELETE FROM services WHERE id = $1",
|
"DELETE FROM services WHERE id = $1",
|
||||||
|
|||||||
@@ -0,0 +1,290 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"containr/internal/database"
|
||||||
|
"containr/internal/docker"
|
||||||
|
|
||||||
|
"github.com/docker/docker/api/types/network"
|
||||||
|
"github.com/docker/docker/api/types/registry"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Platform setting keys. Values stored in app_settings take precedence over
|
||||||
|
// environment variables; env remains the fallback for optional overrides.
|
||||||
|
const (
|
||||||
|
settingSignupEnabled = "signup_enabled"
|
||||||
|
settingCloudflareTunnelToken = "cloudflare_tunnel_token"
|
||||||
|
|
||||||
|
cloudflaredContainerName = "containr-cloudflared"
|
||||||
|
cloudflaredImage = "cloudflare/cloudflared:latest"
|
||||||
|
)
|
||||||
|
|
||||||
|
// settingValue resolves a setting: app_settings row first, env fallback,
|
||||||
|
// then the caller's default.
|
||||||
|
func settingValue(db *database.DB, key, envKey, fallback string) string {
|
||||||
|
if db != nil && db.DB != nil {
|
||||||
|
var value string
|
||||||
|
err := db.QueryRow(`SELECT value FROM app_settings WHERE key = $1`, key).Scan(&value)
|
||||||
|
if err == nil {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if err != sql.ErrNoRows {
|
||||||
|
log.Printf("Failed to read setting %s: %v", key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if envKey != "" {
|
||||||
|
if value := strings.TrimSpace(os.Getenv(envKey)); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSetting(db *database.DB, key, value string, secret bool) error {
|
||||||
|
_, err := db.Exec(`
|
||||||
|
INSERT INTO app_settings (key, value, is_secret, updated_at)
|
||||||
|
VALUES ($1, $2, $3, NOW())
|
||||||
|
ON CONFLICT (key) DO UPDATE
|
||||||
|
SET value = EXCLUDED.value, is_secret = EXCLUDED.is_secret, updated_at = NOW()
|
||||||
|
`, key, value, secret)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteSetting(db *database.DB, key string) error {
|
||||||
|
_, err := db.Exec(`DELETE FROM app_settings WHERE key = $1`, key)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// signupEnabled controls whether public registration stays open after the
|
||||||
|
// first account exists. Defaults to closed; the platform owner can reopen it
|
||||||
|
// in Settings or via SIGNUP_ENABLED=true.
|
||||||
|
func signupEnabled(db *database.DB) bool {
|
||||||
|
return strings.EqualFold(settingValue(db, settingSignupEnabled, "SIGNUP_ENABLED", "false"), "true")
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAdminUser(db *database.DB, userID string) bool {
|
||||||
|
var isAdmin bool
|
||||||
|
if err := db.QueryRow(`SELECT is_admin FROM users WHERE id = $1`, userID).Scan(&isAdmin); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return isAdmin
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireAdmin(c *gin.Context) (*database.DB, bool) {
|
||||||
|
userID, ok := requireAuthenticatedUserID(c)
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
db, _ := c.Get("db")
|
||||||
|
database, _ := db.(*database.DB)
|
||||||
|
if database == nil || database.DB == nil || !isAdminUser(database, userID) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return database, true
|
||||||
|
}
|
||||||
|
|
||||||
|
type cloudflareTunnelStatus struct {
|
||||||
|
TokenSet bool `json:"token_set"`
|
||||||
|
Source string `json:"source"` // "app" | "env" | "none"
|
||||||
|
Container string `json:"container"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleGetSettings(c *gin.Context) {
|
||||||
|
db, ok := requireAdmin(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dockerClient, _ := c.Get("docker_client")
|
||||||
|
client, _ := dockerClient.(*docker.Client)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"signup_enabled": signupEnabled(db),
|
||||||
|
"cloudflare_tunnel": cloudflareTunnelState(c.Request.Context(), db, client),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleUpdateSettings(c *gin.Context) {
|
||||||
|
db, ok := requireAdmin(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
SignupEnabled *bool `json:"signup_enabled"`
|
||||||
|
CloudflareTunnelToken *string `json:"cloudflare_tunnel_token"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.SignupEnabled != nil {
|
||||||
|
value := "false"
|
||||||
|
if *req.SignupEnabled {
|
||||||
|
value = "true"
|
||||||
|
}
|
||||||
|
if err := setSetting(db, settingSignupEnabled, value, false); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update signup setting"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.CloudflareTunnelToken != nil {
|
||||||
|
token := strings.TrimSpace(*req.CloudflareTunnelToken)
|
||||||
|
if token == "" {
|
||||||
|
if err := deleteSetting(db, settingCloudflareTunnelToken); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clear tunnel token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if err := setSetting(db, settingCloudflareTunnelToken, token, true); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save tunnel token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if dockerClient, exists := c.Get("docker_client"); exists {
|
||||||
|
if client, ok := dockerClient.(*docker.Client); ok && client != nil {
|
||||||
|
go applyCloudflaredTunnel(client, token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleGetSettings(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolvedTunnelToken prefers the in-app value and falls back to the env.
|
||||||
|
func resolvedTunnelToken(db *database.DB) (string, string) {
|
||||||
|
if db != nil && db.DB != nil {
|
||||||
|
var value string
|
||||||
|
if err := db.QueryRow(`SELECT value FROM app_settings WHERE key = $1`, settingCloudflareTunnelToken).Scan(&value); err == nil && strings.TrimSpace(value) != "" {
|
||||||
|
return strings.TrimSpace(value), "app"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if value := strings.TrimSpace(os.Getenv("CLOUDFLARED_TOKEN")); value != "" && value != "your_cloudflare_tunnel_token_here" {
|
||||||
|
return value, "env"
|
||||||
|
}
|
||||||
|
return "", "none"
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloudflareTunnelState(ctx context.Context, db *database.DB, client *docker.Client) cloudflareTunnelStatus {
|
||||||
|
token, source := resolvedTunnelToken(db)
|
||||||
|
state := cloudflareTunnelStatus{TokenSet: token != "", Source: source, Container: "missing"}
|
||||||
|
|
||||||
|
if client == nil {
|
||||||
|
state.Container = "unavailable"
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
container, err := findCloudflaredContainer(ctx, client)
|
||||||
|
if err != nil || container == "" {
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
if inspect, err := client.GetContainer(ctx, container); err == nil && inspect.State != nil {
|
||||||
|
state.Container = inspect.State.Status
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncManagedContainers reconciles containers the platform manages from
|
||||||
|
// settings - currently only the optional Cloudflare Tunnel sidecar.
|
||||||
|
func syncManagedContainers(client *docker.Client, db *database.DB) {
|
||||||
|
token, _ := resolvedTunnelToken(db)
|
||||||
|
if token == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go applyCloudflaredTunnel(client, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
func findCloudflaredContainer(ctx context.Context, client *docker.Client) (string, error) {
|
||||||
|
containers, err := client.ListContainers(ctx, true)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
for _, item := range containers {
|
||||||
|
for _, name := range item.Names {
|
||||||
|
if strings.TrimPrefix(name, "/") == cloudflaredContainerName {
|
||||||
|
return item.ID, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyCloudflaredTunnel ensures a cloudflared container mirrors the stored
|
||||||
|
// token: created/recreated when set, removed when cleared. It joins the same
|
||||||
|
// networks as the backend so tunnel targets can use compose service names.
|
||||||
|
func applyCloudflaredTunnel(client *docker.Client, token string) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if existing, err := findCloudflaredContainer(ctx, client); err == nil && existing != "" {
|
||||||
|
if err := client.RemoveContainer(ctx, existing, true); err != nil {
|
||||||
|
log.Printf("Failed to remove existing cloudflared container: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if token == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := client.PullImageWait(ctx, cloudflaredImage, registry.AuthConfig{}); err != nil {
|
||||||
|
log.Printf("Failed to pull %s: %v", cloudflaredImage, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
networks, err := backendNetworks(ctx, client)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to resolve backend networks for cloudflared: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := client.CreateContainer(ctx, docker.ContainerConfig{
|
||||||
|
Name: cloudflaredContainerName,
|
||||||
|
Image: cloudflaredImage,
|
||||||
|
Cmd: []string{"tunnel", "--no-autoupdate", "run", "--token", token},
|
||||||
|
RestartPolicy: "unless-stopped",
|
||||||
|
Labels: map[string]string{"containr.managed": "cloudflared"},
|
||||||
|
Networks: networks,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to create cloudflared container: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := client.StartContainer(ctx, id); err != nil {
|
||||||
|
log.Printf("Failed to start cloudflared container: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// backendNetworks returns the networks this backend container sits on, so the
|
||||||
|
// managed cloudflared container can reach the frontend/backend by name.
|
||||||
|
func backendNetworks(ctx context.Context, client *docker.Client) (map[string]*network.EndpointSettings, error) {
|
||||||
|
hostname, err := os.Hostname()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
self, err := client.GetContainer(ctx, hostname)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
networks := map[string]*network.EndpointSettings{}
|
||||||
|
for name := range self.NetworkSettings.Networks {
|
||||||
|
networks[name] = &network.EndpointSettings{}
|
||||||
|
}
|
||||||
|
if len(networks) == 0 {
|
||||||
|
return nil, sql.ErrNoRows
|
||||||
|
}
|
||||||
|
return networks, nil
|
||||||
|
}
|
||||||
@@ -93,10 +93,10 @@ func (bm *BuildManager) buildPrebuilt(ctx context.Context, req *types.BuildReque
|
|||||||
return nil, fmt.Errorf("prebuilt image not specified")
|
return nil, fmt.Errorf("prebuilt image not specified")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pull the prebuilt image
|
// Pull the prebuilt image — must drain the pull stream or the daemon
|
||||||
|
// aborts it and the tag below races a missing image.
|
||||||
auth := registry.AuthConfig{}
|
auth := registry.AuthConfig{}
|
||||||
_, err := bm.dockerClient.PullImage(ctx, req.PrebuiltImage, auth)
|
if err := bm.dockerClient.PullImageWait(ctx, req.PrebuiltImage, auth); err != nil {
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to pull prebuilt image: %w", err)
|
return nil, fmt.Errorf("failed to pull prebuilt image: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +104,7 @@ func (bm *BuildManager) buildPrebuilt(ctx context.Context, req *types.BuildReque
|
|||||||
if req.ImageName != "" && req.ImageTag != "" {
|
if req.ImageName != "" && req.ImageTag != "" {
|
||||||
targetImage := fmt.Sprintf("%s:%s", req.ImageName, req.ImageTag)
|
targetImage := fmt.Sprintf("%s:%s", req.ImageName, req.ImageTag)
|
||||||
if targetImage != req.PrebuiltImage {
|
if targetImage != req.PrebuiltImage {
|
||||||
err = bm.dockerClient.TagImage(ctx, req.PrebuiltImage, targetImage)
|
err := bm.dockerClient.TagImage(ctx, req.PrebuiltImage, targetImage)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to tag image: %w", err)
|
return nil, fmt.Errorf("failed to tag image: %w", err)
|
||||||
}
|
}
|
||||||
@@ -114,7 +114,7 @@ func (bm *BuildManager) buildPrebuilt(ctx context.Context, req *types.BuildReque
|
|||||||
|
|
||||||
// Push to registry if specified
|
// Push to registry if specified
|
||||||
if req.RegistryURL != "" {
|
if req.RegistryURL != "" {
|
||||||
err = bm.dockerClient.PushImage(ctx, req.PrebuiltImage, req.RegistryURL)
|
err := bm.dockerClient.PushImage(ctx, req.PrebuiltImage, req.RegistryURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to push image: %w", err)
|
return nil, fmt.Errorf("failed to push image: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -215,9 +215,8 @@ func (c *Config) Validate() error {
|
|||||||
return fmt.Errorf("SEED_DATA_ON_START must be false in production")
|
return fmt.Errorf("SEED_DATA_ON_START must be false in production")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !c.CookieSecure {
|
// COOKIE_SECURE=false is legal for plain-HTTP self-hosted installs and is
|
||||||
return fmt.Errorf("COOKIE_SECURE must be true in production")
|
// reported as a startup warning in main.go rather than a fatal error.
|
||||||
}
|
|
||||||
|
|
||||||
if c.BetterAuthEnabled {
|
if c.BetterAuthEnabled {
|
||||||
if len(strings.TrimSpace(c.BetterAuthSecret)) < 32 ||
|
if len(strings.TrimSpace(c.BetterAuthSecret)) < 32 ||
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ func TestValidateAllowsWildcardCorsWithoutCredentialsInProduction(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidateRejectsInsecureCookieInProduction(t *testing.T) {
|
func TestValidateAllowsInsecureCookieInProduction(t *testing.T) {
|
||||||
t.Setenv("ENVIRONMENT", "production")
|
t.Setenv("ENVIRONMENT", "production")
|
||||||
cfg := Config{
|
cfg := Config{
|
||||||
JWTSecret: "this-is-a-very-strong-production-secret-123",
|
JWTSecret: "this-is-a-very-strong-production-secret-123",
|
||||||
@@ -120,9 +120,10 @@ func TestValidateRejectsInsecureCookieInProduction(t *testing.T) {
|
|||||||
CORSCredentials: true,
|
CORSCredentials: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
err := cfg.Validate()
|
// Plain-HTTP self-hosted installs are a supported production deployment;
|
||||||
if err == nil || !strings.Contains(err.Error(), "COOKIE_SECURE must be true") {
|
// insecure cookies are allowed and surfaced as a startup warning.
|
||||||
t.Fatalf("expected COOKIE_SECURE validation error, got %v", err)
|
if err := cfg.Validate(); err != nil {
|
||||||
|
t.Fatalf("expected production config with insecure cookies to validate, got %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,10 +9,6 @@ import (
|
|||||||
"containr/internal/build"
|
"containr/internal/build"
|
||||||
"containr/internal/docker"
|
"containr/internal/docker"
|
||||||
"containr/internal/types"
|
"containr/internal/types"
|
||||||
|
|
||||||
"github.com/docker/docker/api/types/mount"
|
|
||||||
"github.com/docker/docker/api/types/network"
|
|
||||||
"github.com/docker/go-connections/nat"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type DeploymentEngine struct {
|
type DeploymentEngine struct {
|
||||||
@@ -56,6 +52,13 @@ type ServiceConfig struct {
|
|||||||
Resources ResourceLimits `json:"resources,omitempty"`
|
Resources ResourceLimits `json:"resources,omitempty"`
|
||||||
HealthCheck *HealthCheck `json:"health_check,omitempty"`
|
HealthCheck *HealthCheck `json:"health_check,omitempty"`
|
||||||
Replicas int `json:"replicas"`
|
Replicas int `json:"replicas"`
|
||||||
|
// PublicPort is the container port exposed to users (published host port,
|
||||||
|
// or Traefik routing when Domain is set). 0 = internal-only service.
|
||||||
|
PublicPort int32 `json:"public_port,omitempty"`
|
||||||
|
// Domain is a public hostname routed to PublicPort via Traefik.
|
||||||
|
Domain string `json:"domain,omitempty"`
|
||||||
|
// HealthPath is an HTTP path probed on PublicPort for container health.
|
||||||
|
HealthPath string `json:"health_path,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PortMapping struct {
|
type PortMapping struct {
|
||||||
@@ -297,91 +300,53 @@ func (de *DeploymentEngine) buildImage(ctx context.Context, deployment *Deployme
|
|||||||
return response.ImageName, nil
|
return response.ImageName, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// deployService deploys the service using the built image
|
// deployService deploys the service using the built image. Containers are
|
||||||
|
// reconciled onto the project network with a DNS alias per service name —
|
||||||
|
// other services reach it at http://<name>:<port>.
|
||||||
func (de *DeploymentEngine) deployService(ctx context.Context, deployment *Deployment) error {
|
func (de *DeploymentEngine) deployService(ctx context.Context, deployment *Deployment) error {
|
||||||
// Convert service config to Docker container config
|
cfg := deployment.Config
|
||||||
containerConfig := &docker.ContainerConfig{
|
|
||||||
Name: fmt.Sprintf("containr-%s-%s", deployment.ServiceID, deployment.ID),
|
spec := RuntimeSpec{
|
||||||
Image: deployment.ImageName,
|
ProjectID: deployment.ProjectID,
|
||||||
Cmd: deployment.Config.Command,
|
ServiceID: deployment.ServiceID,
|
||||||
Labels: deployment.Config.Labels,
|
Name: cfg.Name,
|
||||||
Networks: make(map[string]*network.EndpointSettings),
|
Image: deployment.ImageName,
|
||||||
|
Command: cfg.Command,
|
||||||
|
Env: cfg.Environment,
|
||||||
|
Replicas: cfg.Replicas,
|
||||||
|
Port: cfg.PublicPort,
|
||||||
|
Domain: cfg.Domain,
|
||||||
|
HealthPath: cfg.HealthPath,
|
||||||
|
RestartPolicy: cfg.RestartPolicy,
|
||||||
|
MemoryBytes: cfg.Resources.MemoryBytes,
|
||||||
|
NanoCPUs: cfg.Resources.CPUQuota,
|
||||||
}
|
}
|
||||||
|
for _, pm := range cfg.PortMappings {
|
||||||
// Set environment variables
|
if spec.Port == 0 {
|
||||||
for k, v := range deployment.Config.Environment {
|
spec.Port = pm.ContainerPort
|
||||||
containerConfig.Env = append(containerConfig.Env, fmt.Sprintf("%s=%s", k, v))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set restart policy
|
|
||||||
containerConfig.RestartPolicy = deployment.Config.RestartPolicy
|
|
||||||
|
|
||||||
// Configure port mappings
|
|
||||||
portBindings := make(nat.PortMap)
|
|
||||||
for _, pm := range deployment.Config.PortMappings {
|
|
||||||
port := nat.Port(fmt.Sprintf("%d/%s", pm.ContainerPort, pm.Protocol))
|
|
||||||
if pm.HostPort > 0 {
|
|
||||||
portBindings[port] = []nat.PortBinding{
|
|
||||||
{
|
|
||||||
HostIP: pm.HostIP,
|
|
||||||
HostPort: fmt.Sprintf("%d", pm.HostPort),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
containerConfig.PortBindings = portBindings
|
if spec.RestartPolicy == "" {
|
||||||
|
spec.RestartPolicy = "unless-stopped"
|
||||||
// Configure resource limits
|
|
||||||
if deployment.Config.Resources.MemoryBytes > 0 {
|
|
||||||
containerConfig.Memory = deployment.Config.Resources.MemoryBytes
|
|
||||||
}
|
|
||||||
if deployment.Config.Resources.CPUQuota > 0 {
|
|
||||||
containerConfig.NanoCPUs = deployment.Config.Resources.CPUQuota
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configure volume mounts
|
// Volume mounts aren't reconciled replicas-aware yet; still applied on the
|
||||||
for _, vm := range deployment.Config.VolumeMounts {
|
// container config of every replica via spec extension later if needed.
|
||||||
mount := mount.Mount{
|
state, err := de.ReconcileService(ctx, spec)
|
||||||
Type: mount.Type(vm.Type),
|
if err != nil {
|
||||||
Source: vm.Source,
|
return err
|
||||||
Target: vm.Destination,
|
|
||||||
ReadOnly: vm.ReadOnly,
|
|
||||||
}
|
|
||||||
containerConfig.Mounts = append(containerConfig.Mounts, mount)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create containers based on replica count
|
deployment.Containers = make([]ContainerInfo, len(state.Containers))
|
||||||
deployment.Containers = make([]ContainerInfo, deployment.Config.Replicas)
|
for i, c := range state.Containers {
|
||||||
for i := 0; i < deployment.Config.Replicas; i++ {
|
|
||||||
containerName := fmt.Sprintf("%s-%d", containerConfig.Name, i)
|
|
||||||
|
|
||||||
// Create container
|
|
||||||
containerID, err := de.dockerClient.CreateContainer(ctx, *containerConfig)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create container %d: %w", i, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start container
|
|
||||||
err = de.dockerClient.StartContainer(ctx, containerID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to start container %d: %w", i, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get container info
|
|
||||||
_, err = de.dockerClient.GetContainer(ctx, containerID)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Failed to get container info for %s: %v", containerID, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
deployment.Containers[i] = ContainerInfo{
|
deployment.Containers[i] = ContainerInfo{
|
||||||
ID: containerID,
|
ID: c.ID,
|
||||||
Name: containerName,
|
Name: c.Name,
|
||||||
Status: "running",
|
Status: c.State,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
StartedAt: time.Now(),
|
StartedAt: time.Now(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,382 @@
|
|||||||
|
package deployment
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"containr/internal/docker"
|
||||||
|
|
||||||
|
"github.com/docker/docker/api/types/container"
|
||||||
|
"github.com/docker/docker/api/types/filters"
|
||||||
|
"github.com/docker/docker/api/types/network"
|
||||||
|
"github.com/docker/go-connections/nat"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Container labels used to associate runtime containers with services.
|
||||||
|
const (
|
||||||
|
LabelManaged = "containr.managed"
|
||||||
|
LabelProject = "containr.project"
|
||||||
|
LabelService = "containr.service"
|
||||||
|
LabelReplica = "containr.replica"
|
||||||
|
LabelSpecHash = "containr.spec-hash"
|
||||||
|
LabelDeployment = "containr.deployment"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RuntimeSpec is everything needed to run a service's containers.
|
||||||
|
type RuntimeSpec struct {
|
||||||
|
ProjectID string
|
||||||
|
ServiceID string
|
||||||
|
Name string // DNS alias on the project network
|
||||||
|
Image string
|
||||||
|
Command []string
|
||||||
|
Env map[string]string // already resolved
|
||||||
|
Replicas int
|
||||||
|
Port int32 // container port to expose publicly; 0 = none
|
||||||
|
Domain string // public hostname routed via Traefik; empty = none
|
||||||
|
HealthPath string // http path probed on Port for container healthcheck
|
||||||
|
RestartPolicy string
|
||||||
|
MemoryBytes int64
|
||||||
|
NanoCPUs int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// RuntimeContainer describes one live replica.
|
||||||
|
type RuntimeContainer struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
State string `json:"state"` // running, exited, ...
|
||||||
|
Replica int `json:"replica"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RuntimeState is the live view of a service's containers.
|
||||||
|
type RuntimeState struct {
|
||||||
|
Desired int `json:"desired"`
|
||||||
|
Containers []RuntimeContainer `json:"containers"`
|
||||||
|
URLs []string `json:"urls"`
|
||||||
|
Status string `json:"status"` // running, degraded, stopped
|
||||||
|
}
|
||||||
|
|
||||||
|
func projectNetworkName(projectID string) string {
|
||||||
|
short := strings.ReplaceAll(projectID, "-", "")
|
||||||
|
if len(short) > 12 {
|
||||||
|
short = short[:12]
|
||||||
|
}
|
||||||
|
return "containr-proj-" + short
|
||||||
|
}
|
||||||
|
|
||||||
|
func serviceNamePrefix(serviceID string) string {
|
||||||
|
return fmt.Sprintf("containr-%s-", serviceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureProjectNetwork creates the per-project bridge network if missing.
|
||||||
|
// Containers on it resolve each other by service name (private networking).
|
||||||
|
func (de *DeploymentEngine) EnsureProjectNetwork(ctx context.Context, projectID string) (string, error) {
|
||||||
|
name := projectNetworkName(projectID)
|
||||||
|
|
||||||
|
networks, err := de.dockerClient.ListNetworks(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
for _, n := range networks {
|
||||||
|
if n.Name == name {
|
||||||
|
return name, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = de.dockerClient.CreateNetwork(ctx, docker.NetworkConfig{
|
||||||
|
Name: name,
|
||||||
|
Driver: "bridge",
|
||||||
|
Labels: map[string]string{
|
||||||
|
LabelManaged: "true",
|
||||||
|
LabelProject: projectID,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
// Concurrent reconciles race on create; re-resolve before failing.
|
||||||
|
networks, lerr := de.dockerClient.ListNetworks(ctx)
|
||||||
|
if lerr == nil {
|
||||||
|
for _, n := range networks {
|
||||||
|
if n.Name == name {
|
||||||
|
return name, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("create project network: %w", err)
|
||||||
|
}
|
||||||
|
return name, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// traefikNetworkName finds the shared edge network (the infra stack's
|
||||||
|
// containr-network where Traefik listens) if it exists.
|
||||||
|
func (de *DeploymentEngine) traefikNetworkName(ctx context.Context) string {
|
||||||
|
networks, err := de.dockerClient.ListNetworks(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, n := range networks {
|
||||||
|
if n.Name == "containr-network" {
|
||||||
|
return n.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListServiceContainers returns containers managed for a service.
|
||||||
|
func (de *DeploymentEngine) ListServiceContainers(ctx context.Context, serviceID string) ([]container.Summary, error) {
|
||||||
|
return de.dockerClient.ListContainersFiltered(ctx, filters.NewArgs(
|
||||||
|
filters.Arg("label", LabelService+"="+serviceID),
|
||||||
|
), true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReconcileService brings live containers in line with the spec. When the
|
||||||
|
// spec hash changed (image/env/ports/etc.) all replicas are recreated;
|
||||||
|
// otherwise only the replica delta is applied.
|
||||||
|
func (de *DeploymentEngine) ReconcileService(ctx context.Context, spec RuntimeSpec) (*RuntimeState, error) {
|
||||||
|
if spec.Replicas < 1 {
|
||||||
|
spec.Replicas = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
networkName, err := de.EnsureProjectNetwork(ctx, spec.ProjectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
edgeNetwork := ""
|
||||||
|
if spec.Domain != "" {
|
||||||
|
edgeNetwork = de.traefikNetworkName(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
hash := specHash(spec)
|
||||||
|
existing, err := de.ListServiceContainers(ctx, spec.ServiceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Deterministic order: scale-down removes the highest replica indices.
|
||||||
|
sort.Slice(existing, func(i, j int) bool {
|
||||||
|
return replicaIndex(existing[i]) < replicaIndex(existing[j])
|
||||||
|
})
|
||||||
|
|
||||||
|
drifted := false
|
||||||
|
for _, c := range existing {
|
||||||
|
if c.Labels[LabelSpecHash] != hash {
|
||||||
|
drifted = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if drifted {
|
||||||
|
for _, c := range existing {
|
||||||
|
_ = de.dockerClient.RemoveContainer(ctx, c.ID, true)
|
||||||
|
}
|
||||||
|
existing = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := len(existing); i > spec.Replicas; i-- {
|
||||||
|
victim := existing[i-1]
|
||||||
|
_ = de.dockerClient.StopContainer(ctx, victim.ID, nil)
|
||||||
|
_ = de.dockerClient.RemoveContainer(ctx, victim.ID, true)
|
||||||
|
existing = existing[:i-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := len(existing); i < spec.Replicas; i++ {
|
||||||
|
if _, err := de.createReplica(ctx, spec, networkName, edgeNetwork, i, hash); err != nil {
|
||||||
|
return nil, fmt.Errorf("create replica %d: %w", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range existing {
|
||||||
|
if c.State != "running" {
|
||||||
|
_ = de.dockerClient.StartContainer(ctx, c.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return de.RuntimeState(ctx, spec.ServiceID, spec.Replicas)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (de *DeploymentEngine) createReplica(ctx context.Context, spec RuntimeSpec, projectNet, edgeNet string, index int, hash string) (string, error) {
|
||||||
|
env := make([]string, 0, len(spec.Env))
|
||||||
|
for k, v := range spec.Env {
|
||||||
|
env = append(env, k+"="+v)
|
||||||
|
}
|
||||||
|
sort.Strings(env)
|
||||||
|
|
||||||
|
labels := map[string]string{
|
||||||
|
LabelManaged: "true",
|
||||||
|
LabelProject: spec.ProjectID,
|
||||||
|
LabelService: spec.ServiceID,
|
||||||
|
LabelReplica: fmt.Sprintf("%d", index),
|
||||||
|
LabelSpecHash: hash,
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoints := map[string]*network.EndpointSettings{
|
||||||
|
projectNet: {Aliases: []string{spec.Name}},
|
||||||
|
}
|
||||||
|
if edgeNet != "" {
|
||||||
|
endpoints[edgeNet] = &network.EndpointSettings{}
|
||||||
|
labels["traefik.enable"] = "true"
|
||||||
|
labels["traefik.docker.network"] = edgeNet
|
||||||
|
labels["traefik.http.routers.svc-"+spec.ServiceID[:8]+".rule"] = "Host(`" + spec.Domain + "`)"
|
||||||
|
labels["traefik.http.routers.svc-"+spec.ServiceID[:8]+".entrypoints"] = "web"
|
||||||
|
labels["traefik.http.services.svc-"+spec.ServiceID[:8]+".loadbalancer.server.port"] = fmt.Sprintf("%d", spec.Port)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := docker.ContainerConfig{
|
||||||
|
Name: fmt.Sprintf("%s%d", serviceNamePrefix(spec.ServiceID), index),
|
||||||
|
Image: spec.Image,
|
||||||
|
Cmd: spec.Command,
|
||||||
|
Env: env,
|
||||||
|
Labels: labels,
|
||||||
|
RestartPolicy: spec.RestartPolicy,
|
||||||
|
Memory: spec.MemoryBytes,
|
||||||
|
NanoCPUs: spec.NanoCPUs,
|
||||||
|
Networks: endpoints,
|
||||||
|
}
|
||||||
|
|
||||||
|
if spec.Port > 0 {
|
||||||
|
port := nat.Port(fmt.Sprintf("%d/tcp", spec.Port))
|
||||||
|
cfg.ExposedPorts = nat.PortSet{port: struct{}{}}
|
||||||
|
// Always publish an ephemeral host port for direct access; a configured
|
||||||
|
// domain is additionally routed via Traefik when the edge network exists.
|
||||||
|
cfg.PortBindings = nat.PortMap{port: []nat.PortBinding{{HostIP: "0.0.0.0", HostPort: ""}}}
|
||||||
|
if spec.HealthPath != "" {
|
||||||
|
cfg.Healthcheck = &container.HealthConfig{
|
||||||
|
Test: []string{"CMD-SHELL", fmt.Sprintf("wget -q -O /dev/null http://127.0.0.1:%d%s || exit 1", spec.Port, spec.HealthPath)},
|
||||||
|
Interval: 30 * time.Second,
|
||||||
|
Timeout: 5 * time.Second,
|
||||||
|
Retries: 3,
|
||||||
|
StartPeriod: 10 * time.Second,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := de.dockerClient.CreateContainer(ctx, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := de.dockerClient.StartContainer(ctx, id); err != nil {
|
||||||
|
return "", fmt.Errorf("start: %w", err)
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RuntimeState reads live container state for a service.
|
||||||
|
func (de *DeploymentEngine) RuntimeState(ctx context.Context, serviceID string, desired int) (*RuntimeState, error) {
|
||||||
|
containers, err := de.ListServiceContainers(ctx, serviceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
state := &RuntimeState{Desired: desired, Status: "stopped"}
|
||||||
|
running := 0
|
||||||
|
for _, c := range containers {
|
||||||
|
rc := RuntimeContainer{ID: c.ID, Name: strings.TrimPrefix(c.Names[0], "/"), State: c.State, Replica: replicaIndex(c)}
|
||||||
|
state.Containers = append(state.Containers, rc)
|
||||||
|
if c.State == "running" {
|
||||||
|
running++
|
||||||
|
}
|
||||||
|
for _, p := range c.Ports {
|
||||||
|
if p.PublicPort != 0 {
|
||||||
|
state.URLs = append(state.URLs, fmt.Sprintf("http://localhost:%d", p.PublicPort))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(state.Containers, func(i, j int) bool { return state.Containers[i].Replica < state.Containers[j].Replica })
|
||||||
|
sort.Strings(state.URLs)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case running == 0:
|
||||||
|
state.Status = "stopped"
|
||||||
|
case running < desired:
|
||||||
|
state.Status = "degraded"
|
||||||
|
default:
|
||||||
|
state.Status = "running"
|
||||||
|
}
|
||||||
|
return state, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopService stops (but keeps) all replicas of a service.
|
||||||
|
func (de *DeploymentEngine) StopService(ctx context.Context, serviceID string) error {
|
||||||
|
containers, err := de.ListServiceContainers(ctx, serviceID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, c := range containers {
|
||||||
|
if c.State == "running" {
|
||||||
|
if err := de.dockerClient.StopContainer(ctx, c.ID, nil); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartService starts all stopped replicas.
|
||||||
|
func (de *DeploymentEngine) StartService(ctx context.Context, serviceID string) error {
|
||||||
|
containers, err := de.ListServiceContainers(ctx, serviceID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, c := range containers {
|
||||||
|
if c.State != "running" {
|
||||||
|
if err := de.dockerClient.StartContainer(ctx, c.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveServiceContainers deletes every replica of a service.
|
||||||
|
func (de *DeploymentEngine) RemoveServiceContainers(ctx context.Context, serviceID string) error {
|
||||||
|
containers, err := de.ListServiceContainers(ctx, serviceID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, c := range containers {
|
||||||
|
_ = de.dockerClient.RemoveContainer(ctx, c.ID, true)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveProjectContainers deletes every managed container in a project.
|
||||||
|
func (de *DeploymentEngine) RemoveProjectContainers(ctx context.Context, projectID string) error {
|
||||||
|
containers, err := de.dockerClient.ListContainersFiltered(ctx, filters.NewArgs(
|
||||||
|
filters.Arg("label", LabelProject+"="+projectID),
|
||||||
|
), true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, c := range containers {
|
||||||
|
_ = de.dockerClient.RemoveContainer(ctx, c.ID, true)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveProjectNetwork removes the per-project bridge network if present.
|
||||||
|
func (de *DeploymentEngine) RemoveProjectNetwork(ctx context.Context, projectID string) error {
|
||||||
|
return de.dockerClient.RemoveNetwork(ctx, projectNetworkName(projectID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func replicaIndex(c container.Summary) int {
|
||||||
|
replica := 0
|
||||||
|
fmt.Sscanf(c.Labels[LabelReplica], "%d", &replica)
|
||||||
|
return replica
|
||||||
|
}
|
||||||
|
|
||||||
|
func specHash(spec RuntimeSpec) string {
|
||||||
|
payload, _ := json.Marshal(struct {
|
||||||
|
Image, Name, Domain, HealthPath, RestartPolicy string
|
||||||
|
Command []string
|
||||||
|
Env map[string]string
|
||||||
|
Port int32
|
||||||
|
MemoryBytes, NanoCPUs int64
|
||||||
|
}{spec.Image, spec.Name, spec.Domain, spec.HealthPath, spec.RestartPolicy,
|
||||||
|
spec.Command, spec.Env, spec.Port, spec.MemoryBytes, spec.NanoCPUs})
|
||||||
|
sum := sha256.Sum256(payload)
|
||||||
|
return hex.EncodeToString(sum[:8])
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package docker
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"time"
|
"time"
|
||||||
@@ -10,6 +11,7 @@ import (
|
|||||||
"github.com/docker/docker/api/types"
|
"github.com/docker/docker/api/types"
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/events"
|
"github.com/docker/docker/api/types/events"
|
||||||
|
"github.com/docker/docker/api/types/filters"
|
||||||
"github.com/docker/docker/api/types/image"
|
"github.com/docker/docker/api/types/image"
|
||||||
"github.com/docker/docker/api/types/network"
|
"github.com/docker/docker/api/types/network"
|
||||||
"github.com/docker/docker/api/types/registry"
|
"github.com/docker/docker/api/types/registry"
|
||||||
@@ -50,6 +52,14 @@ func (c *Client) ListContainers(ctx context.Context, all bool) ([]types.Containe
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListContainersFiltered returns containers matching the given filters.
|
||||||
|
func (c *Client) ListContainersFiltered(ctx context.Context, f filters.Args, all bool) ([]types.Container, error) {
|
||||||
|
return c.cli.ContainerList(ctx, container.ListOptions{
|
||||||
|
All: all,
|
||||||
|
Filters: f,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// GetContainer returns detailed information about a specific container
|
// GetContainer returns detailed information about a specific container
|
||||||
func (c *Client) GetContainer(ctx context.Context, containerID string) (types.ContainerJSON, error) {
|
func (c *Client) GetContainer(ctx context.Context, containerID string) (types.ContainerJSON, error) {
|
||||||
return c.cli.ContainerInspect(ctx, containerID)
|
return c.cli.ContainerInspect(ctx, containerID)
|
||||||
@@ -63,6 +73,7 @@ func (c *Client) CreateContainer(ctx context.Context, config ContainerConfig) (s
|
|||||||
Env: config.Env,
|
Env: config.Env,
|
||||||
Labels: config.Labels,
|
Labels: config.Labels,
|
||||||
ExposedPorts: config.ExposedPorts,
|
ExposedPorts: config.ExposedPorts,
|
||||||
|
Healthcheck: config.Healthcheck,
|
||||||
}
|
}
|
||||||
|
|
||||||
hostConfig := &container.HostConfig{
|
hostConfig := &container.HostConfig{
|
||||||
@@ -168,7 +179,9 @@ func (c *Client) ListImages(ctx context.Context, all bool) ([]image.Summary, err
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// PullImage pulls an image from a registry
|
// PullImage pulls an image from a registry. The pull runs as the returned
|
||||||
|
// stream is consumed — callers must drain and close it (or use
|
||||||
|
// PullImageWait) or the pull may be aborted.
|
||||||
func (c *Client) PullImage(ctx context.Context, ref string, auth registry.AuthConfig) (io.ReadCloser, error) {
|
func (c *Client) PullImage(ctx context.Context, ref string, auth registry.AuthConfig) (io.ReadCloser, error) {
|
||||||
authStr, _ := registry.EncodeAuthConfig(auth)
|
authStr, _ := registry.EncodeAuthConfig(auth)
|
||||||
return c.cli.ImagePull(ctx, ref, image.PullOptions{
|
return c.cli.ImagePull(ctx, ref, image.PullOptions{
|
||||||
@@ -176,6 +189,33 @@ func (c *Client) PullImage(ctx context.Context, ref string, auth registry.AuthCo
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PullImageWait pulls an image and blocks until the pull stream completes.
|
||||||
|
func (c *Client) PullImageWait(ctx context.Context, ref string, auth registry.AuthConfig) error {
|
||||||
|
reader, err := c.PullImage(ctx, ref, auth)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
// The daemon performs the pull while streaming progress; drain it fully
|
||||||
|
// and surface any errorDetail the daemon reports in the stream.
|
||||||
|
dec := json.NewDecoder(reader)
|
||||||
|
var msg struct {
|
||||||
|
ErrorDetail *struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
} `json:"errorDetail"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
for dec.Decode(&msg) == nil {
|
||||||
|
if msg.ErrorDetail != nil {
|
||||||
|
return fmt.Errorf("%s", msg.ErrorDetail.Message)
|
||||||
|
}
|
||||||
|
if msg.Error != "" {
|
||||||
|
return fmt.Errorf("%s", msg.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// BuildImage builds an image from a Dockerfile
|
// BuildImage builds an image from a Dockerfile
|
||||||
func (c *Client) BuildImage(ctx context.Context, buildContext io.Reader, options BuildOptions) (types.ImageBuildResponse, error) {
|
func (c *Client) BuildImage(ctx context.Context, buildContext io.Reader, options BuildOptions) (types.ImageBuildResponse, error) {
|
||||||
return c.cli.ImageBuild(ctx, buildContext, types.ImageBuildOptions{
|
return c.cli.ImageBuild(ctx, buildContext, types.ImageBuildOptions{
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package docker
|
|||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/filters"
|
"github.com/docker/docker/api/types/filters"
|
||||||
"github.com/docker/docker/api/types/mount"
|
"github.com/docker/docker/api/types/mount"
|
||||||
"github.com/docker/docker/api/types/network"
|
"github.com/docker/docker/api/types/network"
|
||||||
@@ -24,6 +25,7 @@ type ContainerConfig struct {
|
|||||||
NanoCPUs int64
|
NanoCPUs int64
|
||||||
NetworkMode string
|
NetworkMode string
|
||||||
Networks map[string]*network.EndpointSettings
|
Networks map[string]*network.EndpointSettings
|
||||||
|
Healthcheck *container.HealthConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
// LogOptions represents options for retrieving container logs
|
// LogOptions represents options for retrieving container logs
|
||||||
|
|||||||
@@ -248,11 +248,18 @@ func ensureLocalUserRecord(c *gin.Context, user betterAuthSessionUser) (string,
|
|||||||
return "", hashErr
|
return "", hashErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The first mirrored account owns the platform.
|
||||||
|
var userCount int
|
||||||
|
if err := db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&userCount); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
isFirstUser := userCount == 0
|
||||||
|
|
||||||
if err := db.QueryRow(`
|
if err := db.QueryRow(`
|
||||||
INSERT INTO users (email, password_hash, name, avatar_url)
|
INSERT INTO users (email, password_hash, name, avatar_url, is_admin)
|
||||||
VALUES ($1, $2, $3, NULLIF($4, ''))
|
VALUES ($1, $2, $3, NULLIF($4, ''), $5)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`, email, string(hashedPassword), name, avatarURL).Scan(&localUserID); err != nil {
|
`, email, string(hashedPassword), name, avatarURL, isFirstUser).Scan(&localUserID); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- In-app platform settings. Values here override environment variables;
|
||||||
|
-- secrets (e.g. Cloudflare tunnel token) are flagged and masked in API reads.
|
||||||
|
CREATE TABLE IF NOT EXISTS public.app_settings (
|
||||||
|
key character varying(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()
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE public.users ADD COLUMN IF NOT EXISTS is_admin boolean NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- The first registered user owns the platform. Backfill existing installs:
|
||||||
|
-- the earliest account becomes admin when no admin is set.
|
||||||
|
UPDATE public.users
|
||||||
|
SET is_admin = true
|
||||||
|
WHERE id = (
|
||||||
|
SELECT id FROM public.users ORDER BY created_at ASC, id ASC LIMIT 1
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM public.users WHERE is_admin);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
ALTER TABLE public.users DROP COLUMN IF EXISTS is_admin;
|
||||||
|
DROP TABLE IF EXISTS public.app_settings;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- Runtime spec for Railway-style services: replica count, the container port
|
||||||
|
-- to expose, an optional public domain routed via Traefik, healthcheck path
|
||||||
|
-- and restart policy.
|
||||||
|
ALTER TABLE public.services ADD COLUMN IF NOT EXISTS replicas integer NOT NULL DEFAULT 1;
|
||||||
|
ALTER TABLE public.services ADD COLUMN IF NOT EXISTS port integer NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE public.services ADD COLUMN IF NOT EXISTS domain character varying(500) NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE public.services ADD COLUMN IF NOT EXISTS healthcheck_path character varying(500) NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE public.services ADD COLUMN IF NOT EXISTS restart_policy character varying(50) NOT NULL DEFAULT 'unless-stopped';
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
ALTER TABLE public.services DROP COLUMN IF EXISTS replicas;
|
||||||
|
ALTER TABLE public.services DROP COLUMN IF EXISTS port;
|
||||||
|
ALTER TABLE public.services DROP COLUMN IF EXISTS domain;
|
||||||
|
ALTER TABLE public.services DROP COLUMN IF EXISTS healthcheck_path;
|
||||||
|
ALTER TABLE public.services DROP COLUMN IF EXISTS restart_policy;
|
||||||
@@ -13,9 +13,10 @@ RUN npm ci
|
|||||||
# Copy source code
|
# Copy source code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Build-time API URL injection for Vite
|
# Build-time API URL injection for Vite. Empty = same-origin via the nginx
|
||||||
ARG VITE_API_URL=http://localhost:8082
|
# /api/ proxy, which keeps the published image usable from any host.
|
||||||
ARG VITE_AUTH_URL=http://localhost:8082/api/auth
|
ARG VITE_API_URL=
|
||||||
|
ARG VITE_AUTH_URL=
|
||||||
ENV VITE_API_URL=${VITE_API_URL}
|
ENV VITE_API_URL=${VITE_API_URL}
|
||||||
ENV VITE_AUTH_URL=${VITE_AUTH_URL}
|
ENV VITE_AUTH_URL=${VITE_AUTH_URL}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ http {
|
|||||||
keepalive_timeout 65;
|
keepalive_timeout 65;
|
||||||
types_hash_max_size 2048;
|
types_hash_max_size 2048;
|
||||||
|
|
||||||
|
# WebSocket upgrade support
|
||||||
|
map $http_upgrade $connection_upgrade {
|
||||||
|
default upgrade;
|
||||||
|
'' close;
|
||||||
|
}
|
||||||
|
|
||||||
# Gzip compression
|
# Gzip compression
|
||||||
gzip on;
|
gzip on;
|
||||||
gzip_vary on;
|
gzip_vary on;
|
||||||
@@ -51,6 +57,22 @@ http {
|
|||||||
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
||||||
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always;
|
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always;
|
||||||
|
|
||||||
|
# Same-origin API proxy - keeps the published image host-agnostic
|
||||||
|
location /api/ {
|
||||||
|
resolver 127.0.0.11 valid=10s;
|
||||||
|
set $backend_upstream http://backend:8080;
|
||||||
|
proxy_pass $backend_upstream;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
}
|
||||||
|
|
||||||
# Handle React Router
|
# Handle React Router
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ export function SignInPage() {
|
|||||||
queryKey: ['auth-bootstrap'],
|
queryKey: ['auth-bootstrap'],
|
||||||
queryFn: getAuthBootstrap,
|
queryFn: getAuthBootstrap,
|
||||||
});
|
});
|
||||||
|
const providers = bootstrapQuery.data?.providers ?? [];
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -168,10 +169,14 @@ export function SignInPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthLayout>
|
<AuthLayout>
|
||||||
<AuthCard title="Sign In" subtitle="Use email/password, GitHub, or Google.">
|
<AuthCard
|
||||||
|
title="Sign In"
|
||||||
|
subtitle={providers.length > 0 ? 'Use email/password or a connected provider.' : 'Use your email and password.'}
|
||||||
|
>
|
||||||
{error ? <AuthErrorNotice message={error} /> : null}
|
{error ? <AuthErrorNotice message={error} /> : null}
|
||||||
{info ? <AuthInfoNotice message={info} /> : null}
|
{info ? <AuthInfoNotice message={info} /> : null}
|
||||||
{bootstrapQuery.isLoading ? <AuthInfoNotice message="Checking platform access mode..." /> : null}
|
{bootstrapQuery.isLoading ? <AuthInfoNotice message="Checking platform access mode..." /> : null}
|
||||||
|
{bootstrapQuery.isError ? <AuthErrorNotice message="Cannot reach the Containr API - check that the backend container is running." /> : null}
|
||||||
|
|
||||||
<form className="space-y-3" onSubmit={submitEmailPassword}>
|
<form className="space-y-3" onSubmit={submitEmailPassword}>
|
||||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
|
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
|
||||||
@@ -211,30 +216,38 @@ export function SignInPage() {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="my-5 flex items-center gap-3">
|
{providers.length > 0 ? (
|
||||||
<div className="h-px flex-1 bg-[var(--border-subtle)]" />
|
<>
|
||||||
<span className="text-[11px] uppercase tracking-wider text-[var(--text-muted)]">or continue with</span>
|
<div className="my-5 flex items-center gap-3">
|
||||||
<div className="h-px flex-1 bg-[var(--border-subtle)]" />
|
<div className="h-px flex-1 bg-[var(--border-subtle)]" />
|
||||||
</div>
|
<span className="text-[11px] uppercase tracking-wider text-[var(--text-muted)]">or continue with</span>
|
||||||
|
<div className="h-px flex-1 bg-[var(--border-subtle)]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2 sm:grid-cols-2">
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
<button
|
{providers.includes('github') ? (
|
||||||
type="button"
|
<button
|
||||||
onClick={() => void signInWithGitHubProvider()}
|
type="button"
|
||||||
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)]"
|
onClick={() => void signInWithGitHubProvider()}
|
||||||
>
|
className="inline-flex h-10 items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm font-medium text-[var(--text-primary)] transition-colors hover:border-[var(--border-default)]"
|
||||||
<Github size={15} />
|
>
|
||||||
GitHub
|
<Github size={15} />
|
||||||
</button>
|
GitHub
|
||||||
<button
|
</button>
|
||||||
type="button"
|
) : null}
|
||||||
onClick={() => void signInWithGoogleProvider()}
|
{providers.includes('google') ? (
|
||||||
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)]"
|
<button
|
||||||
>
|
type="button"
|
||||||
<GoogleMark />
|
onClick={() => void signInWithGoogleProvider()}
|
||||||
Google
|
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)]"
|
||||||
</button>
|
>
|
||||||
</div>
|
<GoogleMark />
|
||||||
|
Google
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="mt-5 flex items-center justify-between text-xs text-[var(--text-secondary)]">
|
<div className="mt-5 flex items-center justify-between text-xs text-[var(--text-secondary)]">
|
||||||
<span>Need access?</span>
|
<span>Need access?</span>
|
||||||
@@ -266,7 +279,8 @@ export function SignUpPage() {
|
|||||||
return <Navigate to={redirectPath} replace />;
|
return <Navigate to={redirectPath} replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bootstrapQuery.data?.mode === 'login') {
|
// Registration stays available when the owner reopened it in Settings.
|
||||||
|
if (bootstrapQuery.data?.mode === 'login' && !bootstrapQuery.data.signupEnabled) {
|
||||||
return <Navigate to={`/auth/sign-in?redirect=${encodeURIComponent(redirectPath)}`} replace />;
|
return <Navigate to={`/auth/sign-in?redirect=${encodeURIComponent(redirectPath)}`} replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,9 +303,15 @@ export function SignUpPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthLayout>
|
<AuthLayout>
|
||||||
<AuthCard title="Create Account" subtitle="Create first platform owner account. Registration closes after bootstrap.">
|
<AuthCard
|
||||||
|
title="Create Account"
|
||||||
|
subtitle={bootstrapQuery.data?.mode === 'register'
|
||||||
|
? 'Create first platform owner account. Registration closes after bootstrap.'
|
||||||
|
: 'Create a new account.'}
|
||||||
|
>
|
||||||
{error ? <AuthErrorNotice message={error} /> : null}
|
{error ? <AuthErrorNotice message={error} /> : null}
|
||||||
{bootstrapQuery.isLoading ? <AuthInfoNotice message="Checking platform bootstrap state..." /> : null}
|
{bootstrapQuery.isLoading ? <AuthInfoNotice message="Checking platform bootstrap state..." /> : null}
|
||||||
|
{bootstrapQuery.isError ? <AuthErrorNotice message="Cannot reach the Containr API - check that the backend container is running." /> : null}
|
||||||
|
|
||||||
<form className="space-y-3" onSubmit={submitSignUp}>
|
<form className="space-y-3" onSubmit={submitSignUp}>
|
||||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
|
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|||||||
import {
|
import {
|
||||||
createProject,
|
createProject,
|
||||||
getHostMonitoring,
|
getHostMonitoring,
|
||||||
listBuilds,
|
listRecentDeployments,
|
||||||
listProjects,
|
listProjects,
|
||||||
type ProjectEntity,
|
type ProjectEntity,
|
||||||
type ProjectStats,
|
type ProjectStats,
|
||||||
@@ -156,10 +156,10 @@ export function ProjectsPage() {
|
|||||||
queryFn: getHostMonitoring,
|
queryFn: getHostMonitoring,
|
||||||
refetchInterval: 30_000,
|
refetchInterval: 30_000,
|
||||||
});
|
});
|
||||||
const buildsQuery = useQuery({
|
const deploysQuery = useQuery({
|
||||||
queryKey: ['recent-deploys'],
|
queryKey: ['recent-deploys'],
|
||||||
enabled: !isDemoMode,
|
enabled: !isDemoMode,
|
||||||
queryFn: () => listBuilds({ page: 1, limit: 5 }),
|
queryFn: () => listRecentDeployments(10),
|
||||||
refetchInterval: 30_000,
|
refetchInterval: 30_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -193,16 +193,16 @@ export function ProjectsPage() {
|
|||||||
const memPct = isDemoMode ? 72 : host ? Math.round(host.memory.usagePercent) : null;
|
const memPct = isDemoMode ? 72 : host ? Math.round(host.memory.usagePercent) : null;
|
||||||
const diskPct = isDemoMode ? 73 : host ? Math.round(host.storage.usagePercent) : null;
|
const diskPct = isDemoMode ? 73 : host ? Math.round(host.storage.usagePercent) : null;
|
||||||
const fmtGB = (v: number) => `${(v / (1024 * 1024 * 1024)).toFixed(0)}G`;
|
const fmtGB = (v: number) => `${(v / (1024 * 1024 * 1024)).toFixed(0)}G`;
|
||||||
const builds = isDemoMode ? [] : buildsQuery.data?.builds ?? [];
|
const deployments = isDemoMode ? [] : deploysQuery.data ?? [];
|
||||||
const deployCount = buildsQuery.data?.total ?? builds.length;
|
const deployCount = deployments.length;
|
||||||
const feed = isDemoMode
|
const feed = isDemoMode
|
||||||
? demoDeploys
|
? demoDeploys
|
||||||
: builds.map((b) => ({
|
: deployments.slice(0, 5).map((d) => ({
|
||||||
id: b.id,
|
id: d.id,
|
||||||
name: b.imageName || b.serviceId || b.id,
|
name: d.serviceName || d.imageName || d.serviceId || d.id,
|
||||||
project: '',
|
project: d.projectName,
|
||||||
status: (b.status || 'queued').toUpperCase(),
|
status: (d.status || 'queued').toUpperCase(),
|
||||||
when: formatRelative(b.startedAt ?? b.completedAt),
|
when: formatRelative(d.startedAt ?? d.completedAt ?? d.createdAt),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const statusClass = (status: string) =>
|
const statusClass = (status: string) =>
|
||||||
@@ -278,7 +278,7 @@ export function ProjectsPage() {
|
|||||||
<div className="v-k"><span>DEPLOYS</span></div>
|
<div className="v-k"><span>DEPLOYS</span></div>
|
||||||
<div className="v-v">{isDemoMode ? 18 : deployCount}</div>
|
<div className="v-v">{isDemoMode ? 18 : deployCount}</div>
|
||||||
<div className="v-meter"><i style={{ width: '94%' }} /></div>
|
<div className="v-meter"><i style={{ width: '94%' }} /></div>
|
||||||
<div className="v-d"><b style={{ color: 'var(--success)' }}>{isDemoMode ? '17 ok' : `${builds.filter((b) => (b.status ?? '').toLowerCase() !== 'failed').length} recent`}</b></div>
|
<div className="v-d"><b style={{ color: 'var(--success)' }}>{isDemoMode ? '17 ok' : `${deployments.filter((d) => (d.status ?? '').toLowerCase() !== 'failed').length} recent`}</b></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ import {
|
|||||||
getGitHubAppInstallUrl,
|
getGitHubAppInstallUrl,
|
||||||
connectGitHubApp,
|
connectGitHubApp,
|
||||||
updateCurrentUserProfile,
|
updateCurrentUserProfile,
|
||||||
|
getPlatformSettings,
|
||||||
|
updatePlatformSettings,
|
||||||
getHAStatus,
|
getHAStatus,
|
||||||
setHAEnabled,
|
setHAEnabled,
|
||||||
triggerFailover,
|
triggerFailover,
|
||||||
@@ -92,6 +94,8 @@ import {
|
|||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Zap,
|
Zap,
|
||||||
HeartPulse,
|
HeartPulse,
|
||||||
|
Cloud,
|
||||||
|
ExternalLink,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
function SecondaryPageHeader({ title, description }: { title: string; description: string }) {
|
function SecondaryPageHeader({ title, description }: { title: string; description: string }) {
|
||||||
@@ -1077,6 +1081,144 @@ function GitProvidersSection() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PlatformSettingsSection() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const settingsQuery = useQuery({
|
||||||
|
queryKey: ['platform-settings'],
|
||||||
|
queryFn: getPlatformSettings,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [signupDraft, setSignupDraft] = useState<boolean | null>(null);
|
||||||
|
const [tokenDraft, setTokenDraft] = useState('');
|
||||||
|
const [feedback, setFeedback] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const signupEnabled = signupDraft ?? settingsQuery.data?.signupEnabled ?? false;
|
||||||
|
const tunnel = settingsQuery.data?.cloudflareTunnel;
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: updatePlatformSettings,
|
||||||
|
onSuccess: (settings) => {
|
||||||
|
queryClient.setQueryData(['platform-settings'], settings);
|
||||||
|
setSignupDraft(null);
|
||||||
|
setTokenDraft('');
|
||||||
|
setFeedback('Settings saved.');
|
||||||
|
setError(null);
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to save settings');
|
||||||
|
setFeedback(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveSignup = (enabled: boolean) => {
|
||||||
|
setSignupDraft(enabled);
|
||||||
|
saveMutation.mutate({ signupEnabled: enabled });
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveToken = () => {
|
||||||
|
if (!tokenDraft.trim()) return;
|
||||||
|
saveMutation.mutate({ cloudflareTunnelToken: tokenDraft.trim() });
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearToken = () => {
|
||||||
|
saveMutation.mutate({ cloudflareTunnelToken: '' });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section 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">
|
||||||
|
<Cloud size={18} className="text-[var(--accent-primary)]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{settingsQuery.isLoading ? (
|
||||||
|
<div className="py-6 text-center">
|
||||||
|
<Loader2 size={18} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<label className="flex items-start gap-3 p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={signupEnabled}
|
||||||
|
onChange={(e) => saveSignup(e.target.checked)}
|
||||||
|
disabled={saveMutation.isPending}
|
||||||
|
className="mt-0.5 accent-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium text-[var(--text-primary)]">Open registration</span>
|
||||||
|
<p className="text-xs text-[var(--text-tertiary)] mt-0.5">
|
||||||
|
Let anyone with the URL create an account. Off by default after the owner account is created.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Cloudflare Tunnel</span>
|
||||||
|
<span className="text-xs text-[var(--text-tertiary)]">
|
||||||
|
{tunnel?.tokenSet
|
||||||
|
? `token saved (${tunnel.source}) - ${tunnel.container}`
|
||||||
|
: tunnel?.container === 'unavailable'
|
||||||
|
? 'Docker socket unavailable'
|
||||||
|
: 'not configured'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-[var(--text-secondary)] mb-3">
|
||||||
|
Expose this instance through a Cloudflare Tunnel. Create a tunnel in the dashboard,
|
||||||
|
copy the token, paste it here - the cloudflared sidecar is managed for you.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={tokenDraft}
|
||||||
|
onChange={(e) => setTokenDraft(e.target.value)}
|
||||||
|
className="flex-1 h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-void)] text-sm focus:border-[var(--accent-primary)] transition-colors"
|
||||||
|
placeholder={tunnel?.tokenSet ? 'Token saved - paste a new one to rotate' : 'eyJhIjo... tunnel token'}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={saveToken}
|
||||||
|
disabled={saveMutation.isPending || !tokenDraft.trim()}
|
||||||
|
className="h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
style={{ background: 'var(--accent-primary)' }}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
{tunnel?.tokenSet && tunnel.source === 'app' ? (
|
||||||
|
<button
|
||||||
|
onClick={clearToken}
|
||||||
|
disabled={saveMutation.isPending}
|
||||||
|
className="h-9 px-3 rounded-[var(--radius-md)] border border-[var(--error-soft)] text-[var(--error)] text-sm font-medium hover:bg-[var(--error-soft)] disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="https://one.dash.cloudflare.com/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex items-center gap-1.5 mt-3 text-xs text-[var(--accent-primary)] hover:underline"
|
||||||
|
>
|
||||||
|
Open Cloudflare Zero Trust dashboard - Networks → Tunnels → Create
|
||||||
|
<ExternalLink size={12} />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{feedback ? <p className="text-xs text-[var(--success)]">{feedback}</p> : null}
|
||||||
|
{error ? <p className="text-xs text-[var(--error)]">{error}</p> : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function SettingsPage() {
|
export function SettingsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -1238,6 +1380,9 @@ export function SettingsPage() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Platform Settings - owner only */}
|
||||||
|
{profileQuery.data?.isAdmin ? <PlatformSettingsSection /> : null}
|
||||||
|
|
||||||
{/* Git Providers Section */}
|
{/* Git Providers Section */}
|
||||||
<GitProvidersSection />
|
<GitProvidersSection />
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ import {
|
|||||||
execInService,
|
execInService,
|
||||||
rollbackDeployment,
|
rollbackDeployment,
|
||||||
updateServiceVariables,
|
updateServiceVariables,
|
||||||
|
updateService,
|
||||||
|
restartService,
|
||||||
|
stopService,
|
||||||
|
getServiceRuntime,
|
||||||
type CronJobEntity,
|
type CronJobEntity,
|
||||||
} from '@/lib/api-client';
|
} from '@/lib/api-client';
|
||||||
import { getDemoProjectById, getDemoServiceById, getDemoCronJobsByService } from '@/lib/demo-data';
|
import { getDemoProjectById, getDemoServiceById, getDemoCronJobsByService } from '@/lib/demo-data';
|
||||||
@@ -284,7 +288,11 @@ export function ServiceDetailPage() {
|
|||||||
});
|
});
|
||||||
const manualScaleMutation = useMutation({
|
const manualScaleMutation = useMutation({
|
||||||
mutationFn: (replicas: number) => manualScaleService(serviceId, replicas),
|
mutationFn: (replicas: number) => manualScaleService(serviceId, replicas),
|
||||||
onSuccess: invalidateScaling,
|
onSuccess: () => {
|
||||||
|
invalidateScaling();
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['service-runtime', serviceId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['project-services'] });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Console ---
|
// --- Console ---
|
||||||
@@ -365,6 +373,39 @@ export function ServiceDetailPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const invalidateService = () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['service', serviceId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['service-runtime', serviceId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['project-services', projectId] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const restartMutation = useMutation({
|
||||||
|
mutationFn: () => restartService(serviceId),
|
||||||
|
onSuccess: invalidateService,
|
||||||
|
});
|
||||||
|
const stopMutation = useMutation({
|
||||||
|
mutationFn: () => stopService(serviceId),
|
||||||
|
onSuccess: invalidateService,
|
||||||
|
});
|
||||||
|
const updateServiceMutation = useMutation({
|
||||||
|
mutationFn: (input: Parameters<typeof updateService>[1]) => updateService(serviceId, input),
|
||||||
|
onSuccess: () => {
|
||||||
|
setNetworkForm(null);
|
||||||
|
invalidateService();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const runtimeQuery = useQuery({
|
||||||
|
queryKey: ['service-runtime', serviceId],
|
||||||
|
queryFn: () => getServiceRuntime(serviceId),
|
||||||
|
enabled: Boolean(serviceId) && !isDemoMode,
|
||||||
|
refetchInterval: 10_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [networkForm, setNetworkForm] = useState<{
|
||||||
|
port: string; domain: string; healthcheckPath: string; restartPolicy: string; replicas: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const project = isDemoMode ? getDemoProjectById(projectId) : projectQuery.data;
|
const project = isDemoMode ? getDemoProjectById(projectId) : projectQuery.data;
|
||||||
const service = isDemoMode ? getDemoServiceById(serviceId) : serviceQuery.data;
|
const service = isDemoMode ? getDemoServiceById(serviceId) : serviceQuery.data;
|
||||||
|
|
||||||
@@ -552,21 +593,23 @@ export function ServiceDetailPage() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex items-center" style={{ gap: '10px' }}>
|
<div className="flex items-center" style={{ gap: '10px' }}>
|
||||||
<span style={{ fontSize: '20px', fontWeight: 800, letterSpacing: '-0.5px', color: '#e8e9f0' }}>{service.name}</span>
|
<span style={{ fontSize: '20px', fontWeight: 800, letterSpacing: '-0.5px', color: '#e8e9f0' }}>{service.name}</span>
|
||||||
<span className={`badge-${service.status === 'running' ? 'active' : 'stopped'}`}>
|
<span
|
||||||
|
className={`badge-${service.status === 'running' ? 'active' : service.status === 'degraded' ? 'degraded' : 'stopped'}`}
|
||||||
|
>
|
||||||
{service.status === 'running' && <span className="live-dot" />}
|
{service.status === 'running' && <span className="live-dot" />}
|
||||||
{service.status === 'running' ? 'Active' : 'Stopped'}
|
{service.status === 'running' ? 'Active' : service.status === 'degraded' ? 'Degraded' : 'Stopped'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center" style={{ gap: '16px', marginTop: '4px' }}>
|
<div className="flex items-center" style={{ gap: '16px', marginTop: '4px' }}>
|
||||||
{service.status === 'running' && (
|
{(service.publicUrl || service.domain) && (
|
||||||
<a
|
<a
|
||||||
href={`https://${service.name}.containr.dev`}
|
href={service.publicUrl ?? `https://${service.domain}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-center hover:text-[#9295a4] transition-colors"
|
className="flex items-center hover:text-[#9295a4] transition-colors"
|
||||||
style={{ color: '#6b6e7d', fontSize: '12.5px', textDecoration: 'none', gap: '4px' }}
|
style={{ color: '#6b6e7d', fontSize: '12.5px', textDecoration: 'none', gap: '4px' }}
|
||||||
>
|
>
|
||||||
https://{service.name}.containr.dev
|
{service.publicUrl ?? `https://${service.domain}`}
|
||||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
||||||
<polyline points="15 3 21 3 21 9"/>
|
<polyline points="15 3 21 3 21 9"/>
|
||||||
@@ -590,15 +633,26 @@ export function ServiceDetailPage() {
|
|||||||
{!isDemoMode && (
|
{!isDemoMode && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => deployMutation.mutate('restart')}
|
onClick={() => stopMutation.mutate()}
|
||||||
disabled={deployMutation.isPending || service.status !== 'running'}
|
disabled={stopMutation.isPending || (service.status !== 'running' && service.status !== 'degraded')}
|
||||||
className={`btn-stop ${service.status !== 'running' ? 'disabled' : ''}`}
|
className={`btn-stop ${service.status !== 'running' && service.status !== 'degraded' ? 'disabled' : ''}`}
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
|
||||||
<circle cx="12" cy="12" r="10"/>
|
<circle cx="12" cy="12" r="10"/>
|
||||||
<circle cx="12" cy="12" r="3" fill="currentColor" stroke="none"/>
|
<circle cx="12" cy="12" r="3" fill="currentColor" stroke="none"/>
|
||||||
</svg>
|
</svg>
|
||||||
STOP
|
{stopMutation.isPending ? 'STOPPING…' : 'STOP'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => restartMutation.mutate()}
|
||||||
|
disabled={restartMutation.isPending || (service.status !== 'running' && service.status !== 'degraded')}
|
||||||
|
className={`btn-restart ${service.status !== 'running' && service.status !== 'degraded' ? 'disabled' : ''}`}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
|
||||||
|
<polyline points="1 4 1 10 7 10"/>
|
||||||
|
<path d="M3.51 15a9 9 0 1 0 .49-4.5"/>
|
||||||
|
</svg>
|
||||||
|
{restartMutation.isPending ? 'RESTARTING…' : 'RESTART'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => deployMutation.mutate('manual')}
|
onClick={() => deployMutation.mutate('manual')}
|
||||||
@@ -606,10 +660,10 @@ export function ServiceDetailPage() {
|
|||||||
className={`btn-restart ${deployMutation.isPending ? 'disabled' : ''}`}
|
className={`btn-restart ${deployMutation.isPending ? 'disabled' : ''}`}
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
|
||||||
<polyline points="1 4 1 10 7 10"/>
|
<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/>
|
||||||
<path d="M3.51 15a9 9 0 1 0 .49-4.5"/>
|
<path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/>
|
||||||
</svg>
|
</svg>
|
||||||
RESTART
|
{deployMutation.isPending ? 'DEPLOYING…' : 'DEPLOY'}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -1078,7 +1132,11 @@ export function ServiceDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Environment Variables</h2>
|
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Environment Variables</h2>
|
||||||
<p className="text-sm text-[var(--text-secondary)]">Runtime values injected into the service container</p>
|
<p className="text-sm text-[var(--text-secondary)]">
|
||||||
|
Runtime values injected at deploy. Reference another service's variable with{' '}
|
||||||
|
<code className="mono text-[var(--accent-primary)]">{'${{service.KEY}}'}</code> — e.g.{' '}
|
||||||
|
<code className="mono text-[var(--accent-primary)]">{'${{postgres.POSTGRES_PASSWORD}}'}</code>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -1543,9 +1601,11 @@ export function ServiceDetailPage() {
|
|||||||
<div className="rounded-[var(--radius-md)] border border-[var(--border-primary)] p-3">
|
<div className="rounded-[var(--radius-md)] border border-[var(--border-primary)] p-3">
|
||||||
<p className="text-xs text-[var(--text-tertiary)] uppercase tracking-wide">Replicas</p>
|
<p className="text-xs text-[var(--text-tertiary)] uppercase tracking-wide">Replicas</p>
|
||||||
<p className="text-xl font-semibold text-[var(--text-primary)]">
|
<p className="text-xl font-semibold text-[var(--text-primary)]">
|
||||||
{scalingStateQuery.data?.CurrentReplicas ?? '—'}
|
{scalingStateQuery.data?.CurrentReplicas ??
|
||||||
|
runtimeQuery.data?.containers.filter((c) => c.state === 'running').length ??
|
||||||
|
'—'}
|
||||||
<span className="text-sm font-normal text-[var(--text-secondary)]">
|
<span className="text-sm font-normal text-[var(--text-secondary)]">
|
||||||
{' '}/ {scalingStateQuery.data?.DesiredReplicas ?? '—'} desired
|
{' '}/ {scalingStateQuery.data?.DesiredReplicas ?? runtimeQuery.data?.desired ?? service?.replicas ?? '—'} desired
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1585,7 +1645,7 @@ export function ServiceDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={manualScaleMutation.isPending || !scaleReplicas || !scalingStateQuery.data}
|
disabled={manualScaleMutation.isPending || !scaleReplicas}
|
||||||
onClick={() => manualScaleMutation.mutate(Number(scaleReplicas))}
|
onClick={() => manualScaleMutation.mutate(Number(scaleReplicas))}
|
||||||
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-contrast)] text-sm font-medium disabled:opacity-50"
|
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-contrast)] text-sm font-medium disabled:opacity-50"
|
||||||
>
|
>
|
||||||
@@ -1775,6 +1835,159 @@ export function ServiceDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!isDemoMode && (
|
||||||
|
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-sm font-medium text-[var(--text-primary)]">Networking</h3>
|
||||||
|
{networkForm === null && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setNetworkForm({
|
||||||
|
port: service.port ? String(service.port) : '',
|
||||||
|
domain: service.domain ?? '',
|
||||||
|
healthcheckPath: service.healthcheckPath ?? '',
|
||||||
|
restartPolicy: service.restartPolicy ?? 'unless-stopped',
|
||||||
|
replicas: String(service.replicas ?? 1),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium hover:border-[var(--border-default)] transition-colors"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{runtimeQuery.data && (
|
||||||
|
<div className="mb-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]/40 p-3">
|
||||||
|
<div className="flex items-center gap-4 text-xs">
|
||||||
|
<span className="text-[var(--text-tertiary)]">
|
||||||
|
Live: <span className="text-[var(--text-primary)] font-medium">{runtimeQuery.data.containers.filter((c) => c.state === 'running').length}/{runtimeQuery.data.desired}</span> replicas
|
||||||
|
</span>
|
||||||
|
<span className="text-[var(--text-tertiary)]">
|
||||||
|
Internal: <span className="mono text-[var(--text-primary)]">{service.name}{service.port ? `:${service.port}` : ''}</span>
|
||||||
|
</span>
|
||||||
|
{runtimeQuery.data.urls.map((url) => (
|
||||||
|
<a key={url} href={url} target="_blank" rel="noopener noreferrer" className="mono text-[var(--accent-primary)] hover:underline">
|
||||||
|
{url}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{networkForm === null ? (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 text-xs">
|
||||||
|
<div className="rounded-[var(--radius-md)] border border-[var(--border-primary)] p-3">
|
||||||
|
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Port</p>
|
||||||
|
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">{service.port || '—'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[var(--radius-md)] border border-[var(--border-primary)] p-3">
|
||||||
|
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Domain</p>
|
||||||
|
<p className="mt-1 text-sm font-medium text-[var(--text-primary)] truncate">{service.domain || '—'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[var(--radius-md)] border border-[var(--border-primary)] p-3">
|
||||||
|
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Replicas</p>
|
||||||
|
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">{service.replicas ?? 1}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[var(--radius-md)] border border-[var(--border-primary)] p-3">
|
||||||
|
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Restart</p>
|
||||||
|
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">{service.restartPolicy || 'unless-stopped'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-[var(--radius-md)] border border-[var(--border-primary)] p-4 space-y-4">
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Container port</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={65535}
|
||||||
|
value={networkForm.port}
|
||||||
|
onChange={(e) => setNetworkForm({ ...networkForm, port: e.target.value })}
|
||||||
|
placeholder="e.g. 3000"
|
||||||
|
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--bg-secondary)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)]"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-[10px] text-[var(--text-tertiary)]">Published on the host; routed via Traefik when a domain is set</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Domain</label>
|
||||||
|
<input
|
||||||
|
value={networkForm.domain}
|
||||||
|
onChange={(e) => setNetworkForm({ ...networkForm, domain: e.target.value })}
|
||||||
|
placeholder="app.example.com"
|
||||||
|
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--bg-secondary)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Health check path</label>
|
||||||
|
<input
|
||||||
|
value={networkForm.healthcheckPath}
|
||||||
|
onChange={(e) => setNetworkForm({ ...networkForm, healthcheckPath: e.target.value })}
|
||||||
|
placeholder="/health"
|
||||||
|
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--bg-secondary)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Replicas</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={20}
|
||||||
|
value={networkForm.replicas}
|
||||||
|
onChange={(e) => setNetworkForm({ ...networkForm, replicas: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--bg-secondary)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Restart policy</label>
|
||||||
|
<select
|
||||||
|
value={networkForm.restartPolicy}
|
||||||
|
onChange={(e) => setNetworkForm({ ...networkForm, restartPolicy: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--bg-secondary)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)]"
|
||||||
|
>
|
||||||
|
<option value="unless-stopped">unless-stopped</option>
|
||||||
|
<option value="always">always</option>
|
||||||
|
<option value="on-failure">on-failure</option>
|
||||||
|
<option value="no">no</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={updateServiceMutation.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
updateServiceMutation.mutate({
|
||||||
|
port: Number(networkForm.port) || 0,
|
||||||
|
domain: networkForm.domain,
|
||||||
|
healthcheck_path: networkForm.healthcheckPath,
|
||||||
|
restart_policy: networkForm.restartPolicy,
|
||||||
|
replicas: Math.max(1, Math.min(20, Number(networkForm.replicas) || 1)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-contrast)] text-sm font-medium disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{updateServiceMutation.isPending ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setNetworkForm(null)}
|
||||||
|
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)]"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<p className="text-[10px] text-[var(--text-tertiary)]">Takes effect on next deploy or redeploy</p>
|
||||||
|
</div>
|
||||||
|
{updateServiceMutation.isError && (
|
||||||
|
<p className="text-xs text-[var(--error)]">{(updateServiceMutation.error as Error).message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{!isDemoMode && (
|
{!isDemoMode && (
|
||||||
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
|
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
|||||||
@@ -11,7 +11,16 @@ import {
|
|||||||
type NodeTypes,
|
type NodeTypes,
|
||||||
} from '@xyflow/react';
|
} from '@xyflow/react';
|
||||||
import '@xyflow/react/dist/style.css';
|
import '@xyflow/react/dist/style.css';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import type { ServiceEntity } from '@/lib/api-client';
|
import type { ServiceEntity } from '@/lib/api-client';
|
||||||
|
import {
|
||||||
|
createDeployment,
|
||||||
|
deleteService,
|
||||||
|
restartService,
|
||||||
|
startService,
|
||||||
|
stopService,
|
||||||
|
} from '@/lib/api-client';
|
||||||
|
import { useToast } from '@/shared/hooks/use-toast';
|
||||||
import { inferAutoConnections, type ServiceVariable } from '../auto-connections';
|
import { inferAutoConnections, type ServiceVariable } from '../auto-connections';
|
||||||
import {
|
import {
|
||||||
DEFAULT_VIEWPORT,
|
DEFAULT_VIEWPORT,
|
||||||
@@ -21,7 +30,20 @@ import {
|
|||||||
} from '../model';
|
} from '../model';
|
||||||
import { canvasStorageKey, loadCanvasMetadata, saveCanvasMetadata } from '../storage';
|
import { canvasStorageKey, loadCanvasMetadata, saveCanvasMetadata } from '../storage';
|
||||||
import { GroupNode, ServiceNode, type GroupNodeData, type ServiceNodeData } from './nodes';
|
import { GroupNode, ServiceNode, type GroupNodeData, type ServiceNodeData } from './nodes';
|
||||||
import { Plus, Layers, Maximize2, RotateCcw, Box, Link2 } from 'lucide-react';
|
import {
|
||||||
|
Plus,
|
||||||
|
Layers,
|
||||||
|
Maximize2,
|
||||||
|
RotateCcw,
|
||||||
|
Box,
|
||||||
|
Link2,
|
||||||
|
Play,
|
||||||
|
Square,
|
||||||
|
Rocket,
|
||||||
|
Trash2,
|
||||||
|
ArrowUpRight,
|
||||||
|
Globe,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
type CanvasProps = {
|
type CanvasProps = {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -46,6 +68,12 @@ const nodeTypes: NodeTypes = {
|
|||||||
groupNode: GroupNode,
|
groupNode: GroupNode,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ContextMenuState = {
|
||||||
|
serviceId: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
} | null;
|
||||||
|
|
||||||
function toFlowNodes(metadata: ProjectCanvasMetadata, services: ServiceEntity[], onOpenService: CanvasProps['onOpenService']): CanvasNode[] {
|
function toFlowNodes(metadata: ProjectCanvasMetadata, services: ServiceEntity[], onOpenService: CanvasProps['onOpenService']): CanvasNode[] {
|
||||||
const groups = metadata.groups.map(
|
const groups = metadata.groups.map(
|
||||||
(group): GroupCanvasNode => ({
|
(group): GroupCanvasNode => ({
|
||||||
@@ -91,27 +119,40 @@ function toFlowNodes(metadata: ProjectCanvasMetadata, services: ServiceEntity[],
|
|||||||
return [...groups, ...serviceNodes];
|
return [...groups, ...serviceNodes];
|
||||||
}
|
}
|
||||||
|
|
||||||
function toFlowEdges(links: ReturnType<typeof inferAutoConnections>): CanvasEdge[] {
|
function toFlowEdges(
|
||||||
return links.map((link) => ({
|
links: ReturnType<typeof inferAutoConnections>,
|
||||||
id: link.edge.id,
|
positionOf: (serviceId: string) => { x: number; y: number } | undefined,
|
||||||
source: link.edge.sourceServiceId,
|
): CanvasEdge[] {
|
||||||
target: link.edge.targetServiceId,
|
return links.map((link) => {
|
||||||
animated: false,
|
const source = positionOf(link.edge.sourceServiceId);
|
||||||
data: {
|
const target = positionOf(link.edge.targetServiceId);
|
||||||
reasons: link.reasons,
|
// Choose handle sides by relative position so edges flow forward
|
||||||
},
|
// instead of looping back through the canvas.
|
||||||
markerEnd: {
|
const forward = !source || !target || source.x <= target.x;
|
||||||
type: MarkerType.ArrowClosed,
|
|
||||||
color: 'var(--accent-secondary)',
|
return {
|
||||||
width: 14,
|
id: link.edge.id,
|
||||||
height: 14,
|
source: link.edge.sourceServiceId,
|
||||||
},
|
target: link.edge.targetServiceId,
|
||||||
style: {
|
sourceHandle: forward ? 's-r' : 's-l',
|
||||||
stroke: 'var(--accent-primary)',
|
targetHandle: forward ? 't-l' : 't-r',
|
||||||
strokeWidth: 2,
|
animated: false,
|
||||||
},
|
data: {
|
||||||
className: 'edge-premium',
|
reasons: link.reasons,
|
||||||
}));
|
},
|
||||||
|
markerEnd: {
|
||||||
|
type: MarkerType.ArrowClosed,
|
||||||
|
color: 'var(--accent-secondary)',
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
stroke: 'var(--accent-primary)',
|
||||||
|
strokeWidth: 2,
|
||||||
|
},
|
||||||
|
className: 'edge-premium',
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMetadataFromFlow(nodes: CanvasNode[], viewport: { x: number; y: number; zoom: number }): ProjectCanvasMetadata {
|
function buildMetadataFromFlow(nodes: CanvasNode[], viewport: { x: number; y: number; zoom: number }): ProjectCanvasMetadata {
|
||||||
@@ -157,9 +198,24 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
|||||||
const [nodes, setNodes, onNodesChange] = useNodesState<CanvasNode>([]);
|
const [nodes, setNodes, onNodesChange] = useNodesState<CanvasNode>([]);
|
||||||
const [selectedServiceId, setSelectedServiceId] = useState<string | null>(null);
|
const [selectedServiceId, setSelectedServiceId] = useState<string | null>(null);
|
||||||
const [viewportTick, setViewportTick] = useState(0);
|
const [viewportTick, setViewportTick] = useState(0);
|
||||||
|
const [envFilter, setEnvFilter] = useState<string>('all');
|
||||||
|
const [contextMenu, setContextMenu] = useState<ContextMenuState>(null);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
const { fitView, getInternalNode, getViewport, screenToFlowPosition, setViewport } = useReactFlow<CanvasNode, CanvasEdge>();
|
const { fitView, getInternalNode, getViewport, screenToFlowPosition, setViewport } = useReactFlow<CanvasNode, CanvasEdge>();
|
||||||
|
|
||||||
|
const environments = useMemo(() => {
|
||||||
|
const envs = new Set(services.map((service) => service.environment ?? 'production'));
|
||||||
|
return Array.from(envs).sort();
|
||||||
|
}, [services]);
|
||||||
|
|
||||||
|
const visibleServices = useMemo(
|
||||||
|
() => (envFilter === 'all' ? services : services.filter((service) => (service.environment ?? 'production') === envFilter)),
|
||||||
|
[services, envFilter],
|
||||||
|
);
|
||||||
|
const visibleIds = useMemo(() => new Set(visibleServices.map((service) => service.id)), [visibleServices]);
|
||||||
|
|
||||||
const serviceFingerprint = useMemo(
|
const serviceFingerprint = useMemo(
|
||||||
() => services.map((service) => service.id).sort().join('|'),
|
() => services.map((service) => service.id).sort().join('|'),
|
||||||
[services],
|
[services],
|
||||||
@@ -168,7 +224,67 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
|||||||
() => inferAutoConnections(services, variablesByService),
|
() => inferAutoConnections(services, variablesByService),
|
||||||
[services, variablesByService],
|
[services, variablesByService],
|
||||||
);
|
);
|
||||||
const edges = useMemo(() => toFlowEdges(inferredLinks), [inferredLinks]);
|
const edges = useMemo(() => {
|
||||||
|
const positionOf = (serviceId: string) => nodes.find((node) => node.id === serviceId)?.position;
|
||||||
|
return toFlowEdges(inferredLinks, positionOf).filter(
|
||||||
|
(edge) => visibleIds.has(edge.source) && visibleIds.has(edge.target),
|
||||||
|
);
|
||||||
|
}, [inferredLinks, visibleIds, nodes]);
|
||||||
|
|
||||||
|
const invalidateServices = useCallback(() => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['project-services', projectId] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['service-runtime'] });
|
||||||
|
}, [projectId, queryClient]);
|
||||||
|
|
||||||
|
const actionError = (error: unknown) =>
|
||||||
|
toast.showToast(error instanceof Error ? error.message : 'Action failed', 'error');
|
||||||
|
|
||||||
|
const deployMutation = useMutation({
|
||||||
|
mutationFn: (serviceId: string) => createDeployment(serviceId, { trigger: 'manual' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.showToast('Deployment started', 'success');
|
||||||
|
invalidateServices();
|
||||||
|
},
|
||||||
|
onError: actionError,
|
||||||
|
});
|
||||||
|
const startMutation = useMutation({
|
||||||
|
mutationFn: (serviceId: string) => startService(serviceId),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.showToast('Service started', 'success');
|
||||||
|
invalidateServices();
|
||||||
|
},
|
||||||
|
onError: actionError,
|
||||||
|
});
|
||||||
|
const restartMutation = useMutation({
|
||||||
|
mutationFn: (serviceId: string) => restartService(serviceId),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.showToast('Service restarted', 'success');
|
||||||
|
invalidateServices();
|
||||||
|
},
|
||||||
|
onError: actionError,
|
||||||
|
});
|
||||||
|
const stopMutation = useMutation({
|
||||||
|
mutationFn: (serviceId: string) => stopService(serviceId),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.showToast('Service stopped', 'success');
|
||||||
|
invalidateServices();
|
||||||
|
},
|
||||||
|
onError: actionError,
|
||||||
|
});
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (serviceId: string) => deleteService(serviceId),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.showToast('Service deleted', 'success');
|
||||||
|
invalidateServices();
|
||||||
|
},
|
||||||
|
onError: actionError,
|
||||||
|
});
|
||||||
|
const actionPending =
|
||||||
|
deployMutation.isPending ||
|
||||||
|
startMutation.isPending ||
|
||||||
|
restartMutation.isPending ||
|
||||||
|
stopMutation.isPending ||
|
||||||
|
deleteMutation.isPending;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const hasStoredViewport = localStorage.getItem(canvasStorageKey(projectId)) !== null;
|
const hasStoredViewport = localStorage.getItem(canvasStorageKey(projectId)) !== null;
|
||||||
@@ -347,6 +463,35 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
|||||||
]);
|
]);
|
||||||
}, [screenToFlowPosition, setNodes]);
|
}, [screenToFlowPosition, setNodes]);
|
||||||
|
|
||||||
|
// Env filter hides nodes via React Flow's `hidden` flag — positions and
|
||||||
|
// persisted layout stay intact for services outside the current filter.
|
||||||
|
const renderedNodes = useMemo(
|
||||||
|
() =>
|
||||||
|
nodes.map((node) =>
|
||||||
|
node.type === 'serviceNode' && !visibleIds.has(node.id) ? { ...node, hidden: true } : node,
|
||||||
|
),
|
||||||
|
[nodes, visibleIds],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onNodeContextMenu = useCallback(
|
||||||
|
(event: MouseEvent, node: CanvasNode) => {
|
||||||
|
if (node.type !== 'serviceNode') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
const bounds = wrapperRef.current?.getBoundingClientRect();
|
||||||
|
setSelectedServiceId(node.id);
|
||||||
|
const menuW = 190;
|
||||||
|
const menuH = 230;
|
||||||
|
const x = Math.min(event.clientX - (bounds?.left ?? 0), Math.max(0, (bounds?.width ?? 0) - menuW));
|
||||||
|
const y = Math.min(event.clientY - (bounds?.top ?? 0), Math.max(0, (bounds?.height ?? 0) - menuH));
|
||||||
|
setContextMenu({ serviceId: node.id, x, y });
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const contextService = contextMenu ? services.find((service) => service.id === contextMenu.serviceId) ?? null : null;
|
||||||
|
|
||||||
const selectedService = services.find((service) => service.id === selectedServiceId) ?? null;
|
const selectedService = services.find((service) => service.id === selectedServiceId) ?? null;
|
||||||
const selectedServiceLinkCount = selectedService
|
const selectedServiceLinkCount = selectedService
|
||||||
? inferredLinks.filter(
|
? inferredLinks.filter(
|
||||||
@@ -378,6 +523,28 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
|||||||
Group
|
Group
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{environments.length > 1 && (
|
||||||
|
<>
|
||||||
|
<div className="w-px h-5 bg-[var(--border-subtle)] mx-1" />
|
||||||
|
<div className="flex items-center gap-1 rounded-lg bg-[var(--surface-muted)] border border-[var(--border-subtle)] p-1">
|
||||||
|
{['all', ...environments].map((env) => (
|
||||||
|
<button
|
||||||
|
key={env}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEnvFilter(env)}
|
||||||
|
className={`px-2.5 py-1 rounded-md text-xs 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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -411,14 +578,19 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Canvas */}
|
{/* Canvas */}
|
||||||
<div ref={wrapperRef} className="subtle-grid h-[66vh] min-h-[420px] bg-[var(--bg-void)]">
|
<div ref={wrapperRef} className="subtle-grid h-[66vh] min-h-[420px] bg-[var(--bg-void)] relative">
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
nodes={nodes}
|
nodes={renderedNodes}
|
||||||
edges={edges}
|
edges={edges}
|
||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
onNodesChange={onNodesChange}
|
onNodesChange={onNodesChange}
|
||||||
onPaneClick={() => setSelectedServiceId(null)}
|
onPaneClick={() => {
|
||||||
|
setSelectedServiceId(null);
|
||||||
|
setContextMenu(null);
|
||||||
|
}}
|
||||||
|
onMoveStart={() => setContextMenu(null)}
|
||||||
onNodeClick={(_event, node) => {
|
onNodeClick={(_event, node) => {
|
||||||
|
setContextMenu(null);
|
||||||
if (node.type === 'serviceNode') {
|
if (node.type === 'serviceNode') {
|
||||||
setSelectedServiceId(node.id);
|
setSelectedServiceId(node.id);
|
||||||
}
|
}
|
||||||
@@ -428,6 +600,7 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
|||||||
onOpenService(node.id);
|
onOpenService(node.id);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onNodeContextMenu={onNodeContextMenu}
|
||||||
onNodeDragStop={onNodeDragStop}
|
onNodeDragStop={onNodeDragStop}
|
||||||
onMoveEnd={() => setViewportTick((value) => value + 1)}
|
onMoveEnd={() => setViewportTick((value) => value + 1)}
|
||||||
panOnDrag
|
panOnDrag
|
||||||
@@ -443,6 +616,132 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
|||||||
</svg>
|
</svg>
|
||||||
<Background color="rgba(255,255,255,0.05)" gap={28} />
|
<Background color="rgba(255,255,255,0.05)" gap={28} />
|
||||||
</ReactFlow>
|
</ReactFlow>
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{services.length === 0 && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||||
|
<div className="text-center pointer-events-auto">
|
||||||
|
<div className="w-14 h-14 mx-auto mb-4 rounded-2xl bg-[var(--surface-card)] border border-[var(--border-subtle)] flex items-center justify-center">
|
||||||
|
<Rocket size={24} className="text-[var(--accent-primary)]" />
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onAddService}
|
||||||
|
className="inline-flex items-center gap-2 h-9 px-4 rounded-lg text-[var(--accent-on)] text-sm font-medium shadow-lg"
|
||||||
|
style={{ background: 'var(--accent-primary)' }}
|
||||||
|
>
|
||||||
|
<Plus size={15} />
|
||||||
|
Add Service
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Node context menu */}
|
||||||
|
{contextMenu && contextService && (
|
||||||
|
<div
|
||||||
|
className="absolute z-50 min-w-[180px] rounded-[var(--radius-md)] border border-[var(--border-default)] bg-[var(--surface-card)] shadow-2xl py-1"
|
||||||
|
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||||
|
>
|
||||||
|
<div className="px-3 py-1.5 border-b border-[var(--border-subtle)] mb-1">
|
||||||
|
<p className="text-xs font-semibold text-[var(--text-primary)] truncate">{contextService.name}</p>
|
||||||
|
<p className="text-[10px] text-[var(--text-tertiary)]">{contextService.status}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setContextMenu(null);
|
||||||
|
onOpenService(contextService.id);
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowUpRight size={13} />
|
||||||
|
Open service
|
||||||
|
</button>
|
||||||
|
{(contextService.domain || contextService.publicUrl) && (
|
||||||
|
<a
|
||||||
|
href={contextService.domain ? `https://${contextService.domain}` : contextService.publicUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] transition-colors"
|
||||||
|
>
|
||||||
|
<Globe size={13} />
|
||||||
|
Visit {(contextService.domain ?? contextService.publicUrl ?? '').replace(/^https?:\/\//, '')}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={actionPending}
|
||||||
|
onClick={() => {
|
||||||
|
setContextMenu(null);
|
||||||
|
deployMutation.mutate(contextService.id);
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Rocket size={13} />
|
||||||
|
Deploy
|
||||||
|
</button>
|
||||||
|
{contextService.status !== 'running' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={actionPending}
|
||||||
|
onClick={() => {
|
||||||
|
setContextMenu(null);
|
||||||
|
startMutation.mutate(contextService.id);
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Play size={13} />
|
||||||
|
Start
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={actionPending || contextService.status !== 'running'}
|
||||||
|
onClick={() => {
|
||||||
|
setContextMenu(null);
|
||||||
|
restartMutation.mutate(contextService.id);
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Play size={13} />
|
||||||
|
Restart
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={actionPending || contextService.status !== 'running'}
|
||||||
|
onClick={() => {
|
||||||
|
setContextMenu(null);
|
||||||
|
stopMutation.mutate(contextService.id);
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Square size={13} />
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
<div className="border-t border-[var(--border-subtle)] mt-1 pt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={actionPending}
|
||||||
|
onClick={() => {
|
||||||
|
setContextMenu(null);
|
||||||
|
if (window.confirm(`Delete service "${contextService.name}"? Its containers will be removed.`)) {
|
||||||
|
deleteMutation.mutate(contextService.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--error)] hover:bg-[var(--error-soft)] transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Trash2 size={13} />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
@@ -465,7 +764,7 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-[var(--text-tertiary)] text-center">
|
<p className="text-xs text-[var(--text-tertiary)] text-center">
|
||||||
Click a service to select • Double-click to open • Drag to reposition • Connections auto-inferred from variables
|
Click to select • Double-click to open • Right-click for actions • Connections auto-inferred from variables
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Handle, Position, type Node, type NodeProps } from '@xyflow/react';
|
import { Handle, Position, type Node, type NodeProps } from '@xyflow/react';
|
||||||
import type { ServiceEntity } from '@/lib/api-client';
|
import type { ServiceEntity } from '@/lib/api-client';
|
||||||
import { serviceStatusClass } from '@/lib/api-client';
|
import { serviceStatusClass } from '@/lib/api-client';
|
||||||
import { Box, ArrowUpRight, Globe, Database, Terminal, MoreHorizontal, ExternalLink } from 'lucide-react';
|
import { Box, Globe, Database, Terminal, ExternalLink, Copy, Layers } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
export type ServiceNodeData = {
|
export type ServiceNodeData = {
|
||||||
@@ -17,40 +17,69 @@ export type GroupNodeData = {
|
|||||||
export type ServiceNodeType = Node<ServiceNodeData, 'serviceNode'>;
|
export type ServiceNodeType = Node<ServiceNodeData, 'serviceNode'>;
|
||||||
export type GroupNodeType = Node<GroupNodeData, 'groupNode'>;
|
export type GroupNodeType = Node<GroupNodeData, 'groupNode'>;
|
||||||
|
|
||||||
function serviceTypeIcon(type: string): typeof Box {
|
function renderServiceTypeIcon(type: string, size = 18) {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'web':
|
case 'web':
|
||||||
return Globe;
|
return <Globe size={size} />;
|
||||||
case 'database':
|
case 'database':
|
||||||
return Database;
|
return <Database size={size} />;
|
||||||
case 'worker':
|
case 'worker':
|
||||||
return Terminal;
|
return <Terminal size={size} />;
|
||||||
default:
|
default:
|
||||||
return Box;
|
return <Box size={size} />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function serviceTypeColor(type: string): string {
|
function serviceTypeColor(type: string): string {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'web':
|
case 'web':
|
||||||
return '#7ab8ff'; // Blue
|
return '#7ab8ff';
|
||||||
case 'database':
|
case 'database':
|
||||||
return '#b4e34a'; // Purple
|
return '#b4e34a';
|
||||||
case 'worker':
|
case 'worker':
|
||||||
return 'var(--accent-primary)'; // Pink
|
return 'var(--accent-primary)';
|
||||||
default:
|
default:
|
||||||
return '#9295a4'; // Gray
|
return '#9295a4';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'running':
|
||||||
|
return 'Online';
|
||||||
|
case 'degraded':
|
||||||
|
return 'Degraded';
|
||||||
|
case 'building':
|
||||||
|
return 'Deploying';
|
||||||
|
case 'failed':
|
||||||
|
return 'Failed';
|
||||||
|
default:
|
||||||
|
return 'Stopped';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusColor(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'running':
|
||||||
|
return 'var(--success)';
|
||||||
|
case 'degraded':
|
||||||
|
case 'building':
|
||||||
|
return 'var(--warning)';
|
||||||
|
case 'failed':
|
||||||
|
return 'var(--error)';
|
||||||
|
default:
|
||||||
|
return 'var(--text-tertiary)';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
|
export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
|
||||||
const { service, selected, onOpen } = data;
|
const { service, selected } = data;
|
||||||
const iconType = serviceTypeIcon(service.type);
|
|
||||||
const typeColor = serviceTypeColor(service.type);
|
const typeColor = serviceTypeColor(service.type);
|
||||||
const isRunning = service.status === 'running';
|
const replicas = service.replicas ?? 0;
|
||||||
|
const stateColor = statusColor(service.status);
|
||||||
|
|
||||||
// Generate a mock domain for display (in real app, this would come from service data)
|
const publicUrl = service.domain ? `https://${service.domain}` : service.publicUrl;
|
||||||
const domain = `${service.name}.containr.local`;
|
const internalAddr = service.port ? `${service.name}:${service.port}` : service.name;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -61,29 +90,22 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
|
|||||||
}`}
|
}`}
|
||||||
style={{ minWidth: 220, maxWidth: 280 }}
|
style={{ minWidth: 220, maxWidth: 280 }}
|
||||||
>
|
>
|
||||||
{/* Ambient overlay */}
|
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none"
|
className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none"
|
||||||
style={{ background: `${typeColor}10` }}
|
style={{ background: `${typeColor}10` }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Invisible connection points - required by React Flow for edge attachment */}
|
<Handle id="t-l" type="target" position={Position.Left} className="!opacity-0 !w-2 !h-2 !border-0" />
|
||||||
<Handle type="target" position={Position.Left} className="!opacity-0 !w-2 !h-2 !border-0" />
|
<Handle id="t-r" type="target" position={Position.Right} className="!opacity-0 !w-2 !h-2 !border-0" />
|
||||||
<Handle type="source" position={Position.Right} className="!opacity-0 !w-2 !h-2 !border-0" />
|
<Handle id="s-l" type="source" position={Position.Left} className="!opacity-0 !w-2 !h-2 !border-0" />
|
||||||
|
<Handle id="s-r" type="source" position={Position.Right} className="!opacity-0 !w-2 !h-2 !border-0" />
|
||||||
|
|
||||||
{/* Status indicator bar at top */}
|
|
||||||
<div
|
<div
|
||||||
className={`absolute top-0 left-0 right-0 h-0.5 transition-all duration-300 ${isRunning ? 'opacity-100' : 'opacity-40'}`}
|
className={`absolute top-0 left-0 right-0 h-0.5 transition-all duration-300 ${service.status === 'running' ? 'opacity-100' : 'opacity-40'}`}
|
||||||
style={{ background: isRunning ? 'var(--success)' : 'var(--text-tertiary)' }}
|
style={{ background: stateColor }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Action menu on hover */}
|
|
||||||
<button className="absolute top-3 right-3 opacity-0 group-hover:opacity-100 transition-all duration-200 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] p-1.5 rounded-lg hover:bg-[var(--surface-muted)]">
|
|
||||||
<MoreHorizontal size={14} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="relative p-4">
|
<div className="relative p-4">
|
||||||
{/* Header with icon and name */}
|
|
||||||
<div className="flex items-start gap-3 mb-3">
|
<div className="flex items-start gap-3 mb-3">
|
||||||
<div
|
<div
|
||||||
className={`w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 transition-all duration-200 ${
|
className={`w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 transition-all duration-200 ${
|
||||||
@@ -91,50 +113,68 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
|
|||||||
}`}
|
}`}
|
||||||
style={{
|
style={{
|
||||||
background: selected ? typeColor : `${typeColor}20`,
|
background: selected ? typeColor : `${typeColor}20`,
|
||||||
color: selected ? 'white' : typeColor
|
color: selected ? 'white' : typeColor,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{iconType === Globe && <Globe size={18} />}
|
{renderServiceTypeIcon(service.type, 18)}
|
||||||
{iconType === Database && <Database size={18} />}
|
|
||||||
{iconType === Terminal && <Terminal size={18} />}
|
|
||||||
{iconType === Box && <Box size={18} />}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1 pt-0.5">
|
<div className="min-w-0 flex-1 pt-0.5">
|
||||||
<h4 className="font-semibold text-sm text-[var(--text-primary)] truncate tracking-tight">{service.name}</h4>
|
<h4 className="font-semibold text-sm text-[var(--text-primary)] truncate tracking-tight">{service.name}</h4>
|
||||||
<p className="text-[11px] text-[var(--text-tertiary)] truncate mt-0.5">{service.type}</p>
|
<p className="text-[11px] text-[var(--text-tertiary)] truncate mt-0.5">
|
||||||
|
{service.type}
|
||||||
|
{service.environment && service.environment !== 'production' ? ` · ${service.environment}` : ''}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{replicas > 1 && (
|
||||||
|
<span className="flex items-center gap-1 px-1.5 py-0.5 rounded bg-[var(--surface-muted)] text-[10px] font-semibold text-[var(--text-secondary)]">
|
||||||
|
<Layers size={9} />
|
||||||
|
{replicas}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Domain display - Railway style */}
|
<div className="flex items-center gap-2 mb-3 px-2.5 py-1.5 rounded-lg bg-[var(--surface-muted)]/50 border border-[var(--border-subtle)]/50 group/addr">
|
||||||
<div className="flex items-center gap-2 mb-3 px-2.5 py-1.5 rounded-lg bg-[var(--surface-muted)]/50 border border-[var(--border-subtle)]/50">
|
|
||||||
<ExternalLink size={10} className="text-[var(--text-tertiary)] flex-shrink-0" />
|
<ExternalLink size={10} className="text-[var(--text-tertiary)] flex-shrink-0" />
|
||||||
<span className="text-[10px] text-[var(--text-secondary)] truncate mono">{domain}</span>
|
{publicUrl ? (
|
||||||
|
<a
|
||||||
|
href={publicUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="text-[10px] text-[var(--accent-primary)] truncate mono hover:underline"
|
||||||
|
>
|
||||||
|
{publicUrl.replace(/^https?:\/\//, '')}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="text-[10px] text-[var(--text-secondary)] truncate mono" title="Internal network address">
|
||||||
|
{internalAddr}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Copy internal address"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
void navigator.clipboard?.writeText(internalAddr);
|
||||||
|
}}
|
||||||
|
className="ml-auto opacity-0 group-hover/addr:opacity-100 transition-opacity text-[var(--text-tertiary)] hover:text-[var(--text-primary)]"
|
||||||
|
>
|
||||||
|
<Copy size={10} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status and action row */}
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className={`status-dot ${serviceStatusClass(service.status)}`} />
|
<span className={`status-dot ${serviceStatusClass(service.status)}`} />
|
||||||
<span
|
<span className="text-[11px] font-semibold tracking-wide uppercase" style={{ color: stateColor }}>
|
||||||
className="text-[11px] font-semibold tracking-wide uppercase"
|
{statusLabel(service.status)}
|
||||||
style={{ color: isRunning ? 'var(--success)' : 'var(--text-tertiary)' }}
|
|
||||||
>
|
|
||||||
{isRunning ? 'Online' : service.status}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{service.image && (
|
||||||
<button
|
<span className="text-[10px] text-[var(--text-tertiary)] truncate mono max-w-[120px]" title={service.image}>
|
||||||
type="button"
|
{service.image}
|
||||||
onClick={() => onOpen(service.id)}
|
</span>
|
||||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-semibold transition-all duration-200 ${
|
)}
|
||||||
selected
|
|
||||||
? 'bg-[var(--accent-primary)] text-[var(--accent-on)] shadow-lg shadow-[var(--accent-primary-glow)]'
|
|
||||||
: 'border border-[var(--border-subtle)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)] hover:text-[var(--accent-primary)]'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Open
|
|
||||||
<ArrowUpRight size={10} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -149,21 +189,19 @@ export function GroupNode({ data }: NodeProps<GroupNodeType>) {
|
|||||||
className="h-full w-full rounded-[var(--radius-xl)] border-2 border-dashed transition-all duration-200 relative overflow-hidden"
|
className="h-full w-full rounded-[var(--radius-xl)] border-2 border-dashed transition-all duration-200 relative overflow-hidden"
|
||||||
style={{
|
style={{
|
||||||
borderColor: isHovered ? 'var(--accent-primary)' : 'var(--border-default)',
|
borderColor: isHovered ? 'var(--accent-primary)' : 'var(--border-default)',
|
||||||
background: isHovered ? 'rgba(232, 49, 106, 0.03)' : 'var(--surface-muted)/30'
|
background: isHovered ? 'rgba(232, 49, 106, 0.03)' : 'var(--surface-muted)/30',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={() => setIsHovered(true)}
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
onMouseLeave={() => setIsHovered(false)}
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
>
|
>
|
||||||
{/* Ambient glow on hover */}
|
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 opacity-0 transition-opacity duration-300 pointer-events-none"
|
className="absolute inset-0 opacity-0 transition-opacity duration-300 pointer-events-none"
|
||||||
style={{
|
style={{
|
||||||
background: 'rgba(232,49,106,0.03)',
|
background: 'rgba(232,49,106,0.03)',
|
||||||
opacity: isHovered ? 1 : 0
|
opacity: isHovered ? 1 : 0,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<div className="relative p-4">
|
<div className="relative p-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
@@ -171,22 +209,19 @@ export function GroupNode({ data }: NodeProps<GroupNodeType>) {
|
|||||||
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors duration-200"
|
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors duration-200"
|
||||||
style={{
|
style={{
|
||||||
background: isHovered ? 'var(--accent-primary-soft)' : 'var(--surface-card)',
|
background: isHovered ? 'var(--accent-primary-soft)' : 'var(--surface-card)',
|
||||||
color: isHovered ? 'var(--accent-primary)' : 'var(--text-tertiary)'
|
color: isHovered ? 'var(--accent-primary)' : 'var(--text-tertiary)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box size={14} />
|
<Box size={14} />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-sm font-semibold text-[var(--text-primary)] tracking-tight">{data.title}</h3>
|
<h3 className="text-sm font-semibold text-[var(--text-primary)] tracking-tight">{data.title}</h3>
|
||||||
</div>
|
</div>
|
||||||
{isHovered && (
|
|
||||||
<button className="text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-colors p-1 rounded-lg hover:bg-[var(--surface-muted)]">
|
|
||||||
<MoreHorizontal size={14} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Helper text */}
|
<p
|
||||||
<p className="text-[10px] text-[var(--text-tertiary)] mt-2 opacity-0 transition-opacity duration-200" style={{ opacity: isHovered ? 1 : 0 }}>
|
className="text-[10px] text-[var(--text-tertiary)] mt-2 transition-opacity duration-200"
|
||||||
|
style={{ opacity: isHovered ? 1 : 0 }}
|
||||||
|
>
|
||||||
Drag services here to group them
|
Drag services here to group them
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -604,6 +604,216 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/services/{id}/runtime": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Service runtime state
|
||||||
|
* @description Live container state — replicas, URLs, aggregate status
|
||||||
|
*/
|
||||||
|
get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Runtime state */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
runtime?: {
|
||||||
|
desired?: number;
|
||||||
|
/** @enum {string} */
|
||||||
|
status?: "running" | "degraded" | "stopped";
|
||||||
|
urls?: string[];
|
||||||
|
containers?: {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
state?: string;
|
||||||
|
replica?: number;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/services/{id}/start": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Start service
|
||||||
|
* @description Start all stopped replicas
|
||||||
|
*/
|
||||||
|
post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Service started */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/services/{id}/stop": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Stop service
|
||||||
|
* @description Stop all replicas without removing them
|
||||||
|
*/
|
||||||
|
post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Service stopped */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/services/{id}/restart": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Restart service
|
||||||
|
* @description Restart all replicas
|
||||||
|
*/
|
||||||
|
post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Service restarted */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/services/{id}/redeploy": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Redeploy service
|
||||||
|
* @description Re-apply the current spec — pulls the image for image-sourced services, reuses the last deployed image otherwise
|
||||||
|
*/
|
||||||
|
post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Reconciled runtime state */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/services/{id}/deployments": {
|
"/services/{id}/deployments": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -676,6 +886,59 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/deployments": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* List recent deployments
|
||||||
|
* @description Latest deployments across all services owned by the caller — feeds the dashboard deploy feed
|
||||||
|
*/
|
||||||
|
get: {
|
||||||
|
parameters: {
|
||||||
|
query?: {
|
||||||
|
limit?: number;
|
||||||
|
};
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Recent deployments */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
deployments?: {
|
||||||
|
id?: string;
|
||||||
|
service_id?: string;
|
||||||
|
service_name?: string;
|
||||||
|
project_name?: string;
|
||||||
|
status?: string;
|
||||||
|
image_name?: string;
|
||||||
|
started_at?: string | null;
|
||||||
|
completed_at?: string | null;
|
||||||
|
created_at?: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/deployments/{id}": {
|
"/deployments/{id}": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -2153,7 +2416,7 @@ export interface paths {
|
|||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* Bootstrap status
|
* Bootstrap status
|
||||||
* @description Reports whether the instance already has a registered user. Drives the first-run setup gate.
|
* @description Reports whether the instance already has a registered user, which auth providers are configured, and whether public registration is open. Drives the first-run setup gate.
|
||||||
*/
|
*/
|
||||||
get: {
|
get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
@@ -2171,9 +2434,18 @@ export interface paths {
|
|||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
/** @description True when no user exists yet */
|
/** @description True when at least one account exists */
|
||||||
needs_setup?: boolean;
|
has_users?: boolean;
|
||||||
user_count?: number;
|
user_count?: number;
|
||||||
|
/**
|
||||||
|
* @description register when the instance has no users, login otherwise
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
mode?: "register" | "login";
|
||||||
|
/** @description OAuth providers with real credentials configured (e.g. github, google). Empty on self-hosted installs. */
|
||||||
|
providers?: string[];
|
||||||
|
/** @description True when the owner re-opened public registration in Settings */
|
||||||
|
signup_enabled?: boolean;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -2187,6 +2459,91 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/settings": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Read platform settings
|
||||||
|
* @description Admin only. Returns in-app settings that override environment variables.
|
||||||
|
*/
|
||||||
|
get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Current platform settings */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["PlatformSettings"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Admin access required */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Update platform settings
|
||||||
|
* @description Admin only. Provided keys are applied immediately; the Cloudflare tunnel token starts/stops the managed cloudflared sidecar.
|
||||||
|
*/
|
||||||
|
put: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
/** @description Allow new public account registration after bootstrap */
|
||||||
|
signup_enabled?: boolean;
|
||||||
|
/** @description Cloudflare Tunnel token. Empty string clears the stored token and removes the cloudflared container. */
|
||||||
|
cloudflare_tunnel_token?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Updated platform settings */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["PlatformSettings"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Admin access required */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/users": {
|
"/users": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -6687,6 +7044,21 @@ export interface components {
|
|||||||
/** @description User display name */
|
/** @description User display name */
|
||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
|
PlatformSettings: {
|
||||||
|
/** @description Whether public account registration is open after bootstrap */
|
||||||
|
signup_enabled?: boolean;
|
||||||
|
cloudflare_tunnel?: {
|
||||||
|
/** @description Whether a tunnel token is configured (value is never returned) */
|
||||||
|
token_set?: boolean;
|
||||||
|
/**
|
||||||
|
* @description Where the active token comes from - in-app setting, env fallback, or unset
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
source?: "app" | "env" | "none";
|
||||||
|
/** @description cloudflared container state - running/exited/missing, or unavailable when Docker is unreachable */
|
||||||
|
container?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
User: {
|
User: {
|
||||||
/** @description User ID */
|
/** @description User ID */
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -6699,6 +7071,8 @@ export interface components {
|
|||||||
name?: string;
|
name?: string;
|
||||||
/** @description Optional avatar image URL */
|
/** @description Optional avatar image URL */
|
||||||
avatar_url?: string;
|
avatar_url?: string;
|
||||||
|
/** @description True for the platform owner (first registered account) */
|
||||||
|
is_admin?: boolean;
|
||||||
/**
|
/**
|
||||||
* Format: date-time
|
* Format: date-time
|
||||||
* @description Account creation timestamp
|
* @description Account creation timestamp
|
||||||
@@ -6817,6 +7191,18 @@ export interface components {
|
|||||||
cpu?: string;
|
cpu?: string;
|
||||||
/** @description Memory allocation */
|
/** @description Memory allocation */
|
||||||
memory?: string;
|
memory?: string;
|
||||||
|
/** @description Desired replica count */
|
||||||
|
replicas?: number;
|
||||||
|
/** @description Container port to expose (published host port, or Traefik when domain set) */
|
||||||
|
port?: number;
|
||||||
|
/** @description Public hostname routed via Traefik */
|
||||||
|
domain?: string;
|
||||||
|
/** @description HTTP path probed on port for container health */
|
||||||
|
healthcheck_path?: string;
|
||||||
|
/** @description Docker restart policy (default unless-stopped) */
|
||||||
|
restart_policy?: string;
|
||||||
|
/** @description Computed public URL (published port or domain) */
|
||||||
|
public_url?: string;
|
||||||
/**
|
/**
|
||||||
* Format: date-time
|
* Format: date-time
|
||||||
* @description Service creation timestamp
|
* @description Service creation timestamp
|
||||||
@@ -6860,6 +7246,16 @@ export interface components {
|
|||||||
cpu?: string;
|
cpu?: string;
|
||||||
/** @description Memory allocation */
|
/** @description Memory allocation */
|
||||||
memory?: string;
|
memory?: string;
|
||||||
|
/** @description Desired replica count */
|
||||||
|
replicas?: number;
|
||||||
|
/** @description Container port to expose */
|
||||||
|
port?: number;
|
||||||
|
/** @description Public hostname routed via Traefik */
|
||||||
|
domain?: string;
|
||||||
|
/** @description HTTP path probed for container health */
|
||||||
|
healthcheck_path?: string;
|
||||||
|
/** @description Docker restart policy */
|
||||||
|
restart_policy?: string;
|
||||||
};
|
};
|
||||||
UpdateServiceRequest: {
|
UpdateServiceRequest: {
|
||||||
/** @description Service name */
|
/** @description Service name */
|
||||||
@@ -6888,6 +7284,16 @@ export interface components {
|
|||||||
cpu?: string;
|
cpu?: string;
|
||||||
/** @description Memory allocation */
|
/** @description Memory allocation */
|
||||||
memory?: string;
|
memory?: string;
|
||||||
|
/** @description Desired replica count */
|
||||||
|
replicas?: number;
|
||||||
|
/** @description Container port to expose */
|
||||||
|
port?: number;
|
||||||
|
/** @description Public hostname routed via Traefik */
|
||||||
|
domain?: string;
|
||||||
|
/** @description HTTP path probed for container health */
|
||||||
|
healthcheck_path?: string;
|
||||||
|
/** @description Docker restart policy */
|
||||||
|
restart_policy?: string;
|
||||||
};
|
};
|
||||||
Deployment: {
|
Deployment: {
|
||||||
/** @description Deployment ID */
|
/** @description Deployment ID */
|
||||||
|
|||||||
@@ -381,6 +381,10 @@ textarea {
|
|||||||
animation: pulse-ring 2s ease-out infinite;
|
animation: pulse-ring 2s ease-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-degraded {
|
||||||
|
background: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
.status-building {
|
.status-building {
|
||||||
background: var(--warning);
|
background: var(--warning);
|
||||||
animation: pulse-dot 1.5s ease-in-out infinite;
|
animation: pulse-dot 1.5s ease-in-out infinite;
|
||||||
@@ -979,6 +983,19 @@ textarea {
|
|||||||
transition: all 0.3s;
|
transition: all 0.3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.badge-degraded {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(242, 201, 76, 0.15);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.7px;
|
||||||
|
color: var(--warning);
|
||||||
|
text-transform: uppercase;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
/* Badge Stopped - self.html exact match */
|
/* Badge Stopped - self.html exact match */
|
||||||
.badge-stopped {
|
.badge-stopped {
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { components, paths } from '@/generated/api-types';
|
import type { components, paths } from '@/generated/api-types';
|
||||||
|
|
||||||
export type ServiceStatus = 'running' | 'stopped' | 'building' | 'failed' | 'unknown';
|
export type ServiceStatus = 'running' | 'degraded' | 'stopped' | 'building' | 'failed' | 'unknown';
|
||||||
|
|
||||||
export type ProjectStats = components['schemas']['ProjectStats'];
|
export type ProjectStats = components['schemas']['ProjectStats'];
|
||||||
|
|
||||||
@@ -18,10 +18,20 @@ export type UserProfile = {
|
|||||||
email: string;
|
email: string;
|
||||||
name: string;
|
name: string;
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
|
isAdmin: boolean;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PlatformSettings = {
|
||||||
|
signupEnabled: boolean;
|
||||||
|
cloudflareTunnel: {
|
||||||
|
tokenSet: boolean;
|
||||||
|
source: 'app' | 'env' | 'none';
|
||||||
|
container: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export type ServiceEntity = {
|
export type ServiceEntity = {
|
||||||
id: string;
|
id: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -38,6 +48,26 @@ export type ServiceEntity = {
|
|||||||
buildPath?: string;
|
buildPath?: string;
|
||||||
cpu?: string;
|
cpu?: string;
|
||||||
memory?: string;
|
memory?: string;
|
||||||
|
replicas?: number;
|
||||||
|
port?: number;
|
||||||
|
domain?: string;
|
||||||
|
healthcheckPath?: string;
|
||||||
|
restartPolicy?: string;
|
||||||
|
publicUrl?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RuntimeContainer = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
state: string;
|
||||||
|
replica: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ServiceRuntime = {
|
||||||
|
desired: number;
|
||||||
|
status: 'running' | 'degraded' | 'stopped';
|
||||||
|
urls: string[];
|
||||||
|
containers: RuntimeContainer[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ServiceVariable = {
|
export type ServiceVariable = {
|
||||||
@@ -333,6 +363,7 @@ type RawNodeAgent = components['schemas']['NodeAgent'];
|
|||||||
|
|
||||||
export type CreateProjectInput = components['schemas']['CreateProjectRequest'];
|
export type CreateProjectInput = components['schemas']['CreateProjectRequest'];
|
||||||
export type CreateServiceInput = components['schemas']['CreateServiceRequest'];
|
export type CreateServiceInput = components['schemas']['CreateServiceRequest'];
|
||||||
|
export type UpdateServiceInput = components['schemas']['UpdateServiceRequest'];
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
readonly status: number;
|
readonly status: number;
|
||||||
@@ -348,7 +379,8 @@ type ProjectsResponse200 = paths['/projects']['get']['responses'][200]['content'
|
|||||||
|
|
||||||
type JsonLike = Record<string, unknown>;
|
type JsonLike = Record<string, unknown>;
|
||||||
|
|
||||||
const rawBase = (import.meta.env.VITE_API_URL as string | undefined) ?? 'http://localhost:8082';
|
const configuredBase = ((import.meta.env.VITE_API_URL as string | undefined) ?? '').trim();
|
||||||
|
const rawBase = configuredBase || window.location.origin;
|
||||||
const normalizedBase = rawBase.replace(/\/$/, '');
|
const normalizedBase = rawBase.replace(/\/$/, '');
|
||||||
const API_ROOT = normalizedBase.replace(/\/api\/v1$/, '').replace(/\/api$/, '');
|
const API_ROOT = normalizedBase.replace(/\/api\/v1$/, '').replace(/\/api$/, '');
|
||||||
const API_BASE = /\/api\/v1$/.test(normalizedBase) ? normalizedBase : `${normalizedBase}/api/v1`;
|
const API_BASE = /\/api\/v1$/.test(normalizedBase) ? normalizedBase : `${normalizedBase}/api/v1`;
|
||||||
@@ -441,11 +473,46 @@ function normalizeUserProfile(profile: RawUserProfile): UserProfile | null {
|
|||||||
email: profile.email,
|
email: profile.email,
|
||||||
name: profile.name,
|
name: profile.name,
|
||||||
avatarUrl: profile.avatar_url ?? undefined,
|
avatarUrl: profile.avatar_url ?? undefined,
|
||||||
|
isAdmin: profile.is_admin ?? false,
|
||||||
createdAt: profile.created_at ?? undefined,
|
createdAt: profile.created_at ?? undefined,
|
||||||
updatedAt: profile.updated_at ?? undefined,
|
updatedAt: profile.updated_at ?? undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizePlatformSettings(raw: components['schemas']['PlatformSettings']): PlatformSettings {
|
||||||
|
return {
|
||||||
|
signupEnabled: raw.signup_enabled ?? false,
|
||||||
|
cloudflareTunnel: {
|
||||||
|
tokenSet: raw.cloudflare_tunnel?.token_set ?? false,
|
||||||
|
source: raw.cloudflare_tunnel?.source ?? 'none',
|
||||||
|
container: raw.cloudflare_tunnel?.container ?? 'missing',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPlatformSettings(): Promise<PlatformSettings> {
|
||||||
|
const raw = await requestJson<components['schemas']['PlatformSettings']>('/settings');
|
||||||
|
return normalizePlatformSettings(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updatePlatformSettings(input: {
|
||||||
|
signupEnabled?: boolean;
|
||||||
|
cloudflareTunnelToken?: string;
|
||||||
|
}): Promise<PlatformSettings> {
|
||||||
|
const body: Record<string, unknown> = {};
|
||||||
|
if (input.signupEnabled !== undefined) {
|
||||||
|
body.signup_enabled = input.signupEnabled;
|
||||||
|
}
|
||||||
|
if (input.cloudflareTunnelToken !== undefined) {
|
||||||
|
body.cloudflare_tunnel_token = input.cloudflareTunnelToken;
|
||||||
|
}
|
||||||
|
const raw = await requestJson<components['schemas']['PlatformSettings']>('/settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
return normalizePlatformSettings(raw);
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeService(service: RawService): ServiceEntity | null {
|
function normalizeService(service: RawService): ServiceEntity | null {
|
||||||
if (!service.id || !service.name || !service.project_id) {
|
if (!service.id || !service.name || !service.project_id) {
|
||||||
return null;
|
return null;
|
||||||
@@ -469,6 +536,12 @@ function normalizeService(service: RawService): ServiceEntity | null {
|
|||||||
buildPath: service.build_path,
|
buildPath: service.build_path,
|
||||||
cpu: service.cpu,
|
cpu: service.cpu,
|
||||||
memory: service.memory,
|
memory: service.memory,
|
||||||
|
replicas: service.replicas,
|
||||||
|
port: service.port,
|
||||||
|
domain: service.domain,
|
||||||
|
healthcheckPath: service.healthcheck_path,
|
||||||
|
restartPolicy: service.restart_policy,
|
||||||
|
publicUrl: service.public_url,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -952,12 +1025,55 @@ export async function createService(projectId: string, input: CreateServiceInput
|
|||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateService(serviceId: string, input: UpdateServiceInput): Promise<ServiceEntity> {
|
||||||
|
const payload = await requestJson<RawService | { service?: RawService }>(`/services/${serviceId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
|
||||||
|
const row = (payload as { service?: RawService }).service ?? (payload as RawService);
|
||||||
|
const parsed = normalizeService(row);
|
||||||
|
|
||||||
|
if (!parsed) {
|
||||||
|
throw new ApiError('Update service response is invalid', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteService(serviceId: string): Promise<void> {
|
export async function deleteService(serviceId: string): Promise<void> {
|
||||||
await requestJson<{ message?: string }>(`/services/${serviceId}`, {
|
await requestJson<{ message?: string }>(`/services/${serviceId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getServiceRuntime(serviceId: string): Promise<ServiceRuntime> {
|
||||||
|
const payload = await requestJson<{ runtime?: ServiceRuntime }>(`/services/${serviceId}/runtime`);
|
||||||
|
return (
|
||||||
|
payload.runtime ?? {
|
||||||
|
desired: 0,
|
||||||
|
status: 'stopped',
|
||||||
|
urls: [],
|
||||||
|
containers: [],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function serviceAction(serviceId: string, action: 'start' | 'stop' | 'restart'): Promise<void> {
|
||||||
|
await requestJson<{ message?: string }>(`/services/${serviceId}/${action}`, { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export const startService = (serviceId: string) => serviceAction(serviceId, 'start');
|
||||||
|
export const stopService = (serviceId: string) => serviceAction(serviceId, 'stop');
|
||||||
|
export const restartService = (serviceId: string) => serviceAction(serviceId, 'restart');
|
||||||
|
|
||||||
|
export async function redeployService(serviceId: string): Promise<ServiceRuntime> {
|
||||||
|
const payload = await requestJson<{ runtime?: ServiceRuntime }>(`/services/${serviceId}/redeploy`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
return payload.runtime ?? { desired: 0, status: 'stopped', urls: [], containers: [] };
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeServiceVariables(rows: RawServiceVariable[] | undefined): ServiceVariable[] {
|
function normalizeServiceVariables(rows: RawServiceVariable[] | undefined): ServiceVariable[] {
|
||||||
const result: ServiceVariable[] = [];
|
const result: ServiceVariable[] = [];
|
||||||
for (const row of rows ?? []) {
|
for (const row of rows ?? []) {
|
||||||
@@ -1030,6 +1146,37 @@ export async function listDeployments(serviceId: string): Promise<DeploymentEnti
|
|||||||
return normalizeDeploymentArray(payload.deployments);
|
return normalizeDeploymentArray(payload.deployments);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RecentDeploymentEntity = {
|
||||||
|
id: string;
|
||||||
|
serviceId: string;
|
||||||
|
serviceName: string;
|
||||||
|
projectName: string;
|
||||||
|
status: string;
|
||||||
|
imageName?: string;
|
||||||
|
startedAt?: string;
|
||||||
|
completedAt?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listRecentDeployments(limit = 10): Promise<RecentDeploymentEntity[]> {
|
||||||
|
const payload = await requestJson<{ deployments?: Array<Record<string, unknown>> }>(
|
||||||
|
`/deployments?limit=${limit}`,
|
||||||
|
);
|
||||||
|
return (payload.deployments ?? [])
|
||||||
|
.map((d) => ({
|
||||||
|
id: String(d.id ?? ''),
|
||||||
|
serviceId: String(d.service_id ?? ''),
|
||||||
|
serviceName: String(d.service_name ?? ''),
|
||||||
|
projectName: String(d.project_name ?? ''),
|
||||||
|
status: String(d.status ?? ''),
|
||||||
|
imageName: d.image_name ? String(d.image_name) : undefined,
|
||||||
|
startedAt: d.started_at ? String(d.started_at) : undefined,
|
||||||
|
completedAt: d.completed_at ? String(d.completed_at) : undefined,
|
||||||
|
createdAt: d.created_at ? String(d.created_at) : undefined,
|
||||||
|
}))
|
||||||
|
.filter((d) => d.id !== '');
|
||||||
|
}
|
||||||
|
|
||||||
export async function createDeployment(
|
export async function createDeployment(
|
||||||
serviceId: string,
|
serviceId: string,
|
||||||
input: CreateDeploymentInput = {},
|
input: CreateDeploymentInput = {},
|
||||||
@@ -1203,6 +1350,8 @@ export function serviceStatusClass(status: ServiceStatus): string {
|
|||||||
switch (status) {
|
switch (status) {
|
||||||
case 'running':
|
case 'running':
|
||||||
return 'status-running';
|
return 'status-running';
|
||||||
|
case 'degraded':
|
||||||
|
return 'status-degraded';
|
||||||
case 'building':
|
case 'building':
|
||||||
return 'status-building';
|
return 'status-building';
|
||||||
case 'failed':
|
case 'failed':
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export type AuthBootstrap = {
|
|||||||
hasUsers: boolean;
|
hasUsers: boolean;
|
||||||
userCount: number;
|
userCount: number;
|
||||||
mode: 'register' | 'login';
|
mode: 'register' | 'login';
|
||||||
|
providers: string[];
|
||||||
|
signupEnabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export class AuthError extends Error {
|
export class AuthError extends Error {
|
||||||
@@ -32,15 +34,16 @@ export class AuthError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawAuthBase = (import.meta.env.VITE_AUTH_URL as string | undefined) ?? 'http://localhost:8082/api/auth';
|
const configuredAuthBase = ((import.meta.env.VITE_AUTH_URL as string | undefined) ?? '').trim();
|
||||||
const AUTH_BASE = rawAuthBase.replace(/\/$/, '');
|
const AUTH_BASE = (configuredAuthBase || `${window.location.origin}/api/auth`).replace(/\/$/, '');
|
||||||
|
|
||||||
export function getAuthBaseUrl(): string {
|
export function getAuthBaseUrl(): string {
|
||||||
return AUTH_BASE;
|
return AUTH_BASE;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAuthBootstrap(): Promise<AuthBootstrap> {
|
export async function getAuthBootstrap(): Promise<AuthBootstrap> {
|
||||||
const apiBase = ((import.meta.env.VITE_API_URL as string | undefined) ?? 'http://localhost:8082').replace(/\/$/, '');
|
const configuredApiBase = ((import.meta.env.VITE_API_URL as string | undefined) ?? '').trim();
|
||||||
|
const apiBase = (configuredApiBase || window.location.origin).replace(/\/$/, '');
|
||||||
const normalizedApiBase = /\/api\/v1$/.test(apiBase) ? apiBase : `${apiBase}/api/v1`;
|
const normalizedApiBase = /\/api\/v1$/.test(apiBase) ? apiBase : `${apiBase}/api/v1`;
|
||||||
const response = await fetch(`${normalizedApiBase}/auth/bootstrap`, {
|
const response = await fetch(`${normalizedApiBase}/auth/bootstrap`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
@@ -56,10 +59,16 @@ export async function getAuthBootstrap(): Promise<AuthBootstrap> {
|
|||||||
throw new AuthError(message, response.status);
|
throw new AuthError(message, response.status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const providers = Array.isArray(payload?.providers)
|
||||||
|
? payload.providers.filter((provider): provider is string => typeof provider === 'string')
|
||||||
|
: [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hasUsers: Boolean(payload?.has_users),
|
hasUsers: Boolean(payload?.has_users),
|
||||||
userCount: typeof payload?.user_count === 'number' ? payload.user_count : 0,
|
userCount: typeof payload?.user_count === 'number' ? payload.user_count : 0,
|
||||||
mode: payload?.mode === 'register' ? 'register' : 'login',
|
mode: payload?.mode === 'register' ? 'register' : 'login',
|
||||||
|
providers,
|
||||||
|
signupEnabled: Boolean(payload?.signup_enabled),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,14 @@ export default defineConfig({
|
|||||||
fs: {
|
fs: {
|
||||||
// Allow serving files from project root
|
// Allow serving files from project root
|
||||||
allow: ['..']
|
allow: ['..']
|
||||||
|
},
|
||||||
|
proxy: {
|
||||||
|
// Same-origin API path for dev - matches the nginx proxy in the shipped image
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8082',
|
||||||
|
changeOrigin: true,
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// Preview server optimization
|
// Preview server optimization
|
||||||
|
|||||||
+20
-9
@@ -16,13 +16,17 @@ services:
|
|||||||
retries: 10
|
retries: 10
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
redis:
|
dragonfly:
|
||||||
image: redis:7-alpine
|
image: docker.dragonflydb.io/dragonflydb/dragonfly:${DRAGONFLY_VERSION:-latest}
|
||||||
command: redis-server --requirepass ${REDIS_PASSWORD:-dev_redis_123}
|
command:
|
||||||
|
- --requirepass=${REDIS_PASSWORD:-dev_redis_123}
|
||||||
|
- --proactor_threads=${DRAGONFLY_THREADS:-1}
|
||||||
|
- --maxmemory=${DRAGONFLY_MAXMEMORY:-256mb}
|
||||||
|
- --dir=/data
|
||||||
ports:
|
ports:
|
||||||
- "${REDIS_PORT:-6379}:6379"
|
- "${REDIS_PORT:-6379}:6379"
|
||||||
volumes:
|
volumes:
|
||||||
- redis_data:/data
|
- dragonfly_data:/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "redis-cli -a ${REDIS_PASSWORD:-dev_redis_123} ping | grep PONG"]
|
test: ["CMD-SHELL", "redis-cli -a ${REDIS_PASSWORD:-dev_redis_123} ping | grep PONG"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -40,7 +44,7 @@ services:
|
|||||||
- "${API_PORT:-8082}:8080"
|
- "${API_PORT:-8082}:8080"
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgres://${POSTGRES_USER:-containr_user}:${POSTGRES_PASSWORD:-dev_password_123}@postgres:5432/${POSTGRES_DB:-containr}?sslmode=disable
|
DATABASE_URL: postgres://${POSTGRES_USER:-containr_user}:${POSTGRES_PASSWORD:-dev_password_123}@postgres:5432/${POSTGRES_DB:-containr}?sslmode=disable
|
||||||
REDIS_URL: redis://:${REDIS_PASSWORD:-dev_redis_123}@redis:6379/0
|
REDIS_URL: redis://:${REDIS_PASSWORD:-dev_redis_123}@dragonfly:6379/0
|
||||||
PORT: 8080
|
PORT: 8080
|
||||||
HOST: 0.0.0.0
|
HOST: 0.0.0.0
|
||||||
ENVIRONMENT: ${ENVIRONMENT:-development}
|
ENVIRONMENT: ${ENVIRONMENT:-development}
|
||||||
@@ -80,10 +84,17 @@ services:
|
|||||||
BITBUCKET_API_URL: ${BITBUCKET_API_URL:-https://api.bitbucket.org/2.0}
|
BITBUCKET_API_URL: ${BITBUCKET_API_URL:-https://api.bitbucket.org/2.0}
|
||||||
BITBUCKET_BASE_URL: ${BITBUCKET_BASE_URL:-https://bitbucket.org}
|
BITBUCKET_BASE_URL: ${BITBUCKET_BASE_URL:-https://bitbucket.org}
|
||||||
GITEA_BASE_URL: ${GITEA_BASE_URL:-https://gitea.example.com}
|
GITEA_BASE_URL: ${GITEA_BASE_URL:-https://gitea.example.com}
|
||||||
|
volumes:
|
||||||
|
# Required for service deployments and managed sidecars (e.g. cloudflared)
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
# Grants the non-root backend user access to the Docker socket.
|
||||||
|
# install.sh writes the host's docker gid into DOCKER_GID.
|
||||||
|
group_add:
|
||||||
|
- "${DOCKER_GID:-0}"
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
dragonfly:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -100,8 +111,8 @@ services:
|
|||||||
context: ./app/frontend
|
context: ./app/frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
VITE_API_URL: ${VITE_API_URL:-http://localhost:8082}
|
VITE_API_URL: ${VITE_API_URL:-}
|
||||||
VITE_AUTH_URL: ${VITE_AUTH_URL:-http://localhost:8082/api/auth}
|
VITE_AUTH_URL: ${VITE_AUTH_URL:-}
|
||||||
ports:
|
ports:
|
||||||
- "${HTTP_PORT:-3000}:80"
|
- "${HTTP_PORT:-3000}:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -111,4 +122,4 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
redis_data:
|
dragonfly_data:
|
||||||
+303
-3
@@ -424,6 +424,126 @@ paths:
|
|||||||
'204':
|
'204':
|
||||||
description: Service deleted successfully
|
description: Service deleted successfully
|
||||||
|
|
||||||
|
/services/{id}/runtime:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Services
|
||||||
|
summary: Service runtime state
|
||||||
|
description: Live container state — replicas, URLs, aggregate status
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Runtime state
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
runtime:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
desired:
|
||||||
|
type: integer
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
enum: [running, degraded, stopped]
|
||||||
|
urls:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
containers:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
state:
|
||||||
|
type: string
|
||||||
|
replica:
|
||||||
|
type: integer
|
||||||
|
|
||||||
|
/services/{id}/start:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Services
|
||||||
|
summary: Start service
|
||||||
|
description: Start all stopped replicas
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Service started
|
||||||
|
|
||||||
|
/services/{id}/stop:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Services
|
||||||
|
summary: Stop service
|
||||||
|
description: Stop all replicas without removing them
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Service stopped
|
||||||
|
|
||||||
|
/services/{id}/restart:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Services
|
||||||
|
summary: Restart service
|
||||||
|
description: Restart all replicas
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Service restarted
|
||||||
|
|
||||||
|
/services/{id}/redeploy:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Services
|
||||||
|
summary: Redeploy service
|
||||||
|
description: Re-apply the current spec — pulls the image for image-sourced services, reuses the last deployed image otherwise
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Reconciled runtime state
|
||||||
|
|
||||||
/services/{id}/deployments:
|
/services/{id}/deployments:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
@@ -475,6 +595,56 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/Deployment'
|
$ref: '#/components/schemas/Deployment'
|
||||||
|
|
||||||
|
/deployments:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Deployments
|
||||||
|
summary: List recent deployments
|
||||||
|
description: Latest deployments across all services owned by the caller — feeds the dashboard deploy feed
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: limit
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
default: 10
|
||||||
|
maximum: 50
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Recent deployments
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
deployments:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
service_id:
|
||||||
|
type: string
|
||||||
|
service_name:
|
||||||
|
type: string
|
||||||
|
project_name:
|
||||||
|
type: string
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
image_name:
|
||||||
|
type: string
|
||||||
|
started_at:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
completed_at:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
created_at:
|
||||||
|
type: string
|
||||||
|
|
||||||
/deployments/{id}:
|
/deployments/{id}:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
@@ -1432,7 +1602,7 @@ paths:
|
|||||||
tags:
|
tags:
|
||||||
- Authentication
|
- Authentication
|
||||||
summary: Bootstrap status
|
summary: Bootstrap status
|
||||||
description: Reports whether the instance already has a registered user. Drives the first-run setup gate.
|
description: Reports whether the instance already has a registered user, which auth providers are configured, and whether public registration is open. Drives the first-run setup gate.
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Bootstrap state
|
description: Bootstrap state
|
||||||
@@ -1441,11 +1611,70 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
needs_setup:
|
has_users:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: True when no user exists yet
|
description: True when at least one account exists
|
||||||
user_count:
|
user_count:
|
||||||
type: integer
|
type: integer
|
||||||
|
mode:
|
||||||
|
type: string
|
||||||
|
enum: [register, login]
|
||||||
|
description: register when the instance has no users, login otherwise
|
||||||
|
providers:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
description: OAuth providers with real credentials configured (e.g. github, google). Empty on self-hosted installs.
|
||||||
|
signup_enabled:
|
||||||
|
type: boolean
|
||||||
|
description: True when the owner re-opened public registration in Settings
|
||||||
|
|
||||||
|
/settings:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Settings
|
||||||
|
summary: Read platform settings
|
||||||
|
description: Admin only. Returns in-app settings that override environment variables.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Current platform settings
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/PlatformSettings'
|
||||||
|
'403':
|
||||||
|
description: Admin access required
|
||||||
|
put:
|
||||||
|
tags:
|
||||||
|
- Settings
|
||||||
|
summary: Update platform settings
|
||||||
|
description: Admin only. Provided keys are applied immediately; the Cloudflare tunnel token starts/stops the managed cloudflared sidecar.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
signup_enabled:
|
||||||
|
type: boolean
|
||||||
|
description: Allow new public account registration after bootstrap
|
||||||
|
cloudflare_tunnel_token:
|
||||||
|
type: string
|
||||||
|
description: Cloudflare Tunnel token. Empty string clears the stored token and removes the cloudflared container.
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Updated platform settings
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/PlatformSettings'
|
||||||
|
'403':
|
||||||
|
description: Admin access required
|
||||||
|
|
||||||
# User Management
|
# User Management
|
||||||
/users:
|
/users:
|
||||||
@@ -4111,6 +4340,26 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
description: User display name
|
description: User display name
|
||||||
|
|
||||||
|
PlatformSettings:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
signup_enabled:
|
||||||
|
type: boolean
|
||||||
|
description: Whether public account registration is open after bootstrap
|
||||||
|
cloudflare_tunnel:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
token_set:
|
||||||
|
type: boolean
|
||||||
|
description: Whether a tunnel token is configured (value is never returned)
|
||||||
|
source:
|
||||||
|
type: string
|
||||||
|
enum: [app, env, none]
|
||||||
|
description: Where the active token comes from - in-app setting, env fallback, or unset
|
||||||
|
container:
|
||||||
|
type: string
|
||||||
|
description: cloudflared container state - running/exited/missing, or unavailable when Docker is unreachable
|
||||||
|
|
||||||
User:
|
User:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@@ -4127,6 +4376,9 @@ components:
|
|||||||
avatar_url:
|
avatar_url:
|
||||||
type: string
|
type: string
|
||||||
description: Optional avatar image URL
|
description: Optional avatar image URL
|
||||||
|
is_admin:
|
||||||
|
type: boolean
|
||||||
|
description: True for the platform owner (first registered account)
|
||||||
created_at:
|
created_at:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
@@ -4290,6 +4542,24 @@ components:
|
|||||||
memory:
|
memory:
|
||||||
type: string
|
type: string
|
||||||
description: Memory allocation
|
description: Memory allocation
|
||||||
|
replicas:
|
||||||
|
type: integer
|
||||||
|
description: Desired replica count
|
||||||
|
port:
|
||||||
|
type: integer
|
||||||
|
description: Container port to expose (published host port, or Traefik when domain set)
|
||||||
|
domain:
|
||||||
|
type: string
|
||||||
|
description: Public hostname routed via Traefik
|
||||||
|
healthcheck_path:
|
||||||
|
type: string
|
||||||
|
description: HTTP path probed on port for container health
|
||||||
|
restart_policy:
|
||||||
|
type: string
|
||||||
|
description: Docker restart policy (default unless-stopped)
|
||||||
|
public_url:
|
||||||
|
type: string
|
||||||
|
description: Computed public URL (published port or domain)
|
||||||
created_at:
|
created_at:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
@@ -4342,6 +4612,21 @@ components:
|
|||||||
memory:
|
memory:
|
||||||
type: string
|
type: string
|
||||||
description: Memory allocation
|
description: Memory allocation
|
||||||
|
replicas:
|
||||||
|
type: integer
|
||||||
|
description: Desired replica count
|
||||||
|
port:
|
||||||
|
type: integer
|
||||||
|
description: Container port to expose
|
||||||
|
domain:
|
||||||
|
type: string
|
||||||
|
description: Public hostname routed via Traefik
|
||||||
|
healthcheck_path:
|
||||||
|
type: string
|
||||||
|
description: HTTP path probed for container health
|
||||||
|
restart_policy:
|
||||||
|
type: string
|
||||||
|
description: Docker restart policy
|
||||||
|
|
||||||
UpdateServiceRequest:
|
UpdateServiceRequest:
|
||||||
type: object
|
type: object
|
||||||
@@ -4378,6 +4663,21 @@ components:
|
|||||||
memory:
|
memory:
|
||||||
type: string
|
type: string
|
||||||
description: Memory allocation
|
description: Memory allocation
|
||||||
|
replicas:
|
||||||
|
type: integer
|
||||||
|
description: Desired replica count
|
||||||
|
port:
|
||||||
|
type: integer
|
||||||
|
description: Container port to expose
|
||||||
|
domain:
|
||||||
|
type: string
|
||||||
|
description: Public hostname routed via Traefik
|
||||||
|
healthcheck_path:
|
||||||
|
type: string
|
||||||
|
description: HTTP path probed for container health
|
||||||
|
restart_policy:
|
||||||
|
type: string
|
||||||
|
description: Docker restart policy
|
||||||
|
|
||||||
Deployment:
|
Deployment:
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ Internet → Traefik (Port 80/443)
|
|||||||
├── Frontend (React/Nginx)
|
├── Frontend (React/Nginx)
|
||||||
├── Backend (Go API)
|
├── Backend (Go API)
|
||||||
├── PostgreSQL (Database)
|
├── PostgreSQL (Database)
|
||||||
└── Redis (Cache)
|
└── Dragonfly (Cache)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration Files
|
## Configuration Files
|
||||||
@@ -138,8 +138,8 @@ docker-compose up -d --force-recreate
|
|||||||
# Backup PostgreSQL
|
# Backup PostgreSQL
|
||||||
docker-compose exec postgres pg_dump -U containr_user containr > backup.sql
|
docker-compose exec postgres pg_dump -U containr_user containr > backup.sql
|
||||||
|
|
||||||
# Backup Redis
|
# Backup Dragonfly (snapshot)
|
||||||
docker-compose exec redis redis-cli --rdb /data/dump.rdb
|
docker-compose exec dragonfly redis-cli -a "$REDIS_PASSWORD" save
|
||||||
```
|
```
|
||||||
|
|
||||||
### SSL Certificates
|
### SSL Certificates
|
||||||
@@ -211,7 +211,7 @@ docker-compose up -d
|
|||||||
### Performance Tuning
|
### Performance Tuning
|
||||||
|
|
||||||
1. **Nginx Caching** - Already configured in `nginx.conf`
|
1. **Nginx Caching** - Already configured in `nginx.conf`
|
||||||
2. **Redis Caching** - Configure in your application
|
2. **Dragonfly Caching** - Configure in your application
|
||||||
3. **Database Pooling** - Adjust connection limits in Go app
|
3. **Database Pooling** - Adjust connection limits in Go app
|
||||||
|
|
||||||
## Production Tips
|
## Production Tips
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
### Prerequisites
|
### Prerequisites
|
||||||
- Go 1.21+
|
- Go 1.21+
|
||||||
- PostgreSQL 12+
|
- PostgreSQL 12+
|
||||||
- Redis (optional)
|
- Dragonfly or Redis-compatible cache (optional)
|
||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
||||||
|
|||||||
+18
-10
@@ -65,12 +65,16 @@ services:
|
|||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
redis:
|
dragonfly:
|
||||||
image: redis:7-alpine
|
image: docker.dragonflydb.io/dragonflydb/dragonfly:${DRAGONFLY_VERSION:-latest}
|
||||||
container_name: containr-redis
|
container_name: containr-dragonfly
|
||||||
command: redis-server --requirepass ${REDIS_PASSWORD:-dev_redis_123}
|
command:
|
||||||
|
- --requirepass=${REDIS_PASSWORD:-dev_redis_123}
|
||||||
|
- --proactor_threads=${DRAGONFLY_THREADS:-1}
|
||||||
|
- --maxmemory=${DRAGONFLY_MAXMEMORY:-256mb}
|
||||||
|
- --dir=/data
|
||||||
volumes:
|
volumes:
|
||||||
- redis_data:/data
|
- dragonfly_data:/data
|
||||||
networks:
|
networks:
|
||||||
- containr-network
|
- containr-network
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -89,7 +93,7 @@ services:
|
|||||||
- "8082:8080"
|
- "8082:8080"
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=postgres://${POSTGRES_USER:-containr_user}:${POSTGRES_PASSWORD:-dev_password_123}@postgres:5432/${POSTGRES_DB:-containr}?sslmode=disable
|
- DATABASE_URL=postgres://${POSTGRES_USER:-containr_user}:${POSTGRES_PASSWORD:-dev_password_123}@postgres:5432/${POSTGRES_DB:-containr}?sslmode=disable
|
||||||
- REDIS_URL=redis://:${REDIS_PASSWORD:-dev_redis_123}@redis:6379/0
|
- REDIS_URL=redis://:${REDIS_PASSWORD:-dev_redis_123}@dragonfly:6379/0
|
||||||
- PORT=8080
|
- PORT=8080
|
||||||
- HOST=0.0.0.0
|
- HOST=0.0.0.0
|
||||||
- ENVIRONMENT=${ENVIRONMENT:-production}
|
- ENVIRONMENT=${ENVIRONMENT:-production}
|
||||||
@@ -130,12 +134,16 @@ services:
|
|||||||
- BITBUCKET_API_URL=${BITBUCKET_API_URL:-https://api.bitbucket.org/2.0}
|
- BITBUCKET_API_URL=${BITBUCKET_API_URL:-https://api.bitbucket.org/2.0}
|
||||||
- BITBUCKET_BASE_URL=${BITBUCKET_BASE_URL:-https://bitbucket.org}
|
- BITBUCKET_BASE_URL=${BITBUCKET_BASE_URL:-https://bitbucket.org}
|
||||||
- GITEA_BASE_URL=${GITEA_BASE_URL:-https://gitea.example.com}
|
- GITEA_BASE_URL=${GITEA_BASE_URL:-https://gitea.example.com}
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
group_add:
|
||||||
|
- "${DOCKER_GID:-0}"
|
||||||
networks:
|
networks:
|
||||||
- containr-network
|
- containr-network
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
dragonfly:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -160,8 +168,8 @@ services:
|
|||||||
context: ../app/frontend
|
context: ../app/frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
VITE_API_URL: ${VITE_API_URL:-http://localhost:8082}
|
VITE_API_URL: ${VITE_API_URL:-}
|
||||||
VITE_AUTH_URL: ${VITE_AUTH_URL:-http://localhost:8082/api/auth}
|
VITE_AUTH_URL: ${VITE_AUTH_URL:-}
|
||||||
container_name: containr-frontend
|
container_name: containr-frontend
|
||||||
ports:
|
ports:
|
||||||
- "3000:80"
|
- "3000:80"
|
||||||
@@ -203,7 +211,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
driver: local
|
driver: local
|
||||||
redis_data:
|
dragonfly_data:
|
||||||
driver: local
|
driver: local
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
+11
@@ -71,6 +71,7 @@ else
|
|||||||
}
|
}
|
||||||
|
|
||||||
PG_PASS="$(gen)"; REDIS_PASS="$(gen)"
|
PG_PASS="$(gen)"; REDIS_PASS="$(gen)"
|
||||||
|
setvar ENVIRONMENT "production"
|
||||||
setvar JWT_SECRET "$(gen)"
|
setvar JWT_SECRET "$(gen)"
|
||||||
setvar BETTER_AUTH_SECRET "$(gen)"
|
setvar BETTER_AUTH_SECRET "$(gen)"
|
||||||
setvar BETTER_AUTH_INTERNAL_TOKEN "$(gen)"
|
setvar BETTER_AUTH_INTERNAL_TOKEN "$(gen)"
|
||||||
@@ -81,6 +82,16 @@ else
|
|||||||
sed -i.bak "s|your_secure_postgres_password|$PG_PASS|g" .env
|
sed -i.bak "s|your_secure_postgres_password|$PG_PASS|g" .env
|
||||||
sed -i.bak "s|your_secure_redis_password|$REDIS_PASS|g" .env
|
sed -i.bak "s|your_secure_redis_password|$REDIS_PASS|g" .env
|
||||||
|
|
||||||
|
# Docker socket gid as seen INSIDE containers (modern Docker maps it to
|
||||||
|
# root). Probe a throwaway container; fall back to the host gid, then 0.
|
||||||
|
if [[ -z "${DOCKER_GID:-}" ]]; then
|
||||||
|
DOCKER_GID="$(docker run --rm -v /var/run/docker.sock:/var/run/docker.sock alpine:3 stat -c %g /var/run/docker.sock 2>/dev/null \
|
||||||
|
|| stat -c %g /var/run/docker.sock 2>/dev/null \
|
||||||
|
|| stat -f %g /var/run/docker.sock 2>/dev/null \
|
||||||
|
|| echo 0)"
|
||||||
|
fi
|
||||||
|
setvar DOCKER_GID "$DOCKER_GID"
|
||||||
|
|
||||||
# Persist any overrides passed via the environment.
|
# Persist any overrides passed via the environment.
|
||||||
for v in HTTP_PORT API_PORT POSTGRES_PORT REDIS_PORT CONTAINR_VERSION; do
|
for v in HTTP_PORT API_PORT POSTGRES_PORT REDIS_PORT CONTAINR_VERSION; do
|
||||||
[[ -n "${!v:-}" ]] && setvar "$v" "${!v}"
|
[[ -n "${!v:-}" ]] && setvar "$v" "${!v}"
|
||||||
|
|||||||
Reference in new issue
Block a user