From 9f3b1960322919028b659ecab838de5f390cd04f Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Tue, 15 Sep 2026 18:59:40 +0200 Subject: [PATCH] Overhaul: real metrics, Git integration UI, open-source hygiene, root cleanup Backend: - GET /services/:id/metrics with real Docker container stats - DELETE /git/providers/:id and GET repo branches endpoint Frontend: - Service detail shows real telemetry or honest empty states - Live status distinguishes idle/polling/reconnecting - Git providers section in Settings; repo+branch pickers in Add Service - Real logo, compact Projects header, canvas fitView fix - index.css deduplicated (1725 -> 1384 lines) - Fixed smoke tests, lint errors, ToastProvider/context unification Repo hygiene: - Added LICENSE (MIT), CONTRIBUTING.md, SECURITY.md; rewrote README - Untracked .env.prod and data/letsencrypt secrets; fixed .gitignore - Removed legacy root src/internal/cmd and stale configs/docs/artifacts - Moved guides and design references under docs/ Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .env.prod | 118 - .gitignore | 12 +- .impeccable.md | 47 - CONTRIBUTING.md | 42 + Dockerfile.backend | 54 - Dockerfile.frontend | 53 - LICENSE | 21 + README.md | 387 +- SECURITY.md | 45 + app/backend/auth/src/auth.js | 197 +- app/backend/cmd/agent/main.go | 566 + app/backend/cmd/server/main.go | 4 +- app/backend/internal/api/agents.go | 163 +- app/backend/internal/api/auth.go | 320 +- app/backend/internal/api/auth_proxy.go | 36 +- app/backend/internal/api/git.go | 205 + app/backend/internal/api/logs.go | 19 +- app/backend/internal/api/metrics.go | 207 + app/backend/internal/api/routes.go | 19 +- app/backend/internal/api/services.go | 80 +- app/backend/internal/api/system.go | 321 + app/backend/internal/api/templates.go | 37 +- app/backend/internal/config/config.go | 54 + app/backend/internal/config/config_test.go | 35 +- .../internal/database/sqlcdb/templates.sql.go | 82 +- app/backend/sqlc/queries/templates.sql | 44 +- app/frontend/package-lock.json | 82 +- app/frontend/public/containr.svg | 172 + app/frontend/public/vite.svg | 1 + app/frontend/src/app/App.smoke.test.tsx | 13 +- app/frontend/src/app/layout/PlatformShell.tsx | 483 +- app/frontend/src/features/auth/pages.tsx | 124 +- .../src/features/builds/pages/BuildsPage.tsx | 16 +- .../features/projects/pages/ProjectsPage.tsx | 53 +- app/frontend/src/features/secondary/pages.tsx | 584 +- .../service/pages/ServiceDetailPage.tsx | 220 +- .../service/pages/ServiceMetricsDashboard.tsx | 31 +- .../workspace/canvas/ProjectCanvas.tsx | 25 +- .../workspace/pages/ProjectWorkspacePage.tsx | 224 +- app/frontend/src/index.css | 428 +- app/frontend/src/lib/api-client.ts | 440 + app/frontend/src/lib/auth-client.ts | 61 +- app/frontend/src/lib/time.ts | 10 + app/frontend/src/lib/use-build-updates.ts | 9 +- .../shared/components/EnhancedMetricCard.tsx | 2 - .../src/shared/components/ErrorBoundary.tsx | 3 +- app/frontend/src/shared/components/Toast.tsx | 26 +- app/frontend/src/shared/components/index.ts | 2 + app/frontend/src/shared/hooks/use-toast.ts | 4 +- cloudflared.yml | 13 - cmd/apwhy/main.go | 29 - cmd/cli/main.go | 14 - cmd/server/main.go | 112 - cmd/test-build-manager/main.go | 54 - cmd/test-railpack/main.go | 32 - components.json | 17 - dashboard.md | 385 - data/letsencrypt/acme.json | 0 docker-compose.yml | 21 +- .../design/inspiration.png | Bin inspo.md => docs/design/inspo.md | 0 inspo2.md => docs/design/inspo2.md | 0 .../guides/APP_DEPLOY_GUIDE.md | 0 AUTOSCALING.md => docs/guides/AUTOSCALING.md | 0 .../guides/AUTO_DEPLOY_GUIDE.md | 0 .../guides/CLOUDFLARE_SETUP.md | 0 .../guides/DOCKER_SETUP.md | 0 .../guides/README_BACKEND.md | 0 SETUP_GUIDE.md => docs/guides/SETUP_GUIDE.md | 0 .../2026-04-16-containr-auth-shell-design.md | 69 + eslint.config.js | 23 - go.mod | 104 - go.sum | 291 - index.html | 13 - infra/docker-compose.yml | 21 +- internal/analytics/umami.go | 74 - internal/api/agents.go | 679 - internal/api/apwhy_handlers.go | 541 - internal/api/audit.go | 177 - internal/api/auth.go | 220 - internal/api/build.go | 368 - internal/api/cron.go | 416 - internal/api/databases.go | 543 - internal/api/deployments.go | 569 - internal/api/git.go | 784 - internal/api/ha.go | 607 - internal/api/logs.go | 244 - internal/api/params.go | 13 - internal/api/preview_environments.go | 617 - internal/api/projects.go | 396 - internal/api/proxmox.go | 480 - internal/api/routes.go | 259 - internal/api/scaling.go | 455 - internal/api/security.go | 612 - internal/api/server.go | 2694 ---- internal/api/services.go | 462 - ...ex-mono-cyrillic-400-normal-BSMlKf0J.woff2 | Bin 8356 -> 0 bytes ...lex-mono-cyrillic-400-normal-CEL4l2ZJ.woff | Bin 7204 -> 0 bytes ...mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff | Bin 5820 -> 0 bytes ...ono-cyrillic-ext-400-normal-xuaO2J-f.woff2 | Bin 6912 -> 0 bytes ...m-plex-mono-latin-400-normal-CvHOgSBP.woff | Bin 13144 -> 0 bytes ...-plex-mono-latin-400-normal-DMJ8VG8y.woff2 | Bin 14708 -> 0 bytes ...x-mono-latin-ext-400-normal-BmRBH3aV.woff2 | Bin 13348 -> 0 bytes ...ex-mono-latin-ext-400-normal-D3D2R8hC.woff | Bin 11768 -> 0 bytes ...-mono-vietnamese-400-normal-BulugwFq.woff2 | Bin 5868 -> 0 bytes ...x-mono-vietnamese-400-normal-DDuiU_S-.woff | Bin 5620 -> 0 bytes internal/api/static/assets/index-DRUelTBf.css | 1 - internal/api/static/assets/index-DwfYiTMH.js | 1 - ...manrope-cyrillic-400-normal-BMzJvInZ.woff2 | Bin 7840 -> 0 bytes .../manrope-cyrillic-400-normal-Dvx59UGC.woff | Bin 10032 -> 0 bytes ...manrope-cyrillic-600-normal-DvRl3Mj-.woff2 | Bin 7872 -> 0 bytes .../manrope-cyrillic-600-normal-It4mZcQk.woff | Bin 10060 -> 0 bytes .../manrope-cyrillic-700-normal-7JNVKxyl.woff | Bin 10004 -> 0 bytes ...manrope-cyrillic-700-normal-Dw_fZAg2.woff2 | Bin 7852 -> 0 bytes .../manrope-greek-400-normal-CM4qok81.woff2 | Bin 5280 -> 0 bytes .../manrope-greek-400-normal-DuX9RsAR.woff | Bin 6772 -> 0 bytes .../manrope-greek-600-normal-BoRV6lzK.woff2 | Bin 5232 -> 0 bytes .../manrope-greek-600-normal-CF2i9ZRY.woff | Bin 6740 -> 0 bytes .../manrope-greek-700-normal-CHUG9PD8.woff2 | Bin 5236 -> 0 bytes .../manrope-greek-700-normal-DyfsrCpP.woff | Bin 6744 -> 0 bytes .../manrope-latin-400-normal-8tf8FM3T.woff | Bin 18292 -> 0 bytes .../manrope-latin-400-normal-PaqtzbVb.woff2 | Bin 14108 -> 0 bytes .../manrope-latin-600-normal-4f0koTD-.woff2 | Bin 14172 -> 0 bytes .../manrope-latin-600-normal-BqgrALkZ.woff | Bin 18384 -> 0 bytes .../manrope-latin-700-normal-BZp_XxE4.woff2 | Bin 14212 -> 0 bytes .../manrope-latin-700-normal-DGRFkw-m.woff | Bin 18412 -> 0 bytes ...manrope-latin-ext-400-normal-C-X6QNXX.woff | Bin 11316 -> 0 bytes ...anrope-latin-ext-400-normal-CMDvPJRp.woff2 | Bin 8292 -> 0 bytes ...anrope-latin-ext-600-normal-_gBojHdJ.woff2 | Bin 8304 -> 0 bytes ...manrope-latin-ext-600-normal-u5Pl7hTU.woff | Bin 11248 -> 0 bytes ...anrope-latin-ext-700-normal-DYOwVNan.woff2 | Bin 8276 -> 0 bytes ...manrope-latin-ext-700-normal-eVCcYqtJ.woff | Bin 11352 -> 0 bytes ...anrope-vietnamese-400-normal-D7E_mLGF.woff | Bin 6108 -> 0 bytes ...nrope-vietnamese-400-normal-DHb3EETF.woff2 | Bin 4464 -> 0 bytes ...nrope-vietnamese-600-normal-C1J5PCl_.woff2 | Bin 4468 -> 0 bytes ...anrope-vietnamese-600-normal-lA7a_7Ok.woff | Bin 6140 -> 0 bytes ...nrope-vietnamese-700-normal-CUqMx5-1.woff2 | Bin 4552 -> 0 bytes ...anrope-vietnamese-700-normal-pt65Fn2Z.woff | Bin 6364 -> 0 bytes internal/api/static/index.html | 13 - internal/api/templates.go | 964 -- internal/api/variables.go | 207 - internal/api/websocket.go | 270 - internal/apwhy/analytics/umami.go | 74 - internal/apwhy/api/server.go | 2694 ---- ...ex-mono-cyrillic-400-normal-BSMlKf0J.woff2 | Bin 8356 -> 0 bytes ...lex-mono-cyrillic-400-normal-CEL4l2ZJ.woff | Bin 7204 -> 0 bytes ...mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff | Bin 5820 -> 0 bytes ...ono-cyrillic-ext-400-normal-xuaO2J-f.woff2 | Bin 6912 -> 0 bytes ...m-plex-mono-latin-400-normal-CvHOgSBP.woff | Bin 13144 -> 0 bytes ...-plex-mono-latin-400-normal-DMJ8VG8y.woff2 | Bin 14708 -> 0 bytes ...x-mono-latin-ext-400-normal-BmRBH3aV.woff2 | Bin 13348 -> 0 bytes ...ex-mono-latin-ext-400-normal-D3D2R8hC.woff | Bin 11768 -> 0 bytes ...-mono-vietnamese-400-normal-BulugwFq.woff2 | Bin 5868 -> 0 bytes ...x-mono-vietnamese-400-normal-DDuiU_S-.woff | Bin 5620 -> 0 bytes .../api/static/assets/index-DRUelTBf.css | 1 - .../apwhy/api/static/assets/index-DwfYiTMH.js | 1 - ...manrope-cyrillic-400-normal-BMzJvInZ.woff2 | Bin 7840 -> 0 bytes .../manrope-cyrillic-400-normal-Dvx59UGC.woff | Bin 10032 -> 0 bytes ...manrope-cyrillic-600-normal-DvRl3Mj-.woff2 | Bin 7872 -> 0 bytes .../manrope-cyrillic-600-normal-It4mZcQk.woff | Bin 10060 -> 0 bytes .../manrope-cyrillic-700-normal-7JNVKxyl.woff | Bin 10004 -> 0 bytes ...manrope-cyrillic-700-normal-Dw_fZAg2.woff2 | Bin 7852 -> 0 bytes .../manrope-greek-400-normal-CM4qok81.woff2 | Bin 5280 -> 0 bytes .../manrope-greek-400-normal-DuX9RsAR.woff | Bin 6772 -> 0 bytes .../manrope-greek-600-normal-BoRV6lzK.woff2 | Bin 5232 -> 0 bytes .../manrope-greek-600-normal-CF2i9ZRY.woff | Bin 6740 -> 0 bytes .../manrope-greek-700-normal-CHUG9PD8.woff2 | Bin 5236 -> 0 bytes .../manrope-greek-700-normal-DyfsrCpP.woff | Bin 6744 -> 0 bytes .../manrope-latin-400-normal-8tf8FM3T.woff | Bin 18292 -> 0 bytes .../manrope-latin-400-normal-PaqtzbVb.woff2 | Bin 14108 -> 0 bytes .../manrope-latin-600-normal-4f0koTD-.woff2 | Bin 14172 -> 0 bytes .../manrope-latin-600-normal-BqgrALkZ.woff | Bin 18384 -> 0 bytes .../manrope-latin-700-normal-BZp_XxE4.woff2 | Bin 14212 -> 0 bytes .../manrope-latin-700-normal-DGRFkw-m.woff | Bin 18412 -> 0 bytes ...manrope-latin-ext-400-normal-C-X6QNXX.woff | Bin 11316 -> 0 bytes ...anrope-latin-ext-400-normal-CMDvPJRp.woff2 | Bin 8292 -> 0 bytes ...anrope-latin-ext-600-normal-_gBojHdJ.woff2 | Bin 8304 -> 0 bytes ...manrope-latin-ext-600-normal-u5Pl7hTU.woff | Bin 11248 -> 0 bytes ...anrope-latin-ext-700-normal-DYOwVNan.woff2 | Bin 8276 -> 0 bytes ...manrope-latin-ext-700-normal-eVCcYqtJ.woff | Bin 11352 -> 0 bytes ...anrope-vietnamese-400-normal-D7E_mLGF.woff | Bin 6108 -> 0 bytes ...nrope-vietnamese-400-normal-DHb3EETF.woff2 | Bin 4464 -> 0 bytes ...nrope-vietnamese-600-normal-C1J5PCl_.woff2 | Bin 4468 -> 0 bytes ...anrope-vietnamese-600-normal-lA7a_7Ok.woff | Bin 6140 -> 0 bytes ...nrope-vietnamese-700-normal-CUqMx5-1.woff2 | Bin 4552 -> 0 bytes ...anrope-vietnamese-700-normal-pt65Fn2Z.woff | Bin 6364 -> 0 bytes internal/apwhy/api/static/index.html | 13 - internal/apwhy/auth/security.go | 119 - internal/apwhy/config/config.go | 127 - internal/apwhy/gateway/proxy.go | 85 - internal/apwhy/rbac/defaults.go | 49 - internal/apwhy/storage/db.go | 236 - internal/apwhy/storage/schema.go | 194 - internal/auth/security.go | 119 - internal/build/README.md | 67 - internal/build/cache.go | 489 - internal/build/custom.go | 520 - internal/build/dockerfile.go | 578 - internal/build/manager.go | 240 - internal/build/nixpacks.go | 575 - internal/build/parallel.go | 428 - internal/build/railpack.go | 309 - internal/build/railpack_test.go | 107 - internal/cli/commands/auth.go | 103 - internal/cli/commands/projects.go | 219 - internal/cli/root.go | 75 - internal/cli/utils.go | 25 - internal/config/config.go | 272 - internal/database/migrations.go | 155 - internal/database/postgres.go | 59 - internal/database/redis.go | 54 - internal/deployment/engine.go | 490 - internal/deployment/history.go | 718 - internal/deployment/scheduler.go | 379 - internal/docker/client.go | 363 - internal/docker/types.go | 281 - internal/gateway/proxy.go | 85 - internal/ha/manager.go | 736 - internal/metrics/collector.go | 473 - internal/metrics/storage.go | 553 - internal/middleware/middleware.go | 126 - internal/networking/dns.go | 361 - internal/networking/service_discovery.go | 447 - internal/networking/traefik.go | 441 - internal/pkg/utils/time.go | 18 - internal/proxmox/client.go | 334 - internal/proxmox/service.go | 456 - internal/proxmox/types.go | 262 - internal/rbac/defaults.go | 49 - internal/scaling/autoscaler.go | 581 - internal/security/compliance.go | 500 - internal/security/encryption.go | 358 - internal/security/scanner.go | 409 - internal/storage/db.go | 433 - internal/storage/schema.go | 194 - internal/types/build.go | 140 - migrations/0001_init.sql | 188 - migrations/0001_init.sql.bak | 188 - migrations/001_initial_schema.sql | 157 - migrations/002_git_integration.sql | 136 - migrations/003_agent_system.sql | 191 - migrations/003_metrics_schema.sql | 352 - migrations/004_database_services.sql | 260 - migrations/004_preview_environments.sql | 54 - migrations/005_security_features.sql | 247 - migrations/006_deployment_enhancements.sql | 28 - migrations/007_templates_cron_audit.sql | 90 - migrations/008_apwhy_integration.sql | 229 - migrations/018_performance_optimization.sql | 81 - migrations/019_community_templates.sql | 16 - migrations/020_compose_templates.sql | 49 - nginx.conf | 65 - .../compose-template-catalog-smoke.png | Bin 161736 -> 0 bytes output/playwright/railway-workspace-smoke.png | Bin 109738 -> 0 bytes package-lock.json | 8028 ---------- package.json | 84 - postcss.config.js | 6 - project.md | 263 - review_output.json | 118 - scorecard.png | Bin 60647 -> 0 bytes src/App.css | 42 - src/App.d.ts | 2 - src/App.test.tsx | 118 - src/App.tsx | 95 - src/AppDocker.tsx | 12 - src/assets/react.svg | 1 - src/components/CanvasContextMenu.d.ts | 6 - src/components/CanvasContextMenu.test.tsx | 159 - src/components/CanvasContextMenu.tsx | 129 - src/components/CommandPalette.d.ts | 6 - src/components/CommandPalette.test.tsx | 215 - src/components/CommandPalette.tsx | 243 - src/components/DockerTemplateManager.tsx | 669 - src/components/DockerTemplateManagerApp.tsx | 480 - src/components/Layout.d.ts | 1 - src/components/Layout.test.tsx | 245 - src/components/Layout.tsx | 267 - .../analytics/AnalyticsOverview.d.ts | 5 - .../analytics/AnalyticsOverview.test.tsx | 171 - .../analytics/AnalyticsOverview.tsx | 148 - .../analytics/ContentAnalytics.d.ts | 5 - .../analytics/ContentAnalytics.test.tsx | 163 - src/components/analytics/ContentAnalytics.tsx | 322 - .../analytics/CustomMetricsDashboard.d.ts | 6 - .../analytics/CustomMetricsDashboard.test.tsx | 449 - .../analytics/CustomMetricsDashboard.tsx | 511 - .../analytics/RealTimeAnalytics.d.ts | 1 - .../analytics/RealTimeAnalytics.test.tsx | 301 - .../analytics/RealTimeAnalytics.tsx | 313 - .../analytics/TrafficAnalytics.d.ts | 5 - .../analytics/TrafficAnalytics.test.tsx | 321 - src/components/analytics/TrafficAnalytics.tsx | 248 - .../analytics/VisitorAnalytics.d.ts | 5 - .../analytics/VisitorAnalytics.test.tsx | 354 - src/components/analytics/VisitorAnalytics.tsx | 200 - src/components/apwhy/APwhyDashboard.tsx | 115 - src/components/apwhy/AnalyticsPanel.tsx | 232 - src/components/apwhy/KeysPanel.tsx | 213 - src/components/apwhy/ServicesPanel.tsx | 410 - src/components/canvas/AutoscalingPanel.tsx | 351 - src/components/canvas/ConnectionLine.tsx | 67 - src/components/canvas/DeploymentPanel.tsx | 241 - .../canvas/EnhancedServiceCanvas.tsx | 619 - src/components/canvas/EnhancedServiceNode.tsx | 308 - src/components/canvas/ServiceCanvas.tsx | 502 - src/components/canvas/ServiceControls.tsx | 316 - src/components/canvas/ServiceNode.tsx | 307 - src/components/dashboard/MetricCard.d.ts | 1 - src/components/dashboard/ProjectCanvas.d.ts | 1 - .../dashboard/ProjectCanvas.test.tsx | 397 - src/components/dashboard/ProjectCanvas.tsx | 871 -- src/components/database/BackupManager.d.ts | 1 - .../database/DatabaseDetailPanel.d.ts | 6 - .../deployments/DeploymentsPanel.d.ts | 1 - .../deployments/DeploymentsPanel.test.tsx | 571 - .../deployments/DeploymentsPanel.tsx | 294 - .../deployments/EnvVariablesEditor.d.ts | 1 - .../deployments/EnvVariablesEditor.test.tsx | 539 - .../deployments/EnvVariablesEditor.tsx | 206 - src/components/deployments/ServiceLogs.d.ts | 1 - .../deployments/ServiceLogs.test.tsx | 670 - src/components/deployments/ServiceLogs.tsx | 174 - src/components/edges/AnimatedEdge.d.ts | 15 - src/components/git/DeploymentTriggers.d.ts | 7 - src/components/git/DeploymentTriggers.tsx | 498 - src/components/git/WebhookManager.d.ts | 7 - src/components/git/WebhookManager.tsx | 422 - .../monitoring/ResourceMonitor.d.ts | 1 - src/components/nodes/EmptyCanvasNode.d.ts | 9 - src/components/nodes/ServiceNode.d.ts | 11 - .../preview/PreviewEnvironments.d.ts | 5 - .../preview/PreviewEnvironments.tsx | 490 - .../security/SecurityDashboard.d.ts | 1 - .../security/VulnerabilityManager.d.ts | 1 - src/components/templates/TemplateGallery.d.ts | 1 - src/components/ui/accessibility.tsx | 475 - src/components/ui/accordion.d.ts | 7 - src/components/ui/accordion.test.tsx | 85 - src/components/ui/accordion.tsx | 55 - src/components/ui/advanced-forms.tsx | 467 - src/components/ui/advanced-loading.tsx | 279 - src/components/ui/alert-dialog.d.ts | 20 - src/components/ui/alert.d.ts | 8 - src/components/ui/alert.test.tsx | 228 - src/components/ui/alert.tsx | 59 - src/components/ui/avatar.d.ts | 6 - src/components/ui/avatar.test.tsx | 40 - src/components/ui/avatar.tsx | 48 - src/components/ui/badge.d.ts | 9 - src/components/ui/badge.test.tsx | 112 - src/components/ui/badge.tsx | 50 - src/components/ui/button.d.ts | 11 - src/components/ui/button.test.tsx | 201 - src/components/ui/button.tsx | 60 - src/components/ui/card.d.ts | 8 - src/components/ui/card.test.tsx | 161 - src/components/ui/card.tsx | 79 - src/components/ui/checkbox.d.ts | 4 - src/components/ui/checkbox.test.tsx | 82 - src/components/ui/checkbox.tsx | 28 - src/components/ui/collapsible.d.ts | 5 - src/components/ui/collapsible.test.tsx | 515 - src/components/ui/collapsible.tsx | 9 - src/components/ui/data-visualization.tsx | 389 - src/components/ui/dialog.d.ts | 19 - src/components/ui/dialog.test.tsx | 326 - src/components/ui/dialog.tsx | 112 - src/components/ui/dropdown-menu.d.ts | 27 - src/components/ui/dropdown-menu.test.tsx | 508 - src/components/ui/dropdown-menu.tsx | 64 - src/components/ui/empty-state.d.ts | 13 - src/components/ui/empty-state.test.tsx | 51 - src/components/ui/empty-state.tsx | 48 - src/components/ui/enhanced-effects.tsx | 235 - src/components/ui/input.d.ts | 5 - src/components/ui/input.test.tsx | 140 - src/components/ui/input.tsx | 29 - src/components/ui/label.d.ts | 5 - src/components/ui/label.test.tsx | 65 - src/components/ui/label.tsx | 24 - src/components/ui/loading-skeleton.d.ts | 7 - src/components/ui/loading-skeleton.test.tsx | 330 - src/components/ui/loading-skeleton.tsx | 83 - src/components/ui/navigation-menu.d.ts | 12 - src/components/ui/notification-system.tsx | 337 - src/components/ui/page-header.d.ts | 14 - src/components/ui/page-header.test.tsx | 60 - src/components/ui/page-header.tsx | 53 - src/components/ui/popover.d.ts | 6 - src/components/ui/popover.test.tsx | 74 - src/components/ui/popover.tsx | 34 - src/components/ui/progress.d.ts | 4 - src/components/ui/progress.test.tsx | 83 - src/components/ui/progress.tsx | 26 - src/components/ui/resizable.d.ts | 8 - src/components/ui/scroll-area.d.ts | 5 - src/components/ui/scroll-area.test.tsx | 64 - src/components/ui/scroll-area.tsx | 46 - src/components/ui/select.d.ts | 13 - src/components/ui/select.tsx | 158 - src/components/ui/separator.d.ts | 4 - src/components/ui/separator.test.tsx | 36 - src/components/ui/separator.tsx | 29 - src/components/ui/sheet.d.ts | 25 - src/components/ui/sheet.test.tsx | 520 - src/components/ui/sheet.tsx | 111 - src/components/ui/skeleton.d.ts | 3 - src/components/ui/skeleton.test.tsx | 41 - src/components/ui/skeleton.tsx | 17 - src/components/ui/stat-card.d.ts | 1 - src/components/ui/switch.d.ts | 4 - src/components/ui/switch.test.tsx | 63 - src/components/ui/switch.tsx | 27 - src/components/ui/table.d.ts | 10 - src/components/ui/table.tsx | 117 - src/components/ui/tabs.d.ts | 7 - src/components/ui/tabs.test.tsx | 621 - src/components/ui/tabs.tsx | 55 - src/components/ui/textarea.d.ts | 5 - src/components/ui/textarea.test.tsx | 69 - src/components/ui/textarea.tsx | 24 - src/components/ui/theme-customizer.tsx | 477 - src/components/ui/theme-toggle.d.ts | 1 - src/components/ui/theme-toggle.tsx | 39 - src/components/ui/toast.d.ts | 15 - src/components/ui/toast.test.tsx | 791 - src/components/ui/toast.tsx | 128 - src/components/ui/toaster.d.ts | 18 - src/components/ui/toaster.tsx | 70 - src/components/ui/tooltip.d.ts | 7 - src/contexts/ThemeContext.d.ts | 4 - src/contexts/ThemeContext.tsx | 84 - src/hooks/useAppConfig.ts | 87 - src/hooks/useAuth.d.ts | 14 - src/hooks/useAuth.test.tsx | 117 - src/hooks/useAuth.tsx | 100 - src/index.css | 498 - src/lib/agents.d.ts | 58 - src/lib/agents.ts | 291 - src/lib/api.d.ts | 370 - src/lib/api.test.ts | 275 - src/lib/api.ts | 609 - src/lib/api/databases.d.ts | 1 - src/lib/utils.d.ts | 2 - src/lib/utils.test.ts | 42 - src/lib/utils.ts | 6 - src/main.d.ts | 1 - src/main.test.tsx | 269 - src/main.tsx | 10 - src/pages/Analytics.d.ts | 1 - src/pages/Analytics.tsx | 143 - src/pages/Canvas.tsx | 161 - src/pages/Dashboard.d.ts | 1 - src/pages/Dashboard.tsx | 551 - src/pages/DatabaseServices.d.ts | 1 - src/pages/DatabaseServices.tsx | 626 - src/pages/Docs.tsx | 78 - src/pages/GitIntegration.d.ts | 1 - src/pages/GitIntegration.tsx | 503 - src/pages/Infrastructure.d.ts | 1 - src/pages/Infrastructure.tsx | 475 - src/pages/Login.d.ts | 1 - src/pages/Login.tsx | 313 - src/pages/NodeAgents.d.ts | 1 - src/pages/NodeAgents.tsx | 458 - src/pages/People.tsx | 48 - src/pages/ProjectDetail.d.ts | 1 - src/pages/ProjectDetail.tsx | 857 -- src/pages/Projects.d.ts | 1 - src/pages/Projects.tsx | 505 - src/pages/Scaling.d.ts | 1 - src/pages/Scaling.test.tsx | 491 - src/pages/Scaling.tsx | 696 - src/pages/Security.tsx | 272 - src/pages/Settings.d.ts | 1 - src/pages/Settings.tsx | 575 - src/pages/Templates.tsx | 746 - src/pages/Usage.tsx | 108 - src/store/canvasStore.d.ts | 23 - src/store/canvasStore.test.ts | 143 - src/store/canvasStore.ts | 125 - src/test/setup.ts | 1 - src/types.d.ts | 160 - src/types.ts | 432 - src/types/agent.d.ts | 228 - src/types/agent.ts | 264 - src/utils/lazy-loading.d.ts | 1 - style_copy.css | 12573 ---------------- tailwind.config.ts | 272 - timeline.md | 437 - traefik-dynamic.yml | 117 - traefik.yml | 69 - tsconfig.app.json | 34 - tsconfig.json | 7 - tsconfig.node.json | 26 - usables.md | 149 - vite.config.d.ts | 2 - vite.config.js | 51 - vite.config.ts | 64 - 499 files changed, 4471 insertions(+), 94235 deletions(-) delete mode 100644 .env.prod delete mode 100644 .impeccable.md create mode 100644 CONTRIBUTING.md delete mode 100644 Dockerfile.backend delete mode 100644 Dockerfile.frontend create mode 100644 LICENSE create mode 100644 SECURITY.md create mode 100644 app/backend/cmd/agent/main.go create mode 100644 app/backend/internal/api/metrics.go create mode 100644 app/backend/internal/api/system.go create mode 100644 app/frontend/public/containr.svg create mode 100644 app/frontend/public/vite.svg delete mode 100644 cloudflared.yml delete mode 100644 cmd/apwhy/main.go delete mode 100644 cmd/cli/main.go delete mode 100644 cmd/server/main.go delete mode 100644 cmd/test-build-manager/main.go delete mode 100644 cmd/test-railpack/main.go delete mode 100644 components.json delete mode 100644 dashboard.md delete mode 100644 data/letsencrypt/acme.json rename inspiration.png => docs/design/inspiration.png (100%) rename inspo.md => docs/design/inspo.md (100%) rename inspo2.md => docs/design/inspo2.md (100%) rename APP_DEPLOY_GUIDE.md => docs/guides/APP_DEPLOY_GUIDE.md (100%) rename AUTOSCALING.md => docs/guides/AUTOSCALING.md (100%) rename AUTO_DEPLOY_GUIDE.md => docs/guides/AUTO_DEPLOY_GUIDE.md (100%) rename CLOUDFLARE_SETUP.md => docs/guides/CLOUDFLARE_SETUP.md (100%) rename DOCKER_SETUP.md => docs/guides/DOCKER_SETUP.md (100%) rename README_BACKEND.md => docs/guides/README_BACKEND.md (100%) rename SETUP_GUIDE.md => docs/guides/SETUP_GUIDE.md (100%) create mode 100644 docs/superpowers/specs/2026-04-16-containr-auth-shell-design.md delete mode 100644 eslint.config.js delete mode 100644 go.mod delete mode 100644 go.sum delete mode 100644 index.html delete mode 100644 internal/analytics/umami.go delete mode 100644 internal/api/agents.go delete mode 100644 internal/api/apwhy_handlers.go delete mode 100644 internal/api/audit.go delete mode 100644 internal/api/auth.go delete mode 100644 internal/api/build.go delete mode 100644 internal/api/cron.go delete mode 100644 internal/api/databases.go delete mode 100644 internal/api/deployments.go delete mode 100644 internal/api/git.go delete mode 100644 internal/api/ha.go delete mode 100644 internal/api/logs.go delete mode 100644 internal/api/params.go delete mode 100644 internal/api/preview_environments.go delete mode 100644 internal/api/projects.go delete mode 100644 internal/api/proxmox.go delete mode 100644 internal/api/routes.go delete mode 100644 internal/api/scaling.go delete mode 100644 internal/api/security.go delete mode 100644 internal/api/server.go delete mode 100644 internal/api/services.go delete mode 100644 internal/api/static/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 delete mode 100644 internal/api/static/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff delete mode 100644 internal/api/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff delete mode 100644 internal/api/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 delete mode 100644 internal/api/static/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff delete mode 100644 internal/api/static/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 delete mode 100644 internal/api/static/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 delete mode 100644 internal/api/static/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff delete mode 100644 internal/api/static/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 delete mode 100644 internal/api/static/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff delete mode 100644 internal/api/static/assets/index-DRUelTBf.css delete mode 100644 internal/api/static/assets/index-DwfYiTMH.js delete mode 100644 internal/api/static/assets/manrope-cyrillic-400-normal-BMzJvInZ.woff2 delete mode 100644 internal/api/static/assets/manrope-cyrillic-400-normal-Dvx59UGC.woff delete mode 100644 internal/api/static/assets/manrope-cyrillic-600-normal-DvRl3Mj-.woff2 delete mode 100644 internal/api/static/assets/manrope-cyrillic-600-normal-It4mZcQk.woff delete mode 100644 internal/api/static/assets/manrope-cyrillic-700-normal-7JNVKxyl.woff delete mode 100644 internal/api/static/assets/manrope-cyrillic-700-normal-Dw_fZAg2.woff2 delete mode 100644 internal/api/static/assets/manrope-greek-400-normal-CM4qok81.woff2 delete mode 100644 internal/api/static/assets/manrope-greek-400-normal-DuX9RsAR.woff delete mode 100644 internal/api/static/assets/manrope-greek-600-normal-BoRV6lzK.woff2 delete mode 100644 internal/api/static/assets/manrope-greek-600-normal-CF2i9ZRY.woff delete mode 100644 internal/api/static/assets/manrope-greek-700-normal-CHUG9PD8.woff2 delete mode 100644 internal/api/static/assets/manrope-greek-700-normal-DyfsrCpP.woff delete mode 100644 internal/api/static/assets/manrope-latin-400-normal-8tf8FM3T.woff delete mode 100644 internal/api/static/assets/manrope-latin-400-normal-PaqtzbVb.woff2 delete mode 100644 internal/api/static/assets/manrope-latin-600-normal-4f0koTD-.woff2 delete mode 100644 internal/api/static/assets/manrope-latin-600-normal-BqgrALkZ.woff delete mode 100644 internal/api/static/assets/manrope-latin-700-normal-BZp_XxE4.woff2 delete mode 100644 internal/api/static/assets/manrope-latin-700-normal-DGRFkw-m.woff delete mode 100644 internal/api/static/assets/manrope-latin-ext-400-normal-C-X6QNXX.woff delete mode 100644 internal/api/static/assets/manrope-latin-ext-400-normal-CMDvPJRp.woff2 delete mode 100644 internal/api/static/assets/manrope-latin-ext-600-normal-_gBojHdJ.woff2 delete mode 100644 internal/api/static/assets/manrope-latin-ext-600-normal-u5Pl7hTU.woff delete mode 100644 internal/api/static/assets/manrope-latin-ext-700-normal-DYOwVNan.woff2 delete mode 100644 internal/api/static/assets/manrope-latin-ext-700-normal-eVCcYqtJ.woff delete mode 100644 internal/api/static/assets/manrope-vietnamese-400-normal-D7E_mLGF.woff delete mode 100644 internal/api/static/assets/manrope-vietnamese-400-normal-DHb3EETF.woff2 delete mode 100644 internal/api/static/assets/manrope-vietnamese-600-normal-C1J5PCl_.woff2 delete mode 100644 internal/api/static/assets/manrope-vietnamese-600-normal-lA7a_7Ok.woff delete mode 100644 internal/api/static/assets/manrope-vietnamese-700-normal-CUqMx5-1.woff2 delete mode 100644 internal/api/static/assets/manrope-vietnamese-700-normal-pt65Fn2Z.woff delete mode 100644 internal/api/static/index.html delete mode 100644 internal/api/templates.go delete mode 100644 internal/api/variables.go delete mode 100644 internal/api/websocket.go delete mode 100644 internal/apwhy/analytics/umami.go delete mode 100644 internal/apwhy/api/server.go delete mode 100644 internal/apwhy/api/static/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 delete mode 100644 internal/apwhy/api/static/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff delete mode 100644 internal/apwhy/api/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff delete mode 100644 internal/apwhy/api/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 delete mode 100644 internal/apwhy/api/static/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff delete mode 100644 internal/apwhy/api/static/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 delete mode 100644 internal/apwhy/api/static/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 delete mode 100644 internal/apwhy/api/static/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff delete mode 100644 internal/apwhy/api/static/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 delete mode 100644 internal/apwhy/api/static/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff delete mode 100644 internal/apwhy/api/static/assets/index-DRUelTBf.css delete mode 100644 internal/apwhy/api/static/assets/index-DwfYiTMH.js delete mode 100644 internal/apwhy/api/static/assets/manrope-cyrillic-400-normal-BMzJvInZ.woff2 delete mode 100644 internal/apwhy/api/static/assets/manrope-cyrillic-400-normal-Dvx59UGC.woff delete mode 100644 internal/apwhy/api/static/assets/manrope-cyrillic-600-normal-DvRl3Mj-.woff2 delete mode 100644 internal/apwhy/api/static/assets/manrope-cyrillic-600-normal-It4mZcQk.woff delete mode 100644 internal/apwhy/api/static/assets/manrope-cyrillic-700-normal-7JNVKxyl.woff delete mode 100644 internal/apwhy/api/static/assets/manrope-cyrillic-700-normal-Dw_fZAg2.woff2 delete mode 100644 internal/apwhy/api/static/assets/manrope-greek-400-normal-CM4qok81.woff2 delete mode 100644 internal/apwhy/api/static/assets/manrope-greek-400-normal-DuX9RsAR.woff delete mode 100644 internal/apwhy/api/static/assets/manrope-greek-600-normal-BoRV6lzK.woff2 delete mode 100644 internal/apwhy/api/static/assets/manrope-greek-600-normal-CF2i9ZRY.woff delete mode 100644 internal/apwhy/api/static/assets/manrope-greek-700-normal-CHUG9PD8.woff2 delete mode 100644 internal/apwhy/api/static/assets/manrope-greek-700-normal-DyfsrCpP.woff delete mode 100644 internal/apwhy/api/static/assets/manrope-latin-400-normal-8tf8FM3T.woff delete mode 100644 internal/apwhy/api/static/assets/manrope-latin-400-normal-PaqtzbVb.woff2 delete mode 100644 internal/apwhy/api/static/assets/manrope-latin-600-normal-4f0koTD-.woff2 delete mode 100644 internal/apwhy/api/static/assets/manrope-latin-600-normal-BqgrALkZ.woff delete mode 100644 internal/apwhy/api/static/assets/manrope-latin-700-normal-BZp_XxE4.woff2 delete mode 100644 internal/apwhy/api/static/assets/manrope-latin-700-normal-DGRFkw-m.woff delete mode 100644 internal/apwhy/api/static/assets/manrope-latin-ext-400-normal-C-X6QNXX.woff delete mode 100644 internal/apwhy/api/static/assets/manrope-latin-ext-400-normal-CMDvPJRp.woff2 delete mode 100644 internal/apwhy/api/static/assets/manrope-latin-ext-600-normal-_gBojHdJ.woff2 delete mode 100644 internal/apwhy/api/static/assets/manrope-latin-ext-600-normal-u5Pl7hTU.woff delete mode 100644 internal/apwhy/api/static/assets/manrope-latin-ext-700-normal-DYOwVNan.woff2 delete mode 100644 internal/apwhy/api/static/assets/manrope-latin-ext-700-normal-eVCcYqtJ.woff delete mode 100644 internal/apwhy/api/static/assets/manrope-vietnamese-400-normal-D7E_mLGF.woff delete mode 100644 internal/apwhy/api/static/assets/manrope-vietnamese-400-normal-DHb3EETF.woff2 delete mode 100644 internal/apwhy/api/static/assets/manrope-vietnamese-600-normal-C1J5PCl_.woff2 delete mode 100644 internal/apwhy/api/static/assets/manrope-vietnamese-600-normal-lA7a_7Ok.woff delete mode 100644 internal/apwhy/api/static/assets/manrope-vietnamese-700-normal-CUqMx5-1.woff2 delete mode 100644 internal/apwhy/api/static/assets/manrope-vietnamese-700-normal-pt65Fn2Z.woff delete mode 100644 internal/apwhy/api/static/index.html delete mode 100644 internal/apwhy/auth/security.go delete mode 100644 internal/apwhy/config/config.go delete mode 100644 internal/apwhy/gateway/proxy.go delete mode 100644 internal/apwhy/rbac/defaults.go delete mode 100644 internal/apwhy/storage/db.go delete mode 100644 internal/apwhy/storage/schema.go delete mode 100644 internal/auth/security.go delete mode 100644 internal/build/README.md delete mode 100644 internal/build/cache.go delete mode 100644 internal/build/custom.go delete mode 100644 internal/build/dockerfile.go delete mode 100644 internal/build/manager.go delete mode 100644 internal/build/nixpacks.go delete mode 100644 internal/build/parallel.go delete mode 100644 internal/build/railpack.go delete mode 100644 internal/build/railpack_test.go delete mode 100644 internal/cli/commands/auth.go delete mode 100644 internal/cli/commands/projects.go delete mode 100644 internal/cli/root.go delete mode 100644 internal/cli/utils.go delete mode 100644 internal/config/config.go delete mode 100644 internal/database/migrations.go delete mode 100644 internal/database/postgres.go delete mode 100644 internal/database/redis.go delete mode 100644 internal/deployment/engine.go delete mode 100644 internal/deployment/history.go delete mode 100644 internal/deployment/scheduler.go delete mode 100644 internal/docker/client.go delete mode 100644 internal/docker/types.go delete mode 100644 internal/gateway/proxy.go delete mode 100644 internal/ha/manager.go delete mode 100644 internal/metrics/collector.go delete mode 100644 internal/metrics/storage.go delete mode 100644 internal/middleware/middleware.go delete mode 100644 internal/networking/dns.go delete mode 100644 internal/networking/service_discovery.go delete mode 100644 internal/networking/traefik.go delete mode 100644 internal/pkg/utils/time.go delete mode 100644 internal/proxmox/client.go delete mode 100644 internal/proxmox/service.go delete mode 100644 internal/proxmox/types.go delete mode 100644 internal/rbac/defaults.go delete mode 100644 internal/scaling/autoscaler.go delete mode 100644 internal/security/compliance.go delete mode 100644 internal/security/encryption.go delete mode 100644 internal/security/scanner.go delete mode 100644 internal/storage/db.go delete mode 100644 internal/storage/schema.go delete mode 100644 internal/types/build.go delete mode 100644 migrations/0001_init.sql delete mode 100644 migrations/0001_init.sql.bak delete mode 100644 migrations/001_initial_schema.sql delete mode 100644 migrations/002_git_integration.sql delete mode 100644 migrations/003_agent_system.sql delete mode 100644 migrations/003_metrics_schema.sql delete mode 100644 migrations/004_database_services.sql delete mode 100644 migrations/004_preview_environments.sql delete mode 100644 migrations/005_security_features.sql delete mode 100644 migrations/006_deployment_enhancements.sql delete mode 100644 migrations/007_templates_cron_audit.sql delete mode 100644 migrations/008_apwhy_integration.sql delete mode 100644 migrations/018_performance_optimization.sql delete mode 100644 migrations/019_community_templates.sql delete mode 100644 migrations/020_compose_templates.sql delete mode 100644 nginx.conf delete mode 100644 output/playwright/compose-template-catalog-smoke.png delete mode 100644 output/playwright/railway-workspace-smoke.png delete mode 100644 package-lock.json delete mode 100644 package.json delete mode 100644 postcss.config.js delete mode 100644 project.md delete mode 100644 review_output.json delete mode 100644 scorecard.png delete mode 100644 src/App.css delete mode 100644 src/App.d.ts delete mode 100644 src/App.test.tsx delete mode 100644 src/App.tsx delete mode 100644 src/AppDocker.tsx delete mode 100644 src/assets/react.svg delete mode 100644 src/components/CanvasContextMenu.d.ts delete mode 100644 src/components/CanvasContextMenu.test.tsx delete mode 100644 src/components/CanvasContextMenu.tsx delete mode 100644 src/components/CommandPalette.d.ts delete mode 100644 src/components/CommandPalette.test.tsx delete mode 100644 src/components/CommandPalette.tsx delete mode 100644 src/components/DockerTemplateManager.tsx delete mode 100644 src/components/DockerTemplateManagerApp.tsx delete mode 100644 src/components/Layout.d.ts delete mode 100644 src/components/Layout.test.tsx delete mode 100644 src/components/Layout.tsx delete mode 100644 src/components/analytics/AnalyticsOverview.d.ts delete mode 100644 src/components/analytics/AnalyticsOverview.test.tsx delete mode 100644 src/components/analytics/AnalyticsOverview.tsx delete mode 100644 src/components/analytics/ContentAnalytics.d.ts delete mode 100644 src/components/analytics/ContentAnalytics.test.tsx delete mode 100644 src/components/analytics/ContentAnalytics.tsx delete mode 100644 src/components/analytics/CustomMetricsDashboard.d.ts delete mode 100644 src/components/analytics/CustomMetricsDashboard.test.tsx delete mode 100644 src/components/analytics/CustomMetricsDashboard.tsx delete mode 100644 src/components/analytics/RealTimeAnalytics.d.ts delete mode 100644 src/components/analytics/RealTimeAnalytics.test.tsx delete mode 100644 src/components/analytics/RealTimeAnalytics.tsx delete mode 100644 src/components/analytics/TrafficAnalytics.d.ts delete mode 100644 src/components/analytics/TrafficAnalytics.test.tsx delete mode 100644 src/components/analytics/TrafficAnalytics.tsx delete mode 100644 src/components/analytics/VisitorAnalytics.d.ts delete mode 100644 src/components/analytics/VisitorAnalytics.test.tsx delete mode 100644 src/components/analytics/VisitorAnalytics.tsx delete mode 100644 src/components/apwhy/APwhyDashboard.tsx delete mode 100644 src/components/apwhy/AnalyticsPanel.tsx delete mode 100644 src/components/apwhy/KeysPanel.tsx delete mode 100644 src/components/apwhy/ServicesPanel.tsx delete mode 100644 src/components/canvas/AutoscalingPanel.tsx delete mode 100644 src/components/canvas/ConnectionLine.tsx delete mode 100644 src/components/canvas/DeploymentPanel.tsx delete mode 100644 src/components/canvas/EnhancedServiceCanvas.tsx delete mode 100644 src/components/canvas/EnhancedServiceNode.tsx delete mode 100644 src/components/canvas/ServiceCanvas.tsx delete mode 100644 src/components/canvas/ServiceControls.tsx delete mode 100644 src/components/canvas/ServiceNode.tsx delete mode 100644 src/components/dashboard/MetricCard.d.ts delete mode 100644 src/components/dashboard/ProjectCanvas.d.ts delete mode 100644 src/components/dashboard/ProjectCanvas.test.tsx delete mode 100644 src/components/dashboard/ProjectCanvas.tsx delete mode 100644 src/components/database/BackupManager.d.ts delete mode 100644 src/components/database/DatabaseDetailPanel.d.ts delete mode 100644 src/components/deployments/DeploymentsPanel.d.ts delete mode 100644 src/components/deployments/DeploymentsPanel.test.tsx delete mode 100644 src/components/deployments/DeploymentsPanel.tsx delete mode 100644 src/components/deployments/EnvVariablesEditor.d.ts delete mode 100644 src/components/deployments/EnvVariablesEditor.test.tsx delete mode 100644 src/components/deployments/EnvVariablesEditor.tsx delete mode 100644 src/components/deployments/ServiceLogs.d.ts delete mode 100644 src/components/deployments/ServiceLogs.test.tsx delete mode 100644 src/components/deployments/ServiceLogs.tsx delete mode 100644 src/components/edges/AnimatedEdge.d.ts delete mode 100644 src/components/git/DeploymentTriggers.d.ts delete mode 100644 src/components/git/DeploymentTriggers.tsx delete mode 100644 src/components/git/WebhookManager.d.ts delete mode 100644 src/components/git/WebhookManager.tsx delete mode 100644 src/components/monitoring/ResourceMonitor.d.ts delete mode 100644 src/components/nodes/EmptyCanvasNode.d.ts delete mode 100644 src/components/nodes/ServiceNode.d.ts delete mode 100644 src/components/preview/PreviewEnvironments.d.ts delete mode 100644 src/components/preview/PreviewEnvironments.tsx delete mode 100644 src/components/security/SecurityDashboard.d.ts delete mode 100644 src/components/security/VulnerabilityManager.d.ts delete mode 100644 src/components/templates/TemplateGallery.d.ts delete mode 100644 src/components/ui/accessibility.tsx delete mode 100644 src/components/ui/accordion.d.ts delete mode 100644 src/components/ui/accordion.test.tsx delete mode 100644 src/components/ui/accordion.tsx delete mode 100644 src/components/ui/advanced-forms.tsx delete mode 100644 src/components/ui/advanced-loading.tsx delete mode 100644 src/components/ui/alert-dialog.d.ts delete mode 100644 src/components/ui/alert.d.ts delete mode 100644 src/components/ui/alert.test.tsx delete mode 100644 src/components/ui/alert.tsx delete mode 100644 src/components/ui/avatar.d.ts delete mode 100644 src/components/ui/avatar.test.tsx delete mode 100644 src/components/ui/avatar.tsx delete mode 100644 src/components/ui/badge.d.ts delete mode 100644 src/components/ui/badge.test.tsx delete mode 100644 src/components/ui/badge.tsx delete mode 100644 src/components/ui/button.d.ts delete mode 100644 src/components/ui/button.test.tsx delete mode 100644 src/components/ui/button.tsx delete mode 100644 src/components/ui/card.d.ts delete mode 100644 src/components/ui/card.test.tsx delete mode 100644 src/components/ui/card.tsx delete mode 100644 src/components/ui/checkbox.d.ts delete mode 100644 src/components/ui/checkbox.test.tsx delete mode 100644 src/components/ui/checkbox.tsx delete mode 100644 src/components/ui/collapsible.d.ts delete mode 100644 src/components/ui/collapsible.test.tsx delete mode 100644 src/components/ui/collapsible.tsx delete mode 100644 src/components/ui/data-visualization.tsx delete mode 100644 src/components/ui/dialog.d.ts delete mode 100644 src/components/ui/dialog.test.tsx delete mode 100644 src/components/ui/dialog.tsx delete mode 100644 src/components/ui/dropdown-menu.d.ts delete mode 100644 src/components/ui/dropdown-menu.test.tsx delete mode 100644 src/components/ui/dropdown-menu.tsx delete mode 100644 src/components/ui/empty-state.d.ts delete mode 100644 src/components/ui/empty-state.test.tsx delete mode 100644 src/components/ui/empty-state.tsx delete mode 100644 src/components/ui/enhanced-effects.tsx delete mode 100644 src/components/ui/input.d.ts delete mode 100644 src/components/ui/input.test.tsx delete mode 100644 src/components/ui/input.tsx delete mode 100644 src/components/ui/label.d.ts delete mode 100644 src/components/ui/label.test.tsx delete mode 100644 src/components/ui/label.tsx delete mode 100644 src/components/ui/loading-skeleton.d.ts delete mode 100644 src/components/ui/loading-skeleton.test.tsx delete mode 100644 src/components/ui/loading-skeleton.tsx delete mode 100644 src/components/ui/navigation-menu.d.ts delete mode 100644 src/components/ui/notification-system.tsx delete mode 100644 src/components/ui/page-header.d.ts delete mode 100644 src/components/ui/page-header.test.tsx delete mode 100644 src/components/ui/page-header.tsx delete mode 100644 src/components/ui/popover.d.ts delete mode 100644 src/components/ui/popover.test.tsx delete mode 100644 src/components/ui/popover.tsx delete mode 100644 src/components/ui/progress.d.ts delete mode 100644 src/components/ui/progress.test.tsx delete mode 100644 src/components/ui/progress.tsx delete mode 100644 src/components/ui/resizable.d.ts delete mode 100644 src/components/ui/scroll-area.d.ts delete mode 100644 src/components/ui/scroll-area.test.tsx delete mode 100644 src/components/ui/scroll-area.tsx delete mode 100644 src/components/ui/select.d.ts delete mode 100644 src/components/ui/select.tsx delete mode 100644 src/components/ui/separator.d.ts delete mode 100644 src/components/ui/separator.test.tsx delete mode 100644 src/components/ui/separator.tsx delete mode 100644 src/components/ui/sheet.d.ts delete mode 100644 src/components/ui/sheet.test.tsx delete mode 100644 src/components/ui/sheet.tsx delete mode 100644 src/components/ui/skeleton.d.ts delete mode 100644 src/components/ui/skeleton.test.tsx delete mode 100644 src/components/ui/skeleton.tsx delete mode 100644 src/components/ui/stat-card.d.ts delete mode 100644 src/components/ui/switch.d.ts delete mode 100644 src/components/ui/switch.test.tsx delete mode 100644 src/components/ui/switch.tsx delete mode 100644 src/components/ui/table.d.ts delete mode 100644 src/components/ui/table.tsx delete mode 100644 src/components/ui/tabs.d.ts delete mode 100644 src/components/ui/tabs.test.tsx delete mode 100644 src/components/ui/tabs.tsx delete mode 100644 src/components/ui/textarea.d.ts delete mode 100644 src/components/ui/textarea.test.tsx delete mode 100644 src/components/ui/textarea.tsx delete mode 100644 src/components/ui/theme-customizer.tsx delete mode 100644 src/components/ui/theme-toggle.d.ts delete mode 100644 src/components/ui/theme-toggle.tsx delete mode 100644 src/components/ui/toast.d.ts delete mode 100644 src/components/ui/toast.test.tsx delete mode 100644 src/components/ui/toast.tsx delete mode 100644 src/components/ui/toaster.d.ts delete mode 100644 src/components/ui/toaster.tsx delete mode 100644 src/components/ui/tooltip.d.ts delete mode 100644 src/contexts/ThemeContext.d.ts delete mode 100644 src/contexts/ThemeContext.tsx delete mode 100644 src/hooks/useAppConfig.ts delete mode 100644 src/hooks/useAuth.d.ts delete mode 100644 src/hooks/useAuth.test.tsx delete mode 100644 src/hooks/useAuth.tsx delete mode 100644 src/index.css delete mode 100644 src/lib/agents.d.ts delete mode 100644 src/lib/agents.ts delete mode 100644 src/lib/api.d.ts delete mode 100644 src/lib/api.test.ts delete mode 100644 src/lib/api.ts delete mode 100644 src/lib/api/databases.d.ts delete mode 100644 src/lib/utils.d.ts delete mode 100644 src/lib/utils.test.ts delete mode 100644 src/lib/utils.ts delete mode 100644 src/main.d.ts delete mode 100644 src/main.test.tsx delete mode 100644 src/main.tsx delete mode 100644 src/pages/Analytics.d.ts delete mode 100644 src/pages/Analytics.tsx delete mode 100644 src/pages/Canvas.tsx delete mode 100644 src/pages/Dashboard.d.ts delete mode 100644 src/pages/Dashboard.tsx delete mode 100644 src/pages/DatabaseServices.d.ts delete mode 100644 src/pages/DatabaseServices.tsx delete mode 100644 src/pages/Docs.tsx delete mode 100644 src/pages/GitIntegration.d.ts delete mode 100644 src/pages/GitIntegration.tsx delete mode 100644 src/pages/Infrastructure.d.ts delete mode 100644 src/pages/Infrastructure.tsx delete mode 100644 src/pages/Login.d.ts delete mode 100644 src/pages/Login.tsx delete mode 100644 src/pages/NodeAgents.d.ts delete mode 100644 src/pages/NodeAgents.tsx delete mode 100644 src/pages/People.tsx delete mode 100644 src/pages/ProjectDetail.d.ts delete mode 100644 src/pages/ProjectDetail.tsx delete mode 100644 src/pages/Projects.d.ts delete mode 100644 src/pages/Projects.tsx delete mode 100644 src/pages/Scaling.d.ts delete mode 100644 src/pages/Scaling.test.tsx delete mode 100644 src/pages/Scaling.tsx delete mode 100644 src/pages/Security.tsx delete mode 100644 src/pages/Settings.d.ts delete mode 100644 src/pages/Settings.tsx delete mode 100644 src/pages/Templates.tsx delete mode 100644 src/pages/Usage.tsx delete mode 100644 src/store/canvasStore.d.ts delete mode 100644 src/store/canvasStore.test.ts delete mode 100644 src/store/canvasStore.ts delete mode 100644 src/test/setup.ts delete mode 100644 src/types.d.ts delete mode 100644 src/types.ts delete mode 100644 src/types/agent.d.ts delete mode 100644 src/types/agent.ts delete mode 100644 src/utils/lazy-loading.d.ts delete mode 100644 style_copy.css delete mode 100644 tailwind.config.ts delete mode 100644 timeline.md delete mode 100644 traefik-dynamic.yml delete mode 100644 traefik.yml delete mode 100644 tsconfig.app.json delete mode 100644 tsconfig.json delete mode 100644 tsconfig.node.json delete mode 100644 usables.md delete mode 100644 vite.config.d.ts delete mode 100644 vite.config.js delete mode 100644 vite.config.ts diff --git a/.env.prod b/.env.prod deleted file mode 100644 index 5faab8f..0000000 --- a/.env.prod +++ /dev/null @@ -1,118 +0,0 @@ -# Canonical production environment template for: -# - self-hosted full stack via ./start-unified.sh prod -# - frontend deployment on Vercel (copy VITE_* and FRONTEND_URL/BACKEND_URL values) -# - backend deployment on Railway (copy backend/auth/database values) - -ENVIRONMENT=production - -# Public application URLs -FRONTEND_URL=https://containr-web.vercel.app -BACKEND_URL=https://containr-api.up.railway.app -VITE_API_URL=https://containr-api.up.railway.app -VITE_AUTH_URL=https://containr-api.up.railway.app/api/auth -BETTER_AUTH_URL=https://containr-api.up.railway.app -BETTER_AUTH_TRUSTED_ORIGINS=https://containr-web.vercel.app,https://containr-api.up.railway.app -CORS_ORIGINS=https://containr-web.vercel.app,https://containr-api.up.railway.app -CORS_CREDENTIALS=true - -# Self-hosted domain settings -DOMAIN=containr.example.com -ACME_EMAIL=ops@example.com -TRAEFIK_API_INSECURE=false -TRAEFIK_AUTH=admin:$$apr1$$replace_me$$replace_me - -# Backend runtime -PORT=8080 -HOST=0.0.0.0 -AUTH_PORT=3001 -BETTER_AUTH_ENABLED=true -BETTER_AUTH_ENTRYPOINT=auth/src/server.js -BETTER_AUTH_NODE_BINARY=node -BETTER_AUTH_STARTUP_TIMEOUT=20s -BETTER_AUTH_PROXY_URL=http://127.0.0.1:3001 -BETTER_AUTH_INTERNAL_URL=http://127.0.0.1:3001/internal/session -MAX_REQUEST_BODY_BYTES=10485760 -READ_TIMEOUT=30s -WRITE_TIMEOUT=30s -IDLE_TIMEOUT=60s -SHUTDOWN_TIMEOUT=30s -AUTO_MIGRATE=true -MIGRATION_LOCK_TIMEOUT=5m -SEED_DATA_ON_START=false -TRUSTED_PROXY_CIDR=172.20.0.0/16 -LOG_LEVEL=info -LOG_FORMAT=json -LOG_OUTPUT=stdout -DEBUG=false - -# Secrets -JWT_SECRET=CHANGE_ME_AT_LEAST_32_CHARACTERS_LONG -BETTER_AUTH_SECRET=CHANGE_ME_AT_LEAST_32_CHARACTERS_LONG -BETTER_AUTH_INTERNAL_TOKEN=CHANGE_ME_INTERNAL_AUTH_TOKEN -CONTAINR_AGENT_AUTH_TOKEN=CHANGE_ME_AGENT_AUTH_TOKEN -# Optional rotating token list -# CONTAINR_AGENT_AUTH_TOKENS=current_secret,next_secret - -# Cookies -COOKIE_SECURE=true -COOKIE_DOMAIN= -COOKIE_PATH=/ -COOKIE_SAME_SITE=lax - -# Self-hosted database defaults. -# Railway: replace DATABASE_URL and REDIS_URL with Railway-provided values. -POSTGRES_DB=containr -POSTGRES_USER=containr_user -POSTGRES_PASSWORD=CHANGE_ME_POSTGRES_PASSWORD -DATABASE_URL=postgres://containr_user:CHANGE_ME_POSTGRES_PASSWORD@postgres:5432/containr?sslmode=disable -MAX_CONNECTIONS=50 -MAX_IDLE_CONNECTIONS=10 -CONN_MAX_LIFETIME=10m -CONN_MAX_IDLE_TIME=5m - -REDIS_PASSWORD=CHANGE_ME_REDIS_PASSWORD -REDIS_URL=redis://:CHANGE_ME_REDIS_PASSWORD@redis:6379/0 - -# Explicit DB settings for the embedded Better Auth runtime. -DB_HOST=postgres -DB_PORT=5432 -DB_NAME=containr -DB_USER=containr_user -DB_PASSWORD=CHANGE_ME_POSTGRES_PASSWORD - -# Optional OAuth providers -GITHUB_CLIENT_ID= -GITHUB_CLIENT_SECRET= -GITLAB_CLIENT_ID=PLACEHOLDER_GITLAB_CLIENT_ID -GITLAB_CLIENT_SECRET=PLACEHOLDER_GITLAB_CLIENT_SECRET -GITLAB_OAUTH_AUTHORIZE_URL=https://gitlab.com/oauth/authorize -GITLAB_OAUTH_TOKEN_URL=https://gitlab.com/oauth/token -GITLAB_OAUTH_USERINFO_URL=https://gitlab.com/api/v4/user -BITBUCKET_CLIENT_ID=PLACEHOLDER_BITBUCKET_CLIENT_ID -BITBUCKET_CLIENT_SECRET=PLACEHOLDER_BITBUCKET_CLIENT_SECRET -BITBUCKET_OAUTH_AUTHORIZE_URL=https://bitbucket.org/site/oauth2/authorize -BITBUCKET_OAUTH_TOKEN_URL=https://bitbucket.org/site/oauth2/access_token -BITBUCKET_OAUTH_USERINFO_URL=https://api.bitbucket.org/2.0/user -BITBUCKET_OAUTH_EMAILS_URL=https://api.bitbucket.org/2.0/user/emails -GITEA_CLIENT_ID=PLACEHOLDER_GITEA_CLIENT_ID -GITEA_CLIENT_SECRET=PLACEHOLDER_GITEA_CLIENT_SECRET -GITEA_OAUTH_AUTHORIZE_URL=https://gitea.example.com/login/oauth/authorize -GITEA_OAUTH_TOKEN_URL=https://gitea.example.com/login/oauth/access_token -GITEA_OAUTH_USERINFO_URL=https://gitea.example.com/api/v1/user - -# Repo sync / SCM integrations -GITHUB_APP_ID= -GITHUB_APP_SLUG= -GITHUB_APP_PRIVATE_KEY= -GITHUB_APP_BASE_URL=https://api.github.com -GITLAB_API_URL=https://gitlab.com/api/v4 -GITLAB_BASE_URL=https://gitlab.com -BITBUCKET_API_URL=https://api.bitbucket.org/2.0 -BITBUCKET_BASE_URL=https://bitbucket.org -GITEA_BASE_URL=https://gitea.example.com - -# Optional analytics / observability -UMAMI_BASE_URL= -UMAMI_API_KEY= -UMAMI_WEBSITE_ID= -CLOUDFLARED_TOKEN= diff --git a/.gitignore b/.gitignore index 8bb1a2e..6d5c93a 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,11 @@ build/ .env.development.local .env.test.local .env.production.local +.env.prod +.env.production +.env.*.local +!.env.example +!.env.production.example # Logs logs/ @@ -69,7 +74,7 @@ jspm_packages/ # Gatsby files .cache/ -public +/public/ # Storybook build outputs .out @@ -109,4 +114,7 @@ test-results.xml .opencode .claude .desloppify -bin \ No newline at end of file +bin +# Tool session dirs +.jj/ +.playwright-cli/ diff --git a/.impeccable.md b/.impeccable.md deleted file mode 100644 index a2d4b8a..0000000 --- a/.impeccable.md +++ /dev/null @@ -1,47 +0,0 @@ -# Containr Design System - -## Design Context - -### Users -- **Primary**: DevOps engineers, system administrators, and developers managing container infrastructure -- **Secondary**: Small team leads and solo developers looking for self-hosted container management -- **Context**: Users access the dashboard to deploy, monitor, and manage containers. They need quick access to critical information, real-time metrics, and intuitive workflows. -- **Job to be done**: "I want to deploy and manage containers without the complexity of Kubernetes or expensive cloud platforms." - -### Brand Personality -- **Voice**: Professional yet approachable, technical but not intimidating -- **Three words**: Modern, reliable, empowering -- **Emotional goals**: Users should feel in control, confident, and efficient. The interface should reduce anxiety around infrastructure management. - -### Aesthetic Direction -- **Visual tone**: Dark mode SaaS dashboard with subtle gradients and refined details -- **References**: Railway (primary inspiration) - clean, modern, professional; Vercel - polished developer experience; Linear - refined typography and spacing -- **Anti-references**: Avoid cluttered interfaces, overly bright colors, generic Bootstrap aesthetics -- **Theme**: Dark mode with accent colors for status indicators (emerald for success, amber for warning, rose for errors) - -### Design Principles -1. **Clarity over density** - Every element serves a purpose, generous whitespace reduces cognitive load -2. **Progressive disclosure** - Surface critical information first, details available on interaction -3. **Consistent visual language** - Reusable patterns for cards, buttons, forms, and status indicators -4. **Performance perception** - Loading states and transitions that feel snappy and responsive -5. **Accessibility by default** - WCAG 2.1 AA compliance, readable contrast ratios, keyboard navigation - -### Typography -- **Headlines**: Geist (modern, technical, distinctive) -- **Body**: Inter (highly readable at all sizes) -- **Code/Mono**: JetBrains Mono (developer-friendly) - -### Color System -- **Background**: #0a0a0a (near-black with subtle warmth) -- **Surface**: #141414 (elevated cards and containers) -- **Border**: #1f1f1f (subtle separation) -- **Primary accent**: #6366f1 (indigo - modern tech feel) -- **Success**: #10b981 (emerald) -- **Warning**: #f59e0b (amber) -- **Danger**: #f43f5e (rose) - -### Spacing Scale -- Use 4px base unit -- Component padding: 16px-24px -- Section gaps: 32px-48px -- Page padding: 24px-48px (responsive) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..5a329d6 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,42 @@ +# Contributing to Containr + +Thanks for your interest in contributing. Containr is a self-hosted deployment +platform — contributions of all sizes are welcome. + +## Project Layout + +``` +app/backend/ Go control-plane API (Gin, PostgreSQL, Redis, Docker SDK) +app/frontend/ React + Vite + TypeScript dashboard +infra/ Self-hosted compose stack (Traefik, Cloudflare Tunnel) +docs/ Guides, API spec, and design documents +templates/ Service template catalog notes +``` + +## Development Setup + +1. Copy `.env.example` to `.env` and fill in values (never commit `.env`). +2. Start Postgres and Redis: `docker compose up -d postgres redis` +3. Backend: `cd app/backend && go run ./cmd/server` +4. Frontend: `cd app/frontend && npm install && npm run dev` + +## Checks Before Submitting + +- Backend: `cd app/backend && go build ./... && go test ./... && go vet ./...` +- Frontend: `cd app/frontend && npm run build && npm test` +- Compose: `docker compose config -q` + +## Conventions + +- Reuse existing utilities before adding dependencies. +- Keep handlers thin; business logic lives in `internal/` packages. +- Regenerate API types after schema changes: + `npm --prefix app/frontend run generate:api`. +- Never commit secrets, tokens, or environment files. +- Write tests for non-trivial logic. + +## Pull Requests + +- Keep PRs focused — one concern per PR. +- Describe the "why", not just the "what". +- Include a test plan for anything touching deployment, auth, or networking. diff --git a/Dockerfile.backend b/Dockerfile.backend deleted file mode 100644 index 333a8b7..0000000 --- a/Dockerfile.backend +++ /dev/null @@ -1,54 +0,0 @@ -# Build stage -FROM golang:1.24-alpine AS builder - -# Install build dependencies -RUN apk add --no-cache git ca-certificates tzdata - -# Set working directory -WORKDIR /app - -# Copy go mod files -COPY go.mod go.sum ./ - -# Download dependencies -RUN go mod download - -# Copy source code -COPY . . - -# Build the application -RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main cmd/server/main.go - -# Final stage -FROM alpine:latest - -# Install ca-certificates for HTTPS requests -RUN apk --no-cache add ca-certificates tzdata - -# Create non-root user -RUN addgroup -g 1001 -S appgroup && \ - adduser -u 1001 -S appuser -G appgroup - -WORKDIR /app - -# Copy binary from builder stage -COPY --from=builder /app/main . - -# Copy migrations -COPY --from=builder /app/migrations ./migrations - -# Change ownership to non-root user -RUN chown -R appuser:appgroup /app - -# Switch to non-root user -USER appuser - -# Expose port -EXPOSE 8080 - -# Health check -HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1 - -# Run the binary -CMD ["./main"] diff --git a/Dockerfile.frontend b/Dockerfile.frontend deleted file mode 100644 index 3de8cc6..0000000 --- a/Dockerfile.frontend +++ /dev/null @@ -1,53 +0,0 @@ -# Build stage -FROM node:20-alpine AS builder - -# Set working directory -WORKDIR /app - -# Copy package files -COPY package*.json ./ - -# Install dependencies -RUN npm ci && npm install --save-dev terser - -# Copy source code -COPY . . - -# Build the application -RUN npm run build - -# Production stage -FROM nginx:alpine - -# Copy custom nginx config -COPY nginx.conf /etc/nginx/nginx.conf - -# Copy built assets from builder stage -COPY --from=builder /app/dist /usr/share/nginx/html - -# Create non-root user -RUN addgroup -g 1001 -S appgroup && \ - adduser -u 1001 -S appuser -G appgroup - -# Change ownership of nginx directories -RUN chown -R appuser:appgroup /usr/share/nginx/html && \ - chown -R appuser:appgroup /var/cache/nginx && \ - chown -R appuser:appgroup /var/log/nginx && \ - chown -R appuser:appgroup /etc/nginx/conf.d - -# Create nginx PID directory -RUN touch /var/run/nginx.pid && \ - chown -R appuser:appgroup /var/run/nginx.pid - -# Switch to non-root user -USER appuser - -# Expose port -EXPOSE 80 - -# Health check -HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost:80 || exit 1 - -# Start nginx -CMD ["nginx", "-g", "daemon off;"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..10fdba5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Tomas Dvorak + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index a7f73ac..c1f648b 100644 --- a/README.md +++ b/README.md @@ -1,354 +1,123 @@

- Containr Logo + Containr

-

- Containr - Modern Container Management Platform -

+

Containr

+

- 🚧 Work in Progress - Not yet fully tested -

-

- Deploy, manage, and scale containers with built-in Cloudflare tunnel support. + Self-hosted platform for deploying and managing containerized services. + Railway-style project canvases on your own hardware.

- Quick Start -   •   - Features -   •   - Tech Stack -   •   - Documentation -   •   - Contributing + Quick Start • + Features • + Architecture • + Guides • + Contributing

-

- Code Quality Scorecard -

+## What is Containr? -> **🚀 Inspired by Railway**: This project draws inspiration from [Railway](https://railway.app)'s seamless deployment experience and developer-friendly approach. The goal is to bring that same level of simplicity and power to self-hosted container management. +Containr is an open-source control plane for Docker. It gives you a +project-first deployment workflow — create a project, drop services onto a +visual canvas, wire them together with environment variables, and deploy — +without Kubernetes, without a cloud bill, and without giving up control of +your infrastructure. -## Introduction - -I built Containr because I was tired of complex container orchestration tools that required weeks of setup and deep expertise. You know how it is – Kubernetes clusters to configure, CI/CD pipelines to maintain, networking nightmares to debug, and that simple app deployment that somehow took three days to get running. - -So I decided to build my own solution. Containr is basically the platform I wish I had – one place to deploy, manage, and scale containers without the complexity. It's open-source and self-hosted because I believe your infrastructure should belong to you, not some cloud provider. - -It's like if Docker Compose, Traefik, and a modern dashboard had a baby. Built by one person (with AI's help), for anyone who's tired of container management being a mess. - -### My Design Inspiration - -I've always been a huge fan of **Portainer** – their clean, intuitive approach to container management just makes sense. Everything feels intentional and puts your containers first. - -So yeah, Containr borrows heavily from that design philosophy. But I wrote every line of code from scratch – this isn't some fork or copy-paste job. I took that beautiful simplicity and tried to expand it into more of a comprehensive container platform with modern features. - -### The AI-Assisted Journey - -This project was built with **Windsurf SWE 1.5**. - -Look, I'll be honest – I'm not the best DevOps engineer out there. But I really wanted to build something that solves real problems, and I think passion matters more than being perfect. Windsurf helped me figure out the hard stuff, learn new technologies, and actually build something I'm proud of. - -As a solo dev, having an AI pair programmer made this whole thing possible. Go backend, React frontend, Docker orchestration – stuff I probably couldn't have tackled alone. It's pretty cool what you can create when humans and AI work together, even when you're still learning. - -With Containr, your container deployments are centralized, scalable, and easy to manage, while remaining self-hosted so you maintain full control and privacy – just as it should be. - -## Project Status - -Containr is production-ready for small to medium deployments with comprehensive features for container management. The platform has been enhanced with enterprise-grade security, monitoring, and reliability features. - -**Production Readiness Score: 75/100** - -- ✅ Core functionality complete and tested -- ✅ Security hardening implemented -- ✅ Structured logging and error handling -- ✅ Rate limiting and request validation -- ✅ Comprehensive documentation -- ⚠️ Monitoring integration recommended -- ⚠️ Load testing recommended for high-traffic scenarios - -See [PRODUCTION_READINESS.md](./PRODUCTION_READINESS.md) for detailed assessment. +The UI is inspired by Railway's project canvas: services appear as nodes, +connections are inferred automatically, and everything — logs, metrics, +deployments, builds — is one click away. ## Features -### Core Functionality -- **Container Management**: Deploy, start, stop, and restart containers with ease -- **Service Orchestration**: Manage multi-container applications with Docker Compose integration -- **Automatic SSL**: Built-in Let's Encrypt certificate management with Traefik -- **Cloudflare Tunnel**: Zero-config deployment with automatic public URLs -- **Web Dashboard**: Modern React-based interface for container management -- **Real-time Logs**: View and search container logs in real-time +- **Project canvas** — visual service topology with groups, drag/drop layout, + and auto-inferred connections +- **Deployments** — Docker-based deploys with history, logs, and rollback +- **Git integration** — deploy from GitHub, GitLab, Bitbucket, or Gitea; + repository and branch pickers, webhooks, GitHub App support +- **Builds** — build pipeline with live status over WebSocket +- **Service metrics** — real CPU/memory/network telemetry from Docker stats +- **Templates** — one-click service catalog (databases, apps, tooling) +- **Databases** — managed database services with backup/restore actions +- **Cron jobs** — scheduled jobs with execution history +- **Preview environments** — per-project preview deploys +- **Variables & environments** — per-service env vars, production/preview/dev +- **Node monitoring** — host CPU, memory, disk, and network telemetry +- **Security** — audit logs, vulnerability scans, compliance reports +- **Auth** — Better Auth sessions (email + OAuth), per-user resource scoping +- **Self-hosted networking** — Traefik reverse proxy, optional Cloudflare + Tunnel for exposure without port forwarding -### Advanced Features -- **Auto-scaling**: Automatic horizontal scaling based on resource metrics -- **Health Monitoring**: Comprehensive health checks and monitoring for all services -- **Resource Metrics**: CPU, memory, and network usage tracking -- **Preview Environments**: Automatic preview deployments for branches -- **Git Integration**: Connect repositories for automatic deployments -- **Environment Variables**: Secure management of application secrets and configuration -- **Database Management**: Built-in PostgreSQL and Redis with automated backups -- **Security Scanning**: Container vulnerability scanning and compliance reporting +## Architecture -### Deployment & Infrastructure -- **Multi-environment Support**: Development, staging, and production configurations -- **Load Balancing**: Intelligent traffic distribution with Traefik -- **Service Discovery**: Automatic service registration and discovery -- **Network Management**: Isolated networks and secure inter-service communication -- **Volume Management**: Persistent storage with automated backups -- **CI/CD Integration**: GitHub Actions and GitLab CI integration +``` +app/frontend/ React 19 + Vite + TypeScript + Tailwind + React Flow +app/backend/ Go API (Gin) · PostgreSQL · Redis · Docker SDK · Better Auth sidecar +infra/ Self-hosted compose: Traefik + Cloudflare Tunnel +docs/ Guides, OpenAPI spec, design documents +``` -### Security & Compliance -- **Authentication**: JWT-based authentication with OAuth2 support -- **Role-based Access Control**: Granular permissions for users and teams -- **Audit Logging**: Comprehensive audit trails for all actions -- **Data Encryption**: Encrypted storage for sensitive data -- **Security Scanning**: Automated vulnerability assessment -- **Compliance Reporting**: GDPR and SOC2 compliance frameworks - -## Tech Stack - -### Frontend -- **React + TypeScript** – Modern, reactive UI framework with type safety -- **Vite** – Fast build tool and development server with HMR -- **TailwindCSS** – Utility-first CSS framework for rapid UI development -- **@tanstack/react-query** – Powerful data fetching and state management -- **React Router** – Client-side routing for SPAs -- **@xyflow/react** – Interactive diagrams and visualizations -- **Recharts** – Beautiful charts and data visualization -- **Zustand** – Lightweight state management - -### Backend Services -- **Main Backend (Go)** – Core API, container management, and business logic - - Gin web framework for HTTP routing - - GORM for database operations - - JWT authentication and authorization - - Docker SDK integration -- **Database Layer** – PostgreSQL for production data -- **Cache Layer** – Redis for session management and caching -- **Message Queue** – Background job processing - -### Infrastructure & DevOps -- **Docker & Docker Compose** – Containerized deployment -- **Traefik** – Reverse proxy with automatic SSL -- **Cloudflare Tunnel** – Zero-config public exposure -- **PostgreSQL** – Primary database -- **Redis** – Caching and session storage -- **Nginx** – Static file serving +| Component | Default port | +| ------------ | ------------ | +| Frontend | 3000 (prod) / 5173 (dev) | +| API | 8082 | +| Auth sidecar | 3001 | +| PostgreSQL | 5432 | +| Redis | 6379 | ## Quick Start -### Prerequisites -- Docker Engine with Compose v2 (`docker compose`) -- Git - -### Installation with Docker (Recommended) - -1. **Clone the repository** - ```bash - git clone https://github.com/your-username/containr.git - cd containr - ``` - -2. **Configure environment** - ```bash - cp .env.example .env - cp .env.production.example .env.prod - # Edit .env for local development and .env.prod for production - ``` - -3. **Start the full local stack** - ```bash - docker compose up -d --build - ``` - -4. **Access the application** - - **Local full stack**: http://localhost:3000 (Frontend), http://localhost:8082 (API) - - **Self-hosted production**: `./start-unified.sh prod` - - **Cloudflare profile**: `./start-unified.sh cloudflare` - -### Split Production Deployment +### Docker Compose (everything) ```bash -# Frontend -> Vercel -cd app/frontend -npm run remote - -# Backend only -> self-hosted Docker Compose -cd ../backend -docker compose up -d --build - -# Backend -> Railway -# Configure Railway service root directory: /app/backend -# Configure Railway config file path: /app/backend/railway.toml -railway up +cp .env.example .env # fill in secrets +docker compose up -d ``` -Use `.env.prod` in the repository root as the canonical production variable set. Copy the relevant values into Vercel and Railway project environments so the remote frontend and backend talk to each other over `FRONTEND_URL`, `BACKEND_URL`, `VITE_API_URL`, and `VITE_AUTH_URL`. +Frontend at `http://localhost:3000`, API at `http://localhost:8082`. -### Management Commands +### Local development ```bash -./start-unified.sh dev # Start local full stack -./start-unified.sh prod # Start self-hosted production stack from infra/ -./start-unified.sh cloudflare # Start self-hosted production stack with cloudflared -./start-unified.sh stop # Stop both compose stacks -./start-unified.sh logs # View local stack logs -./start-unified.sh status # Check service status -./start-unified.sh config # Validate compose configuration -./start-unified.sh clean # Clean up containers and volumes +docker compose up -d postgres redis # data services only +cd app/backend && go run ./cmd/server # API + embedded auth sidecar +cd app/frontend && npm install && npm run dev ``` -## Development +Frontend dev server at `http://localhost:5173`. -### Project Structure -``` -containr/ -├── app/ -│ ├── frontend/ # React + Vite frontend (deploy from here to Vercel) -│ └── backend/ # Go API + embedded Better Auth runtime -├── infra/ # Self-hosted production compose + Traefik/cloudflared -├── docs/ # API contract and design references -├── scripts/ # Utility scripts -├── .env.prod # Canonical production env template -└── README.md -``` +### Self-hosted with Traefik + Cloudflare Tunnel -### Local Development - -```bash -# Backend -cd app/backend -go mod download -go run cmd/migrate/main.go up # optional manual migration run (legacy + goose) -go run cmd/server/main.go - -# Frontend -cd ../frontend -npm install -npm run dev -``` - -## Documentation - -Comprehensive documentation is available in the project: - -- **[Cloudflare Setup](./CLOUDFLARE_SETUP.md)** – Cloudflare tunnel configuration -- **[Docker Setup](./DOCKER_SETUP.md)** – Docker deployment guide -- **[Autoscaling](./AUTOSCALING.md)** – Auto-scaling configuration -- **[API Documentation](./docs/api/openapi.yaml)** – REST API reference +See `infra/docker-compose.yml` and +[docs/guides/CLOUDFLARE_SETUP.md](docs/guides/CLOUDFLARE_SETUP.md). ## Configuration -### Environment Variables +All configuration is via environment variables — see `.env.example`. +Required for production: `JWT_SECRET`, `BETTER_AUTH_SECRET`, +`BETTER_AUTH_INTERNAL_TOKEN`, database and Redis credentials. -Key configuration options in `.env`: +**Never commit `.env` or `.env.prod`.** Both are gitignored; if a secret was +ever committed, rotate it. -```bash -# Domain Configuration (optional if using Cloudflare) -DOMAIN=yourdomain.com -ACME_EMAIL=admin@yourdomain.com +## Documentation -# Database Configuration -POSTGRES_DB=containr -POSTGRES_USER=containr_user -POSTGRES_PASSWORD=your_secure_postgres_password -MAX_CONNECTIONS=25 -MAX_IDLE_CONNECTIONS=5 -CONN_MAX_LIFETIME=5m -CONN_MAX_IDLE_TIME=5m - -# Redis Configuration -REDIS_PASSWORD=your_secure_redis_password - -# Application Configuration -JWT_SECRET=your_very_secure_jwt_secret_key_here # min 32 chars in production -CONTAINR_AGENT_AUTH_TOKEN=your_agent_shared_secret # required for node-agent auth -# Optional rotating token list (comma-separated) -# CONTAINR_AGENT_AUTH_TOKENS=current_secret,next_secret -CORS_ORIGINS=https://yourdomain.com,https://www.yourdomain.com -COOKIE_SECURE=true - -# Traefik Authentication (Basic Auth for dashboard) -TRAEFIK_AUTH=admin:$$apr1$$b8mh8c8v$$KkR8hQZQZQZQZQZQZQZQZ/ -# true for local dev dashboard on :8080, set false in production -TRAEFIK_API_INSECURE=true - -# Cloudflare Tunnel (alternative to domain) -CLOUDFLARED_TOKEN=your_cloudflare_tunnel_token_here - -# Optional: Custom Docker Registry -DOCKER_REGISTRY=your-registry.com -DOCKER_USERNAME=your_username -DOCKER_PASSWORD=your_password - -# Optional: External Services -SENTRY_DSN=https://your-sentry-dsn -SLACK_WEBHOOK_URL=https://hooks.slack.com/services/your/webhook/url - -# Development/Testing -ENVIRONMENT=development -DEBUG=true -TRUSTED_PROXY_CIDR=172.20.0.0/16 -``` +- [docs/guides/](docs/guides/) — setup, deployment, Cloudflare, autoscaling +- [docs/api/openapi.yaml](docs/api/openapi.yaml) — API contract +- [docs/developers/onboarding.md](docs/developers/onboarding.md) — contributor onboarding +- [docs/design/](docs/design/) — UI design references +- `make help` — common tasks ## Contributing -Building Containr as a solo developer has been an incredible journey, but it's always better when we build together! Whether you're fixing a typo, adding a feature, or just sharing ideas – your contribution matters. +See [CONTRIBUTING.md](CONTRIBUTING.md). Issues and PRs welcome. -**Feel free to contribute and enhance it to your liking. Help me make this project successful!** +## Security -Every contribution, no matter how small, helps turn this solo dream into a community success story. Whether you're a seasoned developer or just starting out, your perspective and skills can help make Containr better for everyone. - -Here's how you can join this adventure: - -1. **Fork the repository** and make it your own -2. **Create a feature branch** (`git checkout -b feature/amazing-feature`) -3. **Commit your changes** (`git commit -m 'Add some amazing feature'`) -4. **Push to the branch** (`git push origin feature/amazing-feature`) -5. **Open a Pull Request** – I'll be excited to review it! - -### Development Guidelines -- Follow Go coding standards for backend development -- Use TypeScript for frontend development -- Write tests for new features (helps future you!) -- Update documentation as needed -- Remember: this is a labor of love, so let's keep it fun and welcoming - -Don't hesitate to reach out if you're new to contributing – we all started somewhere, and I'm happy to help you get started! +See [SECURITY.md](SECURITY.md). Report vulnerabilities privately — do not +open public issues. ## License -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. - -## Community & Support - -- **Issues**: Use GitHub Issues for bug reports and feature requests -- **Discussions**: Use GitHub Discussions for questions and community support -- **Documentation**: Check the project files for comprehensive guides - -## Acknowledgements - -### Inspiration -- **[Portainer](https://www.portainer.io/)** – Container management UI inspiration -- **[Docker Compose](https://docs.docker.com/compose/)** – Service orchestration concepts -- **[Traefik](https://traefik.io/)** – Reverse proxy and load balancing -- **[Cloudflare Tunnel](https://www.cloudflare.com/products/tunnel/)** – Zero-config deployment - -### Technology Stack -This project is built with amazing open-source technologies: -- **Frontend**: React, TypeScript, Vite, TailwindCSS -- **Backend**: Go, Gin, GORM, PostgreSQL -- **Infrastructure**: Docker, Traefik, Cloudflare, Redis - -## A Personal Note - -Thank you for taking the time to look at my project. Containr represents months of learning, building, and dreaming – all in the service of creating something that makes container management a little more accessible and a lot more powerful. - -In a world of complex orchestration tools and expensive cloud services, I believe there's beauty in owning your infrastructure. Containr is my attempt to build that bridge between simplicity and power. - -Whether you use it, contribute to it, or just find inspiration here – know that you're part of something special. A solo developer's dream, powered by AI assistance, and shared with the open-source community. - ---- - -**Containr** – Built with ❤️ by one human, one AI, and a passion for better container management. +[MIT](LICENSE) © 2026 Tomas Dvorak diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..dd14e28 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,45 @@ +# Security Policy + +## Reporting a Vulnerability + +Please do **not** open a public issue for security vulnerabilities. + +Report privately via GitHub's private vulnerability reporting +(Security tab → Report a vulnerability), or contact the maintainer directly. + +Include: + +- A description of the vulnerability and its impact +- Steps to reproduce +- Affected version/commit if known + +You will receive an acknowledgement as soon as possible. Please allow +reasonable time for a fix before any public disclosure. + +## Scope + +Containr is a self-hosted platform that runs Docker containers, stores +credentials, and terminates user traffic. In scope: + +- Authentication and session handling (Better Auth sidecar, JWT, agent tokens) +- Secrets storage (Git provider tokens, environment variables) +- API authorization and per-user resource scoping +- Webhook signature validation +- CORS and trusted-origin configuration +- Deployment engine command/path handling + +## Secrets + +- Never commit `.env`, `.env.prod`, or any file containing credentials. +- Git provider access tokens are stored server-side only and are never + returned by the API. +- Rotate any secret that has ever been committed to git history. + +## Self-Hosting Notes + +- Set strong `JWT_SECRET`, `BETTER_AUTH_SECRET`, and + `BETTER_AUTH_INTERNAL_TOKEN` values in production. +- Set `COOKIE_SECURE=true` when serving over HTTPS. +- Restrict `CORS_ALLOWED_ORIGINS` to your actual frontend origin. +- Keep Traefik's dashboard disabled or behind auth in production + (`TRAEFIK_API_INSECURE=false`). diff --git a/app/backend/auth/src/auth.js b/app/backend/auth/src/auth.js index dc3ba0a..1e63520 100644 --- a/app/backend/auth/src/auth.js +++ b/app/backend/auth/src/auth.js @@ -1,6 +1,4 @@ import { betterAuth } from 'better-auth'; -import { magicLink } from 'better-auth/plugins/magic-link'; -import { genericOAuth } from 'better-auth/plugins/generic-oauth'; import { Pool } from 'pg'; import { PostgresDialect } from 'kysely'; import { getMigrations } from 'better-auth/db/migration'; @@ -50,41 +48,22 @@ const database = new PostgresDialect({ const githubClientId = env.GITHUB_CLIENT_ID?.trim() || ''; const githubClientSecret = env.GITHUB_CLIENT_SECRET?.trim() || ''; - -const gitlabClientId = env.GITLAB_CLIENT_ID?.trim() || 'PLACEHOLDER_GITLAB_CLIENT_ID'; -const gitlabClientSecret = env.GITLAB_CLIENT_SECRET?.trim() || 'PLACEHOLDER_GITLAB_CLIENT_SECRET'; -const gitlabAuthorizeUrl = - env.GITLAB_OAUTH_AUTHORIZE_URL?.trim() || 'https://gitlab.com/oauth/authorize'; -const gitlabTokenUrl = env.GITLAB_OAUTH_TOKEN_URL?.trim() || 'https://gitlab.com/oauth/token'; -const gitlabUserUrl = env.GITLAB_OAUTH_USERINFO_URL?.trim() || 'https://gitlab.com/api/v4/user'; - -const giteaClientId = env.GITEA_CLIENT_ID?.trim() || 'PLACEHOLDER_GITEA_CLIENT_ID'; -const giteaClientSecret = env.GITEA_CLIENT_SECRET?.trim() || 'PLACEHOLDER_GITEA_CLIENT_SECRET'; -const giteaAuthorizeUrl = - env.GITEA_OAUTH_AUTHORIZE_URL?.trim() || - 'https://gitea.example.com/login/oauth/authorize'; -const giteaTokenUrl = - env.GITEA_OAUTH_TOKEN_URL?.trim() || - 'https://gitea.example.com/login/oauth/access_token'; -const giteaUserUrl = env.GITEA_OAUTH_USERINFO_URL?.trim() || 'https://gitea.example.com/api/v1/user'; - -const bitbucketClientId = env.BITBUCKET_CLIENT_ID?.trim() || 'PLACEHOLDER_BITBUCKET_CLIENT_ID'; -const bitbucketClientSecret = - env.BITBUCKET_CLIENT_SECRET?.trim() || 'PLACEHOLDER_BITBUCKET_CLIENT_SECRET'; -const bitbucketAuthorizeUrl = - env.BITBUCKET_OAUTH_AUTHORIZE_URL?.trim() || 'https://bitbucket.org/site/oauth2/authorize'; -const bitbucketTokenUrl = - env.BITBUCKET_OAUTH_TOKEN_URL?.trim() || 'https://bitbucket.org/site/oauth2/access_token'; -const bitbucketUserUrl = - env.BITBUCKET_OAUTH_USERINFO_URL?.trim() || 'https://api.bitbucket.org/2.0/user'; -const bitbucketEmailsUrl = - env.BITBUCKET_OAUTH_EMAILS_URL?.trim() || 'https://api.bitbucket.org/2.0/user/emails'; +const googleClientId = env.GOOGLE_CLIENT_ID?.trim() || ''; +const googleClientSecret = env.GOOGLE_CLIENT_SECRET?.trim() || ''; const socialProviders = {}; if (githubClientId && githubClientSecret) { socialProviders.github = { clientId: githubClientId, clientSecret: githubClientSecret, + disableImplicitSignUp: true, + }; +} +if (googleClientId && googleClientSecret) { + socialProviders.google = { + clientId: googleClientId, + clientSecret: googleClientSecret, + disableImplicitSignUp: true, }; } @@ -97,6 +76,10 @@ const trustedOrigins = [ 'http://localhost:3000', 'http://localhost:8082', 'http://localhost:3001', + 'http://localhost:5173', + 'http://localhost:5174', + 'http://127.0.0.1:5173', + 'http://127.0.0.1:5174', ] .map((origin) => (origin || '').trim()) .filter(Boolean); @@ -130,158 +113,6 @@ export const auth = betterAuth({ maxPasswordLength: 128, }, socialProviders: Object.keys(socialProviders).length > 0 ? socialProviders : undefined, - plugins: [ - magicLink({ - sendMagicLink: async ({ email, url, token, metadata }) => { - const payload = { - email, - url, - token, - metadata, - }; - console.log('[better-auth] magic-link', JSON.stringify(payload)); - }, - disableSignUp: false, - }), - genericOAuth({ - config: [ - { - providerId: 'gitlab', - clientId: gitlabClientId, - clientSecret: gitlabClientSecret, - authorizationUrl: gitlabAuthorizeUrl, - tokenUrl: gitlabTokenUrl, - userInfoUrl: gitlabUserUrl, - scopes: ['read_user', 'read_api'], - getUserInfo: async (tokens) => { - if (!tokens.accessToken) { - return null; - } - - const response = await fetch(gitlabUserUrl, { - method: 'GET', - headers: { - Accept: 'application/json', - Authorization: `Bearer ${tokens.accessToken}`, - }, - }); - - if (!response.ok) { - return null; - } - - const profile = await response.json(); - if (!profile || (!profile.id && !profile.username)) { - return null; - } - - return { - id: profile.id || profile.username, - email: profile.email || null, - name: profile.name || profile.username || 'GitLab User', - image: profile.avatar_url || null, - emailVerified: Boolean(profile.email), - }; - }, - }, - { - providerId: 'gitea', - clientId: giteaClientId, - clientSecret: giteaClientSecret, - authorizationUrl: giteaAuthorizeUrl, - tokenUrl: giteaTokenUrl, - userInfoUrl: giteaUserUrl, - scopes: ['read:user', 'read:repository'], - getUserInfo: async (tokens) => { - if (!tokens.accessToken) { - return null; - } - - const response = await fetch(giteaUserUrl, { - method: 'GET', - headers: { - Accept: 'application/json', - Authorization: `token ${tokens.accessToken}`, - }, - }); - - if (!response.ok) { - return null; - } - - const profile = await response.json(); - if (!profile || (!profile.id && !profile.login)) { - return null; - } - - return { - id: profile.id || profile.login, - email: profile.email || null, - name: profile.full_name || profile.login || 'Gitea User', - image: profile.avatar_url || null, - emailVerified: Boolean(profile.email), - }; - }, - }, - { - providerId: 'bitbucket', - clientId: bitbucketClientId, - clientSecret: bitbucketClientSecret, - authorizationUrl: bitbucketAuthorizeUrl, - tokenUrl: bitbucketTokenUrl, - userInfoUrl: bitbucketUserUrl, - scopes: ['account', 'email', 'repository'], - getUserInfo: async (tokens) => { - if (!tokens.accessToken) { - return null; - } - - const userResponse = await fetch(bitbucketUserUrl, { - method: 'GET', - headers: { - Accept: 'application/json', - Authorization: `Bearer ${tokens.accessToken}`, - }, - }); - - if (!userResponse.ok) { - return null; - } - - const user = await userResponse.json(); - if (!user || (!user.account_id && !user.username && !user.nickname)) { - return null; - } - - let primaryEmail = null; - const emailsResponse = await fetch(bitbucketEmailsUrl, { - method: 'GET', - headers: { - Accept: 'application/json', - Authorization: `Bearer ${tokens.accessToken}`, - }, - }); - if (emailsResponse.ok) { - const emailsPayload = await emailsResponse.json(); - const values = Array.isArray(emailsPayload?.values) ? emailsPayload.values : []; - const primary = values.find((entry) => entry?.is_primary) || values[0]; - if (primary && typeof primary.email === 'string') { - primaryEmail = primary.email; - } - } - - return { - id: user.account_id || user.username || user.nickname, - email: primaryEmail, - name: user.display_name || user.nickname || 'Bitbucket User', - image: user?.links?.avatar?.href || null, - emailVerified: Boolean(primaryEmail), - }; - }, - }, - ], - }), - ], }); export async function runAuthMigrations() { diff --git a/app/backend/cmd/agent/main.go b/app/backend/cmd/agent/main.go new file mode 100644 index 0000000..9d88336 --- /dev/null +++ b/app/backend/cmd/agent/main.go @@ -0,0 +1,566 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "log" + "net" + "net/http" + "os" + "os/exec" + "runtime" + "strconv" + "strings" + "syscall" + "time" +) + +type agentConfig struct { + APIURL string + Token string + Name string + Hostname string + IP string + Port int + Interval time.Duration +} + +type capabilities struct { + ContainerRuntimes []string `json:"container_runtimes"` + SupportedArchitectures []string `json:"supported_architectures"` + MaxContainers int `json:"max_containers"` + StorageDriver string `json:"storage_driver"` + NetworkPlugins []string `json:"network_plugins"` + Features []string `json:"features"` +} + +type nodeResources struct { + CPU cpuResources `json:"cpu"` + Memory memoryResources `json:"memory"` + Storage storageResources `json:"storage"` + Network networkResources `json:"network"` +} + +type cpuResources struct { + Cores int `json:"cores"` + Allocation float64 `json:"allocation"` + Usage float64 `json:"usage"` +} + +type memoryResources struct { + Total int `json:"total"` + Allocated int `json:"allocated"` + Used int `json:"used"` + Available int `json:"available"` +} + +type storageResources struct { + Total int `json:"total"` + Allocated int `json:"allocated"` + Used int `json:"used"` + Available int `json:"available"` +} + +type networkResources struct { + Interfaces []networkInterface `json:"interfaces"` + Bandwidth bandwidthInfo `json:"bandwidth"` +} + +type networkInterface struct { + Name string `json:"name"` + IPAddress string `json:"ip_address"` + MACAddress string `json:"mac_address"` + Speed int `json:"speed"` + Status string `json:"status"` +} + +type bandwidthInfo struct { + Inbound int `json:"inbound"` + Outbound int `json:"outbound"` +} + +type systemLoad struct { + Load1M float64 `json:"load_1m"` + Load5M float64 `json:"load_5m"` + Load15M float64 `json:"load_15m"` +} + +type command struct { + ID string `json:"id"` + Type string `json:"type"` + Payload map[string]interface{} `json:"payload"` + CreatedAt string `json:"created_at"` +} + +type commandList struct { + Commands []command `json:"commands"` +} + +type registerResponse struct { + AgentID string `json:"agent_id"` + Status string `json:"status"` +} + +func main() { + cfg, err := loadConfig() + if err != nil { + log.Fatal(err) + } + + client := &http.Client{Timeout: 20 * time.Second} + agentID, err := register(context.Background(), client, cfg) + if err != nil { + log.Fatalf("register agent: %v", err) + } + + log.Printf("containr agent connected: id=%s api=%s", agentID, cfg.APIURL) + ticker := time.NewTicker(cfg.Interval) + defer ticker.Stop() + + for { + if err := sendHeartbeat(context.Background(), client, cfg, agentID); err != nil { + log.Printf("heartbeat failed: %v", err) + } + if err := processCommands(context.Background(), client, cfg, agentID); err != nil { + log.Printf("command poll failed: %v", err) + } + <-ticker.C + } +} + +func loadConfig() (agentConfig, error) { + hostname, _ := os.Hostname() + apiURL := strings.TrimRight(firstEnv("CONTAINR_API_URL", "http://localhost:8082"), "/") + apiURL = strings.TrimSuffix(strings.TrimSuffix(apiURL, "/api/v1"), "/api") + token := strings.TrimSpace(os.Getenv("CONTAINR_AGENT_AUTH_TOKEN")) + if token == "" { + token = strings.TrimSpace(os.Getenv("CONTAINR_AGENT_TOKEN")) + } + if token == "" { + return agentConfig{}, errors.New("set CONTAINR_AGENT_AUTH_TOKEN") + } + + intervalSeconds, _ := strconv.Atoi(firstEnv("CONTAINR_AGENT_INTERVAL_SECONDS", "15")) + if intervalSeconds < 5 { + intervalSeconds = 5 + } + port, _ := strconv.Atoi(firstEnv("CONTAINR_AGENT_PORT", "9090")) + + return agentConfig{ + APIURL: apiURL, + Token: token, + Name: firstEnv("CONTAINR_AGENT_NAME", hostname), + Hostname: firstEnv("CONTAINR_AGENT_HOSTNAME", hostname), + IP: firstEnv("CONTAINR_AGENT_IP", detectOutboundIP()), + Port: port, + Interval: time.Duration(intervalSeconds) * time.Second, + }, nil +} + +func register(ctx context.Context, client *http.Client, cfg agentConfig) (string, error) { + payload := map[string]interface{}{ + "name": cfg.Name, + "hostname": cfg.Hostname, + "ip_address": cfg.IP, + "port": cfg.Port, + "capabilities": detectCapabilities(), + } + var response registerResponse + if err := postJSON(ctx, client, cfg, "/api/agents/register", payload, &response); err != nil { + return "", err + } + if response.AgentID == "" { + return "", errors.New("register response missing agent_id") + } + return response.AgentID, nil +} + +func sendHeartbeat(ctx context.Context, client *http.Client, cfg agentConfig, agentID string) error { + resources := collectResources(cfg.IP) + payload := map[string]interface{}{ + "node_agent_id": agentID, + "timestamp": time.Now().UTC().Format(time.RFC3339), + "status": agentStatus(), + "resources": resources, + "container_count": dockerContainerCount(), + "system_load": readSystemLoad(), + "uptime": readSystemUptime(), + "version": firstEnv("CONTAINR_AGENT_VERSION", "dev"), + } + return postJSON(ctx, client, cfg, "/api/agents/heartbeat", payload, nil) +} + +func processCommands(ctx context.Context, client *http.Client, cfg agentConfig, agentID string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/api/agents/%s/commands", cfg.APIURL, agentID), nil) + if err != nil { + return err + } + req.Header.Set("X-Containr-Agent-Token", cfg.Token) + + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + return fmt.Errorf("command poll status %d", resp.StatusCode) + } + + var list commandList + if err := json.NewDecoder(resp.Body).Decode(&list); err != nil { + return err + } + for _, item := range list.Commands { + result, runErr := executeCommand(item) + status := "completed" + errText := "" + if runErr != nil { + status = "failed" + errText = runErr.Error() + } + payload := map[string]string{ + "status": status, + "result": result, + "error": errText, + } + path := fmt.Sprintf("/api/agents/%s/commands/%s/result", agentID, item.ID) + if err := postJSON(ctx, client, cfg, path, payload, nil); err != nil { + log.Printf("report command result failed: command=%s err=%v", item.ID, err) + } + } + return nil +} + +func executeCommand(item command) (string, error) { + switch item.Type { + case "create_container": + return createContainer(item.Payload) + case "start_container": + return runDocker("start", commandTarget(item.Payload)) + case "stop_container": + return runDocker("stop", commandTarget(item.Payload)) + case "restart_container": + return runDocker("restart", commandTarget(item.Payload)) + case "remove_container": + return runDocker("rm", "-f", commandTarget(item.Payload)) + default: + return "", fmt.Errorf("unsupported command type %q", item.Type) + } +} + +func createContainer(payload map[string]interface{}) (string, error) { + container, ok := payload["container"].(map[string]interface{}) + if !ok { + return "", errors.New("missing container payload") + } + + name := stringValue(container["name"]) + image := stringValue(container["image"]) + if name == "" || image == "" { + return "", errors.New("container name and image are required") + } + + args := []string{"run", "-d", "--name", name, "--restart", "unless-stopped"} + if env, ok := container["environment"].(map[string]interface{}); ok { + for key, value := range env { + args = append(args, "-e", fmt.Sprintf("%s=%v", key, value)) + } + } + if ports, ok := container["ports"].([]interface{}); ok { + for _, rawPort := range ports { + port, ok := rawPort.(map[string]interface{}) + if !ok || !boolValue(port["published"]) { + continue + } + hostPort := intValue(port["host_port"]) + containerPort := intValue(port["container_port"]) + protocol := firstString(stringValue(port["protocol"]), "tcp") + if hostPort > 0 && containerPort > 0 { + args = append(args, "-p", fmt.Sprintf("%d:%d/%s", hostPort, containerPort, protocol)) + } + } + } + if volumes, ok := container["volumes"].([]interface{}); ok { + for _, rawVolume := range volumes { + volume, ok := rawVolume.(map[string]interface{}) + if !ok { + continue + } + source := stringValue(volume["source"]) + target := stringValue(volume["target"]) + if source != "" && target != "" { + mount := source + ":" + target + if boolValue(volume["read_only"]) { + mount += ":ro" + } + args = append(args, "-v", mount) + } + } + } + args = append(args, image) + return runDocker(args...) +} + +func commandTarget(payload map[string]interface{}) string { + for _, key := range []string{"container_name", "docker_name", "name", "container_id"} { + if value := stringValue(payload[key]); value != "" { + return value + } + } + return "" +} + +func postJSON(ctx context.Context, client *http.Client, cfg agentConfig, path string, payload interface{}, out interface{}) error { + body, err := json.Marshal(payload) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.APIURL+path, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Containr-Agent-Token", cfg.Token) + + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + return fmt.Errorf("request %s failed with status %d", path, resp.StatusCode) + } + if out == nil { + return nil + } + return json.NewDecoder(resp.Body).Decode(out) +} + +func detectCapabilities() capabilities { + runtimes := []string{} + if dockerAvailable() { + runtimes = append(runtimes, "docker") + } + return capabilities{ + ContainerRuntimes: runtimes, + SupportedArchitectures: []string{runtime.GOARCH}, + MaxContainers: 100, + StorageDriver: dockerStorageDriver(), + NetworkPlugins: []string{"bridge"}, + Features: []string{"host-metrics", "heartbeats", "command-poll", "docker-lifecycle"}, + } +} + +func collectResources(ip string) nodeResources { + mem := readMemory() + storage := readStorage("/") + return nodeResources{ + CPU: cpuResources{ + Cores: runtime.NumCPU(), + Allocation: 0, + Usage: readCPULoadPercent(), + }, + Memory: mem, + Storage: storage, + Network: networkResources{ + Interfaces: []networkInterface{{ + Name: "primary", + IPAddress: ip, + Speed: 0, + Status: "up", + }}, + Bandwidth: bandwidthInfo{}, + }, + } +} + +func readMemory() memoryResources { + values := map[string]int{} + data, err := os.ReadFile("/proc/meminfo") + if err != nil { + return memoryResources{} + } + for _, line := range strings.Split(string(data), "\n") { + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + value, err := strconv.Atoi(fields[1]) + if err == nil { + values[strings.TrimSuffix(fields[0], ":")] = value * 1024 + } + } + total := values["MemTotal"] + available := values["MemAvailable"] + used := total - available + if used < 0 { + used = 0 + } + return memoryResources{Total: total, Used: used, Available: available} +} + +func readStorage(path string) storageResources { + var stat syscall.Statfs_t + if err := syscall.Statfs(path, &stat); err != nil { + return storageResources{} + } + total := int(stat.Blocks * uint64(stat.Bsize)) + available := int(stat.Bavail * uint64(stat.Bsize)) + used := total - available + if used < 0 { + used = 0 + } + return storageResources{Total: total, Used: used, Available: available} +} + +func readSystemLoad() systemLoad { + data, err := os.ReadFile("/proc/loadavg") + if err != nil { + return systemLoad{} + } + fields := strings.Fields(string(data)) + if len(fields) < 3 { + return systemLoad{} + } + load1, _ := strconv.ParseFloat(fields[0], 64) + load5, _ := strconv.ParseFloat(fields[1], 64) + load15, _ := strconv.ParseFloat(fields[2], 64) + return systemLoad{Load1M: load1, Load5M: load5, Load15M: load15} +} + +func readSystemUptime() int64 { + data, err := os.ReadFile("/proc/uptime") + if err != nil { + return 0 + } + fields := strings.Fields(string(data)) + if len(fields) == 0 { + return 0 + } + uptime, _ := strconv.ParseFloat(fields[0], 64) + return int64(uptime) +} + +func readCPULoadPercent() float64 { + load := readSystemLoad() + cores := runtime.NumCPU() + if cores == 0 { + return 0 + } + percent := load.Load1M / float64(cores) * 100 + if percent < 0 { + return 0 + } + if percent > 100 { + return 100 + } + return percent +} + +func runDocker(args ...string) (string, error) { + if len(args) == 0 || args[len(args)-1] == "" { + return "", errors.New("missing docker target") + } + cmd := exec.Command("docker", args...) + output, err := cmd.CombinedOutput() + return strings.TrimSpace(string(output)), err +} + +func dockerAvailable() bool { + _, err := exec.LookPath("docker") + if err != nil { + return false + } + cmd := exec.Command("docker", "info", "--format", "{{.ServerVersion}}") + return cmd.Run() == nil +} + +func dockerStorageDriver() string { + out, err := exec.Command("docker", "info", "--format", "{{.Driver}}").Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +func dockerContainerCount() int { + out, err := exec.Command("docker", "ps", "-q").Output() + if err != nil { + return 0 + } + count := 0 + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if strings.TrimSpace(line) != "" { + count++ + } + } + return count +} + +func agentStatus() string { + if dockerAvailable() { + return "online" + } + return "degraded" +} + +func detectOutboundIP() string { + conn, err := net.DialTimeout("udp", "8.8.8.8:80", 2*time.Second) + if err != nil { + return "127.0.0.1" + } + defer conn.Close() + if addr, ok := conn.LocalAddr().(*net.UDPAddr); ok { + return addr.IP.String() + } + return "127.0.0.1" +} + +func firstEnv(key, fallback string) string { + if value := strings.TrimSpace(os.Getenv(key)); value != "" { + return value + } + return fallback +} + +func firstString(value, fallback string) string { + if value != "" { + return value + } + return fallback +} + +func stringValue(value interface{}) string { + switch typed := value.(type) { + case string: + return typed + case fmt.Stringer: + return typed.String() + default: + return "" + } +} + +func intValue(value interface{}) int { + switch typed := value.(type) { + case int: + return typed + case int64: + return int(typed) + case float64: + return int(typed) + case json.Number: + out, _ := typed.Int64() + return int(out) + default: + return 0 + } +} + +func boolValue(value interface{}) bool { + typed, _ := value.(bool) + return typed +} diff --git a/app/backend/cmd/server/main.go b/app/backend/cmd/server/main.go index 36feb1e..fbebca7 100644 --- a/app/backend/cmd/server/main.go +++ b/app/backend/cmd/server/main.go @@ -48,8 +48,8 @@ func main() { log.Println("AUTO_MIGRATE disabled; skipping startup migrations") } - // Seed demo data in development (or when explicitly requested). - if cfg.IsDevelopment() || cfg.SeedDataOnStart { + // Seed demo data only when explicitly requested. + if cfg.SeedDataOnStart { if err := db.SeedData(); err != nil { log.Printf("Warning: Failed to seed data: %v", err) } diff --git a/app/backend/internal/api/agents.go b/app/backend/internal/api/agents.go index e568254..dd79fc4 100644 --- a/app/backend/internal/api/agents.go +++ b/app/backend/internal/api/agents.go @@ -171,6 +171,7 @@ type AgentCommand struct { // AgentHeartbeat represents a heartbeat message from an agent type AgentHeartbeat struct { NodeAgentID string `json:"node_agent_id"` + AuthToken string `json:"auth_token,omitempty"` Timestamp time.Time `json:"timestamp"` Status string `json:"status"` Resources NodeResources `json:"resources"` @@ -193,6 +194,10 @@ type AgentHeartbeatRecord struct { CreatedAt time.Time `json:"created_at"` } +func (AgentHeartbeatRecord) TableName() string { + return "agent_heartbeats" +} + type SystemLoad struct { Load1M float64 `json:"load_1m"` Load5M float64 `json:"load_5m"` @@ -216,7 +221,7 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) { IPAddress string `json:"ip_address" binding:"required"` Port int `json:"port" binding:"required"` Capabilities AgentCapabilities `json:"capabilities" binding:"required"` - AuthToken string `json:"auth_token" binding:"required"` + AuthToken string `json:"auth_token"` } if err := c.ShouldBindJSON(&req); err != nil { @@ -224,7 +229,8 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) { return } - if !isValidAgentAuthToken(req.AuthToken) { + authToken := firstNonEmpty(req.AuthToken, agentAuthTokenFromRequest(c)) + if !isValidAgentAuthToken(authToken) { c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid auth token"}) return } @@ -246,7 +252,7 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "agent_id": existingAgent.ID, - "auth_token": req.AuthToken, + "auth_token": authToken, "status": "updated", }) return @@ -308,7 +314,7 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) { c.JSON(http.StatusCreated, gin.H{ "agent_id": agent.ID, - "auth_token": req.AuthToken, + "auth_token": authToken, "status": "registered", }) } @@ -388,6 +394,11 @@ func (h *NodeAgentHandler) SendHeartbeat(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + authToken := firstNonEmpty(heartbeat.AuthToken, agentAuthTokenFromRequest(c)) + if !isValidAgentAuthToken(authToken) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid auth token"}) + return + } if heartbeat.Timestamp.IsZero() { heartbeat.Timestamp = time.Now() } @@ -436,6 +447,118 @@ func (h *NodeAgentHandler) SendHeartbeat(c *gin.Context) { c.Status(http.StatusOK) } +// GetPendingCommandsForAgent exposes queued commands to token-authenticated node agents. +func (h *NodeAgentHandler) GetPendingCommandsForAgent(c *gin.Context) { + if !isValidAgentAuthToken(agentAuthTokenFromRequest(c)) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid auth token"}) + return + } + + agentID := c.Param("id") + var commands []AgentCommand + if err := h.db. + Where("node_agent_id = ? AND status = ?", agentID, "pending"). + Order("created_at ASC"). + Limit(25). + Find(&commands).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch commands"}) + return + } + + c.JSON(http.StatusOK, gin.H{"commands": commands}) +} + +// CompleteCommand lets a node agent report command completion or failure. +func (h *NodeAgentHandler) CompleteCommand(c *gin.Context) { + if !isValidAgentAuthToken(agentAuthTokenFromRequest(c)) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid auth token"}) + return + } + + agentID := c.Param("id") + commandID := c.Param("commandId") + var req struct { + Status string `json:"status" binding:"required"` + Result string `json:"result"` + Error string `json:"error"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if req.Status != "completed" && req.Status != "failed" { + c.JSON(http.StatusBadRequest, gin.H{"error": "status must be completed or failed"}) + return + } + + var command AgentCommand + if err := h.db.First(&command, "id = ? AND node_agent_id = ?", commandID, agentID).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "Command not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch command"}) + return + } + + now := time.Now() + command.Status = req.Status + command.UpdatedAt = now + command.CompletedAt = &now + if req.Result != "" { + command.Result = &req.Result + } + if req.Error != "" { + command.Error = &req.Error + } + + if err := h.db.Save(&command).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update command"}) + return + } + + if command.ContainerID != nil { + h.updateContainerStatusAfterCommand(command) + } + + c.JSON(http.StatusOK, gin.H{"command": command}) +} + +func (h *NodeAgentHandler) updateContainerStatusAfterCommand(command AgentCommand) { + var container ContainerInstance + if err := h.db.First(&container, "id = ?", *command.ContainerID).Error; err != nil { + return + } + + if command.Status == "failed" { + errorMessage := "" + if command.Error != nil { + errorMessage = *command.Error + } + container.Status.State = "error" + container.Status.Error = &errorMessage + _ = h.db.Save(&container).Error + return + } + + now := time.Now() + switch command.Type { + case "create_container", "start_container", "restart_container": + container.Status.State = "running" + container.Status.Health = "unknown" + container.Status.StartedAt = &now + container.Status.Error = nil + case "stop_container": + container.Status.State = "stopped" + container.Status.FinishedAt = &now + case "remove_container": + container.Status.State = "removed" + container.Status.FinishedAt = &now + } + container.UpdatedAt = now + _ = h.db.Save(&container).Error +} + // GetAgentContainers returns containers running on a specific agent func (h *NodeAgentHandler) GetAgentContainers(c *gin.Context) { agentID := c.Param("id") @@ -634,7 +757,9 @@ func (h *NodeAgentHandler) ContainerAction(c *gin.Context) { NodeAgentID: agentID, ContainerID: &container.ID, Payload: map[string]interface{}{ - "container_id": containerID, + "container_id": containerID, + "container_name": container.Name, + "docker_name": container.Name, }, Status: "pending", CreatedAt: time.Now(), @@ -757,6 +882,19 @@ func isValidAgentAuthToken(token string) bool { return false } +func agentAuthTokenFromRequest(c *gin.Context) string { + if token := strings.TrimSpace(c.GetHeader("X-Containr-Agent-Token")); token != "" { + return token + } + if authHeader := strings.TrimSpace(c.GetHeader("Authorization")); authHeader != "" { + const bearerPrefix = "Bearer " + if strings.HasPrefix(authHeader, bearerPrefix) { + return strings.TrimSpace(strings.TrimPrefix(authHeader, bearerPrefix)) + } + } + return strings.TrimSpace(c.Query("auth_token")) +} + func configuredAgentAuthTokens() []string { candidateCSV := strings.TrimSpace(os.Getenv("CONTAINR_AGENT_AUTH_TOKENS")) if candidateCSV != "" { @@ -806,16 +944,25 @@ func maxInt(a, b int) int { return b } -// SetupRoutes registers the agent routes -func (h *NodeAgentHandler) SetupRoutes(router *gin.RouterGroup) { +// SetupPublicRoutes registers token-authenticated agent ingestion routes. +func (h *NodeAgentHandler) SetupPublicRoutes(router *gin.RouterGroup) { agents := router.Group("/agents") { agents.POST("/register", h.RegisterAgent) + agents.POST("/heartbeat", h.SendHeartbeat) + agents.GET("/:id/commands", h.GetPendingCommandsForAgent) + agents.POST("/:id/commands/:commandId/result", h.CompleteCommand) + } +} + +// SetupRoutes registers authenticated dashboard/control agent routes. +func (h *NodeAgentHandler) SetupRoutes(router *gin.RouterGroup) { + agents := router.Group("/agents") + { agents.GET("", h.GetAgents) agents.GET("/:id", h.GetAgent) agents.PUT("/:id", h.UpdateAgent) agents.DELETE("/:id", h.DeleteAgent) - agents.POST("/heartbeat", h.SendHeartbeat) agents.GET("/:id/containers", h.GetAgentContainers) agents.POST("/:id/containers", h.CreateContainer) diff --git a/app/backend/internal/api/auth.go b/app/backend/internal/api/auth.go index 3217162..4d9f5f9 100644 --- a/app/backend/internal/api/auth.go +++ b/app/backend/internal/api/auth.go @@ -1,13 +1,21 @@ package api import ( + "bytes" "containr/internal/database" "database/sql" + "encoding/json" + "errors" + "log" "net/http" + "net/url" + "os" + "strings" "time" "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" "golang.org/x/crypto/bcrypt" ) @@ -18,10 +26,16 @@ type LoginRequest struct { type RegisterRequest struct { Email string `json:"email" binding:"required,email"` - Password string `json:"password" binding:"required,min=6"` + Password string `json:"password" binding:"required,min=8,max=128"` Name string `json:"name" binding:"required,min=2"` } +type ManualUserCreateRequest struct { + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required,min=8,max=128"` + Name string `json:"name" binding:"required,min=2,max=255"` +} + type AuthResponse struct { Token string `json:"token"` User interface{} `json:"user"` @@ -55,17 +69,28 @@ func handleLogin(c *gin.Context) { `, req.Email).Scan(&user.ID, &user.Email, &hashedPassword, &user.Name, &user.AvatarURL, &user.CreatedAt) if err == sql.ErrNoRows { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"}) - return + authUser, ok := verifyBetterAuthCredentials(c, req.Email, req.Password) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"}) + return + } + + user, err = createLocalUserFromBetterAuth(db, authUser) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create local user"}) + return + } } else if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"}) return - } - - // Check password - if err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(req.Password)); err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"}) - return + } else { + // Check password + if err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(req.Password)); err != nil { + if _, ok := verifyBetterAuthCredentials(c, req.Email, req.Password); !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"}) + return + } + } } // Generate JWT token @@ -81,6 +106,154 @@ func handleLogin(c *gin.Context) { }) } +func handleAuthBootstrap(c *gin.Context) { + db := c.MustGet("db").(*database.DB) + + count, err := countLocalUsers(db) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to inspect auth bootstrap state"}) + return + } + + mode := "login" + if count == 0 { + mode = "register" + } + + c.JSON(http.StatusOK, gin.H{ + "has_users": count > 0, + "user_count": count, + "mode": mode, + }) +} + +type betterAuthLoginUser struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + Image string `json:"image"` +} + +type betterAuthLoginResponse struct { + User betterAuthLoginUser `json:"user"` +} + +func verifyBetterAuthCredentials(c *gin.Context, email string, password string) (betterAuthLoginUser, bool) { + endpoint, err := betterAuthSignInURL() + if err != nil { + log.Printf("Better Auth fallback disabled: %v", err) + return betterAuthLoginUser{}, false + } + + body, err := json.Marshal(gin.H{ + "email": strings.TrimSpace(email), + "password": password, + }) + if err != nil { + return betterAuthLoginUser{}, false + } + + ctx := c.Request.Context() + request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return betterAuthLoginUser{}, false + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Accept", "application/json") + + client := &http.Client{Timeout: 5 * time.Second} + response, err := client.Do(request) + if err != nil { + log.Printf("Better Auth fallback failed: %v", err) + return betterAuthLoginUser{}, false + } + defer response.Body.Close() + + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + return betterAuthLoginUser{}, false + } + + var payload betterAuthLoginResponse + if err := json.NewDecoder(response.Body).Decode(&payload); err != nil { + log.Printf("Better Auth fallback response decode failed: %v", err) + return betterAuthLoginUser{}, false + } + + payload.User.Email = strings.ToLower(strings.TrimSpace(firstNonEmpty(payload.User.Email, email))) + payload.User.Name = strings.TrimSpace(payload.User.Name) + if payload.User.Name == "" { + payload.User.Name = "Containr User" + } + + if payload.User.Email == "" { + return betterAuthLoginUser{}, false + } + + return payload.User, true +} + +func betterAuthSignInURL() (string, error) { + rawTarget := strings.TrimSpace(os.Getenv("BETTER_AUTH_PROXY_URL")) + if rawTarget == "" { + rawTarget = "http://127.0.0.1:3001" + } + + target, err := parseAuthProxyTarget(rawTarget) + if err != nil { + return "", err + } + + return target.ResolveReference(&url.URL{Path: "/api/auth/sign-in/email"}).String(), nil +} + +func betterAuthSignUpURL() (string, error) { + rawTarget := strings.TrimSpace(os.Getenv("BETTER_AUTH_PROXY_URL")) + if rawTarget == "" { + rawTarget = "http://127.0.0.1:3001" + } + + target, err := parseAuthProxyTarget(rawTarget) + if err != nil { + return "", err + } + + return target.ResolveReference(&url.URL{Path: "/api/auth/sign-up/email"}).String(), nil +} + +func createLocalUserFromBetterAuth(db *database.DB, authUser betterAuthLoginUser) (User, error) { + email := strings.ToLower(strings.TrimSpace(authUser.Email)) + if email == "" { + return User{}, errors.New("email is required") + } + + name := strings.TrimSpace(authUser.Name) + if name == "" { + name = "Containr User" + } + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(uuid.NewString()), bcrypt.DefaultCost) + if err != nil { + return User{}, err + } + + var user User + err = db.QueryRow(` + INSERT INTO users (email, password_hash, name, avatar_url) + VALUES ($1, $2, $3, NULLIF($4, '')) + ON CONFLICT (email) DO UPDATE + SET name = EXCLUDED.name, + avatar_url = COALESCE(EXCLUDED.avatar_url, users.avatar_url), + updated_at = NOW() + RETURNING id, email, name, COALESCE(avatar_url, ''), created_at + `, email, string(hashedPassword), name, strings.TrimSpace(authUser.Image)). + Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.CreatedAt) + if err != nil { + return User{}, err + } + + return user, nil +} + func handleRegister(c *gin.Context) { var req RegisterRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -91,9 +264,19 @@ func handleRegister(c *gin.Context) { db := c.MustGet("db").(*database.DB) jwtSecret := c.MustGet("jwt_secret").(string) - // Check if user already exists var count int - err := db.QueryRow("SELECT COUNT(*) FROM users WHERE email = $1", req.Email).Scan(&count) + err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"}) + return + } + + if count > 0 { + c.JSON(http.StatusForbidden, gin.H{"error": "Public registration is disabled after bootstrap"}) + return + } + + err = db.QueryRow("SELECT COUNT(*) FROM users WHERE email = $1", strings.ToLower(strings.TrimSpace(req.Email))).Scan(&count) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"}) return @@ -137,6 +320,121 @@ func handleRegister(c *gin.Context) { }) } +func handleCreateUser(c *gin.Context) { + var req ManualUserCreateRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + db := c.MustGet("db").(*database.DB) + req.Email = strings.ToLower(strings.TrimSpace(req.Email)) + req.Name = strings.TrimSpace(req.Name) + if req.Email == "" || req.Name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Email and name are required"}) + return + } + + var existing int + if err := db.QueryRow("SELECT COUNT(*) FROM users WHERE email = $1", req.Email).Scan(&existing); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"}) + return + } + if existing > 0 { + c.JSON(http.StatusConflict, gin.H{"error": "User already exists"}) + return + } + + if err := createBetterAuthUser(c, req.Name, req.Email, req.Password); err != nil { + status := http.StatusInternalServerError + if errors.Is(err, errBetterAuthUserExists) { + status = http.StatusConflict + } + c.JSON(status, gin.H{"error": err.Error()}) + return + } + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"}) + return + } + + var user User + err = db.QueryRow(` + INSERT INTO users (email, password_hash, name) + VALUES ($1, $2, $3) + RETURNING id, email, name, COALESCE(avatar_url, ''), created_at + `, req.Email, string(hashedPassword), req.Name).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.CreatedAt) + if err != nil { + c.JSON(http.StatusConflict, gin.H{"error": "User already exists"}) + return + } + + c.JSON(http.StatusCreated, gin.H{"user": user}) +} + +var errBetterAuthUserExists = errors.New("User already exists") + +func createBetterAuthUser(c *gin.Context, name string, email string, password string) error { + endpoint, err := betterAuthSignUpURL() + if err != nil { + return err + } + + body, err := json.Marshal(gin.H{ + "name": name, + "email": email, + "password": password, + }) + if err != nil { + return err + } + + request, err := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return err + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Accept", "application/json") + if token := strings.TrimSpace(os.Getenv("BETTER_AUTH_INTERNAL_TOKEN")); token != "" { + request.Header.Set("X-Containr-Auth-Internal", token) + } + + response, err := (&http.Client{Timeout: 5 * time.Second}).Do(request) + if err != nil { + return err + } + defer response.Body.Close() + + if response.StatusCode >= http.StatusOK && response.StatusCode < http.StatusMultipleChoices { + return nil + } + + var payload map[string]interface{} + _ = json.NewDecoder(response.Body).Decode(&payload) + message := "Failed to create auth user" + if rawMessage, ok := payload["message"].(string); ok && strings.TrimSpace(rawMessage) != "" { + message = rawMessage + } else if rawError, ok := payload["error"].(string); ok && strings.TrimSpace(rawError) != "" { + message = rawError + } + + if response.StatusCode == http.StatusConflict || strings.Contains(strings.ToLower(message), "already") { + return errBetterAuthUserExists + } + + return errors.New(message) +} + +func countLocalUsers(db *database.DB) (int, error) { + var count int + if err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count); err != nil { + return 0, err + } + return count, nil +} + func handleGetProfile(c *gin.Context) { userID := c.MustGet("user_id").(string) db := c.MustGet("db").(*database.DB) diff --git a/app/backend/internal/api/auth_proxy.go b/app/backend/internal/api/auth_proxy.go index be53875..ddced07 100644 --- a/app/backend/internal/api/auth_proxy.go +++ b/app/backend/internal/api/auth_proxy.go @@ -1,6 +1,7 @@ package api import ( + "containr/internal/database" "log" "net/http" "net/http/httputil" @@ -12,7 +13,7 @@ import ( "github.com/gin-gonic/gin" ) -func setupAuthProxyRoutes(router *gin.Engine, cfg *config.Config) { +func setupAuthProxyRoutes(router *gin.Engine, cfg *config.Config, db *database.DB) { targetURL, err := parseAuthProxyTarget(cfg.BetterAuthProxyURL) if err != nil { log.Printf("Warning: auth proxy disabled: %v", err) @@ -28,6 +29,12 @@ func setupAuthProxyRoutes(router *gin.Engine, cfg *config.Config) { } handler := func(c *gin.Context) { + if shouldBlockPublicBetterAuthSignup(c, cfg, db) { + c.JSON(http.StatusForbidden, gin.H{"error": "Public registration is disabled after bootstrap"}) + c.Abort() + return + } + proxy.ServeHTTP(c.Writer, c.Request) c.Abort() } @@ -36,6 +43,33 @@ func setupAuthProxyRoutes(router *gin.Engine, cfg *config.Config) { router.Any("/api/auth/*proxyPath", handler) } +func shouldBlockPublicBetterAuthSignup(c *gin.Context, cfg *config.Config, db *database.DB) bool { + if db == nil || db.DB == nil { + return false + } + + if !strings.EqualFold(c.Request.Method, http.MethodPost) { + return false + } + + if !strings.HasSuffix(c.Request.URL.Path, "/sign-up/email") { + return false + } + + internalToken := strings.TrimSpace(cfg.BetterAuthInternalToken) + if internalToken != "" && c.GetHeader("X-Containr-Auth-Internal") == internalToken { + return false + } + + count, err := countLocalUsers(db) + if err != nil { + log.Printf("Failed to inspect signup bootstrap state: %v", err) + return true + } + + return count > 0 +} + func parseAuthProxyTarget(rawTarget string) (*url.URL, error) { trimmed := strings.TrimSpace(rawTarget) if trimmed == "" { diff --git a/app/backend/internal/api/git.go b/app/backend/internal/api/git.go index f4299a2..a8c6e30 100644 --- a/app/backend/internal/api/git.go +++ b/app/backend/internal/api/git.go @@ -1306,3 +1306,208 @@ func generateWebhookSecret() (string, error) { } return hex.EncodeToString(buf), nil } + +// handleDeleteGitProvider removes a provider connection. Repositories and +// webhooks are removed by ON DELETE CASCADE. +func handleDeleteGitProvider(c *gin.Context) { + ctx, ok := requireGitRequestContext(c) + if !ok { + return + } + + providerID := strings.TrimSpace(c.Param("providerId")) + if _, err := uuid.Parse(providerID); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid provider ID"}) + return + } + + result, err := ctx.db.Exec( + "DELETE FROM git_providers WHERE id = $1 AND user_id = $2", + providerID, ctx.userID, + ) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete provider"}) + return + } + + affected, _ := result.RowsAffected() + if affected == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "Provider not found"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Provider disconnected"}) +} + +// handleGetGitRepositoryBranches lists branches for a repository through the +// provider's stored credentials. +func handleGetGitRepositoryBranches(c *gin.Context) { + ctx, ok := requireGitRequestContext(c) + if !ok { + return + } + + providerID := strings.TrimSpace(c.Param("providerId")) + if _, err := uuid.Parse(providerID); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid provider ID"}) + return + } + + owner := strings.TrimSpace(c.Param("owner")) + repo := strings.TrimSpace(c.Param("repo")) + if owner == "" || repo == "" || strings.ContainsAny(owner+repo, " \t\r\n") { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid repository reference"}) + return + } + + var providerName, providerAPIURL, providerAccessToken string + err := ctx.db.QueryRow(` + SELECT name, api_url, access_token + FROM git_providers + WHERE id = $1 AND user_id = $2 + `, providerID, ctx.userID).Scan(&providerName, &providerAPIURL, &providerAccessToken) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + c.JSON(http.StatusNotFound, gin.H{"error": "Provider not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"}) + return + } + + branches, err := fetchRepositoryBranches(providerName, providerAPIURL, providerAccessToken, owner, repo) + if err != nil { + log.Printf("branch fetch failed for %s/%s via %s: %v", owner, repo, providerName, err) + c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to fetch branches from provider"}) + return + } + + c.JSON(http.StatusOK, gin.H{"branches": branches}) +} + +type remoteBranch struct { + Name string `json:"name"` + Protected bool `json:"protected"` +} + +func fetchRepositoryBranches(providerName, providerAPIURL, accessToken, owner, repo string) ([]remoteBranch, error) { + switch strings.ToLower(strings.TrimSpace(providerName)) { + case "github", "github_app": + return fetchGitHubBranches(providerName, providerAPIURL, accessToken, owner, repo) + case "gitea": + return fetchGiteaBranches(providerAPIURL, accessToken, owner, repo) + default: + return nil, fmt.Errorf("branch listing is not supported for provider %s", providerName) + } +} + +func fetchGitHubBranches(providerName, providerAPIURL, accessToken, owner, repo string) ([]remoteBranch, error) { + baseURL := strings.TrimSuffix(strings.TrimSpace(providerAPIURL), "/") + if baseURL == "" { + baseURL = "https://api.github.com" + } + + token := strings.TrimSpace(accessToken) + if strings.EqualFold(providerName, "github_app") { + installationID, err := strconv.ParseInt(token, 10, 64) + if err != nil || installationID <= 0 { + return nil, fmt.Errorf("github_app installation id is invalid") + } + token, err = getGitHubAppInstallationToken(baseURL, installationID) + if err != nil { + return nil, err + } + } + if token == "" { + return nil, fmt.Errorf("github access token is missing") + } + + branches := make([]remoteBranch, 0) + for page := 1; page <= 5; page++ { + request, err := http.NewRequest(http.MethodGet, + fmt.Sprintf("%s/repos/%s/%s/branches?per_page=100&page=%d", baseURL, owner, repo, page), nil) + if err != nil { + return nil, err + } + request.Header.Set("Accept", "application/vnd.github+json") + request.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{Timeout: 10 * time.Second} + response, err := client.Do(request) + if err != nil { + return nil, err + } + + if response.StatusCode != http.StatusOK { + payload, _ := io.ReadAll(io.LimitReader(response.Body, 2048)) + response.Body.Close() + return nil, fmt.Errorf("github branches request failed with status %d: %s", response.StatusCode, strings.TrimSpace(string(payload))) + } + + var rows []map[string]interface{} + decodeErr := json.NewDecoder(response.Body).Decode(&rows) + response.Body.Close() + if decodeErr != nil { + return nil, decodeErr + } + + for _, row := range rows { + name := asGitString(row["name"]) + if name == "" { + continue + } + branches = append(branches, remoteBranch{Name: name, Protected: asBoolValue(row["protected"])}) + } + + if len(rows) < 100 { + break + } + } + + return branches, nil +} + +func fetchGiteaBranches(providerAPIURL, accessToken, owner, repo string) ([]remoteBranch, error) { + baseURL := strings.TrimSuffix(strings.TrimSpace(providerAPIURL), "/") + if baseURL == "" { + baseURL = strings.TrimSuffix(strings.TrimSpace(getenvOrDefault("GITEA_BASE_URL", "")), "/") + } + if baseURL == "" { + return nil, fmt.Errorf("gitea api_url is required") + } + + request, err := http.NewRequest(http.MethodGet, + fmt.Sprintf("%s/api/v1/repos/%s/%s/branches?limit=100", baseURL, owner, repo), nil) + if err != nil { + return nil, err + } + request.Header.Set("Accept", "application/json") + if token := strings.TrimSpace(accessToken); token != "" { + request.Header.Set("Authorization", "token "+token) + } + + client := &http.Client{Timeout: 10 * time.Second} + response, err := client.Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + payload, _ := io.ReadAll(io.LimitReader(response.Body, 2048)) + return nil, fmt.Errorf("gitea branches request failed with status %d: %s", response.StatusCode, strings.TrimSpace(string(payload))) + } + + var rows []map[string]interface{} + if err := json.NewDecoder(response.Body).Decode(&rows); err != nil { + return nil, err + } + + branches := make([]remoteBranch, 0, len(rows)) + for _, row := range rows { + if name := asGitString(row["name"]); name != "" { + branches = append(branches, remoteBranch{Name: name, Protected: asBoolValue(row["protected"])}) + } + } + return branches, nil +} diff --git a/app/backend/internal/api/logs.go b/app/backend/internal/api/logs.go index 349e07d..c33cf95 100644 --- a/app/backend/internal/api/logs.go +++ b/app/backend/internal/api/logs.go @@ -64,11 +64,15 @@ func handleGetLogs(c *gin.Context) { dockerClient, exists := c.Get("docker_client") if !exists || dockerClient == nil { - respondDependencyUnavailable(c, "docker", "Container log streaming is unavailable because Docker is not configured.") + respondEmptyServiceLogs(c, "Container log streaming is unavailable because Docker is not configured.") return } - client := dockerClient.(*docker.Client) + client, ok := dockerClient.(*docker.Client) + if !ok || client == nil { + respondEmptyServiceLogs(c, "Container log streaming is unavailable because Docker is not configured.") + return + } containerName := fmt.Sprintf("containr-%s", serviceID) logOpts := docker.LogOptions{ @@ -82,6 +86,10 @@ func handleGetLogs(c *gin.Context) { ctx := context.Background() logsReader, err := client.GetContainerLogs(ctx, containerName, logOpts) if err != nil { + if isDockerNotFoundError(err) { + respondEmptyServiceLogs(c, "No container logs are available for this service yet.") + return + } respondDependencyUnavailable(c, "docker", "Failed to fetch container logs. The container may be unavailable.") return } @@ -156,6 +164,13 @@ func handleGetLogs(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"logs": logEntries}) } +func respondEmptyServiceLogs(c *gin.Context, message string) { + c.JSON(http.StatusOK, gin.H{ + "logs": []LogEntry{}, + "message": message, + }) +} + func handleGetDeploymentLogs(c *gin.Context) { db, exists := c.Get("db") if !exists { diff --git a/app/backend/internal/api/metrics.go b/app/backend/internal/api/metrics.go new file mode 100644 index 0000000..4b8fd65 --- /dev/null +++ b/app/backend/internal/api/metrics.go @@ -0,0 +1,207 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "containr/internal/database" + "containr/internal/docker" + + containertypes "github.com/docker/docker/api/types/container" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// ServiceMetricsResponse is the aggregated live telemetry for a service, +// sourced from Docker container stats for containers named +// containr---. +type ServiceMetricsResponse struct { + ServiceID string `json:"service_id"` + Status string `json:"status"` // ok, no_containers, docker_unavailable + Instances []ServiceInstanceMetrics `json:"instances"` + CPUPercent float64 `json:"cpu_percent"` + MemoryUsageBytes uint64 `json:"memory_usage_bytes"` + MemoryLimitBytes uint64 `json:"memory_limit_bytes"` + NetworkRxBytes uint64 `json:"network_rx_bytes"` + NetworkTxBytes uint64 `json:"network_tx_bytes"` + CollectedAt time.Time `json:"collected_at"` +} + +// ServiceInstanceMetrics holds live stats for one running container. +type ServiceInstanceMetrics struct { + ContainerID string `json:"container_id"` + Name string `json:"name"` + State string `json:"state"` + CPUPercent float64 `json:"cpu_percent"` + MemoryUsageBytes uint64 `json:"memory_usage_bytes"` + MemoryLimitBytes uint64 `json:"memory_limit_bytes"` + NetworkRxBytes uint64 `json:"network_rx_bytes"` + NetworkTxBytes uint64 `json:"network_tx_bytes"` + StartedAt time.Time `json:"started_at"` +} + +// handleGetServiceMetrics returns real resource usage for the service's +// Docker containers. Empty result when the service has never deployed. +func handleGetServiceMetrics(c *gin.Context) { + dbValue, exists := c.Get("db") + if !exists { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"}) + return + } + db := dbValue.(*database.DB) + + serviceID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID"}) + return + } + + userID, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) + return + } + + var ownerID string + var status string + err = db.QueryRow( + `SELECT p.owner_id, COALESCE(s.status, '') + FROM services s JOIN projects p ON s.project_id = p.id + WHERE s.id = $1`, + serviceID, + ).Scan(&ownerID, &status) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"}) + return + } + if ownerID != userID.(string) { + c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) + return + } + + response := ServiceMetricsResponse{ + ServiceID: serviceID.String(), + Status: "ok", + Instances: []ServiceInstanceMetrics{}, + CollectedAt: time.Now(), + } + + dockerValue, exists := c.Get("docker_client") + if !exists || dockerValue == nil { + response.Status = "docker_unavailable" + c.JSON(http.StatusOK, gin.H{"metrics": response}) + return + } + dockerClient := dockerValue.(*docker.Client) + + ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second) + defer cancel() + + containers, err := dockerClient.ListContainers(ctx, false) + if err != nil { + response.Status = "docker_unavailable" + c.JSON(http.StatusOK, gin.H{"metrics": response}) + return + } + + namePrefix := fmt.Sprintf("containr-%s-", serviceID.String()) + matched := 0 + for _, ctr := range containers { + name := "" + for _, n := range ctr.Names { + name = strings.TrimPrefix(n, "/") + if strings.HasPrefix(name, namePrefix) { + break + } + name = "" + } + if name == "" { + continue + } + matched++ + + instance := ServiceInstanceMetrics{ + ContainerID: ctr.ID, + Name: name, + State: ctr.State, + } + + if info, err := dockerClient.GetContainer(ctx, ctr.ID); err == nil { + if info.State != nil && info.State.StartedAt != "" { + if parsed, perr := time.Parse(time.RFC3339Nano, info.State.StartedAt); perr == nil { + instance.StartedAt = parsed + } + } + } + + if ctr.State == "running" { + if stats, err := readContainerStats(ctx, dockerClient, ctr.ID); err == nil { + instance.CPUPercent = stats.cpuPercent + instance.MemoryUsageBytes = stats.memoryUsage + instance.MemoryLimitBytes = stats.memoryLimit + instance.NetworkRxBytes = stats.networkRx + instance.NetworkTxBytes = stats.networkTx + } + } + + response.Instances = append(response.Instances, instance) + response.CPUPercent += instance.CPUPercent + response.MemoryUsageBytes += instance.MemoryUsageBytes + response.MemoryLimitBytes += instance.MemoryLimitBytes + response.NetworkRxBytes += instance.NetworkRxBytes + response.NetworkTxBytes += instance.NetworkTxBytes + } + + if matched == 0 { + response.Status = "no_containers" + } + + c.JSON(http.StatusOK, gin.H{"metrics": response}) +} + +type containerStatsSnapshot struct { + cpuPercent float64 + memoryUsage uint64 + memoryLimit uint64 + networkRx uint64 + networkTx uint64 +} + +func readContainerStats(ctx context.Context, client *docker.Client, containerID string) (*containerStatsSnapshot, error) { + reader, err := client.GetContainerStats(ctx, containerID, false) + if err != nil { + return nil, err + } + defer reader.Body.Close() + + var stats containertypes.StatsResponse + if err := json.NewDecoder(reader.Body).Decode(&stats); err != nil { + return nil, err + } + + snapshot := &containerStatsSnapshot{ + memoryUsage: stats.MemoryStats.Usage, + memoryLimit: stats.MemoryStats.Limit, + } + + cpuDelta := float64(stats.CPUStats.CPUUsage.TotalUsage) - float64(stats.PreCPUStats.CPUUsage.TotalUsage) + systemDelta := float64(stats.CPUStats.SystemUsage) - float64(stats.PreCPUStats.SystemUsage) + onlineCPUs := float64(stats.CPUStats.OnlineCPUs) + if onlineCPUs == 0 { + onlineCPUs = float64(len(stats.CPUStats.CPUUsage.PercpuUsage)) + } + if cpuDelta > 0 && systemDelta > 0 && onlineCPUs > 0 { + snapshot.cpuPercent = (cpuDelta / systemDelta) * onlineCPUs * 100 + } + + for _, net := range stats.Networks { + snapshot.networkRx += net.RxBytes + snapshot.networkTx += net.TxBytes + } + + return snapshot, nil +} diff --git a/app/backend/internal/api/routes.go b/app/backend/internal/api/routes.go index 5f9e636..0f19221 100644 --- a/app/backend/internal/api/routes.go +++ b/app/backend/internal/api/routes.go @@ -23,7 +23,7 @@ import ( func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg *config.Config) { // Expose Better Auth through backend so frontend can use a single backend origin. - setupAuthProxyRoutes(router, cfg) + setupAuthProxyRoutes(router, cfg, db) // Initialize Docker client (non-fatal if it fails) var dockerClient *docker.Client @@ -158,11 +158,15 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg router.HEAD("/ready", healthHandler) // API v1 routes + publicAgents := router.Group("/api") + agentHandler.SetupPublicRoutes(publicAgents) + v1 := router.Group("/api/v1") { // Public routes (no authentication required) public := v1.Group("/") { + public.GET("/auth/bootstrap", handleAuthBootstrap) public.POST("/auth/login", handleLogin) public.POST("/auth/register", handleRegister) } @@ -174,6 +178,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg // User routes protected.GET("/user/profile", handleGetProfile) protected.PUT("/user/profile", handleUpdateProfile) + protected.POST("/users", handleCreateUser) // Project routes protected.GET("/projects", handleGetProjects) @@ -192,6 +197,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg protected.GET("/services/:id", handleGetService) protected.PUT("/services/:id", handleUpdateService) protected.DELETE("/services/:id", handleDeleteService) + protected.GET("/services/:id/metrics", handleGetServiceMetrics) // Deployment routes protected.GET("/services/:id/deployments", handleGetDeployments) @@ -212,7 +218,9 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg protected.POST("/git/github-app/connect", handleConnectGitHubApp) protected.GET("/git/providers", handleGetGitProviders) protected.POST("/git/providers", handleCreateGitProvider) + protected.DELETE("/git/providers/:providerId", handleDeleteGitProvider) protected.GET("/git/providers/:providerId/repositories", handleGetGitRepositories) + protected.GET("/git/providers/:providerId/repositories/:owner/:repo/branches", handleGetGitRepositoryBranches) protected.POST("/git/repositories/connect", handleConnectGitRepository) protected.GET("/git/repositories", handleGetConnectedRepositories) protected.POST("/git/webhooks", handleCreateWebhook) @@ -226,6 +234,11 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg protected.POST("/builds/plan", buildHandler.GetBuildPlan) protected.GET("/builds/detect", buildHandler.DetectBuildType) + // System routes + protected.GET("/system/upgrade/status", handleGetUpgradeStatus) + protected.POST("/system/upgrade/pull", handlePullUpgradeImage) + protected.GET("/system/host", handleGetHostMonitoring) + // Scaling routes scalingHandler.RegisterRoutes(protected) haAPIManager.RegisterRoutes(protected) @@ -241,9 +254,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg protected.POST("/databases/:id/restore", databaseHandler.RestoreBackup) // Node Agent routes - api := router.Group("/api") - api.Use(middleware.Auth(cfg.JWTSecret)) - agentHandler.SetupRoutes(api) + agentHandler.SetupRoutes(protected) // Preview Environments routes protected.GET("/projects/:id/preview-environments", handleGetPreviewEnvironments) diff --git a/app/backend/internal/api/services.go b/app/backend/internal/api/services.go index 15cd647..e7d4d98 100644 --- a/app/backend/internal/api/services.go +++ b/app/backend/internal/api/services.go @@ -99,8 +99,18 @@ func handleGetServices(c *gin.Context) { // Get services for the project rows, err := db.(*database.DB).Query( - `SELECT id, project_id, name, type, status, image, command, environment, - git_repo, git_branch, build_path, cpu, memory, created_at, updated_at + `SELECT id, project_id, name, + COALESCE(type, service_type, ''), + COALESCE(status, ''), + COALESCE(image, image_name, ''), + COALESCE(command, start_command, ''), + COALESCE(environment, ''), + COALESCE(git_repo, source_url, ''), + COALESCE(git_branch, ''), + COALESCE(build_path, ''), + COALESCE(cpu, ''), + COALESCE(memory, ''), + created_at, updated_at FROM services WHERE project_id = $1 ORDER BY created_at DESC`, @@ -228,15 +238,25 @@ func handleCreateService(c *gin.Context) { service.Memory = "512Mi" } + environmentID, err := getProjectEnvironmentID(db.(*database.DB), service.ProjectID, service.Environment) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to resolve service environment"}) + return + } + + sourceType := inferServiceSourceType(service) + // Insert service into database _, err = db.(*database.DB).Exec( `INSERT INTO services - (id, project_id, name, type, status, image, command, environment, + (id, project_id, name, environment_id, service_type, source_type, source_url, image_name, + build_command, start_command, type, status, image, command, environment, git_repo, git_branch, build_path, cpu, memory, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)`, - 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, + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)`, + service.ID, service.ProjectID, service.Name, environmentID, service.Type, + sourceType, firstNonEmpty(service.GitRepo, service.Image), service.Image, + "", service.Command, service.Type, service.Status, service.Image, service.Command, + service.Environment, service.GitRepo, service.GitBranch, service.BuildPath, service.CPU, service.Memory, service.CreatedAt, service.UpdatedAt, ) @@ -248,6 +268,26 @@ func handleCreateService(c *gin.Context) { c.JSON(http.StatusCreated, gin.H{"service": service}) } +func getProjectEnvironmentID(db *database.DB, projectID uuid.UUID, environment string) (uuid.UUID, error) { + var environmentID uuid.UUID + err := db.QueryRow( + "SELECT id FROM environments WHERE project_id = $1 AND name = $2", + projectID, + environment, + ).Scan(&environmentID) + return environmentID, err +} + +func inferServiceSourceType(service Service) string { + if service.GitRepo != "" { + return "github" + } + if service.Image != "" { + return "image" + } + return "dockerfile" +} + // handleGetService retrieves a specific service func handleGetService(c *gin.Context) { db, exists := c.Get("db") @@ -273,8 +313,17 @@ func handleGetService(c *gin.Context) { // Get service with project ownership check var service Service err = db.(*database.DB).QueryRow( - `SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command, - s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory, + `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, ''), s.created_at, s.updated_at FROM services s JOIN projects p ON s.project_id = p.id @@ -326,8 +375,17 @@ func handleUpdateService(c *gin.Context) { // Check if service exists and user has access var existingService Service err = db.(*database.DB).QueryRow( - `SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command, - s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory, + `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, ''), s.created_at, s.updated_at FROM services s JOIN projects p ON s.project_id = p.id diff --git a/app/backend/internal/api/system.go b/app/backend/internal/api/system.go new file mode 100644 index 0000000..fac4de0 --- /dev/null +++ b/app/backend/internal/api/system.go @@ -0,0 +1,321 @@ +package api + +import ( + "bufio" + "context" + "io" + "net/http" + "os" + "runtime" + "strconv" + "strings" + "syscall" + "time" + + "containr/internal/docker" + + "github.com/docker/docker/api/types/registry" + "github.com/gin-gonic/gin" +) + +type upgradeStatusResponse struct { + ImageRef string `json:"image_ref"` + Registry string `json:"registry"` + DockerAvailable bool `json:"docker_available"` + Installed bool `json:"installed"` + Digest string `json:"digest,omitempty"` + Size int64 `json:"size,omitempty"` + AuthConfigured bool `json:"auth_configured"` + Message string `json:"message"` +} + +type hostMonitoringResponse struct { + Hostname string `json:"hostname"` + OS string `json:"os"` + Architecture string `json:"architecture"` + CPU hostCPU `json:"cpu"` + Memory hostMemory `json:"memory"` + Storage hostStorage `json:"storage"` + Load SystemLoad `json:"load"` + UptimeSeconds int64 `json:"uptime_seconds"` + DockerAvailable bool `json:"docker_available"` + Docker map[string]any `json:"docker,omitempty"` + CollectedAt string `json:"collected_at"` +} + +type hostCPU struct { + Cores int `json:"cores"` +} + +type hostMemory struct { + Total uint64 `json:"total"` + Used uint64 `json:"used"` + Available uint64 `json:"available"` + UsagePct float64 `json:"usage_percent"` +} + +type hostStorage struct { + Path string `json:"path"` + Total uint64 `json:"total"` + Used uint64 `json:"used"` + Available uint64 `json:"available"` + UsagePct float64 `json:"usage_percent"` +} + +func handleGetUpgradeStatus(c *gin.Context) { + imageRef := configuredUpgradeImageRef() + dockerClient, _ := c.Get("docker_client") + client, _ := dockerClient.(*docker.Client) + + response := upgradeStatusResponse{ + ImageRef: imageRef, + Registry: registryHost(imageRef), + DockerAvailable: client != nil, + AuthConfigured: upgradeRegistryAuthConfigured(), + Message: "Upgrade image auto-detected", + } + + if imageRef == "" { + response.Message = "Set CONTAINR_UPGRADE_IMAGE_REF or CONTAINR_IMAGE_REF to enable image pulls" + c.JSON(http.StatusOK, response) + return + } + + if client == nil { + response.Message = "Docker client unavailable" + c.JSON(http.StatusOK, response) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second) + defer cancel() + + info, err := client.GetImageInfo(ctx, imageRef) + if err != nil { + response.Message = "Image not installed locally" + c.JSON(http.StatusOK, response) + return + } + + response.Installed = true + response.Digest = info.Digest + response.Size = info.Size + response.Message = "Image installed locally" + c.JSON(http.StatusOK, response) +} + +func handlePullUpgradeImage(c *gin.Context) { + imageRef := configuredUpgradeImageRef() + if imageRef == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Set CONTAINR_UPGRADE_IMAGE_REF or CONTAINR_IMAGE_REF before pulling"}) + return + } + + dockerClient, _ := c.Get("docker_client") + client, ok := dockerClient.(*docker.Client) + if !ok || client == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Docker client unavailable"}) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Minute) + defer cancel() + + reader, err := client.PullImage(ctx, imageRef, upgradeRegistryAuth()) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + defer reader.Close() + _, _ = io.Copy(io.Discard, reader) + + info, infoErr := client.GetImageInfo(ctx, imageRef) + response := gin.H{ + "image_ref": imageRef, + "registry": registryHost(imageRef), + "message": "Image pull completed", + } + if infoErr == nil { + response["digest"] = info.Digest + response["size"] = info.Size + } + + c.JSON(http.StatusOK, response) +} + +func handleGetHostMonitoring(c *gin.Context) { + hostname, _ := os.Hostname() + memory := readHostMemory() + storage := readHostStorage("/") + load := readSystemLoad() + uptime := readSystemUptime() + + response := hostMonitoringResponse{ + Hostname: hostname, + OS: runtime.GOOS, + Architecture: runtime.GOARCH, + CPU: hostCPU{Cores: runtime.NumCPU()}, + Memory: memory, + Storage: storage, + Load: load, + UptimeSeconds: uptime, + CollectedAt: time.Now().UTC().Format(time.RFC3339), + } + + if dockerClient, ok := c.Get("docker_client"); ok { + if client, ok := dockerClient.(*docker.Client); ok && client != nil { + ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second) + defer cancel() + if info, err := client.GetSystemInfo(ctx); err == nil { + response.DockerAvailable = true + response.Docker = map[string]any{ + "containers": info.Containers, + "images": info.Images, + "driver": info.Driver, + "server": info.ServerVersion, + } + } + } + } + + c.JSON(http.StatusOK, response) +} + +func configuredUpgradeImageRef() string { + for _, key := range []string{"CONTAINR_UPGRADE_IMAGE_REF", "CONTAINR_IMAGE_REF", "DOCKER_IMAGE_REF"} { + if value := strings.TrimSpace(os.Getenv(key)); value != "" { + return value + } + } + return "" +} + +func registryHost(imageRef string) string { + parts := strings.Split(imageRef, "/") + if len(parts) > 1 && (strings.Contains(parts[0], ".") || strings.Contains(parts[0], ":")) { + return parts[0] + } + if strings.HasPrefix(imageRef, "ghcr.io/") { + return "ghcr.io" + } + return "docker.io" +} + +func upgradeRegistryAuthConfigured() bool { + auth := upgradeRegistryAuth() + return strings.TrimSpace(auth.Username) != "" || strings.TrimSpace(auth.Password) != "" || strings.TrimSpace(auth.Auth) != "" +} + +func upgradeRegistryAuth() registry.AuthConfig { + username := firstConfigured("GITHUB_CONTAINER_REGISTRY_USER", "GHCR_USERNAME", "DOCKER_REGISTRY_USERNAME") + password := firstConfigured("GITHUB_CONTAINER_REGISTRY_TOKEN", "GHCR_TOKEN", "DOCKER_REGISTRY_PASSWORD") + return registry.AuthConfig{ + Username: username, + Password: password, + } +} + +func firstConfigured(keys ...string) string { + for _, key := range keys { + if value := strings.TrimSpace(os.Getenv(key)); value != "" { + return value + } + } + return "" +} + +func readHostMemory() hostMemory { + values := map[string]uint64{} + file, err := os.Open("/proc/meminfo") + if err != nil { + return hostMemory{} + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + fields := strings.Fields(scanner.Text()) + if len(fields) < 2 { + continue + } + key := strings.TrimSuffix(fields[0], ":") + value, parseErr := strconv.ParseUint(fields[1], 10, 64) + if parseErr != nil { + continue + } + values[key] = value * 1024 + } + + total := values["MemTotal"] + available := values["MemAvailable"] + used := uint64(0) + if total > available { + used = total - available + } + + usage := 0.0 + if total > 0 { + usage = float64(used) / float64(total) * 100 + } + + return hostMemory{ + Total: total, + Used: used, + Available: available, + UsagePct: usage, + } +} + +func readHostStorage(path string) hostStorage { + var stat syscall.Statfs_t + if err := syscall.Statfs(path, &stat); err != nil { + return hostStorage{Path: path} + } + + total := stat.Blocks * uint64(stat.Bsize) + available := stat.Bavail * uint64(stat.Bsize) + used := total - available + usage := 0.0 + if total > 0 { + usage = float64(used) / float64(total) * 100 + } + + return hostStorage{ + Path: path, + Total: total, + Used: used, + Available: available, + UsagePct: usage, + } +} + +func readSystemLoad() SystemLoad { + data, err := os.ReadFile("/proc/loadavg") + if err != nil { + return SystemLoad{} + } + fields := strings.Fields(string(data)) + if len(fields) < 3 { + return SystemLoad{} + } + load1, _ := strconv.ParseFloat(fields[0], 64) + load5, _ := strconv.ParseFloat(fields[1], 64) + load15, _ := strconv.ParseFloat(fields[2], 64) + return SystemLoad{Load1M: load1, Load5M: load5, Load15M: load15} +} + +func readSystemUptime() int64 { + data, err := os.ReadFile("/proc/uptime") + if err != nil { + return 0 + } + fields := strings.Fields(string(data)) + if len(fields) == 0 { + return 0 + } + uptime, err := strconv.ParseFloat(fields[0], 64) + if err != nil { + return 0 + } + return int64(uptime) +} diff --git a/app/backend/internal/api/templates.go b/app/backend/internal/api/templates.go index c1fa5e9..6d638ca 100644 --- a/app/backend/internal/api/templates.go +++ b/app/backend/internal/api/templates.go @@ -279,9 +279,16 @@ func handleCreateFromTemplate(c *gin.Context) { serviceImage := resolveTemplateRuntimeImage(config.Runtime) serviceCommand := strings.TrimSpace(config.StartCommand) + buildCommand := strings.TrimSpace(config.BuildCommand) cpu, memory := defaultTemplateResources(serviceType) serviceEnvironment := "production" + environmentID, err := getProjectEnvironmentID(db, projectID, serviceEnvironment) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to resolve service environment"}) + return + } + serviceID := uuid.New() now := time.Now() @@ -294,18 +301,24 @@ func handleCreateFromTemplate(c *gin.Context) { txQueries := queries.WithTx(tx) err = txQueries.CreateServiceFromTemplate(ctx, sqlcdb.CreateServiceFromTemplateParams{ - ID: serviceID, - ProjectID: projectID, - Name: req.Name, - Type: serviceType, - Status: "stopped", - Image: sql.NullString{String: serviceImage, Valid: true}, - Command: sql.NullString{String: serviceCommand, Valid: true}, - Environment: sql.NullString{String: serviceEnvironment, Valid: true}, - Cpu: sql.NullString{String: cpu, Valid: true}, - Memory: sql.NullString{String: memory, Valid: true}, - CreatedAt: sql.NullTime{Time: now, Valid: true}, - UpdatedAt: sql.NullTime{Time: now, Valid: true}, + ID: serviceID, + ProjectID: projectID, + Name: req.Name, + EnvironmentID: environmentID, + ServiceType: serviceType, + SourceType: "template", + ImageName: sql.NullString{String: serviceImage, Valid: serviceImage != ""}, + BuildCommand: sql.NullString{String: buildCommand, Valid: buildCommand != ""}, + StartCommand: sql.NullString{String: serviceCommand, Valid: serviceCommand != ""}, + Type: serviceType, + Status: "stopped", + Image: sql.NullString{String: serviceImage, Valid: serviceImage != ""}, + Command: sql.NullString{String: serviceCommand, Valid: serviceCommand != ""}, + Environment: sql.NullString{String: serviceEnvironment, Valid: true}, + Cpu: sql.NullString{String: cpu, Valid: true}, + Memory: sql.NullString{String: memory, Valid: true}, + CreatedAt: sql.NullTime{Time: now, Valid: true}, + UpdatedAt: sql.NullTime{Time: now, Valid: true}, }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create service from template"}) diff --git a/app/backend/internal/config/config.go b/app/backend/internal/config/config.go index 739037e..2b137df 100644 --- a/app/backend/internal/config/config.go +++ b/app/backend/internal/config/config.go @@ -146,6 +146,9 @@ func Load() *Config { cfg.CORSMethods = getenvSlice("CORS_METHODS", []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}) cfg.CORSHeaders = getenvSlice("CORS_HEADERS", []string{"Origin", "Content-Type", "Accept", "Authorization"}) cfg.CORSCredentials = getenvBool("CORS_CREDENTIALS", true) + if cfg.IsDevelopment() { + cfg.CORSOrigins = expandDevelopmentCORSOrigins(cfg.CORSOrigins) + } cfg.APIKeyHeader = getenv("API_KEY_HEADER", "X-API-Key") cfg.ServiceTokenHeader = getenv("SERVICE_TOKEN_HEADER", "X-Service-Token") @@ -331,6 +334,57 @@ func getenvSliceWithAliases(keys []string, defaultValue []string) []string { return defaultValue } +func expandDevelopmentCORSOrigins(origins []string) []string { + hasLocalOrigin := false + seen := make(map[string]struct{}, len(origins)) + expanded := make([]string, 0, len(origins)+12) + + for _, origin := range origins { + trimmed := strings.TrimSpace(origin) + if trimmed == "" { + continue + } + if _, ok := seen[trimmed]; ok { + continue + } + seen[trimmed] = struct{}{} + expanded = append(expanded, trimmed) + + if strings.HasPrefix(trimmed, "http://localhost") || + strings.HasPrefix(trimmed, "http://127.0.0.1") || + strings.HasPrefix(trimmed, "http://0.0.0.0") { + hasLocalOrigin = true + } + } + + if !hasLocalOrigin { + return expanded + } + + for _, origin := range []string{ + "http://localhost:3000", + "http://localhost:5173", + "http://localhost:5174", + "http://localhost:5175", + "http://127.0.0.1:3000", + "http://127.0.0.1:5173", + "http://127.0.0.1:5174", + "http://127.0.0.1:5175", + "http://0.0.0.0:3000", + "http://0.0.0.0:5173", + "http://0.0.0.0:5174", + "http://0.0.0.0:5175", + } { + if _, ok := seen[origin]; ok { + continue + } + seen[origin] = struct{}{} + expanded = append(expanded, origin) + } + + return expanded +} + // encodeDatabasePassword URL-encodes the password in a database connection string func encodeDatabasePassword(databaseURL string) string { // Find the password part between :@ and encode it diff --git a/app/backend/internal/config/config_test.go b/app/backend/internal/config/config_test.go index 5b39ea7..e2ca0e4 100644 --- a/app/backend/internal/config/config_test.go +++ b/app/backend/internal/config/config_test.go @@ -12,9 +12,14 @@ func TestLoadUsesCORSAllowedOriginsAlias(t *testing.T) { t.Setenv("CORS_ALLOWED_ORIGINS", "http://localhost:3000,https://app.example.com") cfg := Load() - want := []string{"http://localhost:3000", "https://app.example.com"} - if !reflect.DeepEqual(cfg.CORSOrigins, want) { - t.Fatalf("unexpected CORS origins: got %v want %v", cfg.CORSOrigins, want) + if !containsAll(cfg.CORSOrigins, []string{ + "http://localhost:3000", + "http://localhost:5173", + "http://localhost:5174", + "http://127.0.0.1:5174", + "https://app.example.com", + }) { + t.Fatalf("unexpected CORS origins: got %v", cfg.CORSOrigins) } } @@ -180,3 +185,27 @@ func TestLoadUsesNewStartupDefaults(t *testing.T) { t.Fatalf("expected positive MAX_REQUEST_BODY_BYTES, got %d", cfg.MaxRequestBody) } } + +func TestLoadDoesNotExpandNonLocalDevelopmentCORSOrigins(t *testing.T) { + t.Setenv("ENVIRONMENT", "development") + t.Setenv("CORS_ORIGINS", "https://preferred.example.com") + + cfg := Load() + want := []string{"https://preferred.example.com"} + if !reflect.DeepEqual(cfg.CORSOrigins, want) { + t.Fatalf("unexpected CORS origins: got %v want %v", cfg.CORSOrigins, want) + } +} + +func containsAll(haystack []string, needles []string) bool { + seen := make(map[string]struct{}, len(haystack)) + for _, item := range haystack { + seen[item] = struct{}{} + } + for _, needle := range needles { + if _, ok := seen[needle]; !ok { + return false + } + } + return true +} diff --git a/app/backend/internal/database/sqlcdb/templates.sql.go b/app/backend/internal/database/sqlcdb/templates.sql.go index 30354c8..d8d3afa 100644 --- a/app/backend/internal/database/sqlcdb/templates.sql.go +++ b/app/backend/internal/database/sqlcdb/templates.sql.go @@ -31,23 +31,70 @@ func (q *Queries) CountServicesByProjectAndName(ctx context.Context, arg CountSe } const createServiceFromTemplate = `-- name: CreateServiceFromTemplate :exec -INSERT INTO services (id, project_id, name, type, status, image, command, environment, cpu, memory, created_at, updated_at) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) +INSERT INTO services ( + id, + project_id, + name, + environment_id, + service_type, + source_type, + source_url, + image_name, + build_command, + start_command, + type, + status, + image, + command, + environment, + cpu, + memory, + created_at, + updated_at +) +VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8, + $9, + $10, + $11, + $12, + $13, + $14, + $15, + $16, + $17, + $18, + $19 +) ` type CreateServiceFromTemplateParams struct { - ID uuid.UUID `json:"id"` - ProjectID uuid.UUID `json:"project_id"` - Name string `json:"name"` - Type string `json:"type"` - Status string `json:"status"` - Image sql.NullString `json:"image"` - Command sql.NullString `json:"command"` - Environment sql.NullString `json:"environment"` - Cpu sql.NullString `json:"cpu"` - Memory sql.NullString `json:"memory"` - CreatedAt sql.NullTime `json:"created_at"` - UpdatedAt sql.NullTime `json:"updated_at"` + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` + Name string `json:"name"` + EnvironmentID uuid.UUID `json:"environment_id"` + ServiceType string `json:"service_type"` + SourceType string `json:"source_type"` + SourceUrl sql.NullString `json:"source_url"` + ImageName sql.NullString `json:"image_name"` + BuildCommand sql.NullString `json:"build_command"` + StartCommand sql.NullString `json:"start_command"` + Type string `json:"type"` + Status string `json:"status"` + Image sql.NullString `json:"image"` + Command sql.NullString `json:"command"` + Environment sql.NullString `json:"environment"` + Cpu sql.NullString `json:"cpu"` + Memory sql.NullString `json:"memory"` + CreatedAt sql.NullTime `json:"created_at"` + UpdatedAt sql.NullTime `json:"updated_at"` } func (q *Queries) CreateServiceFromTemplate(ctx context.Context, arg CreateServiceFromTemplateParams) error { @@ -55,6 +102,13 @@ func (q *Queries) CreateServiceFromTemplate(ctx context.Context, arg CreateServi arg.ID, arg.ProjectID, arg.Name, + arg.EnvironmentID, + arg.ServiceType, + arg.SourceType, + arg.SourceUrl, + arg.ImageName, + arg.BuildCommand, + arg.StartCommand, arg.Type, arg.Status, arg.Image, diff --git a/app/backend/sqlc/queries/templates.sql b/app/backend/sqlc/queries/templates.sql index 827ae59..1bafe20 100644 --- a/app/backend/sqlc/queries/templates.sql +++ b/app/backend/sqlc/queries/templates.sql @@ -25,8 +25,48 @@ FROM services WHERE project_id = $1 AND name = $2; -- name: CreateServiceFromTemplate :exec -INSERT INTO services (id, project_id, name, type, status, image, command, environment, cpu, memory, created_at, updated_at) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12); +INSERT INTO services ( + id, + project_id, + name, + environment_id, + service_type, + source_type, + source_url, + image_name, + build_command, + start_command, + type, + status, + image, + command, + environment, + cpu, + memory, + created_at, + updated_at +) +VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8, + $9, + $10, + $11, + $12, + $13, + $14, + $15, + $16, + $17, + $18, + $19 +); -- name: UpsertEnvironmentVariable :exec INSERT INTO environment_variables (id, service_id, key, value, is_secret, created_at, updated_at) diff --git a/app/frontend/package-lock.json b/app/frontend/package-lock.json index a4a4366..1cdbe97 100644 --- a/app/frontend/package-lock.json +++ b/app/frontend/package-lock.json @@ -526,7 +526,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -543,7 +542,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -560,7 +558,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -577,7 +574,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -594,7 +590,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -611,7 +606,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -628,7 +622,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -645,7 +638,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -662,7 +654,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -679,7 +670,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -696,7 +686,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -713,7 +702,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -730,7 +718,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -747,7 +734,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -764,7 +750,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -781,7 +766,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -798,7 +782,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -815,7 +798,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -832,7 +814,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -849,7 +830,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -866,7 +846,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -883,7 +862,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -900,7 +878,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -917,7 +894,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -934,7 +910,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -951,7 +926,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1241,7 +1215,7 @@ "version": "0.3.11", "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -3108,7 +3082,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3122,7 +3095,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3136,7 +3108,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3150,7 +3121,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3164,7 +3134,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3178,7 +3147,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3192,7 +3160,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3206,7 +3173,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3220,7 +3186,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3234,7 +3199,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3248,7 +3212,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3262,7 +3225,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3276,7 +3238,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3290,7 +3251,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3304,7 +3264,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3318,7 +3277,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3332,7 +3290,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3346,7 +3303,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3360,7 +3316,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3374,7 +3329,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3388,7 +3342,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3402,7 +3355,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3416,7 +3368,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3430,7 +3381,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3444,7 +3394,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4221,7 +4170,7 @@ "version": "24.10.15", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.15.tgz", "integrity": "sha512-BgjLoRuSr0MTI5wA6gMw9Xy0sFudAaUuvrnjgGx9wZ522fYYLA5SYJ+1Y30vTcJEG+DRCyDHx/gzQVfofYzSdg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~7.16.0" @@ -4257,14 +4206,14 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -4792,7 +4741,7 @@ "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -5179,7 +5128,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/call-bind-apply-helpers": { @@ -5343,7 +5292,7 @@ "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/concat-map": { @@ -5726,7 +5675,7 @@ "version": "7.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", - "dev": true, + "devOptional": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -6214,7 +6163,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -6338,7 +6286,7 @@ "version": "20.7.0", "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.7.0.tgz", "integrity": "sha512-hR/uLYQdngTyEfxnOoa+e6KTcfBFyc1hgFj/Cc144A5JJUuHFYqIEBDcD4FeGqUeKLRZqJ9eN9u7/GDjYEgS1g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/node": ">=20.0.0", @@ -7858,7 +7806,7 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -7877,7 +7825,7 @@ "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "buffer-from": "^1.0.0", @@ -7979,7 +7927,7 @@ "version": "5.46.0", "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz", "integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==", - "dev": true, + "devOptional": true, "license": "BSD-2-Clause", "dependencies": { "@jridgewell/source-map": "^0.3.3", @@ -8130,7 +8078,7 @@ "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/update-browserslist-db": { @@ -8429,7 +8377,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12" @@ -8482,7 +8430,7 @@ "version": "8.19.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/app/frontend/public/containr.svg b/app/frontend/public/containr.svg new file mode 100644 index 0000000..6184e83 --- /dev/null +++ b/app/frontend/public/containr.svg @@ -0,0 +1,172 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/frontend/public/vite.svg b/app/frontend/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/app/frontend/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/src/app/App.smoke.test.tsx b/app/frontend/src/app/App.smoke.test.tsx index 68f572b..7c0a9db 100644 --- a/app/frontend/src/app/App.smoke.test.tsx +++ b/app/frontend/src/app/App.smoke.test.tsx @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { render, screen } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { MemoryRouter } from 'react-router-dom'; +import { ToastProvider } from '@/shared/components'; import App from './App'; function renderApp(initialPath: string) { @@ -16,9 +17,11 @@ function renderApp(initialPath: string) { return render( - - - + + + + + , ); } @@ -29,14 +32,14 @@ describe('App smoke routes', () => { expect(await screen.findByRole('heading', { name: /^projects$/i })).toBeInTheDocument(); expect(screen.getByText(/demo mode active/i)).toBeInTheDocument(); - expect(screen.getByText(/visual topology mapping and real-time observability/i)).toBeInTheDocument(); + expect(screen.getByText(/deploy, manage, and monitor containerized services/i)).toBeInTheDocument(); }); it('renders builds page in demo mode', async () => { renderApp('/builds?demo=1'); expect(await screen.findByRole('heading', { name: /build pipeline/i })).toBeInTheDocument(); - expect(screen.getByText(/offline/i)).toBeInTheDocument(); + expect(screen.getByText(/polling/i)).toBeInTheDocument(); }); it('renders templates page in demo mode', async () => { diff --git a/app/frontend/src/app/layout/PlatformShell.tsx b/app/frontend/src/app/layout/PlatformShell.tsx index c4646f1..40c4f60 100644 --- a/app/frontend/src/app/layout/PlatformShell.tsx +++ b/app/frontend/src/app/layout/PlatformShell.tsx @@ -1,22 +1,35 @@ -import { NavLink, Outlet, useLocation } from 'react-router-dom'; -import { - FolderKanban, - Hammer, - LayoutTemplate, - ChartBar, - Users, - Settings, - BookOpen, - Container, +import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'; +import { useEffect, useMemo, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Bell, + BookOpen, + ChartBar, + CheckCircle2, + Container, + FolderKanban, + LayoutTemplate, + LogOut, + Moon, + RefreshCw, Search, - Zap, - Activity, + Settings, + Sun, + UploadCloud, + Users, } from 'lucide-react'; +import { + getUpgradeStatus, + listAuditLogs, + listBuilds, + pullUpgradeImage, +} from '@/lib/api-client'; +import { signOutAuthSession } from '@/lib/auth-client'; +import { useAuthSession } from '@/lib/use-auth-session'; +import { useToast } from '@/shared/components'; const navItems = [ { label: 'Projects', href: '/projects', icon: FolderKanban }, - { label: 'Builds', href: '/builds', icon: Hammer }, { label: 'Templates', href: '/templates', icon: LayoutTemplate }, { label: 'Usage', href: '/usage', icon: ChartBar }, { label: 'People', href: '/people', icon: Users }, @@ -24,240 +37,299 @@ const navItems = [ { label: 'Docs', href: '/docs', icon: BookOpen }, ]; -// Developer/Demo pages -const demoNavItems = [ - { label: 'Metrics Demo', href: '/metrics-demo', icon: Activity }, - { label: 'Showcase', href: '/showcase', icon: Zap }, -]; +type ThemeMode = 'dark' | 'light'; + +function getInitialTheme(): ThemeMode { + if (typeof window === 'undefined') { + return 'dark'; + } + return localStorage.getItem('containr.theme') === 'light' ? 'light' : 'dark'; +} export function PlatformShell() { const location = useLocation(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { showToast } = useToast(); const isDemoMode = new URLSearchParams(location.search).get('demo') === '1'; const href = (target: string) => (isDemoMode ? `${target}?demo=1` : target); + const [theme, setTheme] = useState(() => getInitialTheme()); + const [notificationsOpen, setNotificationsOpen] = useState(false); - const isActiveRoute = (itemHref: string) => + const sessionQuery = useAuthSession({ enabled: !isDemoMode }); + const upgradeQuery = useQuery({ + queryKey: ['upgrade-status'], + queryFn: getUpgradeStatus, + enabled: !isDemoMode, + }); + const buildsQuery = useQuery({ + queryKey: ['shell-build-notifications'], + queryFn: () => listBuilds({ page: 1, limit: 5 }), + enabled: !isDemoMode, + }); + const auditQuery = useQuery({ + queryKey: ['shell-audit-notifications'], + queryFn: () => listAuditLogs({ page: 1, limit: 5 }), + enabled: !isDemoMode, + }); + + useEffect(() => { + document.documentElement.dataset.theme = theme; + localStorage.setItem('containr.theme', theme); + }, [theme]); + + const pullMutation = useMutation({ + mutationFn: pullUpgradeImage, + onSuccess: (status) => { + queryClient.invalidateQueries({ queryKey: ['upgrade-status'] }); + showToast('success', 'Upgrade image pulled', status.digest || status.imageRef || status.message); + }, + onError: (error) => { + showToast('error', 'Upgrade failed', error instanceof Error ? error.message : 'Unable to pull image'); + }, + }); + + const notifications = useMemo(() => { + const builds = (buildsQuery.data?.builds ?? []).map((build) => ({ + id: `build-${build.id}`, + title: `${build.status} build`, + body: build.imageName || build.serviceId || build.id, + })); + const audits = (auditQuery.data ?? []).map((log) => ({ + id: `audit-${log.id}`, + title: log.action, + body: `${log.resource}${log.resourceId ? ` / ${log.resourceId}` : ''}`, + })); + return [...builds, ...audits].slice(0, 6); + }, [auditQuery.data, buildsQuery.data?.builds]); + + const signOut = async () => { + try { + await signOutAuthSession(); + } finally { + queryClient.clear(); + navigate('/auth/sign-in', { replace: true }); + } + }; + + const isActiveRoute = (itemHref: string) => location.pathname === itemHref || location.pathname.startsWith(`${itemHref}/`); + const userName = sessionQuery.data?.user.name ?? 'Account'; + const userEmail = sessionQuery.data?.user.email ?? 'Local session'; + const initials = userName + .split(' ') + .map((part) => part[0]) + .join('') + .slice(0, 2) + .toUpperCase() || 'C'; + return (
- {/* Ambient glow background */}
- -
- {/* Desktop Sidebar - self.html exact match: 64px width */} -
- - - - -
- No account yet? - - Create one - + Need access? + Ask platform owner to create account.
@@ -321,6 +268,10 @@ export function SignUpPage() { const redirectPath = useMemo(() => sanitizeRedirect(searchParams.get('redirect')), [searchParams]); const sessionQuery = useAuthSession(); + const bootstrapQuery = useQuery({ + queryKey: ['auth-bootstrap'], + queryFn: getAuthBootstrap, + }); const [name, setName] = useState(''); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); @@ -331,6 +282,10 @@ export function SignUpPage() { return ; } + if (bootstrapQuery.data?.mode === 'login') { + return ; + } + const submitSignUp = async (event: FormEvent) => { event.preventDefault(); setError(null); @@ -350,8 +305,9 @@ export function SignUpPage() { return ( - + {error ? : null} + {bootstrapQuery.isLoading ? : null}
) : ( -
- - - +
+
+ + + +
+ +
+
+
+ +
+
+

Manual User Creation

+

Public registration is closed after bootstrap.

+
+
+ + { + event.preventDefault(); + createUserMutation.mutate(); + }} + > + setNewUserName(event.target.value)} + required + className="h-10 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm" + placeholder="Name" + /> + setNewUserEmail(event.target.value)} + type="email" + required + className="h-10 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm" + placeholder="email@example.com" + /> + setNewUserPassword(event.target.value)} + type="password" + minLength={8} + required + className="h-10 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm" + placeholder="Temporary password" + /> + + + + {createUserMutation.isError ? ( +
+ {createUserMutation.error instanceof Error ? createUserMutation.error.message : 'Failed to create user'} +
+ ) : null} + {createUserMutation.isSuccess ? ( +
+ User created. Share credentials through secure channel. +
+ ) : null} +
)}
@@ -341,6 +623,196 @@ export function PeoplePage() { ); } +const gitProviderTypes = [ + { value: 'github', label: 'GitHub', tokenHint: 'Personal access token with repo scope' }, + { value: 'gitlab', label: 'GitLab', tokenHint: 'Personal access token with read_api scope' }, + { value: 'gitea', label: 'Gitea', tokenHint: 'Access token from your Gitea instance' }, + { value: 'bitbucket', label: 'Bitbucket', tokenHint: 'App password with repository read' }, +] as const; + +function GitProvidersSection() { + const queryClient = useQueryClient(); + const providersQuery = useQuery({ + queryKey: ['git-providers'], + queryFn: listGitProviders, + }); + + const [form, setForm] = useState({ + name: 'github' as (typeof gitProviderTypes)[number]['value'], + displayName: '', + accessToken: '', + apiUrl: '', + }); + const [formOpen, setFormOpen] = useState(false); + + const createMutation = useMutation({ + mutationFn: () => + createGitProvider({ + name: form.name, + display_name: form.displayName.trim() || gitProviderTypes.find((t) => t.value === form.name)!.label, + access_token: form.accessToken.trim(), + api_url: form.apiUrl.trim() || undefined, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['git-providers'] }); + setForm({ name: 'github', displayName: '', accessToken: '', apiUrl: '' }); + setFormOpen(false); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: (providerId: string) => deleteGitProvider(providerId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['git-providers'] }); + }, + }); + + const providers = providersQuery.data ?? []; + const selectedType = gitProviderTypes.find((t) => t.value === form.name)!; + + return ( +
+
+
+
+ +
+
+

Git Providers

+

Connect accounts to deploy services from repositories

+
+
+ +
+ + {providersQuery.isLoading ? ( +
+ +
+ ) : providersQuery.isError ? ( +
+ Failed to load providers +
+ ) : providers.length === 0 ? ( +
+

No Git providers connected

+

+ Connect GitHub, GitLab, Gitea, or Bitbucket to deploy services from source +

+
+ ) : ( +
+ {providers.map((provider) => ( +
+
+
+ +
+
+

{provider.display_name}

+

{provider.name.replace('_', ' ')}

+
+
+ +
+ ))} +
+ )} + + {formOpen && ( +
+
+
+ + +
+
+ + setForm((p) => ({ ...p, displayName: e.target.value }))} + className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm focus:border-[var(--accent-primary)] transition-colors" + placeholder={selectedType.label} + /> +
+
+
+ + setForm((p) => ({ ...p, accessToken: e.target.value }))} + className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm mono focus:border-[var(--accent-primary)] transition-colors" + placeholder="••••••••••••••••" + /> +

{selectedType.tokenHint}

+
+ {form.name === 'gitea' && ( +
+ + setForm((p) => ({ ...p, apiUrl: e.target.value }))} + className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm mono focus:border-[var(--accent-primary)] transition-colors" + placeholder="https://gitea.example.com" + /> +
+ )} + {createMutation.isError && ( +
+ {createMutation.error instanceof Error ? createMutation.error.message : 'Failed to connect provider'} +
+ )} +
+ +
+
+ )} +
+ ); +} + export function SettingsPage() { const navigate = useNavigate(); const queryClient = useQueryClient(); @@ -502,6 +974,9 @@ export function SettingsPage() { )} + {/* Git Providers Section */} + + {/* Runtime & Local State Section */}
@@ -701,7 +1176,7 @@ export function DocsPage() { Generated Types
-

frontend/src/generated/api-types.ts

+

app/frontend/src/generated/api-types.ts

@@ -715,7 +1190,7 @@ export function DocsPage() { UI References
-

docs/references/self.html

+

docs/references/dashboard.png • projects.png

@@ -735,13 +1210,16 @@ export function DocsPage() {
 {`# Regenerate frontend API types
-npm --prefix frontend run generate:api
+npm --prefix app/frontend run generate:api
 
 # Frontend type-check + build
-npm --prefix frontend run build:check
+npm --prefix app/frontend run build:check
 
 # Backend API tests
-cd backend && go test ./internal/api/...`}
+cd app/backend && go test ./internal/api/...
+
+# Build remote node agent
+cd app/backend && go build -o bin/containr-agent ./cmd/agent`}
               
diff --git a/app/frontend/src/features/service/pages/ServiceDetailPage.tsx b/app/frontend/src/features/service/pages/ServiceDetailPage.tsx index 3fe21dd..21e31b7 100644 --- a/app/frontend/src/features/service/pages/ServiceDetailPage.tsx +++ b/app/frontend/src/features/service/pages/ServiceDetailPage.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { @@ -7,21 +7,18 @@ import { getProjectById, getDeploymentLogs, getServiceById, + getServiceMetrics, listDeployments, listServiceLogs, rollbackDeployment, } from '@/lib/api-client'; import { getDemoProjectById, getDemoServiceById } from '@/lib/demo-data'; -import { formatDate, formatRelative, seededMetric } from '@/lib/time'; +import { formatBytes, formatDate, formatRelative, seededMetric } from '@/lib/time'; import { - ArrowLeft, Activity, FileText, Settings, Sliders, - Play, - RotateCw, - Trash2, Check, X, Loader2, @@ -171,23 +168,50 @@ export function ServiceDetailPage() { const project = isDemoMode ? getDemoProjectById(projectId) : projectQuery.data; const service = isDemoMode ? getDemoServiceById(serviceId) : serviceQuery.data; + const metricsQuery = useQuery({ + queryKey: ['service-metrics', serviceId], + queryFn: () => getServiceMetrics(serviceId), + enabled: !isDemoMode && Boolean(serviceId), + refetchInterval: 5000, + }); + + // Accumulate live samples into a small history for the timeline charts. + const [metricHistory, setMetricHistory] = useState<{ cpu: number[]; memory: number[] }>({ cpu: [], memory: [] }); + useEffect(() => { + const sample = metricsQuery.data; + if (!sample || sample.status !== 'ok') { + return; + } + const memPercent = sample.memory_limit_bytes > 0 + ? (sample.memory_usage_bytes / sample.memory_limit_bytes) * 100 + : 0; + // eslint-disable-next-line react-hooks/set-state-in-effect -- accumulating polled samples is a legitimate sync-to-external pattern + setMetricHistory((prev) => ({ + cpu: [...prev.cpu, Math.min(100, sample.cpu_percent)].slice(-24), + memory: [...prev.memory, Math.min(100, memPercent)].slice(-24), + })); + }, [metricsQuery.data]); + + const liveMetrics = isDemoMode ? null : metricsQuery.data ?? null; + const hasLiveTelemetry = Boolean(liveMetrics && liveMetrics.status === 'ok' && liveMetrics.instances.length > 0); + const runningInstances = liveMetrics?.instances.filter((instance) => instance.state === 'running').length ?? 0; + const memoryPercent = liveMetrics && liveMetrics.memory_limit_bytes > 0 + ? (liveMetrics.memory_usage_bytes / liveMetrics.memory_limit_bytes) * 100 + : 0; + + // Demo mode renders seeded values so the layout can be previewed without a backend. const metricSet = useMemo(() => { - if (!service) { + if (!service || !isDemoMode) { return null; } - const cpu = seededMetric(`${service.id}:cpu`, 12, 78); - const memory = seededMetric(`${service.id}:mem`, 24, 91); - const req = seededMetric(`${service.id}:req`, 120, 5100); - const latency = seededMetric(`${service.id}:lat`, 17, 210); - return { - cpu, - memory, - req, - latency, + cpu: seededMetric(`${service.id}:cpu`, 12, 78), + memory: seededMetric(`${service.id}:mem`, 24, 91), + req: seededMetric(`${service.id}:req`, 120, 5100), + latency: seededMetric(`${service.id}:lat`, 17, 210), }; - }, [service]); + }, [service, isDemoMode]); const deploymentSummary = useMemo(() => { const deployments = isDemoMode ? [] : deploymentsQuery.data ?? []; @@ -372,30 +396,29 @@ export function ServiceDetailPage() {
60 ? 'up' : metricSet && metricSet.cpu < 30 ? 'down' : 'stable'} + trend={hasLiveTelemetry && liveMetrics!.cpu_percent > 60 ? 'up' : 'stable'} /> 70 ? 'up' : 'stable'} + trend={hasLiveTelemetry && memoryPercent > 70 ? 'up' : 'stable'} /> 150 ? 'up' : 'stable'} />
@@ -458,6 +481,20 @@ export function ServiceDetailPage() { {activeSection === 'metrics' && (
{/* Metrics Grid with enhanced cards */} + {!isDemoMode && !hasLiveTelemetry && ( +
+
+ +
+

No telemetry yet

+

+ {liveMetrics?.status === 'docker_unavailable' + ? 'Docker is not reachable on this host, so container metrics cannot be collected.' + : 'Metrics appear once this service has running containers. Deploy it to start collecting.'} +

+
+ )} + {(isDemoMode || hasLiveTelemetry) && (
@@ -467,28 +504,36 @@ export function ServiceDetailPage() {

CPU Timeline

-

Last 24 intervals

+

+ {isDemoMode ? 'Last 24 intervals' : 'Live samples · every 5s'} +

-
- Click to expand -
- {Array.from({ length: 24 }).map((_, i) => { - const value = seededMetric(`${service.id}:cpu:${i}`, 8, 80); - return ( -
- ); - })} + {isDemoMode + ? Array.from({ length: 24 }).map((_, i) => { + const value = seededMetric(`${service.id}:cpu:${i}`, 8, 80); + return ( +
+ ); + }) + : metricHistory.cpu.map((value, i) => ( +
+ ))} + {!isDemoMode && metricHistory.cpu.length === 0 && ( +

Collecting first sample...

+ )}
@@ -496,32 +541,77 @@ export function ServiceDetailPage() {
- +
-

Request Timeline

-

Last 24 intervals

+

Memory Timeline

+

+ {isDemoMode ? 'Last 24 intervals' : 'Live samples · every 5s'} +

- {Array.from({ length: 24 }).map((_, i) => { - const value = seededMetric(`${service.id}:req:${i}`, 18, 96); - return ( -
- ); - })} + {isDemoMode + ? Array.from({ length: 24 }).map((_, i) => { + const value = seededMetric(`${service.id}:req:${i}`, 18, 96); + return ( +
+ ); + }) + : metricHistory.memory.map((value, i) => ( +
+ ))} + {!isDemoMode && metricHistory.memory.length === 0 && ( +

Collecting first sample...

+ )}
+ )} + + {/* Instance list — real container states */} + {!isDemoMode && hasLiveTelemetry && ( +
+
+
+ +
+
+

Instances

+

Containers backing this service

+
+
+
+ {liveMetrics!.instances.map((instance) => ( +
+
+
+ {instance.name} +
+
+ {instance.cpu_percent.toFixed(1)}% CPU + {formatBytes(instance.memory_usage_bytes)} + {instance.started_at ? `up ${formatRelative(instance.started_at).replace(' ago', '')}` : instance.state} +
+
+ ))} +
+
+ )} {/* Domain & Networking Panel */} {service.status === 'running' && ( diff --git a/app/frontend/src/features/service/pages/ServiceMetricsDashboard.tsx b/app/frontend/src/features/service/pages/ServiceMetricsDashboard.tsx index 7f85088..6a16486 100644 --- a/app/frontend/src/features/service/pages/ServiceMetricsDashboard.tsx +++ b/app/frontend/src/features/service/pages/ServiceMetricsDashboard.tsx @@ -1,25 +1,21 @@ import { useMemo, useState, useEffect } from 'react'; -import { useParams, useSearchParams, useNavigate } from 'react-router-dom'; -import { - Cpu, - MemoryStick, - Zap, - Users, +import { useParams, useNavigate } from 'react-router-dom'; +import { + Cpu, + MemoryStick, + Zap, + Users, Globe, - ArrowLeft, Search, Bell, ChevronUp, Activity, - FileText, Sliders, - Settings as SettingsIcon } from 'lucide-react'; -import { - LineChart, - LineAreaChart, - DonutChart, - MultiLineChart, +import { + LineChart, + LineAreaChart, + DonutChart, EnhancedMetricCard, CacheMetricCard, PerformanceMetricCard @@ -49,7 +45,6 @@ const timePeriods = ['Day', 'Month', 'Year']; export function ServiceMetricsDashboard() { const { serviceId = 'demo-service' } = useParams(); - const [searchParams] = useSearchParams(); const navigate = useNavigate(); const [activeTab, setActiveTab] = useState('metrics'); @@ -86,10 +81,10 @@ export function ServiceMetricsDashboard() { { data: perfData1, color: '#6c8ef0', fillOpacity: 0.15 }, { data: perfData2, color: '#9c7ef0', fillOpacity: 0.15 } ], - upSpeed: (Math.random() * 5 + 8).toFixed(1), - downSpeed: (Math.random() * 3 + 4).toFixed(1), + upSpeed: seededRandom(`${serviceId}:upspeed`, 8, 13).toFixed(1), + downSpeed: seededRandom(`${serviceId}:downspeed`, 4, 7).toFixed(1), }; - }, [serviceId, timePeriod]); + }, [serviceId]); // Auto-update metrics every 2 seconds useEffect(() => { diff --git a/app/frontend/src/features/workspace/canvas/ProjectCanvas.tsx b/app/frontend/src/features/workspace/canvas/ProjectCanvas.tsx index 467491b..de4f7be 100644 --- a/app/frontend/src/features/workspace/canvas/ProjectCanvas.tsx +++ b/app/frontend/src/features/workspace/canvas/ProjectCanvas.tsx @@ -14,11 +14,12 @@ import '@xyflow/react/dist/style.css'; import type { ServiceEntity } from '@/lib/api-client'; import { inferAutoConnections, type ServiceVariable } from '../auto-connections'; import { + DEFAULT_VIEWPORT, type CanvasGroup, type CanvasNodeLayout, type ProjectCanvasMetadata, } from '../model'; -import { loadCanvasMetadata, saveCanvasMetadata } from '../storage'; +import { canvasStorageKey, loadCanvasMetadata, saveCanvasMetadata } from '../storage'; import { GroupNode, ServiceNode, type GroupNodeData, type ServiceNodeData } from './nodes'; import { Plus, Layers, Maximize2, RotateCcw, Box, Link2 } from 'lucide-react'; @@ -152,6 +153,7 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on const wrapperRef = useRef(null); const persistTimeout = useRef(null); const hydratedRef = useRef(false); + const viewportRestoredRef = useRef(false); const [nodes, setNodes, onNodesChange] = useNodesState([]); const [selectedServiceId, setSelectedServiceId] = useState(null); const [viewportTick, setViewportTick] = useState(0); @@ -169,20 +171,34 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on const edges = useMemo(() => toFlowEdges(inferredLinks), [inferredLinks]); useEffect(() => { + const hasStoredViewport = localStorage.getItem(canvasStorageKey(projectId)) !== null; const metadata = loadCanvasMetadata(projectId, services); const nextNodes = toFlowNodes(metadata, services, onOpenService); + viewportRestoredRef.current = false; setNodes(nextNodes); window.requestAnimationFrame(() => { - setViewport(metadata.viewport, { duration: 120 }); + window.requestAnimationFrame(() => { + const isDefaultViewport = + metadata.viewport.x === DEFAULT_VIEWPORT.x && + metadata.viewport.y === DEFAULT_VIEWPORT.y && + metadata.viewport.zoom === DEFAULT_VIEWPORT.zoom; + + if (!hasStoredViewport || isDefaultViewport) { + void fitView({ padding: 0.15, duration: 120 }); + } else { + void setViewport(metadata.viewport, { duration: 120 }); + } + viewportRestoredRef.current = true; + }); }); hydratedRef.current = true; - }, [projectId, serviceFingerprint, onOpenService, setNodes, setViewport, services]); + }, [projectId, serviceFingerprint, onOpenService, setNodes, setViewport, fitView, services]); useEffect(() => { - if (!hydratedRef.current) { + if (!hydratedRef.current || !viewportRestoredRef.current) { return; } @@ -414,7 +430,6 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on }} onNodeDragStop={onNodeDragStop} onMoveEnd={() => setViewportTick((value) => value + 1)} - fitView panOnDrag zoomOnScroll minZoom={0.25} diff --git a/app/frontend/src/features/workspace/pages/ProjectWorkspacePage.tsx b/app/frontend/src/features/workspace/pages/ProjectWorkspacePage.tsx index 6f7ad26..069aafe 100644 --- a/app/frontend/src/features/workspace/pages/ProjectWorkspacePage.tsx +++ b/app/frontend/src/features/workspace/pages/ProjectWorkspacePage.tsx @@ -4,6 +4,9 @@ import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { createService, getProjectById, + listGitBranches, + listGitProviders, + listGitRepositories, listServiceLogs, listServiceVariables, listServicesByProject, @@ -31,6 +34,8 @@ import { Sparkles, Box, Search, + GitBranch, + Container, } from 'lucide-react'; type WorkspaceView = 'canvas' | 'observability' | 'logs' | 'settings'; @@ -59,6 +64,44 @@ function ServiceCreateDialog(props: { image: '', command: '', }); + const [source, setSource] = useState<'image' | 'git'>('image'); + const [providerId, setProviderId] = useState(''); + const [repoFullName, setRepoFullName] = useState(''); + const [repoSearch, setRepoSearch] = useState(''); + + const providersQuery = useQuery({ + queryKey: ['git-providers'], + queryFn: listGitProviders, + enabled: props.open && source === 'git', + }); + + const repositoriesQuery = useQuery({ + queryKey: ['git-repositories', providerId, repoSearch], + queryFn: () => listGitRepositories(providerId, repoSearch), + enabled: props.open && source === 'git' && Boolean(providerId), + }); + + const selectedRepo = useMemo( + () => (repositoriesQuery.data ?? []).find((repo) => repo.full_name === repoFullName) ?? null, + [repositoriesQuery.data, repoFullName], + ); + + const branchesQuery = useQuery({ + queryKey: ['git-branches', providerId, repoFullName], + queryFn: () => { + const [owner, repo] = repoFullName.split('/'); + return listGitBranches(providerId, owner, repo); + }, + enabled: props.open && source === 'git' && Boolean(providerId) && Boolean(repoFullName), + }); + + const providers = providersQuery.data ?? []; + const repositories = repositoriesQuery.data ?? []; + const branches = branchesQuery.data ?? []; + + const canSubmit = + Boolean(form.name.trim()) && + (source === 'image' || Boolean(repoFullName)); if (!props.open) return null; @@ -122,16 +165,169 @@ function ServiceCreateDialog(props: {
- setForm((p) => ({ ...p, image: e.target.value }))} - className="w-full h-11 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all mono text-sm" - placeholder="ghcr.io/org/app:latest" - /> +
+ + +
+ {source === 'image' ? ( +
+ + setForm((p) => ({ ...p, image: e.target.value }))} + className="w-full h-11 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all mono text-sm" + placeholder="ghcr.io/org/app:latest" + /> +
+ ) : ( +
+
+ + + {!providersQuery.isLoading && providers.length === 0 && ( +

+ No providers connected — add one in Settings → Git Providers +

+ )} +
+ + {providerId && ( +
+ + setRepoSearch(e.target.value)} + className="w-full h-9 px-3 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm mono mb-2" + placeholder="Search repositories..." + /> + {repositoriesQuery.isLoading ? ( +
+ +
+ ) : repositoriesQuery.isError ? ( +

Failed to load repositories — check the provider token

+ ) : repositories.length === 0 ? ( +

No repositories found

+ ) : ( +
+ {repositories.slice(0, 50).map((repo) => ( + + ))} +
+ )} +
+ )} + + {repoFullName && ( + <> +
+ + {branchesQuery.isLoading ? ( +
+ +
+ ) : branches.length > 0 ? ( + + ) : ( + setForm((p) => ({ ...p, git_branch: e.target.value }))} + className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm mono" + placeholder={selectedRepo?.default_branch ?? 'main'} + /> + )} +
+
+ + setForm((p) => ({ ...p, build_path: e.target.value }))} + className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm mono" + placeholder="/ (repo root)" + /> +
+ + )} +
+ )} +