vibecarbon 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/carbon/backup/compose-backup.sh +45 -121
- package/carbon/biome.json +1 -1
- package/carbon/docker-compose.metabase.prod.yml +1 -2
- package/carbon/docker-compose.n8n.prod.yml +1 -2
- package/carbon/docker-compose.prod.yml +9 -7
- package/carbon/k8s/base/traefik/certificate.yaml +13 -7
- package/carbon/k8s/values/supabase.values.yaml +8 -0
- package/carbon/package.json +25 -27
- package/carbon/scripts/docker-up.js +13 -1
- package/carbon/src/client/components/DeploymentScenariosSection.tsx +171 -0
- package/carbon/src/client/components/ui/calendar.tsx +1 -1
- package/carbon/src/client/locales/de.json +29 -0
- package/carbon/src/client/locales/en.json +29 -0
- package/carbon/src/client/locales/es.json +29 -0
- package/carbon/src/client/locales/fr.json +29 -0
- package/carbon/src/client/locales/pt.json +29 -0
- package/carbon/src/client/pages/Home.tsx +5 -0
- package/package.json +13 -13
- package/src/backup.js +11 -30
- package/src/destroy.js +5 -43
- package/src/lib/deploy/bundle.js +120 -5
- package/src/lib/deploy/compose/ha.js +114 -1
- package/src/lib/deploy/compose/index.js +243 -177
- package/src/lib/deploy/k8s/k3s.js +120 -67
- package/src/lib/deploy/orchestrator.js +22 -0
- package/src/lib/iac/index.js +45 -7
- package/src/restore.js +135 -54
- package/src/scale.js +33 -57
|
@@ -2,139 +2,63 @@
|
|
|
2
2
|
set -e
|
|
3
3
|
|
|
4
4
|
# ============================================================================
|
|
5
|
-
# Vibecarbon Compose Backup Script
|
|
5
|
+
# Vibecarbon Compose Backup Script — single source of truth for compose backups
|
|
6
6
|
#
|
|
7
|
-
# Runs via cron on the VPS
|
|
8
|
-
#
|
|
9
|
-
# old backups
|
|
7
|
+
# Runs via cron on the VPS (and on-demand from the CLI). Triggers a wal-g base
|
|
8
|
+
# backup from inside the db container (which already has WALG_S3_PREFIX + AWS_*
|
|
9
|
+
# configured from .env) and prunes old base backups via `wal-g delete retain`.
|
|
10
10
|
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
11
|
+
# Invoked as `cd <project-dir> && RETAIN=<n> bash backup/compose-backup.sh`, so
|
|
12
|
+
# it operates relative to the current working directory — no PROJECT_NAME
|
|
13
|
+
# needed. Both the cron path and the on-demand path use the SAME invocation
|
|
14
|
+
# (src/lib/deploy/compose/index.js composeBackupCmd), so the quoting/behavior
|
|
15
|
+
# can never drift.
|
|
16
|
+
#
|
|
17
|
+
# Environment Variables:
|
|
18
|
+
# RETAIN - Number of full base backups to keep (default: 7)
|
|
19
19
|
# ============================================================================
|
|
20
20
|
|
|
21
|
-
# Prevent concurrent backups
|
|
21
|
+
# Prevent concurrent backups (cron + a manual run racing each other).
|
|
22
22
|
LOCKFILE="/tmp/vibecarbon-backup.lock"
|
|
23
23
|
exec 200>"${LOCKFILE}"
|
|
24
24
|
flock -n 200 || { echo "Backup already running — skipping"; exit 0; }
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
REMOTE_DIR="/opt/${PROJECT}"
|
|
28
|
-
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
|
29
|
-
WORK_DIR="/tmp/backup_${TIMESTAMP}"
|
|
30
|
-
ARCHIVE_NAME="${PROJECT}_${TIMESTAMP}_full.tar.gz"
|
|
31
|
-
RETENTION_DAYS="${BACKUP_RETENTION_DAYS:-30}"
|
|
26
|
+
RETAIN="${RETAIN:-7}"
|
|
32
27
|
|
|
33
|
-
echo "=== Vibecarbon Compose Backup ==="
|
|
28
|
+
echo "=== Vibecarbon Compose Backup (wal-g) ==="
|
|
34
29
|
echo "Started at $(date)"
|
|
35
|
-
echo "
|
|
36
|
-
|
|
37
|
-
mkdir -p "${WORK_DIR}"
|
|
38
|
-
|
|
39
|
-
# ============================================================================
|
|
40
|
-
# Step 1: Dump postgres database
|
|
41
|
-
# ============================================================================
|
|
42
|
-
|
|
43
|
-
echo "Backing up postgres database..."
|
|
44
|
-
cd "${REMOTE_DIR}" && docker compose exec -T db pg_dump -U postgres postgres \
|
|
45
|
-
| gzip > "${WORK_DIR}/postgres.sql.gz"
|
|
46
|
-
DB_SIZE=$(du -h "${WORK_DIR}/postgres.sql.gz" | cut -f1)
|
|
47
|
-
echo "postgres: ${DB_SIZE}"
|
|
48
|
-
|
|
49
|
-
# ============================================================================
|
|
50
|
-
# Step 2: Dump optional service databases (n8n, metabase)
|
|
51
|
-
# ============================================================================
|
|
30
|
+
echo "Retain: ${RETAIN} full base backups"
|
|
52
31
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
32
|
+
# wal-g connects to PG via libpq; PGUSER=supabase_admin is REQUIRED (only the
|
|
33
|
+
# superuser may call pg_backup_start; unset, libpq defaults to OS user root →
|
|
34
|
+
# "role root does not exist"). The pg_is_in_recovery()='f' guard makes the
|
|
35
|
+
# backup a no-op on a standby (exit 0), so only the primary pushes to S3.
|
|
36
|
+
#
|
|
37
|
+
# The S3-configured guard exits 0 BEFORE wal-g runs when no S3 backup target is
|
|
38
|
+
# configured. docker-compose.yml renders WALG_S3_PREFIX as
|
|
39
|
+
# s3://${S3_BACKUP_BUCKET:-${S3_BUCKET:-}}/backups/${PROJECT_NAME}/walg
|
|
40
|
+
# so an unconfigured deploy yields the empty-bucket form `s3:///backups/...`
|
|
41
|
+
# (and empty AWS_ACCESS_KEY_ID). Without this guard an always-installed cron
|
|
42
|
+
# would `set -e`-fail nightly into backup.log on no-S3 deploys. Checking inside
|
|
43
|
+
# the container is robust: it tests the actual env wal-g sees (source of truth),
|
|
44
|
+
# covers both the cron and on-demand paths, and needs no deploy-time S3 plumbing.
|
|
45
|
+
docker compose exec -T db bash -c '
|
|
46
|
+
export PGUSER=supabase_admin PGHOST=localhost PGPORT=5432 PGDATABASE=postgres
|
|
47
|
+
case "${WALG_S3_PREFIX:-}" in
|
|
48
|
+
""|s3:///*)
|
|
49
|
+
echo "scheduled backup skipped: no S3 backup target configured (empty WALG_S3_PREFIX)."
|
|
50
|
+
exit 0
|
|
51
|
+
;;
|
|
52
|
+
esac
|
|
53
|
+
if [ -z "${AWS_ACCESS_KEY_ID:-}" ] || [ -z "${AWS_SECRET_ACCESS_KEY:-}" ]; then
|
|
54
|
+
echo "scheduled backup skipped: no S3 credentials configured (empty AWS_ACCESS_KEY_ID/SECRET)."
|
|
55
|
+
exit 0
|
|
62
56
|
fi
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
# Step 3: Storage files (file backend only)
|
|
67
|
-
# ============================================================================
|
|
68
|
-
|
|
69
|
-
STORAGE_COUNT=$(cd "${REMOTE_DIR}" && docker compose exec -T storage \
|
|
70
|
-
sh -c 'find /var/lib/storage -type f 2>/dev/null | wc -l' 2>/dev/null || echo "0")
|
|
71
|
-
if [ "${STORAGE_COUNT}" -gt 0 ] 2>/dev/null; then
|
|
72
|
-
echo "Backing up ${STORAGE_COUNT} storage files..."
|
|
73
|
-
cd "${REMOTE_DIR}" && docker compose exec -T storage \
|
|
74
|
-
tar czf - -C / var/lib/storage > "${WORK_DIR}/storage.tar.gz"
|
|
75
|
-
STORAGE_SIZE=$(du -h "${WORK_DIR}/storage.tar.gz" | cut -f1)
|
|
76
|
-
echo "Storage: ${STORAGE_SIZE}"
|
|
77
|
-
fi
|
|
78
|
-
|
|
79
|
-
# ============================================================================
|
|
80
|
-
# Step 4: Create combined archive
|
|
81
|
-
# ============================================================================
|
|
82
|
-
|
|
83
|
-
echo "Creating archive: ${ARCHIVE_NAME}"
|
|
84
|
-
tar czf "${REMOTE_DIR}/backups/${ARCHIVE_NAME}" -C "${WORK_DIR}" .
|
|
85
|
-
TOTAL_SIZE=$(du -h "${REMOTE_DIR}/backups/${ARCHIVE_NAME}" | cut -f1)
|
|
86
|
-
echo "Total archive size: ${TOTAL_SIZE}"
|
|
87
|
-
|
|
88
|
-
# ============================================================================
|
|
89
|
-
# Step 5: Upload to S3 (if configured)
|
|
90
|
-
# ============================================================================
|
|
91
|
-
|
|
92
|
-
if [ -n "${S3_BACKUP_BUCKET}" ] && [ -n "${S3_ACCESS_KEY}" ] && [ -n "${S3_SECRET_KEY}" ] && [ -n "${S3_ENDPOINT}" ]; then
|
|
93
|
-
echo "Uploading to S3: s3://${S3_BACKUP_BUCKET}/backups/${ARCHIVE_NAME}"
|
|
94
|
-
|
|
95
|
-
export AWS_ACCESS_KEY_ID="${S3_ACCESS_KEY}"
|
|
96
|
-
export AWS_SECRET_ACCESS_KEY="${S3_SECRET_KEY}"
|
|
97
|
-
export AWS_DEFAULT_REGION="${S3_REGION:-us-east-1}"
|
|
98
|
-
|
|
99
|
-
aws s3 cp "${REMOTE_DIR}/backups/${ARCHIVE_NAME}" \
|
|
100
|
-
"s3://${S3_BACKUP_BUCKET}/backups/${ARCHIVE_NAME}" \
|
|
101
|
-
--endpoint-url "${S3_ENDPOINT}"
|
|
102
|
-
|
|
103
|
-
echo "Upload complete"
|
|
104
|
-
|
|
105
|
-
# S3 retention cleanup
|
|
106
|
-
echo "Cleaning up S3 backups older than ${RETENTION_DAYS} days..."
|
|
107
|
-
CUTOFF_DATE=$(date -d "-${RETENTION_DAYS} days" +%Y%m%d 2>/dev/null || echo "")
|
|
108
|
-
if [ -n "${CUTOFF_DATE}" ]; then
|
|
109
|
-
aws s3api list-objects-v2 \
|
|
110
|
-
--bucket "${S3_BACKUP_BUCKET}" \
|
|
111
|
-
--prefix "backups/${PROJECT}_" \
|
|
112
|
-
--endpoint-url "${S3_ENDPOINT}" \
|
|
113
|
-
--query "Contents[].Key" \
|
|
114
|
-
--output text 2>/dev/null | tr '\t' '\n' | while read -r key; do
|
|
115
|
-
[ -z "${key}" ] || [ "${key}" = "None" ] && continue
|
|
116
|
-
FILE_DATE=$(echo "${key}" | sed -n 's/.*_\([0-9]\{8\}\)_[0-9]\{6\}_full\.tar\.gz$/\1/p')
|
|
117
|
-
if [ -n "${FILE_DATE}" ] && [ "${FILE_DATE}" -lt "${CUTOFF_DATE}" ] 2>/dev/null; then
|
|
118
|
-
echo "Deleting expired backup: ${key}"
|
|
119
|
-
aws s3 rm "s3://${S3_BACKUP_BUCKET}/${key}" --endpoint-url "${S3_ENDPOINT}"
|
|
120
|
-
fi
|
|
121
|
-
done
|
|
57
|
+
if [ "$(psql -U supabase_admin -d postgres -tAc "SELECT pg_is_in_recovery()")" != "f" ]; then
|
|
58
|
+
echo "supabase-db is in recovery (standby) — skipping base backup."
|
|
59
|
+
exit 0
|
|
122
60
|
fi
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
fi
|
|
126
|
-
|
|
127
|
-
# ============================================================================
|
|
128
|
-
# Step 6: Local retention cleanup
|
|
129
|
-
# ============================================================================
|
|
130
|
-
|
|
131
|
-
find "${REMOTE_DIR}/backups" -name "${PROJECT}_*_full.tar.gz" -mtime "+${RETENTION_DAYS}" -delete 2>/dev/null || true
|
|
132
|
-
|
|
133
|
-
# ============================================================================
|
|
134
|
-
# Cleanup
|
|
135
|
-
# ============================================================================
|
|
136
|
-
|
|
137
|
-
rm -rf "${WORK_DIR}"
|
|
61
|
+
wal-g backup-push "$PGDATA" && wal-g delete retain FULL "'"${RETAIN}"'" --confirm
|
|
62
|
+
'
|
|
138
63
|
|
|
139
64
|
echo "=== Backup completed at $(date) ==="
|
|
140
|
-
echo "Archive: ${ARCHIVE_NAME} (${TOTAL_SIZE})"
|
package/carbon/biome.json
CHANGED
|
@@ -15,11 +15,10 @@ services:
|
|
|
15
15
|
labels:
|
|
16
16
|
# Production Traefik routing with SSL
|
|
17
17
|
- "traefik.enable=true"
|
|
18
|
-
# HTTPS router with super_admin auth
|
|
18
|
+
# HTTPS router with super_admin auth — cert from default store
|
|
19
19
|
- "traefik.http.routers.metabase.rule=Host(`metabase.${DOMAIN}`)"
|
|
20
20
|
- "traefik.http.routers.metabase.entrypoints=websecure"
|
|
21
21
|
- "traefik.http.routers.metabase.tls=true"
|
|
22
|
-
- "traefik.http.routers.metabase.tls.certresolver=letsencrypt"
|
|
23
22
|
- "traefik.http.routers.metabase.middlewares=super-admin-auth@file"
|
|
24
23
|
- "traefik.http.services.metabase.loadbalancer.server.port=3000"
|
|
25
24
|
# HTTP to HTTPS redirect
|
|
@@ -18,11 +18,10 @@ services:
|
|
|
18
18
|
labels:
|
|
19
19
|
# Production Traefik routing with SSL
|
|
20
20
|
- "traefik.enable=true"
|
|
21
|
-
# HTTPS router with super_admin auth
|
|
21
|
+
# HTTPS router with super_admin auth — cert from default store
|
|
22
22
|
- "traefik.http.routers.n8n.rule=Host(`n8n.${DOMAIN}`)"
|
|
23
23
|
- "traefik.http.routers.n8n.entrypoints=websecure"
|
|
24
24
|
- "traefik.http.routers.n8n.tls=true"
|
|
25
|
-
- "traefik.http.routers.n8n.tls.certresolver=letsencrypt"
|
|
26
25
|
- "traefik.http.routers.n8n.middlewares=super-admin-auth@file"
|
|
27
26
|
- "traefik.http.services.n8n.loadbalancer.server.port=5678"
|
|
28
27
|
# HTTP to HTTPS redirect
|
|
@@ -26,7 +26,12 @@ services:
|
|
|
26
26
|
- "traefik.http.routers.app-http.entrypoints=web"
|
|
27
27
|
- "traefik.http.routers.app-http.middlewares=redirect-to-https"
|
|
28
28
|
- "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https"
|
|
29
|
-
# HTTPS router
|
|
29
|
+
# HTTPS router for the app at the bare apex (${DOMAIN}). The apex gets its
|
|
30
|
+
# OWN single-domain cert via certresolver — it is intentionally NOT in the
|
|
31
|
+
# default wildcard store cert, because apex + `*.${DOMAIN}` share the
|
|
32
|
+
# `_acme-challenge.${DOMAIN}` TXT name and some DNS providers (Hetzner)
|
|
33
|
+
# can't hold both challenge values at once. The subdomain routers below use
|
|
34
|
+
# `tls=true` (no resolver) and serve the `*.${DOMAIN}` default cert via SNI.
|
|
30
35
|
- "traefik.http.routers.app.rule=Host(`${DOMAIN:-localhost}`)"
|
|
31
36
|
- "traefik.http.routers.app.entrypoints=websecure"
|
|
32
37
|
- "traefik.http.routers.app.tls=true"
|
|
@@ -109,10 +114,9 @@ services:
|
|
|
109
114
|
- "traefik.http.routers.dashboard-http.rule=Host(`traefik.${DOMAIN}`)"
|
|
110
115
|
- "traefik.http.routers.dashboard-http.entrypoints=web"
|
|
111
116
|
- "traefik.http.routers.dashboard-http.middlewares=redirect-to-https"
|
|
112
|
-
# HTTPS dashboard with super_admin auth
|
|
117
|
+
# HTTPS dashboard with super_admin auth — cert from default store
|
|
113
118
|
- "traefik.http.routers.dashboard.entrypoints=websecure"
|
|
114
119
|
- "traefik.http.routers.dashboard.tls=true"
|
|
115
|
-
- "traefik.http.routers.dashboard.tls.certresolver=letsencrypt"
|
|
116
120
|
- "traefik.http.routers.dashboard.middlewares=super-admin-auth@file"
|
|
117
121
|
deploy:
|
|
118
122
|
resources:
|
|
@@ -127,11 +131,10 @@ services:
|
|
|
127
131
|
studio:
|
|
128
132
|
labels:
|
|
129
133
|
- "traefik.enable=true"
|
|
130
|
-
# HTTPS router with admin auth
|
|
134
|
+
# HTTPS router with admin auth — cert from default store
|
|
131
135
|
- "traefik.http.routers.studio.rule=Host(`studio.${DOMAIN}`)"
|
|
132
136
|
- "traefik.http.routers.studio.entrypoints=websecure"
|
|
133
137
|
- "traefik.http.routers.studio.tls=true"
|
|
134
|
-
- "traefik.http.routers.studio.tls.certresolver=letsencrypt"
|
|
135
138
|
- "traefik.http.routers.studio.middlewares=admin-auth@file"
|
|
136
139
|
- "traefik.http.services.studio.loadbalancer.server.port=3000"
|
|
137
140
|
|
|
@@ -260,11 +263,10 @@ services:
|
|
|
260
263
|
- "traefik.http.routers.kong-http.rule=Host(`api.${DOMAIN:-localhost}`)"
|
|
261
264
|
- "traefik.http.routers.kong-http.entrypoints=web"
|
|
262
265
|
- "traefik.http.routers.kong-http.middlewares=redirect-to-https"
|
|
263
|
-
# HTTPS router for Supabase API
|
|
266
|
+
# HTTPS router for Supabase API — cert from default store
|
|
264
267
|
- "traefik.http.routers.kong.rule=Host(`api.${DOMAIN:-localhost}`)"
|
|
265
268
|
- "traefik.http.routers.kong.entrypoints=websecure"
|
|
266
269
|
- "traefik.http.routers.kong.tls=true"
|
|
267
|
-
- "traefik.http.routers.kong.tls.certresolver=letsencrypt"
|
|
268
270
|
- "traefik.http.services.kong.loadbalancer.server.port=8000"
|
|
269
271
|
deploy:
|
|
270
272
|
resources:
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
# TLS Certificate for Vibecarbon application
|
|
2
2
|
# Requires cert-manager to be installed (kubectl apply -k k8s/infra/)
|
|
3
3
|
#
|
|
4
|
-
# issuerRef.name
|
|
5
|
-
# src/lib/deploy/k8s/k3s.js
|
|
6
|
-
# cloudflare,hetzner,manual}
|
|
7
|
-
#
|
|
8
|
-
#
|
|
4
|
+
# issuerRef.name and dnsNames are BOTH patched per-deployment by
|
|
5
|
+
# applyK3sManifests in src/lib/deploy/k8s/k3s.js:
|
|
6
|
+
# - issuerRef.name → letsencrypt-{prod,staging}-{cloudflare,hetzner,manual}
|
|
7
|
+
# based on dnsProvider + ACME_CA_SERVER.
|
|
8
|
+
# - dnsNames → [domain] for manual (HTTP-01) issuers;
|
|
9
|
+
# [domain, "*.domain"] for DNS-01 (cloudflare/hetzner) issuers.
|
|
10
|
+
# DNS-01 allows a wildcard SAN so one cert covers every subdomain
|
|
11
|
+
# (api, studio, dashboard, grafana, n8n, ...) without separate ACME orders.
|
|
12
|
+
#
|
|
13
|
+
# The placeholders below keep this template valid for kustomize; they are
|
|
14
|
+
# overwritten before cert-manager ever sees a Certificate request.
|
|
9
15
|
apiVersion: cert-manager.io/v1
|
|
10
16
|
kind: Certificate
|
|
11
17
|
metadata:
|
|
@@ -17,5 +23,5 @@ spec:
|
|
|
17
23
|
kind: ClusterIssuer
|
|
18
24
|
dnsNames:
|
|
19
25
|
- app.example.com # Patched with actual domain at deploy time
|
|
20
|
-
|
|
21
|
-
|
|
26
|
+
# For DNS-01 (managed-DNS) deploys the wildcard is added at deploy time:
|
|
27
|
+
# - "*.app.example.com"
|
|
@@ -174,6 +174,14 @@ deployment:
|
|
|
174
174
|
{
|
|
175
175
|
echo "restore_command = 'wal-g wal-fetch \"%f\" \"%p\"'"
|
|
176
176
|
echo "recovery_target_action = 'promote'"
|
|
177
|
+
# Pin recovery to the fetched base backup's OWN timeline. Default
|
|
178
|
+
# 'latest' makes postgres chase the newest timeline with a .history
|
|
179
|
+
# file; in HA, repeated restore→promote cycles leave DIVERGENT
|
|
180
|
+
# timelines in the shared wal-g S3 prefix, and 'latest' can pick one
|
|
181
|
+
# that forked before this base backup → crash-loop "requested
|
|
182
|
+
# timeline N is not a child of this server's history". 'current'
|
|
183
|
+
# recovers along the base backup's timeline, then promotes fresh.
|
|
184
|
+
echo "recovery_target_timeline = 'current'"
|
|
177
185
|
if [ "${RESTORE_TARGET}" != "latest" ]; then
|
|
178
186
|
# PITR: RESTORE_TARGET is an ISO-8601 timestamp — replay only up
|
|
179
187
|
# to that point instead of to the end of the WAL stream.
|
package/carbon/package.json
CHANGED
|
@@ -59,42 +59,41 @@
|
|
|
59
59
|
"@mdx-js/react": "^3.1.1",
|
|
60
60
|
"@mdx-js/rollup": "^3.1.1",
|
|
61
61
|
"@scalar/api-reference-react": "^0.9.41",
|
|
62
|
-
"@supabase/ssr": "^0.9.0",
|
|
63
62
|
"@supabase/supabase-js": "^2.106.2",
|
|
64
|
-
"@tanstack/react-query": "^5.
|
|
63
|
+
"@tanstack/react-query": "^5.100.14",
|
|
65
64
|
"class-variance-authority": "^0.7.1",
|
|
66
65
|
"clsx": "^2.1.1",
|
|
67
66
|
"cmdk": "^1.1.1",
|
|
68
|
-
"date-fns": "^4.
|
|
67
|
+
"date-fns": "^4.4.0",
|
|
69
68
|
"embla-carousel-react": "^8.6.0",
|
|
70
69
|
"framer-motion": "^12.40.0",
|
|
71
70
|
"hono": "^4.12.23",
|
|
72
|
-
"i18next": "^
|
|
71
|
+
"i18next": "^26.3.0",
|
|
73
72
|
"i18next-browser-languagedetector": "^8.2.1",
|
|
74
73
|
"input-otp": "^1.4.2",
|
|
75
|
-
"ioredis": "^5.
|
|
74
|
+
"ioredis": "^5.11.0",
|
|
76
75
|
"lenis": "^1.3.23",
|
|
77
|
-
"lucide-react": "^1.
|
|
76
|
+
"lucide-react": "^1.17.0",
|
|
78
77
|
"next-themes": "^0.4.6",
|
|
79
|
-
"nodemailer": "^8.0.
|
|
78
|
+
"nodemailer": "^8.0.10",
|
|
80
79
|
"pino": "^10.3.1",
|
|
81
|
-
"react": "^19.2.
|
|
82
|
-
"react-day-picker": "^
|
|
83
|
-
"react-dom": "^19.2.
|
|
84
|
-
"react-hook-form": "^7.
|
|
85
|
-
"react-i18next": "^
|
|
80
|
+
"react": "^19.2.7",
|
|
81
|
+
"react-day-picker": "^10.0.1",
|
|
82
|
+
"react-dom": "^19.2.7",
|
|
83
|
+
"react-hook-form": "^7.77.0",
|
|
84
|
+
"react-i18next": "^17.0.8",
|
|
86
85
|
"react-resizable-panels": "^4.11.2",
|
|
87
|
-
"react-router-dom": "^7.
|
|
86
|
+
"react-router-dom": "^7.16.0",
|
|
88
87
|
"recharts": "^3.8.1",
|
|
89
88
|
"rehype-autolink-headings": "^7.1.0",
|
|
90
89
|
"rehype-slug": "^6.0.0",
|
|
91
90
|
"remark-frontmatter": "^5.0.0",
|
|
92
91
|
"remark-mdx-frontmatter": "^5.2.0",
|
|
93
92
|
"sonner": "^2.0.7",
|
|
94
|
-
"stripe": "^
|
|
95
|
-
"tailwind-merge": "^3.
|
|
93
|
+
"stripe": "^22.2.0",
|
|
94
|
+
"tailwind-merge": "^3.6.0",
|
|
96
95
|
"vaul": "^1.1.2",
|
|
97
|
-
"zod": "^4.3
|
|
96
|
+
"zod": "^4.4.3"
|
|
98
97
|
},
|
|
99
98
|
"pnpm": {
|
|
100
99
|
"onlyBuiltDependencies": [
|
|
@@ -123,33 +122,32 @@
|
|
|
123
122
|
}
|
|
124
123
|
},
|
|
125
124
|
"devDependencies": {
|
|
126
|
-
"@biomejs/biome": "^2.4.
|
|
125
|
+
"@biomejs/biome": "^2.4.16",
|
|
127
126
|
"@hono/swagger-ui": "^0.6.1",
|
|
128
127
|
"@scalar/hono-api-reference": "^0.10.19",
|
|
129
128
|
"@tailwindcss/typography": "^0.5.19",
|
|
130
129
|
"@tailwindcss/vite": "^4.3.0",
|
|
131
|
-
"@testing-library/jest-dom": "^6.
|
|
132
|
-
"@testing-library/react": "^16.3.
|
|
130
|
+
"@testing-library/jest-dom": "^6.9.1",
|
|
131
|
+
"@testing-library/react": "^16.3.2",
|
|
133
132
|
"@testing-library/user-event": "^14.6.1",
|
|
134
133
|
"@types/node": "^25.9.1",
|
|
135
134
|
"@types/nodemailer": "^8.0.0",
|
|
136
135
|
"@types/pg": "^8.20.0",
|
|
137
|
-
"@types/react": "^19.2.
|
|
136
|
+
"@types/react": "^19.2.16",
|
|
138
137
|
"@types/react-dom": "^19.2.3",
|
|
139
138
|
"@vitejs/plugin-react": "^6.0.2",
|
|
140
|
-
"@vitest/coverage-v8": "^4.1.
|
|
141
|
-
"concurrently": "^9.2.1",
|
|
139
|
+
"@vitest/coverage-v8": "^4.1.8",
|
|
142
140
|
"esbuild": "^0.28.0",
|
|
143
|
-
"jsdom": "^
|
|
141
|
+
"jsdom": "^29.1.1",
|
|
144
142
|
"pg": "^8.21.0",
|
|
145
143
|
"pino-pretty": "^13.1.3",
|
|
146
144
|
"remark-gfm": "^4.0.1",
|
|
147
|
-
"shadcn": "^4.
|
|
145
|
+
"shadcn": "^4.10.0",
|
|
148
146
|
"tailwindcss": "^4.3.0",
|
|
149
|
-
"tsx": "^4.22.
|
|
147
|
+
"tsx": "^4.22.4",
|
|
150
148
|
"tw-animate-css": "^1.4.0",
|
|
151
149
|
"typescript": "^6.0.3",
|
|
152
|
-
"vite": "^8.0.
|
|
153
|
-
"vitest": "^4.1.
|
|
150
|
+
"vite": "^8.0.16",
|
|
151
|
+
"vitest": "^4.1.8"
|
|
154
152
|
}
|
|
155
153
|
}
|
|
@@ -124,7 +124,19 @@ const manifest = loadManifest();
|
|
|
124
124
|
|
|
125
125
|
// Get port configuration with offset applied
|
|
126
126
|
const portEnv = getPortEnvVars();
|
|
127
|
-
|
|
127
|
+
|
|
128
|
+
// Pin local builds to the `default` (docker-driver) BuildKit builder.
|
|
129
|
+
// `docker compose up` delegates image builds to `buildx bake`, which uses the
|
|
130
|
+
// globally-selected builder. A long-lived `docker-container` builder (e.g. a
|
|
131
|
+
// `vc-multiarch` builder left active via `buildx create --use` after a
|
|
132
|
+
// multi-arch release build) freezes its /etc/resolv.conf at creation time — so
|
|
133
|
+
// after a laptop changes network/subnet it keeps querying a dead DNS server and
|
|
134
|
+
// builds fail with "i/o timeout" resolving registry-1.docker.io. The default
|
|
135
|
+
// builder builds through the daemon's live DNS on every build, sidestepping
|
|
136
|
+
// that whole class. Local dev/test never needs multi-arch; an explicit
|
|
137
|
+
// BUILDX_BUILDER in the environment still wins, for anyone who wants otherwise.
|
|
138
|
+
const buildEnv = { BUILDX_BUILDER: process.env.BUILDX_BUILDER || 'default' };
|
|
139
|
+
const execOpts = { stdio: 'inherit', env: { ...process.env, ...portEnv, ...buildEnv } };
|
|
128
140
|
|
|
129
141
|
// Check if any optional services (metabase, n8n, observability, etc.) are enabled.
|
|
130
142
|
// These can have slow health checks (e.g. Metabase JVM takes 2-4 min), so we start
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { motion } from 'framer-motion';
|
|
2
|
+
import { ArrowLeftRight, CornerDownRight, RefreshCw, Server } from 'lucide-react';
|
|
3
|
+
import { useTranslation } from 'react-i18next';
|
|
4
|
+
import vibecarbonIcon from '../assets/vibecarbon-icon.svg';
|
|
5
|
+
|
|
6
|
+
type Shape = 'single' | 'pair' | 'cluster' | 'multiCluster';
|
|
7
|
+
type Accent = 'teal' | 'magenta';
|
|
8
|
+
|
|
9
|
+
interface Scenario {
|
|
10
|
+
id: string;
|
|
11
|
+
shape: Shape;
|
|
12
|
+
accent: Accent;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const ACCENTS: Record<Accent, { text: string; pill: string; cluster: string; hover: string }> = {
|
|
16
|
+
teal: {
|
|
17
|
+
text: 'text-primary',
|
|
18
|
+
pill: 'border-primary/30 text-primary',
|
|
19
|
+
cluster: 'border-primary/30',
|
|
20
|
+
hover: 'hover:border-primary/30',
|
|
21
|
+
},
|
|
22
|
+
magenta: {
|
|
23
|
+
text: 'text-secondary-accent',
|
|
24
|
+
pill: 'border-secondary-accent/30 text-secondary-accent',
|
|
25
|
+
cluster: 'border-secondary-accent/30',
|
|
26
|
+
hover: 'hover:border-secondary-accent/30',
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const SCENARIOS: Scenario[] = [
|
|
31
|
+
{ id: 'compose', shape: 'single', accent: 'teal' },
|
|
32
|
+
{ id: 'composeHa', shape: 'pair', accent: 'magenta' },
|
|
33
|
+
{ id: 'k8s', shape: 'cluster', accent: 'teal' },
|
|
34
|
+
{ id: 'k8sHa', shape: 'multiCluster', accent: 'magenta' },
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
// A single server "node" — same visual language as WorkflowSection's DeployVisual pods.
|
|
38
|
+
function Node({ color }: { color: string }) {
|
|
39
|
+
return (
|
|
40
|
+
<div className="flex items-center justify-center rounded-md border border-border/60 bg-background/60 p-1.5 dark:border-white/10 dark:bg-white/[0.03]">
|
|
41
|
+
<Server className={`size-3.5 ${color}`} strokeWidth={1.5} />
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// A dashed "cluster" outline wrapping N nodes. `count` is a fixed literal per topology
|
|
47
|
+
// (2 or 3), so naming the nodes gives each a stable, reorder-free key.
|
|
48
|
+
const NODE_KEYS = ['n1', 'n2', 'n3'];
|
|
49
|
+
|
|
50
|
+
function Cluster({ color, border, count }: { color: string; border: string; count: number }) {
|
|
51
|
+
return (
|
|
52
|
+
<div className={`flex items-center gap-1 rounded-lg border border-dashed ${border} p-1.5`}>
|
|
53
|
+
{NODE_KEYS.slice(0, count).map((key) => (
|
|
54
|
+
<Node key={key} color={color} />
|
|
55
|
+
))}
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Topology glyph: 1 node / 2 nodes + failover / 3-node cluster / 2 clusters + region failover.
|
|
61
|
+
function Topology({ shape, accent }: { shape: Shape; accent: Accent }) {
|
|
62
|
+
const { text, cluster } = ACCENTS[accent];
|
|
63
|
+
switch (shape) {
|
|
64
|
+
case 'single':
|
|
65
|
+
return <Node color={text} />;
|
|
66
|
+
case 'pair':
|
|
67
|
+
return (
|
|
68
|
+
<div className="flex items-center gap-2">
|
|
69
|
+
<Node color={text} />
|
|
70
|
+
<RefreshCw className={`size-3.5 ${text}`} strokeWidth={1.5} />
|
|
71
|
+
<Node color={text} />
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
case 'cluster':
|
|
75
|
+
return <Cluster color={text} border={cluster} count={3} />;
|
|
76
|
+
case 'multiCluster':
|
|
77
|
+
return (
|
|
78
|
+
<div className="flex items-center gap-2">
|
|
79
|
+
<Cluster color={text} border={cluster} count={2} />
|
|
80
|
+
<ArrowLeftRight className={`size-3.5 ${text}`} strokeWidth={1.5} />
|
|
81
|
+
<Cluster color={text} border={cluster} count={2} />
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function DeploymentScenariosSection() {
|
|
88
|
+
const { t } = useTranslation();
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<section className="relative py-24 md:py-36">
|
|
92
|
+
<div className="mx-auto max-w-5xl px-6">
|
|
93
|
+
<div className="mb-10 text-center">
|
|
94
|
+
<motion.div
|
|
95
|
+
className="mb-6 flex justify-center"
|
|
96
|
+
animate={{ y: [0, -6, 0] }}
|
|
97
|
+
transition={{ duration: 3, repeat: Infinity, ease: 'easeInOut' }}
|
|
98
|
+
>
|
|
99
|
+
<img
|
|
100
|
+
src={vibecarbonIcon}
|
|
101
|
+
alt=""
|
|
102
|
+
width={56}
|
|
103
|
+
height={56}
|
|
104
|
+
className="drop-shadow-[0_0_12px_oklch(0.82_0.14_192/0.6)]"
|
|
105
|
+
/>
|
|
106
|
+
</motion.div>
|
|
107
|
+
<h2 className="mb-4 text-3xl font-black tracking-tight md:text-5xl">
|
|
108
|
+
{t('landing.deploy.headline')}{' '}
|
|
109
|
+
<span className="bg-gradient-to-r from-primary via-cyan-400 to-secondary-accent bg-clip-text text-transparent">
|
|
110
|
+
{t('landing.deploy.headlineHighlight')}
|
|
111
|
+
</span>
|
|
112
|
+
</h2>
|
|
113
|
+
<p className="mx-auto max-w-2xl text-lg text-muted-foreground">
|
|
114
|
+
{t('landing.deploy.subheading')}
|
|
115
|
+
</p>
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
119
|
+
{SCENARIOS.map((scenario, index) => {
|
|
120
|
+
const accent = ACCENTS[scenario.accent];
|
|
121
|
+
const features = t(`landing.deploy.${scenario.id}.features`, {
|
|
122
|
+
returnObjects: true,
|
|
123
|
+
}) as string[];
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<motion.div
|
|
127
|
+
key={scenario.id}
|
|
128
|
+
initial={{ opacity: 0, y: 20 }}
|
|
129
|
+
whileInView={{ opacity: 1, y: 0 }}
|
|
130
|
+
viewport={{ once: true, margin: '-50px' }}
|
|
131
|
+
transition={{ duration: 0.4, delay: index * 0.08 }}
|
|
132
|
+
className={`group flex flex-col rounded-2xl border border-border/60 bg-muted/40 p-6 transition-all duration-300 dark:border-white/10 dark:bg-white/5 ${accent.hover} hover:bg-muted/80 dark:hover:bg-white/10`}
|
|
133
|
+
>
|
|
134
|
+
{/* Topology glyph */}
|
|
135
|
+
<div className="mb-5 flex h-12 items-center">
|
|
136
|
+
<Topology shape={scenario.shape} accent={scenario.accent} />
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
{/* Name + tagline */}
|
|
140
|
+
<h3 className="text-lg font-bold text-foreground">
|
|
141
|
+
{t(`landing.deploy.${scenario.id}.name`)}
|
|
142
|
+
</h3>
|
|
143
|
+
<p className="mt-1 text-sm text-muted-foreground">
|
|
144
|
+
{t(`landing.deploy.${scenario.id}.tagline`)}
|
|
145
|
+
</p>
|
|
146
|
+
|
|
147
|
+
{/* Attribute pills */}
|
|
148
|
+
<div className="mt-4 flex flex-wrap gap-2">
|
|
149
|
+
{features.map((feature) => (
|
|
150
|
+
<span
|
|
151
|
+
key={feature}
|
|
152
|
+
className={`rounded-full border px-2.5 py-0.5 text-xs font-medium ${accent.pill}`}
|
|
153
|
+
>
|
|
154
|
+
{feature}
|
|
155
|
+
</span>
|
|
156
|
+
))}
|
|
157
|
+
</div>
|
|
158
|
+
|
|
159
|
+
{/* Best for */}
|
|
160
|
+
<div className="mt-5 flex items-center gap-1.5 text-sm text-muted-foreground">
|
|
161
|
+
<CornerDownRight className={`size-4 shrink-0 ${accent.text}`} strokeWidth={1.5} />
|
|
162
|
+
<span>{t(`landing.deploy.${scenario.id}.bestFor`)}</span>
|
|
163
|
+
</div>
|
|
164
|
+
</motion.div>
|
|
165
|
+
);
|
|
166
|
+
})}
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
</section>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
@@ -70,7 +70,7 @@ function Calendar({
|
|
|
70
70
|
: 'cn-calendar-caption-label rounded-(--cell-radius) flex items-center gap-1 text-sm [&>svg]:text-muted-foreground [&>svg]:size-3.5',
|
|
71
71
|
defaultClassNames.caption_label
|
|
72
72
|
),
|
|
73
|
-
|
|
73
|
+
month_grid: 'w-full border-collapse',
|
|
74
74
|
weekdays: cn('flex', defaultClassNames.weekdays),
|
|
75
75
|
weekday: cn(
|
|
76
76
|
'text-muted-foreground rounded-(--cell-radius) flex-1 font-normal text-[0.8rem] select-none',
|