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)" + /> +
+ + )} +
+ )} +